diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 9080efd..8c3bb68 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Compartment } from '@codemirror/state'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { defaultKeymap, indentWithTab } from '@codemirror/commands'; @@ -18,7 +18,6 @@ interface Props { editSlug?: string; } -// CodeMirror theme matching the Catppuccin narlblog style const narlblogTheme = EditorView.theme({ '&': { backgroundColor: 'var(--crust)', @@ -42,7 +41,6 @@ const narlblogTheme = EditorView.theme({ border: 'none', }, '.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' }, - // Search panel styling '.cm-panels': { backgroundColor: 'var(--mantle)', color: 'var(--text)', @@ -50,7 +48,6 @@ const narlblogTheme = EditorView.theme({ }, '.cm-searchMatch': { backgroundColor: 'var(--yellow)', opacity: '0.3' }, '.cm-searchMatch-selected': { backgroundColor: 'var(--peach)', opacity: '0.4' }, - // Vim cursor styling '.cm-fat-cursor': { backgroundColor: 'var(--mauve) !important', color: 'var(--crust) !important', @@ -98,6 +95,9 @@ function renderMathInElement(element: HTMLElement) { for (let i = 0; i < 3; i++) walk(element); } +// Compartment for hot-swapping vim mode without recreating the editor +const vimCompartment = new Compartment(); + export default function Editor({ editSlug }: Props) { const editorRef = useRef(null); const viewRef = useRef(null); @@ -131,7 +131,6 @@ export default function Editor({ editSlug }: Props) { } else { result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; }); } - // Enhance preview after render requestAnimationFrame(() => { if (!previewRef.current) return; renderMathInElement(previewRef.current); @@ -141,52 +140,50 @@ export default function Editor({ editSlug }: Props) { }); }, [showPreview]); - function buildExtensions() { - const exts = [ - ...(vimEnabled ? [vim()] : []), - keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), - search(), - closeBrackets(), - markdown({ base: markdownLanguage, codeLanguages: languages }), - EditorView.lineWrapping, - narlblogTheme, - cmPlaceholder('# Hello World\nWrite your markdown here...'), - EditorView.updateListener.of(update => { - if (!update.docChanged) return; - // Debounced preview update - if (previewTimerRef.current) clearTimeout(previewTimerRef.current); - previewTimerRef.current = setTimeout(updatePreview, 300); - // Autocomplete trigger - const pos = update.state.selection.main.head; - const line = update.state.doc.lineAt(pos); - const textBefore = line.text.slice(0, pos - line.from); - const lastChar = textBefore.slice(-1); - if (lastChar === '/' || lastChar === '!') { - triggerAutocomplete(update.view); - } else if (lastChar === ' ' || textBefore.length === 0) { - setShowAutocomplete(false); - } - }), - ]; - return exts; - } - - // Initialize / rebuild CodeMirror when vim mode toggles + // Initialize CodeMirror once useEffect(() => { - if (!editorRef.current) return; - - // Preserve content if rebuilding - const oldContent = viewRef.current?.state.doc.toString() || ''; - if (viewRef.current) viewRef.current.destroy(); + if (!editorRef.current || viewRef.current) return; const state = EditorState.create({ - doc: oldContent, - extensions: buildExtensions(), + doc: '', + extensions: [ + vimCompartment.of(window.innerWidth > 768 ? vim() : []), + keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), + search(), + closeBrackets(), + markdown({ base: markdownLanguage, codeLanguages: languages }), + EditorView.lineWrapping, + narlblogTheme, + cmPlaceholder('# Hello World\nWrite your markdown here...'), + EditorView.updateListener.of(update => { + if (!update.docChanged) return; + if (previewTimerRef.current) clearTimeout(previewTimerRef.current); + previewTimerRef.current = setTimeout(updatePreview, 300); + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + const textBefore = line.text.slice(0, pos - line.from); + const lastChar = textBefore.slice(-1); + if (lastChar === '/' || lastChar === '!') { + triggerAutocomplete(update.view); + } else if (lastChar === ' ' || textBefore.length === 0) { + setShowAutocomplete(false); + } + }), + ], }); + const view = new EditorView({ state, parent: editorRef.current }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; + }, []); + + // Hot-swap vim mode via compartment reconfiguration + useEffect(() => { + if (!viewRef.current) return; + viewRef.current.dispatch({ + effects: vimCompartment.reconfigure(vimEnabled ? vim() : []), + }); }, [vimEnabled]); // Load existing post for editing @@ -202,7 +199,6 @@ export default function Editor({ editSlug }: Props) { }).catch(() => showAlertMsg('Failed to load post.', 'error')); }, [editSlug]); - // Update preview when toggled on useEffect(() => { if (showPreview) updatePreview(); }, [showPreview, updatePreview]); @@ -389,10 +385,10 @@ export default function Editor({ editSlug }: Props) { - {/* Editor + Preview */} -
-
-
+ {/* Editor + Preview — both columns stretch to the taller one */} +
+
+
{/* Autocomplete dropdown */} {showAutocomplete && autocompleteAssets.length > 0 && ( @@ -427,13 +423,13 @@ export default function Editor({ editSlug }: Props) { )}
- {/* Live Preview */} + {/* Live Preview — stretches to match editor height */} {showPreview && ( -
+
Preview
-
+
)}
diff --git a/frontend/src/pages/posts/[slug].astro b/frontend/src/pages/posts/[slug].astro index 0a00292..5543e47 100644 --- a/frontend/src/pages/posts/[slug].astro +++ b/frontend/src/pages/posts/[slug].astro @@ -73,7 +73,7 @@ const isAdmin = Astro.cookies.has('admin_token');
- + )}