updated editor and pageloading

This commit is contained in:
2026-03-28 13:54:34 +01:00
parent 176f821598
commit 881ffbd89a
6 changed files with 329 additions and 193 deletions
+207 -65
View File
@@ -1,9 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
import { marked } from 'marked';
import hljs from 'highlight.js';
import katex from 'katex';
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
import type { Asset } from '../../../lib/types';
import AssetManager from './AssetManager';
@@ -36,16 +42,76 @@ const narlblogTheme = EditorView.theme({
border: 'none',
},
'.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' },
// Search panel styling
'.cm-panels': {
backgroundColor: 'var(--mantle)',
color: 'var(--text)',
borderTop: '1px solid var(--surface1)',
},
'.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',
},
'&:not(.cm-focused) .cm-fat-cursor': {
outline: '1px solid var(--mauve)',
backgroundColor: 'transparent !important',
},
}, { dark: true });
function renderMathInElement(element: HTMLElement) {
const delimiters = [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
];
const walk = (node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
for (const child of Array.from(el.childNodes)) walk(child);
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
for (const { left, right, display } of delimiters) {
const idx = text.indexOf(left);
if (idx === -1) continue;
const end = text.indexOf(right, idx + left.length);
if (end === -1) continue;
const tex = text.slice(idx + left.length, end);
try {
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
const span = document.createElement('span');
span.innerHTML = rendered;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, end + right.length);
range.deleteContents();
range.insertNode(span);
} catch { /* skip */ }
return;
}
}
};
for (let i = 0; i < 3; i++) walk(element);
}
export default function Editor({ editSlug }: Props) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [slug, setSlug] = useState(editSlug || '');
const [summary, setSummary] = useState('');
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [showModal, setShowModal] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [vimEnabled, setVimEnabled] = useState(() =>
typeof window !== 'undefined' && window.innerWidth > 768
);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteAssets, setAutocompleteAssets] = useState<Asset[]>([]);
const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 });
@@ -56,39 +122,72 @@ export default function Editor({ editSlug }: Props) {
setTimeout(() => setAlert(null), 5000);
}
// Initialize CodeMirror 6
const updatePreview = useCallback(() => {
if (!showPreview || !viewRef.current || !previewRef.current) return;
const content = viewRef.current.state.doc.toString();
const result = marked.parse(content);
if (typeof result === 'string') {
previewRef.current.innerHTML = result;
} else {
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
}
// Enhance preview after render
requestAnimationFrame(() => {
if (!previewRef.current) return;
renderMathInElement(previewRef.current);
previewRef.current.querySelectorAll<HTMLElement>('pre code').forEach(block => {
hljs.highlightElement(block);
});
});
}, [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
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
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: '',
extensions: [
keymap.of([...defaultKeymap, indentWithTab]),
markdown({ base: markdownLanguage, codeLanguages: languages }),
EditorView.lineWrapping,
narlblogTheme,
cmPlaceholder('# Hello World\nWrite your markdown here...'),
EditorView.updateListener.of(update => {
if (!update.docChanged) return;
// Check for 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);
}
}),
],
doc: oldContent,
extensions: buildExtensions(),
});
const view = new EditorView({ state, parent: editorRef.current });
viewRef.current = view;
return () => { view.destroy(); viewRef.current = null; };
}, []);
}, [vimEnabled]);
// Load existing post for editing
useEffect(() => {
@@ -103,11 +202,15 @@ 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]);
async function triggerAutocomplete(view: EditorView) {
try {
const assets = await getAssets();
setAutocompleteAssets(assets.slice(0, 8));
// Position near cursor
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
if (coords) {
@@ -180,7 +283,6 @@ export default function Editor({ editSlug }: Props) {
}
}
// Close autocomplete on outside click
useEffect(() => {
if (!showAutocomplete) return;
const handler = () => setShowAutocomplete(false);
@@ -247,51 +349,91 @@ export default function Editor({ editSlug }: Props) {
/>
</div>
{/* Editor */}
<div className="relative">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-2 gap-2">
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse your assets</label>
{/* Editor Toolbar */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse assets</label>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVimEnabled(v => !v)}
className={`text-xs px-3 py-1.5 rounded border transition-colors font-mono ${
vimEnabled
? 'bg-mauve/20 text-mauve border-mauve/30'
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
}`}
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
>
{vimEnabled ? 'VIM' : 'vim'}
</button>
<button
type="button"
onClick={() => setShowPreview(p => !p)}
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
showPreview
? 'bg-blue/20 text-blue border-blue/30'
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
}`}
>
{showPreview ? 'Hide Preview' : 'Show Preview'}
</button>
<button
type="button"
onClick={() => setShowModal(true)}
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-1.5 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
Asset Library
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
Assets
</button>
</div>
</div>
<div ref={editorRef} className="min-h-[500px]" />
{/* Editor + Preview */}
<div className={`relative ${showPreview ? 'grid grid-cols-1 md:grid-cols-2 gap-4' : ''}`}>
<div className="relative">
<div ref={editorRef} className="min-h-[500px]" />
{/* Autocomplete dropdown */}
{showAutocomplete && autocompleteAssets.length > 0 && (
<div
className="absolute z-50 bg-mantle border border-surface1 rounded-lg shadow-2xl max-h-64 overflow-y-auto w-80"
style={{ top: autocompletePos.top, left: autocompletePos.left }}
onClick={e => e.stopPropagation()}
>
<div className="p-2 text-[10px] text-subtext0 uppercase border-b border-white/5 bg-crust/50">Assets Library</div>
<ul className="py-1">
{autocompleteAssets.map(asset => {
const img = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name);
return (
<li
key={asset.name}
onClick={() => insertAssetMarkdown(asset)}
className="px-4 py-2 hover:bg-mauve/20 cursor-pointer text-sm truncate text-subtext1 hover:text-mauve flex items-center gap-3 transition-colors"
>
<div className="w-6 h-6 flex-shrink-0 bg-surface0 rounded flex items-center justify-center overflow-hidden">
{img ? (
<img src={asset.url} className="w-full h-full object-cover" alt="" />
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/></svg>
)}
</div>
<span className="truncate">{asset.name}</span>
</li>
);
})}
</ul>
{/* Autocomplete dropdown */}
{showAutocomplete && autocompleteAssets.length > 0 && (
<div
className="absolute z-50 bg-mantle border border-surface1 rounded-lg shadow-2xl max-h-64 overflow-y-auto w-80"
style={{ top: autocompletePos.top, left: autocompletePos.left }}
onClick={e => e.stopPropagation()}
>
<div className="p-2 text-[10px] text-subtext0 uppercase border-b border-white/5 bg-crust/50">Assets Library</div>
<ul className="py-1">
{autocompleteAssets.map(asset => {
const img = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name);
return (
<li
key={asset.name}
onClick={() => insertAssetMarkdown(asset)}
className="px-4 py-2 hover:bg-mauve/20 cursor-pointer text-sm truncate text-subtext1 hover:text-mauve flex items-center gap-3 transition-colors"
>
<div className="w-6 h-6 flex-shrink-0 bg-surface0 rounded flex items-center justify-center overflow-hidden">
{img ? (
<img src={asset.url} className="w-full h-full object-cover" alt="" />
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/></svg>
)}
</div>
<span className="truncate">{asset.name}</span>
</li>
);
})}
</ul>
</div>
)}
</div>
{/* Live Preview */}
{showPreview && (
<div className="border border-surface1 rounded-xl bg-crust/50 overflow-y-auto max-h-[600px] 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">
Preview
</div>
<div ref={previewRef} className="prose max-w-none p-4 md:p-6" />
</div>
)}
</div>