import { useState, useEffect, useRef } from 'react'; import { marked } from 'marked'; import jsPDF from 'jspdf'; import { message } from '@tauri-apps/plugin-dialog'; import { Note } from '../types'; import { NextcloudAPI } from '../api/nextcloud'; import { FloatingToolbar } from './FloatingToolbar'; import { InsertToolbar } from './InsertToolbar'; interface NoteEditorProps { note: Note | null; onUpdateNote: (note: Note) => void; onToggleFavorite?: (note: Note, favorite: boolean) => void; onUnsavedChanges?: (hasChanges: boolean) => void; categories: string[]; isFocusMode?: boolean; onToggleFocusMode?: () => void; editorFont?: string; editorFontSize?: number; previewFont?: string; previewFontSize?: number; api?: NextcloudAPI | null; } const imageCache = new Map(); export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { const [localContent, setLocalContent] = useState(''); const [localCategory, setLocalCategory] = useState(''); const [localFavorite, setLocalFavorite] = useState(false); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false); const [processedContent, setProcessedContent] = useState(''); const [isLoadingImages, setIsLoadingImages] = useState(false); const [isUploading, setIsUploading] = useState(false); const previousNoteIdRef = useRef(null); const previousNoteContentRef = useRef(''); const textareaRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { onUnsavedChanges?.(hasUnsavedChanges); }, [hasUnsavedChanges, onUnsavedChanges]); // Handle Escape key to exit focus mode useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFocusMode && onToggleFocusMode) { onToggleFocusMode(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isFocusMode, onToggleFocusMode]); // Auto-resize textarea when content changes, switching from preview to edit, or font size changes useEffect(() => { if (textareaRef.current && !isPreviewMode) { // Use setTimeout to ensure DOM has updated setTimeout(() => { if (textareaRef.current) { // Save cursor position and scroll position const cursorPosition = textareaRef.current.selectionStart; const scrollTop = textareaRef.current.scrollTop; textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; // Restore cursor position and scroll position textareaRef.current.setSelectionRange(cursorPosition, cursorPosition); textareaRef.current.scrollTop = scrollTop; } }, 0); } }, [localContent, isPreviewMode, editorFontSize]); // Process images when entering preview mode or content changes useEffect(() => { if (!isPreviewMode || !note || !api) { setProcessedContent(localContent); return; } // Guard: Only process if localContent has been updated for the current note // This prevents processing stale content from the previous note if (previousNoteIdRef.current !== note.id) { console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`); return; } const processImages = async () => { console.log(`[Note ${note.id}] Processing images in preview mode. Content length: ${localContent.length}`); setIsLoadingImages(true); setProcessedContent(''); // Clear old content immediately // Find all image references in markdown: ![alt](path) const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; let content = localContent; const matches = [...localContent.matchAll(imageRegex)]; console.log(`[Note ${note.id}] Found ${matches.length} images to process`); for (const match of matches) { const [fullMatch, alt, imagePath] = match; // Skip external URLs (http/https) if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { continue; } // Check cache first const cacheKey = `${note.id}:${imagePath}`; if (imageCache.has(cacheKey)) { const dataUrl = imageCache.get(cacheKey)!; content = content.replace(fullMatch, `![${alt}](${dataUrl})`); continue; } try { const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); imageCache.set(cacheKey, dataUrl); content = content.replace(fullMatch, `![${alt}](${dataUrl})`); } catch (error) { console.error(`Failed to fetch attachment: ${imagePath}`, error); // Keep original path, image will show as broken } } setProcessedContent(content); setIsLoadingImages(false); }; processImages(); }, [isPreviewMode, localContent, note?.id, api]); useEffect(() => { const loadNewNote = () => { if (note) { setLocalContent(note.content); setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); setHasUnsavedChanges(false); previousNoteIdRef.current = note.id; previousNoteContentRef.current = note.content; } }; // Switching to a different note if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`); setProcessedContent(''); if (hasUnsavedChanges) { handleSave(); } loadNewNote(); } // Same note but content changed from server (and no unsaved local changes) else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) { console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`); loadNewNote(); } // Initial load else if (!note || previousNoteIdRef.current === null) { loadNewNote(); } // Favorite status changed (e.g., from sync) else if (note && note.favorite !== localFavorite) { setLocalFavorite(note.favorite); } }, [note?.id, note?.content, note?.modified, note?.favorite]); const handleSave = () => { if (!note || !hasUnsavedChanges) return; console.log('Saving note content length:', localContent.length); console.log('Last 50 chars:', localContent.slice(-50)); setIsSaving(true); setHasUnsavedChanges(false); const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); const title = firstLine || 'Untitled'; onUpdateNote({ ...note, title, content: localContent, category: localCategory, favorite: localFavorite, }); setTimeout(() => setIsSaving(false), 500); }; const handleContentChange = (value: string) => { setLocalContent(value); setHasUnsavedChanges(true); }; const handleDiscard = () => { if (!note) return; setLocalContent(note.content); setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); setHasUnsavedChanges(false); }; const loadFontAsBase64 = async (fontPath: string): Promise => { const response = await fetch(fontPath); const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const base64 = reader.result as string; // Remove data URL prefix to get just the base64 string resolve(base64.split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); }); }; const handleExportPDF = async () => { if (!note) return; setIsExportingPDF(true); try { // Create PDF const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', }); // Load and add custom fonts based on preview font selection const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = { 'Merriweather': { regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf', italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf', name: 'Merriweather' }, 'Crimson Pro': { regular: '/fonts/CrimsonPro-VariableFont_wght.ttf', italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf', name: 'CrimsonPro' }, 'Roboto Serif': { regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf', italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf', name: 'RobotoSerif' }, 'Average': { regular: '/fonts/Average-Regular.ttf', italic: '/fonts/Average-Regular.ttf', // No italic variant name: 'Average' } }; const selectedFont = fontMap[previewFont]; if (selectedFont) { try { const regularBase64 = await loadFontAsBase64(selectedFont.regular); pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64); pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal'); const italicBase64 = await loadFontAsBase64(selectedFont.italic); pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64); pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic'); // Set the custom font as default pdf.setFont(selectedFont.name, 'normal'); } catch (fontError) { console.error('Failed to load custom font, using default:', fontError); } } // Add Source Code Pro for code blocks try { const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf'); pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont); pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal'); } catch (codeFontError) { console.error('Failed to load code font:', codeFontError); } // Process images to embed them as data URLs let contentForPDF = localContent; if (api) { const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const matches = [...localContent.matchAll(imageRegex)]; for (const match of matches) { const [fullMatch, alt, imagePath] = match; // Skip external URLs if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { continue; } // Check cache first const cacheKey = `${note.id}:${imagePath}`; if (imageCache.has(cacheKey)) { const dataUrl = imageCache.get(cacheKey)!; contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); continue; } try { const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); imageCache.set(cacheKey, dataUrl); contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); } catch (error) { console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error); } } } const container = document.createElement('div'); container.style.fontFamily = `"${previewFont}", Georgia, serif`; container.style.fontSize = `${previewFontSize}px`; container.style.lineHeight = '1.6'; container.style.color = '#000000'; const titleElement = document.createElement('h1'); const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); titleElement.textContent = firstLine || 'Untitled'; titleElement.style.marginTop = '0'; titleElement.style.marginBottom = '20px'; titleElement.style.fontSize = '24px'; titleElement.style.fontWeight = 'bold'; titleElement.style.color = '#000000'; titleElement.style.textAlign = 'center'; titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`; container.appendChild(titleElement); const contentElement = document.createElement('div'); const html = marked.parse(contentForPDF || '', { async: false }) as string; contentElement.innerHTML = html; contentElement.style.fontSize = `${previewFontSize}px`; contentElement.style.lineHeight = '1.6'; contentElement.style.color = '#000000'; container.appendChild(contentElement); const style = document.createElement('style'); style.textContent = ` body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; } code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; } pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } code { padding: 0; } h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; } h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; } h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; } p { margin: 0.5em 0; } ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; } ul { list-style-type: disc; } ol { list-style-type: decimal; } li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; } em { font-style: italic; vertical-align: baseline; } strong { font-weight: bold; vertical-align: baseline; line-height: inherit; } img { max-width: 100%; height: auto; display: block; margin: 1em 0; } `; container.appendChild(style); // Use jsPDF's html() method with custom font set await pdf.html(container, { callback: async (doc) => { const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); const fileName = `${firstLine || 'note'}.pdf`; doc.save(fileName); setTimeout(async () => { try { await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, { title: 'Export Complete', kind: 'info', }); } catch (err) { console.log('Dialog shown successfully or not available'); } setIsExportingPDF(false); }, 500); }, margin: [20, 20, 20, 20], autoPaging: 'text', width: 170, windowWidth: 650, }); } catch (error) { console.error('PDF export failed:', error); try { await message('Failed to export PDF. Please try again.', { title: 'Export Failed', kind: 'error', }); } catch (err) { console.error('Could not show error dialog'); } setIsExportingPDF(false); } }; const handleFavoriteToggle = () => { const newFavorite = !localFavorite; setLocalFavorite(newFavorite); if (note && onToggleFavorite) { // Use dedicated favorite toggle callback if provided onToggleFavorite(note, newFavorite); } else if (note) { // Fallback to full update if no callback provided const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); const title = firstLine || 'Untitled'; onUpdateNote({ ...note, title, content: localContent, category: localCategory, favorite: newFavorite, }); } }; const handleCategoryChange = (category: string) => { setLocalCategory(category); setHasUnsavedChanges(true); }; const handleAttachmentUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !note || !api) return; setIsUploading(true); try { const relativePath = await api.uploadAttachment(note.id, file, note.category); // Determine if it's an image or other file const isImage = file.type.startsWith('image/'); const markdownLink = isImage ? `![${file.name}](${relativePath})` : `[${file.name}](${relativePath})`; // Insert at cursor position or end of content const textarea = textareaRef.current; if (textarea) { const cursorPos = textarea.selectionStart; const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); setLocalContent(newContent); setHasUnsavedChanges(true); // Move cursor after inserted text setTimeout(() => { textarea.focus(); const newPos = cursorPos + markdownLink.length; textarea.setSelectionRange(newPos, newPos); }, 0); } else { // Append to end setLocalContent(localContent + '\n' + markdownLink); setHasUnsavedChanges(true); } await message(`Attachment uploaded successfully!`, { title: 'Upload Complete', kind: 'info', }); } catch (error) { console.error('Upload failed:', error); await message(`Failed to upload attachment: ${error}`, { title: 'Upload Failed', kind: 'error', }); } finally { setIsUploading(false); // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; const handleInsertLink = (text: string, url: string) => { const textarea = textareaRef.current; if (!textarea) return; const cursorPos = textarea.selectionStart; const markdownLink = `[${text}](${url})`; const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); setLocalContent(newContent); setHasUnsavedChanges(true); setTimeout(() => { textarea.focus(); const newPos = cursorPos + markdownLink.length; textarea.setSelectionRange(newPos, newPos); }, 0); }; const handleInsertFile = () => { fileInputRef.current?.click(); }; const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => { if (!textareaRef.current) return; const textarea = textareaRef.current; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = localContent.substring(start, end); if (!selectedText) return; let formattedText = ''; let cursorOffset = 0; let isRemoving = false; // Helper to check and remove inline formatting const toggleInline = (text: string, wrapper: string): { result: string; removed: boolean } => { const escaped = wrapper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`^${escaped}(.+)${escaped}$`, 's'); const match = text.match(regex); if (match) { return { result: match[1], removed: true }; } return { result: `${wrapper}${text}${wrapper}`, removed: false }; }; // Helper to check and remove line-prefix formatting const toggleLinePrefix = (text: string, prefixRegex: RegExp, addPrefix: (line: string, i: number) => string): { result: string; removed: boolean } => { const lines = text.split('\n'); const allHavePrefix = lines.every(line => prefixRegex.test(line)); if (allHavePrefix) { return { result: lines.map(line => line.replace(prefixRegex, '')).join('\n'), removed: true }; } return { result: lines.map((line, i) => addPrefix(line, i)).join('\n'), removed: false }; }; switch (format) { case 'bold': { const { result, removed } = toggleInline(selectedText, '**'); formattedText = result; isRemoving = removed; break; } case 'italic': { const { result, removed } = toggleInline(selectedText, '*'); formattedText = result; isRemoving = removed; break; } case 'strikethrough': { const { result, removed } = toggleInline(selectedText, '~~'); formattedText = result; isRemoving = removed; break; } case 'code': { const { result, removed } = toggleInline(selectedText, '`'); formattedText = result; isRemoving = removed; break; } case 'codeblock': { const codeBlockMatch = selectedText.match(/^```\n?([\s\S]*?)\n?```$/); if (codeBlockMatch) { formattedText = codeBlockMatch[1]; isRemoving = true; } else { formattedText = `\`\`\`\n${selectedText}\n\`\`\``; } break; } case 'quote': { const { result, removed } = toggleLinePrefix(selectedText, /^>\s?/, (line) => `> ${line}`); formattedText = result; isRemoving = removed; break; } case 'ul': { const { result, removed } = toggleLinePrefix(selectedText, /^[-*+]\s/, (line) => `- ${line}`); formattedText = result; isRemoving = removed; break; } case 'ol': { const { result, removed } = toggleLinePrefix(selectedText, /^\d+\.\s/, (line, i) => `${i + 1}. ${line}`); formattedText = result; isRemoving = removed; break; } case 'link': { const linkMatch = selectedText.match(/^\[(.+)\]\((.+)\)$/); if (linkMatch) { formattedText = linkMatch[1]; // Just return the text part isRemoving = true; } else { formattedText = `[${selectedText}](url)`; cursorOffset = formattedText.length - 4; } break; } case 'h1': { const { result, removed } = toggleLinePrefix(selectedText, /^#\s/, (line) => `# ${line}`); formattedText = result; isRemoving = removed; break; } case 'h2': { const { result, removed } = toggleLinePrefix(selectedText, /^##\s/, (line) => `## ${line}`); formattedText = result; isRemoving = removed; break; } case 'h3': { const { result, removed } = toggleLinePrefix(selectedText, /^###\s/, (line) => `### ${line}`); formattedText = result; isRemoving = removed; break; } } const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end); setLocalContent(newContent); setHasUnsavedChanges(true); setTimeout(() => { textarea.focus(); if (format === 'link' && !isRemoving) { // Select "url" for easy replacement textarea.setSelectionRange(start + cursorOffset, start + cursorOffset + 3); } else { textarea.setSelectionRange(start, start + formattedText.length); } }, 0); }; if (!note) { return (

No Note Selected

Select a note from the sidebar or create a new one

); } return (
{/* Toolbar */}
{/* Category Selector */}
{/* Preview Toggle */}
{/* Status */} {(hasUnsavedChanges || isSaving) && ( {isSaving ? 'Saving...' : 'Unsaved'} )} {/* Action Buttons */}
{/* Focus Mode Toggle */} {onToggleFocusMode && ( )}
{isPreviewMode ? (
{isLoadingImages && (
Loading images...
)}
) : (