import { useEffect, useRef } from 'react'; import { Crepe } from '@milkdown/crepe'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/frame-dark.css'; import './markdown-editor.css'; import { cn } from '@/lib/utils'; interface MarkdownEditorProps { /** Initial markdown. The editor owns its content after mount; edits flow out * through `onChange`, so this is only read when the editor is (re)created. */ value: string; onChange?: (markdown: string) => void; placeholder?: string; /** Render the same engine read-only, for displaying a message. */ readOnly?: boolean; autoFocus?: boolean; className?: string; } /** * Single markdown engine used for both composing and displaying messages * (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full * GFM — headings, lists, task lists, tables, code blocks, quotes, links — and * read-only mode renders the exact same way, so write and read never drift. */ export function MarkdownEditor({ value, onChange, placeholder, readOnly = false, autoFocus = true, className, }: MarkdownEditorProps) { const rootRef = useRef(null); // Keep the latest onChange without forcing the editor to be recreated. const onChangeRef = useRef(onChange); onChangeRef.current = onChange; useEffect(() => { const root = rootRef.current; if (!root) return; let cancelled = false; let created: Crepe | null = null; const crepe = new Crepe({ root, defaultValue: value, features: { [Crepe.Feature.CodeMirror]: true, [Crepe.Feature.ListItem]: true, [Crepe.Feature.Table]: true, [Crepe.Feature.LinkTooltip]: !readOnly, [Crepe.Feature.Cursor]: !readOnly, [Crepe.Feature.BlockEdit]: !readOnly, [Crepe.Feature.Toolbar]: !readOnly, [Crepe.Feature.Placeholder]: !readOnly, [Crepe.Feature.ImageBlock]: false, [Crepe.Feature.Latex]: false, [Crepe.Feature.TopBar]: false, [Crepe.Feature.AI]: false, }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, }, }); crepe.setReadonly(readOnly); if (!readOnly) { crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown) => { onChangeRef.current?.(markdown); }); }); } crepe.create().then(() => { if (cancelled) { crepe.destroy(); return; } created = crepe; if (autoFocus && !readOnly) { root.querySelector('.ProseMirror')?.focus(); } }); return () => { cancelled = true; created?.destroy(); }; // `value` is the initial content only; recreate when the mode flips. // eslint-disable-next-line react-hooks/exhaustive-deps }, [readOnly]); return (
); }