import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import MonacoEditor, { Monaco } from '@monaco-editor/react'; import { useAppStore, PostData, EditorMode, MediaData } from '../../store'; import { showToast } from '../Toast'; import { MilkdownEditor } from '../MilkdownEditor'; import { Lightbox, useMarkdownImages } from '../Lightbox'; import { PostLinks } from '../PostLinks'; import { LinkedMediaPanel } from '../LinkedMediaPanel'; import { ErrorModal } from '../ErrorModal'; import { ConfirmDeleteModal } from '../ConfirmDeleteModal'; import { SettingsView } from '../SettingsView'; import { StyleView } from '../StyleView/StyleView'; import { TagsView } from '../TagsView'; import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; import { ImportAnalysisView } from '../ImportAnalysisView'; import { MenuEditorView } from '../MenuEditorView/MenuEditorView'; import { MetadataDiffPanel } from '../MetadataDiffPanel'; import { GitDiffView } from '../GitDiffView/GitDiffView'; import { DocumentationView } from '../DocumentationView/DocumentationView'; import { SiteValidationView } from '../SiteValidationView'; import { ScriptsView } from '../ScriptsView/ScriptsView'; import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils'; import { InsertModal } from '../InsertModal'; import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; import { openEntityTab } from '../../navigation/tabPolicy'; import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting'; import { useI18n } from '../../i18n'; import documentationContent from '../../../../DOCUMENTATION.md?raw'; import apiDocumentationContent from '../../../../API.md?raw'; import './Editor.css'; /** Debounce a value so it only updates after `delay` ms of inactivity. */ function useDebouncedValue(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; } const UI_DATE_LOCALE: Record = { en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', }; /** Get display name for media: prefer title over originalName */ function getMediaDisplayName(media: { title?: string; originalName: string }): string { return media.title || media.originalName; } // Module-level AutoSaveManager for idle-time based auto-saving const autoSaveManager = new AutoSaveManager({ idleTimeMs: 3000, // Save after 3 seconds of idle time onSave: async (id, changes) => { // Note: We don't check if post exists in store's posts array since that's limited to 500. // If the post was deleted, the update will fail gracefully. // Build update payload from changes const update: Parameters[1] = {}; if ('title' in changes) update.title = changes.title as string; if ('content' in changes) update.content = changes.content as string; if ('tags' in changes) { const tagsStr = changes.tags as string; update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0); } if ('categories' in changes) { update.categories = changes.categories as string[]; } const updated = await window.electronAPI?.posts.update(id, update); if (updated) { useAppStore.getState().updatePost(id, updated as Partial); useAppStore.getState().markClean(id); // Emit event so PostEditor can update its local state window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id, updated } })); } }, onSaveComplete: (id) => { console.log(`Auto-saved post ${id}`); }, onSaveError: (id, error) => { console.error(`Auto-save failed for ${id}:`, error); }, }); /** * Resolves media references in markdown content to bds-media:// URLs * Matches images by: * 1. Media ID in the path (e.g., /media/2025/01/{id}.jpg) * 2. Original filename (e.g., image.jpg) * 3. Filename pattern (e.g., {id}.jpg) */ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => { if (!content || mediaList.length === 0) return content; // Build lookup maps for efficient matching const byId = new Map(); const byOriginalName = new Map(); const byFilename = new Map(); for (const m of mediaList) { byId.set(m.id, m.id); byOriginalName.set(m.originalName.toLowerCase(), m.id); byFilename.set(m.filename.toLowerCase(), m.id); } // Replace image URLs in markdown return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { // Skip if already using bds-media protocol or external URLs if (src.startsWith('bds-media://') || src.startsWith('http://') || src.startsWith('https://')) { return match; } // Extract the filename from the path const filename = src.split('/').pop() || ''; const filenameWithoutExt = filename.replace(/\.[^.]+$/, ''); const filenameLower = filename.toLowerCase(); // Try to match by: // 1. UUID in path (the file is named by ID) if (byId.has(filenameWithoutExt)) { return `![${alt}](bds-media://${filenameWithoutExt})`; } // 2. Filename lookup if (byFilename.has(filenameLower)) { return `![${alt}](bds-media://${byFilename.get(filenameLower)})`; } // 3. Original name lookup if (byOriginalName.has(filenameLower)) { return `![${alt}](bds-media://${byOriginalName.get(filenameLower)})`; } // No match found, return original return match; }); }; interface PostEditorProps { postId: string; } export const PostEditor: React.FC = ({ postId }) => { const { t: tr, language } = useI18n(); const { updatePost, markDirty, markClean, isDirty: checkIsDirty, preferredEditorMode, setPreferredEditorMode, showErrorModal, showConfirmDeleteModal, media, closeTab, } = useAppStore(); // Fetch full post data from backend const [post, setPost] = useState(null); const [isLoadingPost, setIsLoadingPost] = useState(true); // Track whether form state has been initialized from post data const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { let cancelled = false; setIsLoadingPost(true); setIsInitialized(false); window.electronAPI?.posts.get(postId).then((fetchedPost) => { if (cancelled) return; if (fetchedPost) { setPost(fetchedPost as PostData); // Also update the store so other components have the full data useAppStore.getState().updatePost(postId, fetchedPost as Partial); } else { // Post doesn't exist, close the tab closeTab(postId); } setIsLoadingPost(false); }); return () => { cancelled = true; }; }, [postId, closeTab]); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [author, setAuthor] = useState(''); const [tags, setTags] = useState([]); const [selectedCategories, setSelectedCategories] = useState(['article']); const [isSaving, setIsSaving] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [editorMode, setEditorMode] = useState(preferredEditorMode); const [previewUrl, setPreviewUrl] = useState(null); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [showPostSearch, setShowPostSearch] = useState(false); const [showMediaSearch, setShowMediaSearch] = useState(false); const [metadataExpanded, setMetadataExpanded] = useState(true); const editorRef = useRef(null); // Token incremented to signal Monaco that it should re-read its defaultValue. // This is used instead of controlled `value` to avoid cursor-reset races. const [monacoResetToken, setMonacoResetToken] = useState(0); const isDirty = checkIsDirty(postId); // Listen for auto-save events to keep local post state in sync useEffect(() => { const handler = (e: Event) => { const { id, updated } = (e as CustomEvent).detail; if (id === postId && updated) { setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); } }; window.addEventListener('bds:post-auto-saved', handler); return () => window.removeEventListener('bds:post-auto-saved', handler); }, [postId]); // Check if post has a published version for discard functionality useEffect(() => { window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion); }, [postId]); // Debounce content for lightbox-only computations (not time-critical) const debouncedContent = useDebouncedValue(content, 500); // Resolve media URLs in content for display (lightbox only) const resolvedContent = useMemo(() => resolveMediaUrls(debouncedContent, media), [debouncedContent, media]); // Extract images from resolved content for lightbox const images = useMarkdownImages(resolvedContent); useEffect(() => { if (editorMode !== 'preview') return; let cancelled = false; setPreviewUrl(null); window.electronAPI?.posts.getPreviewUrl(postId, { draft: true }) .then((url) => { if (!cancelled) { setPreviewUrl(url); } }) .catch((error) => { console.error('Failed to load post preview URL:', error); if (!cancelled) { setPreviewUrl(null); } }); return () => { cancelled = true; }; }, [editorMode, postId]); // Track latest values for auto-save on unmount/switch const pendingChangesRef = useRef<{ title: string; content: string; tags: string[]; categories: string[]; postId: string; isDirty: boolean; } | null>(null); // Update ref when values change useEffect(() => { pendingChangesRef.current = { title, content, tags, categories: selectedCategories, postId, isDirty, }; }, [title, content, tags, selectedCategories, postId, isDirty]); // Auto-save when switching away from a post or unmounting useEffect(() => { return () => { // Cancel any pending auto-save timer - we'll save immediately autoSaveManager.cancel(postId); const pending = pendingChangesRef.current; // Auto-save if we have pending changes (the update will fail gracefully if post was deleted) if (pending && pending.postId === postId && pending.isDirty) { // Fire and forget auto-save window.electronAPI?.posts.update(pending.postId, { title: pending.title, content: pending.content, tags: pending.tags, categories: pending.categories.length > 0 ? pending.categories : ['article'], }).then((updated) => { if (updated) { useAppStore.getState().updatePost(pending.postId, updated as Partial); useAppStore.getState().markClean(pending.postId); window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id: pending.postId, updated } })); } }).catch((error) => { console.error('Auto-save failed:', error); }); } }; }, [postId]); // Reset when post data is loaded or changes // Only sync local state from post on initial load. // After initialization, local state is the source of truth — this prevents // auto-save or manual-save completions from overwriting the user's in-progress // edits and causing the Monaco editor to reset cursor position. useEffect(() => { if (post && !isInitialized) { setTitle(post.title); setContent(post.content); setAuthor(post.author || ''); setTags(post.tags); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); setMetadataExpanded(post.title === ''); markClean(postId); // Mark as initialized AFTER setting local state setIsInitialized(true); } }, [post, postId, markClean, isInitialized]); // Track changes and notify auto-save manager // Only run after form has been initialized from post data useEffect(() => { if (!post || !isInitialized) return; // Short-circuit: check cheap comparisons first (content changes on every keystroke) const contentChanged = content !== post.content; const titleChanged = title !== post.title; const authorChanged = author !== (post.author || ''); const hasChanges = contentChanged || titleChanged || authorChanged || JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) || JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); if (hasChanges) { if (!isDirty) markDirty(postId); // Notify auto-save manager with accumulated changes // Convert tags array to comma-separated string for auto-save compatibility autoSaveManager.notifyChange(postId, { title, content, author, tags: tags.join(', '), categories: selectedCategories, }); } else { markClean(postId); } }, [title, content, author, tags, selectedCategories, post, postId, isInitialized, isDirty, markDirty, markClean]); // Handle editor mode change and persist preference const handleEditorModeChange = (mode: EditorMode) => { setEditorMode(mode); setPreferredEditorMode(mode); }; const handleSave = useCallback(async () => { if (!isDirty || isSaving) return; // Cancel any pending auto-save since we're saving manually autoSaveManager.cancel(postId); setIsSaving(true); try { const updated = await window.electronAPI?.posts.update(postId, { title, content, author: author || undefined, tags, categories: selectedCategories.length > 0 ? selectedCategories : ['article'], }); if (updated) { updatePost(postId, updated as Partial); setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); markClean(postId); } } catch (error) { console.error('Failed to save post:', error); const err = error as Error; showErrorModal({ title: tr('editor.error.saveTitle'), message: err.message || tr('editor.error.saveMessage'), stack: err.stack, }); } finally { setIsSaving(false); } }, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]); const handlePublish = async () => { await handleSave(); try { const updated = await window.electronAPI?.posts.publish(postId); if (updated) { updatePost(postId, updated as Partial); setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); showToast.success(tr('editor.toast.published')); } } catch (error) { console.error('Failed to publish post:', error); const err = error as Error; showErrorModal({ title: tr('editor.error.publishTitle'), message: err.message || tr('editor.error.publishMessage'), stack: err.stack, }); } }; const handleDiscard = async () => { // If this post has a published version, revert to it // If never published, delete the post entirely const confirmMessage = hasPublishedVersion ? tr('editor.confirm.discardChanges') : tr('editor.confirm.deleteDraft'); if (!confirm(confirmMessage)) { return; } try { if (hasPublishedVersion) { // Revert to published version const reverted = await window.electronAPI?.posts.discard(postId); if (reverted) { setTitle(reverted.title); setContent(reverted.content); setAuthor(reverted.author || ''); setTags(reverted.tags); setSelectedCategories(reverted.categories.length > 0 ? reverted.categories : ['article']); // Force Monaco to remount with the reverted content setMonacoResetToken(prev => prev + 1); // Update local post state so UI reflects the published status setPost(reverted as PostData); updatePost(postId, reverted as Partial); markClean(postId); showToast.success(tr('editor.toast.reverted')); } } else { // Never published - delete the post entirely await window.electronAPI?.posts.delete(postId); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(postId); useAppStore.getState().closeTab(postId); showToast.success(tr('editor.toast.draftDeleted')); } } catch (error) { console.error('Failed to discard/delete:', error); const err = error as Error; showErrorModal({ title: hasPublishedVersion ? tr('editor.error.discardTitle') : tr('editor.error.deleteTitle'), message: err.message || tr('editor.error.operationMessage'), stack: err.stack, }); } }; const handleDelete = async () => { try { // Fetch references to this post const [linkedBy, linkedMedia] = await Promise.all([ window.electronAPI?.posts.getLinkedBy(postId), window.electronAPI?.postMedia.getForPost(postId), ]); // Build references array const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = []; // Add posts that link to this post if (linkedBy && linkedBy.length > 0) { linkedBy.forEach((p: { id: string; title: string }) => { references.push({ id: p.id, title: p.title, type: 'link' }); }); } // Add linked media if (linkedMedia && linkedMedia.length > 0) { linkedMedia.forEach((m: { mediaId: string }) => { const mediaItem = media.find(item => item.id === m.mediaId); if (mediaItem) { references.push({ id: mediaItem.id, title: getMediaDisplayName(mediaItem), type: 'media', }); } }); } // Show confirmation modal showConfirmDeleteModal({ itemType: 'post', itemTitle: title || tr('editor.untitled'), references, onConfirm: async () => { try { await window.electronAPI?.posts.delete(postId); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(postId); useAppStore.getState().closeTab(postId); useAppStore.getState().setSelectedPost(null); showToast.success(tr('editor.toast.postDeleted')); } catch (error) { console.error('Failed to delete post:', error); const err = error as Error; showErrorModal({ title: tr('editor.error.deleteTitle'), message: err.message || tr('editor.error.deletePostMessage'), stack: err.stack, }); } }, }); } catch (error) { console.error('Failed to fetch post references:', error); const err = error as Error; showErrorModal({ title: tr('errorModal.error'), message: err.message || tr('editor.error.fetchPostReferencesMessage'), stack: err.stack, }); } }; // Handle Monaco editor mount const handleEditorDidMount = (editor: unknown, monaco: Monaco) => { editorRef.current = editor; const ed = editor as any; // Add keyboard shortcut and command for inserting post links ed.addAction({ id: 'editor.action.insertPostLink', label: 'Insert Link to Post', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], run: () => { setShowPostSearch(true); } }); }; // Handle link insertion from InsertModal (for posts or external URLs) const handleInsertLink = useCallback((url: string, text?: string) => { const editor = editorRef.current as any; if (!editor) return; const model = editor.getModel(); if (!model) return; const selection = editor.getSelection(); const selectedText = selection ? model.getValueInRange(selection) : ''; const linkText = text || selectedText || url; const linkMarkdown = `[${linkText}](${url})`; editor.executeEdits('insert-link', [{ range: selection || editor.getSelection(), text: linkMarkdown, forceMoveMarkers: true }]); setShowPostSearch(false); }, []); // Handle image insertion from InsertModal (for media library) const handleInsertImage = useCallback(async (url: string, alt: string, mediaId?: string) => { const editor = editorRef.current as any; if (!editor) return; const selection = editor.getSelection(); const imageMarkdown = `![${alt}](${url})`; editor.executeEdits('insert-image', [{ range: selection || editor.getSelection(), text: imageMarkdown, forceMoveMarkers: true }]); // Link the media to this post if mediaId is provided (from media library) if (mediaId) { try { await window.electronAPI?.postMedia.link(postId, mediaId); console.log(`[Editor] Linked media ${mediaId} to post ${postId}`); } catch (error) { console.error('Failed to link media to post:', error); } } setShowMediaSearch(false); }, [postId]); // Configure Monaco before mount to add macro syntax highlighting const handleEditorWillMount = (monaco: Monaco) => { // Register a custom language that extends markdown with macro support monaco.languages.register({ id: 'markdown-with-macros' }); // Define custom tokenization that highlights [[macro]] syntax monaco.languages.setMonarchTokensProvider('markdown-with-macros', { defaultToken: '', tokenPostfix: '.md', tokenizer: { root: [ // Macro syntax: [[macroName param="value"]] [/\[\[[a-zA-Z][\w-]*/, { token: 'keyword.macro', next: '@macroParams' }], // Headers [/^#{1,6}\s.*$/, 'keyword.header'], // Block elements [/^\s*>+/, 'string.quote'], [/^\s*[-+*]\s/, 'keyword'], [/^\s*\d+\.\s/, 'keyword'], [/^\s*```\w*/, { token: 'string.code', next: '@codeblock' }], // Inline elements [/\*\*[^*]+\*\*/, 'strong'], [/\*[^*]+\*/, 'emphasis'], [/__[^_]+__/, 'strong'], [/_[^_]+_/, 'emphasis'], [/`[^`]+`/, 'variable'], // Links and images [/!?\[[^\]]*\]\([^)]*\)/, 'string.link'], [/!?\[[^\]]*\]\[[^\]]*\]/, 'string.link'], ], macroParams: [ [/\]\]/, { token: 'keyword.macro', next: '@root' }], [/[a-zA-Z][\w-]*(?=\s*=)/, 'attribute.name'], [/=/, 'delimiter'], [/"[^"]*"/, 'string'], [/\s+/, 'white'], [/[^\]"=\s]+/, 'attribute.value'], ], codeblock: [ [/^\s*```\s*$/, { token: 'string.code', next: '@root' }], [/.*$/, 'variable.source'], ], }, }); // Define theme colors for macros monaco.editor.defineTheme('vs-dark-macros', { base: 'vs-dark', inherit: true, rules: [ { token: 'keyword.macro', foreground: 'C586C0', fontStyle: 'bold' }, { token: 'attribute.name', foreground: '9CDCFE' }, { token: 'attribute.value', foreground: 'CE9178' }, ], colors: {}, }); }; // Save on Ctrl+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSave]); // Listen for menu events useEffect(() => { const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave); const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish); return () => { unsubscribeSave?.(); unsubscribePublish?.(); }; }, [handleSave]); // Show loading state while fetching post data if (isLoadingPost || !post) { return (

{tr('editor.loadingPost')}

); } return (
{title || tr('editor.untitled')} {isDirty && }
{post.status} {isSaving && {tr('editor.saving')}} {post.status === 'draft' && ( )} {post.status === 'draft' && ( )} {post.status === 'published' && ( )}
{metadataExpanded && (
setTitle(e.target.value)} placeholder={tr('editor.untitled')} />
setAuthor(e.target.value)} placeholder={tr('editor.placeholder.author')} />
{ setSelectedCategories(categories.length > 0 ? categories : ['article']); }} placeholder={tr('editor.placeholder.categories')} mode="category" />
useAppStore.getState().setSelectedPost(id)} />
)}
{images.length > 0 && ( )} {editorMode === 'markdown' && ( <> )}
{editorMode === 'wysiwyg' && ( )} {editorMode === 'markdown' && ( setContent(value || '')} onMount={handleEditorDidMount} beforeMount={handleEditorWillMount} theme="vs-dark-macros" options={{ minimap: { enabled: false }, wordWrap: 'on', lineNumbers: 'on', fontSize: 14, fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", padding: { top: 12, bottom: 12 }, automaticLayout: true, scrollBeyondLastLine: false, renderLineHighlight: 'line', quickSuggestions: false, formatOnPaste: true, cursorStyle: 'line', cursorBlinking: 'smooth', }} /> )} {editorMode === 'preview' && (
{previewUrl ? (