import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; 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'; import { search, searchKeymap } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; const AssetManager = lazy(() => import('./AssetManager')); interface Props { editSlug?: string; } const salonTheme = EditorView.theme({ '&': { backgroundColor: 'var(--base)', color: 'var(--text)', border: '1px solid var(--surface2)', borderRadius: '2px', fontSize: '14px', boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--surface1) 40%, transparent)', }, '.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', padding: '1rem', caretColor: 'var(--mauve)', color: 'var(--text)', }, '.cm-cursor': { borderLeftColor: 'var(--mauve)', borderLeftWidth: '2px' }, '.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--mauve) 25%, transparent) !important' }, '&.cm-focused .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--mauve) 30%, transparent) !important' }, '.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--surface0) 55%, transparent)' }, '.cm-gutters': { backgroundColor: 'var(--surface0)', color: 'var(--subtext0)', border: 'none', borderRight: '1px solid var(--surface2)', fontFamily: 'var(--font-display)', fontStyle: 'italic', }, '.cm-activeLineGutter': { backgroundColor: 'color-mix(in srgb, var(--mauve) 12%, transparent)', color: 'var(--mauve)' }, '.cm-panels': { backgroundColor: 'var(--surface0)', color: 'var(--text)', borderTop: '1px solid var(--surface2)', }, '.cm-searchMatch': { backgroundColor: 'color-mix(in srgb, var(--yellow) 45%, transparent)' }, '.cm-searchMatch-selected': { backgroundColor: 'color-mix(in srgb, var(--peach) 55%, transparent)' }, '.cm-fat-cursor': { backgroundColor: 'var(--mauve) !important', color: 'var(--rosewater) !important', }, '&:not(.cm-focused) .cm-fat-cursor': { outline: '1px solid var(--mauve)', backgroundColor: 'transparent !important', }, }, { dark: false }); // Compartment for hot-swapping vim mode without recreating the editor const vimCompartment = new Compartment(); function clientSlugify(s: string): string { return s .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } export default function Editor({ editSlug }: Props) { const editorRef = useRef(null); const viewRef = useRef(null); const previewRef = useRef(null); const previewTimerRef = useRef | null>(null); const updatePreviewRef = useRef<() => void>(() => {}); const uploadFnRef = useRef<(files: File[], insertAt?: number) => void>(() => {}); const renderMarkdownRef = useRef<((src: string) => string) | null>(null); const today = new Date().toISOString().slice(0, 10); const [title, setTitle] = useState(''); const [slug, setSlug] = useState(editSlug || ''); const [slugTouched, setSlugTouched] = useState(!!editSlug); const [date, setDate] = useState(today); const [summary, setSummary] = useState(''); const [tagsInput, setTagsInput] = useState(''); const [draft, setDraft] = useState(false); 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 [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); const [vimEnabled, setVimEnabled] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteAssets, setAutocompleteAssets] = useState([]); const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 }); const [isDragging, setIsDragging] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); const dragDepthRef = useRef(0); const assetsCacheRef = useRef(null); async function getCachedAssets(): Promise { if (assetsCacheRef.current) return assetsCacheRef.current; const assets = await getAssets(); assetsCacheRef.current = assets; return assets; } function showAlertMsg(msg: string, type: 'success' | 'error') { setAlert({ msg, type }); window.scrollTo({ top: 0, behavior: 'smooth' }); setTimeout(() => setAlert(null), 5000); } const updatePreview = useCallback(async () => { if (!showPreview || !viewRef.current || !previewRef.current) return; if (!renderMarkdownRef.current) { const mod = await import('../../../lib/markdown'); renderMarkdownRef.current = mod.renderMarkdown; } const content = viewRef.current.state.doc.toString(); previewRef.current.innerHTML = renderMarkdownRef.current(content); }, [showPreview]); useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]); useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; }); // Initialize CodeMirror once useEffect(() => { if (!editorRef.current || viewRef.current) return; const state = EditorState.create({ doc: '', extensions: [ vimCompartment.of([]), keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]), search(), closeBrackets(), markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping, salonTheme, cmPlaceholder('# A title for the work\n\n![alt text](/uploads/your-image.jpg "optional caption")\n\nNotes, context, materials...'), EditorView.updateListener.of(update => { if (!update.docChanged) return; if (previewTimerRef.current) clearTimeout(previewTimerRef.current); previewTimerRef.current = setTimeout(() => updatePreviewRef.current(), 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); } }), EditorView.domEventHandlers({ dragenter(event) { if (!event.dataTransfer?.types.includes('Files')) return false; dragDepthRef.current += 1; setIsDragging(true); return false; }, dragover(event) { if (!event.dataTransfer?.types.includes('Files')) return false; event.preventDefault(); return true; }, dragleave() { dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragging(false); return false; }, drop(event, view) { const files = event.dataTransfer?.files; if (!files || files.length === 0) return false; event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head; uploadFnRef.current(Array.from(files), pos); return true; }, paste(event, view) { const items = event.clipboardData?.items; if (!items) return false; const imageFiles: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file' && item.type.startsWith('image/')) { const f = item.getAsFile(); if (f) imageFiles.push(f); } } if (imageFiles.length === 0) return false; event.preventDefault(); uploadFnRef.current(imageFiles, view.state.selection.main.head); return true; }, }), ], }); const view = new EditorView({ state, parent: editorRef.current }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; }, []); // Hot-swap vim mode via compartment reconfiguration; lazy-load vim module useEffect(() => { if (!viewRef.current) return; if (!vimEnabled) { viewRef.current.dispatch({ effects: vimCompartment.reconfigure([]) }); return; } let cancelled = false; import('@replit/codemirror-vim').then(({ vim }) => { if (cancelled || !viewRef.current) return; viewRef.current.dispatch({ effects: vimCompartment.reconfigure(vim()) }); }); return () => { cancelled = true; }; }, [vimEnabled]); // Load existing post for editing useEffect(() => { if (!editSlug) return; getPost(editSlug).then(post => { if (post.title) setTitle(post.title); if (post.summary) setSummary(post.summary); if (post.date) setDate(post.date); if (post.tags?.length) setTagsInput(post.tags.join(', ')); setDraft(!!post.draft); if (post.content && viewRef.current) { viewRef.current.dispatch({ changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content }, }); } }).catch(() => showAlertMsg('Failed to load post.', 'error')); }, [editSlug]); // Auto-derive slug from title until user edits the slug field useEffect(() => { if (slugTouched) return; setSlug(clientSlugify(title)); }, [title, slugTouched]); useEffect(() => { if (showPreview) updatePreview(); }, [showPreview, updatePreview]); async function triggerAutocomplete(view: EditorView) { try { const assets = await getCachedAssets(); setAutocompleteAssets(assets.slice(0, 8)); const pos = view.state.selection.main.head; const coords = view.coordsAtPos(pos); if (coords) { const editorRect = editorRef.current?.getBoundingClientRect(); if (editorRect) { setAutocompletePos({ top: coords.bottom - editorRect.top + 4, left: coords.left - editorRect.left, }); } } setShowAutocomplete(true); } catch { /* ignore */ } } function insertAssetMarkdown(asset: Asset) { const view = viewRef.current; if (!view) return; const isImage = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name); const md = isImage ? `![${asset.name}](${asset.url})` : `[${asset.name}](${asset.url})`; const pos = view.state.selection.main.head; const line = view.state.doc.lineAt(pos); const textBefore = line.text.slice(0, pos - line.from); const triggerIdx = Math.max(textBefore.lastIndexOf('/'), textBefore.lastIndexOf('!')); if (triggerIdx !== -1) { const from = line.from + triggerIdx; view.dispatch({ changes: { from, to: pos, insert: md } }); } else { view.dispatch({ changes: { from: pos, insert: md } }); } view.focus(); setShowAutocomplete(false); } async function uploadFilesAndInsert(files: File[], insertAt?: number) { const view = viewRef.current; if (!view || files.length === 0) return; const images = files.filter(f => f.type.startsWith('image/')); if (images.length === 0) { showAlertMsg('Only image files can be dropped here.', 'error'); return; } setUploadingCount(c => c + images.length); // Fire all uploads in parallel; the browser caps per-origin concurrency. // Insert results in submission order so the markdown reflects user intent. const uploads = images.map(file => uploadAsset(file).then( asset => ({ ok: true as const, asset }), err => ({ ok: false as const, err }), ), ); let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head; const newAssets: Asset[] = []; for (const promise of uploads) { const result = await promise; setUploadingCount(c => Math.max(0, c - 1)); if (result.ok) { const { asset } = result; newAssets.push(asset); const md = `![${asset.name}](${asset.url})`; const line = view.state.doc.lineAt(pos); const atLineEnd = pos === line.to; const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`; view.dispatch({ changes: { from: pos, insert: insertText } }); pos += insertText.length; } else { const e = result.err; showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error'); } } if (newAssets.length > 0) { assetsCacheRef.current = assetsCacheRef.current ? [...newAssets, ...assetsCacheRef.current] : null; } view.focus(); } function handleAssetSelect(asset: Asset) { insertAssetMarkdown(asset); setShowModal(false); } function closeAssetModal() { assetsCacheRef.current = null; setShowModal(false); } async function handleSave() { const content = viewRef.current?.state.doc.toString() || ''; if (!title.trim() || !slug || !content) { showAlertMsg('Title, slug, and body are required.', 'error'); return; } if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) { showAlertMsg('Add at least one image before saving — drag, paste, or use the Add image button.', 'error'); return; } const tags = tagsInput .split(',') .map(t => t.trim()) .filter(Boolean); try { const saved = await savePost({ slug, old_slug: originalSlug || null, title: title.trim(), date, summary: summary || null, tags, draft, content, }); showAlertMsg('Post saved!', 'success'); if (saved?.slug && saved.slug !== slug) { setSlug(saved.slug); setSlugTouched(true); } setOriginalSlug(saved?.slug ?? slug); } catch (e) { showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error'); } } async function handleDelete() { const target = originalSlug || slug; if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) return; try { await deletePost(target); window.location.href = '/admin'; } catch { showAlertMsg('Error deleting post.', 'error'); } } useEffect(() => { if (!showAutocomplete) return; const handler = () => setShowAutocomplete(false); window.addEventListener('click', handler); return () => window.removeEventListener('click', handler); }, [showAutocomplete]); return ( <> {alert && (
{alert.msg}
)} {/* Actions bar */}
{originalSlug && ( )} {originalSlug && ( View work )}
{/* Title + Date */}
setTitle(e.target.value)} required placeholder="Untitled (charcoal on paper)" className="field-input" />
setDate(e.target.value)} className="field-input font-mono" />
{/* Slug */}
{ setSlug(e.target.value); setSlugTouched(true); }} required placeholder="untitled-charcoal-on-paper" className="field-input font-mono" />
{/* Tags + Draft */}
setTagsInput(e.target.value)} placeholder="oil, paper, 2026, study" className="field-input" />
{/* Summary */}