fixde: editor breaking when clicking vim button

This commit is contained in:
2026-03-28 14:00:49 +01:00
parent 881ffbd89a
commit 9662b6af43
2 changed files with 48 additions and 52 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; 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 { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data'; import { languages } from '@codemirror/language-data';
import { defaultKeymap, indentWithTab } from '@codemirror/commands'; import { defaultKeymap, indentWithTab } from '@codemirror/commands';
@@ -18,7 +18,6 @@ interface Props {
editSlug?: string; editSlug?: string;
} }
// CodeMirror theme matching the Catppuccin narlblog style
const narlblogTheme = EditorView.theme({ const narlblogTheme = EditorView.theme({
'&': { '&': {
backgroundColor: 'var(--crust)', backgroundColor: 'var(--crust)',
@@ -42,7 +41,6 @@ const narlblogTheme = EditorView.theme({
border: 'none', border: 'none',
}, },
'.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' }, '.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' },
// Search panel styling
'.cm-panels': { '.cm-panels': {
backgroundColor: 'var(--mantle)', backgroundColor: 'var(--mantle)',
color: 'var(--text)', color: 'var(--text)',
@@ -50,7 +48,6 @@ const narlblogTheme = EditorView.theme({
}, },
'.cm-searchMatch': { backgroundColor: 'var(--yellow)', opacity: '0.3' }, '.cm-searchMatch': { backgroundColor: 'var(--yellow)', opacity: '0.3' },
'.cm-searchMatch-selected': { backgroundColor: 'var(--peach)', opacity: '0.4' }, '.cm-searchMatch-selected': { backgroundColor: 'var(--peach)', opacity: '0.4' },
// Vim cursor styling
'.cm-fat-cursor': { '.cm-fat-cursor': {
backgroundColor: 'var(--mauve) !important', backgroundColor: 'var(--mauve) !important',
color: 'var(--crust) !important', color: 'var(--crust) !important',
@@ -98,6 +95,9 @@ function renderMathInElement(element: HTMLElement) {
for (let i = 0; i < 3; i++) walk(element); 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) { export default function Editor({ editSlug }: Props) {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
@@ -131,7 +131,6 @@ export default function Editor({ editSlug }: Props) {
} else { } else {
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; }); result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
} }
// Enhance preview after render
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!previewRef.current) return; if (!previewRef.current) return;
renderMathInElement(previewRef.current); renderMathInElement(previewRef.current);
@@ -141,9 +140,14 @@ export default function Editor({ editSlug }: Props) {
}); });
}, [showPreview]); }, [showPreview]);
function buildExtensions() { // Initialize CodeMirror once
const exts = [ useEffect(() => {
...(vimEnabled ? [vim()] : []), if (!editorRef.current || viewRef.current) return;
const state = EditorState.create({
doc: '',
extensions: [
vimCompartment.of(window.innerWidth > 768 ? vim() : []),
keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]),
search(), search(),
closeBrackets(), closeBrackets(),
@@ -153,10 +157,8 @@ export default function Editor({ editSlug }: Props) {
cmPlaceholder('# Hello World\nWrite your markdown here...'), cmPlaceholder('# Hello World\nWrite your markdown here...'),
EditorView.updateListener.of(update => { EditorView.updateListener.of(update => {
if (!update.docChanged) return; if (!update.docChanged) return;
// Debounced preview update
if (previewTimerRef.current) clearTimeout(previewTimerRef.current); if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
previewTimerRef.current = setTimeout(updatePreview, 300); previewTimerRef.current = setTimeout(updatePreview, 300);
// Autocomplete trigger
const pos = update.state.selection.main.head; const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos); const line = update.state.doc.lineAt(pos);
const textBefore = line.text.slice(0, pos - line.from); const textBefore = line.text.slice(0, pos - line.from);
@@ -167,26 +169,21 @@ export default function Editor({ editSlug }: Props) {
setShowAutocomplete(false); setShowAutocomplete(false);
} }
}), }),
]; ],
return exts;
}
// Initialize / rebuild CodeMirror when vim mode toggles
useEffect(() => {
if (!editorRef.current) return;
// Preserve content if rebuilding
const oldContent = viewRef.current?.state.doc.toString() || '';
if (viewRef.current) viewRef.current.destroy();
const state = EditorState.create({
doc: oldContent,
extensions: buildExtensions(),
}); });
const view = new EditorView({ state, parent: editorRef.current }); const view = new EditorView({ state, parent: editorRef.current });
viewRef.current = view; viewRef.current = view;
return () => { view.destroy(); viewRef.current = null; }; 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]); }, [vimEnabled]);
// Load existing post for editing // Load existing post for editing
@@ -202,7 +199,6 @@ export default function Editor({ editSlug }: Props) {
}).catch(() => showAlertMsg('Failed to load post.', 'error')); }).catch(() => showAlertMsg('Failed to load post.', 'error'));
}, [editSlug]); }, [editSlug]);
// Update preview when toggled on
useEffect(() => { useEffect(() => {
if (showPreview) updatePreview(); if (showPreview) updatePreview();
}, [showPreview, updatePreview]); }, [showPreview, updatePreview]);
@@ -389,10 +385,10 @@ export default function Editor({ editSlug }: Props) {
</div> </div>
</div> </div>
{/* Editor + Preview */} {/* Editor + Preview — both columns stretch to the taller one */}
<div className={`relative ${showPreview ? 'grid grid-cols-1 md:grid-cols-2 gap-4' : ''}`}> <div className={`relative ${showPreview ? 'grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch' : ''}`}>
<div className="relative"> <div className="relative min-h-[500px]" style={showPreview ? { display: 'flex', flexDirection: 'column' } : undefined}>
<div ref={editorRef} className="min-h-[500px]" /> <div ref={editorRef} style={showPreview ? { flex: '1 1 0', minHeight: 0 } : undefined} className={showPreview ? '' : 'min-h-[500px]'} />
{/* Autocomplete dropdown */} {/* Autocomplete dropdown */}
{showAutocomplete && autocompleteAssets.length > 0 && ( {showAutocomplete && autocompleteAssets.length > 0 && (
@@ -427,13 +423,13 @@ export default function Editor({ editSlug }: Props) {
)} )}
</div> </div>
{/* Live Preview */} {/* Live Preview — stretches to match editor height */}
{showPreview && ( {showPreview && (
<div className="border border-surface1 rounded-xl bg-crust/50 overflow-y-auto max-h-[600px] min-h-[500px]"> <div className="border border-surface1 rounded-xl bg-crust/50 overflow-y-auto flex flex-col min-h-[500px]">
<div className="sticky top-0 bg-mantle px-4 py-2 text-xs text-subtext0 uppercase border-b border-surface1 z-10"> <div className="sticky top-0 bg-mantle px-4 py-2 text-xs text-subtext0 uppercase border-b border-surface1 z-10">
Preview Preview
</div> </div>
<div ref={previewRef} className="prose max-w-none p-4 md:p-6" /> <div ref={previewRef} className="prose max-w-none p-4 md:p-6 flex-1" />
</div> </div>
)} )}
</div> </div>

View File

@@ -73,7 +73,7 @@ const isAdmin = Astro.cookies.has('admin_token');
</div> </div>
</header> </header>
<div id="post-content" class="prose max-w-none" set:html={html} /> <div id="post-content" class="prose max-w-none" set:html={html} />
<PostEnhancer client:load containerId="post-content" /> <PostEnhancer client:only="react" containerId="post-content" />
</> </>
)} )}
</article> </article>