| 1 | // The one React file: thin bindings over the headless package. `CodeMirror` mounts any extension |
| 2 | // set; `MarkdownEditorView` mounts the composer factory and exposes its handle through `ref`. |
| 3 | // Everything stateful lives in CodeMirror/the factory — these components only manage mount, |
| 4 | // unmount, and prop sync. |
| 5 | import { EditorState, Compartment, type Extension } from "@codemirror/state"; |
| 6 | import { EditorView } from "@codemirror/view"; |
| 7 | import { type Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react"; |
| 8 | |
| 9 | import { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts"; |
| 10 | |
| 11 | export interface CodeMirrorProps { |
| 12 | /** Controlled value: when it differs from the doc, the doc is replaced. Omit for uncontrolled. */ |
| 13 | value?: string; |
| 14 | /** Initial doc for uncontrolled use. */ |
| 15 | defaultValue?: string; |
| 16 | onChange?: (value: string) => void; |
| 17 | /** Reconfigured in place (via a compartment) when the array identity changes. */ |
| 18 | extensions?: Extension[]; |
| 19 | className?: string; |
| 20 | autoFocus?: boolean; |
| 21 | /** The underlying view, for imperative access. */ |
| 22 | ref?: Ref<EditorView>; |
| 23 | } |
| 24 | |
| 25 | const EMPTY_EXTENSIONS: Extension[] = []; |
| 26 | |
| 27 | /** A generic CodeMirror mount: one view per component instance, extensions hot-swappable. */ |
| 28 | export function CodeMirror({ |
| 29 | value, |
| 30 | defaultValue, |
| 31 | onChange, |
| 32 | extensions = EMPTY_EXTENSIONS, |
| 33 | className, |
| 34 | autoFocus, |
| 35 | ref, |
| 36 | }: CodeMirrorProps) { |
| 37 | const host = useRef<HTMLDivElement>(null); |
| 38 | const viewRef = useRef<EditorView>(null); |
| 39 | const compartment = useRef(new Compartment()); |
| 40 | const onChangeRef = useRef(onChange); |
| 41 | useLayoutEffect(() => { |
| 42 | onChangeRef.current = onChange; |
| 43 | }); |
| 44 | |
| 45 | // oxlint-disable react/exhaustive-effect-dependencies -- The document is seeded once; controlled updates preserve the mounted view. |
| 46 | useLayoutEffect(() => { |
| 47 | const view = new EditorView({ |
| 48 | state: EditorState.create({ |
| 49 | doc: value ?? defaultValue ?? "", |
| 50 | extensions: [ |
| 51 | compartment.current.of(extensions), |
| 52 | EditorView.updateListener.of((update) => { |
| 53 | if (update.docChanged) onChangeRef.current?.(update.state.doc.toString()); |
| 54 | }), |
| 55 | ], |
| 56 | }), |
| 57 | parent: host.current!, |
| 58 | }); |
| 59 | viewRef.current = view; |
| 60 | if (autoFocus) view.focus(); |
| 61 | return () => { |
| 62 | viewRef.current = null; |
| 63 | view.destroy(); |
| 64 | }; |
| 65 | }, []); |
| 66 | // oxlint-enable react/exhaustive-effect-dependencies |
| 67 | |
| 68 | useEffect(() => { |
| 69 | viewRef.current?.dispatch({ effects: compartment.current.reconfigure(extensions) }); |
| 70 | }, [extensions]); |
| 71 | |
| 72 | useEffect(() => { |
| 73 | const view = viewRef.current; |
| 74 | if (!view || value === undefined) return; |
| 75 | const current = view.state.doc.toString(); |
| 76 | if (value !== current) |
| 77 | view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); |
| 78 | }, [value]); |
| 79 | |
| 80 | useImperativeHandle(ref, () => viewRef.current!, []); |
| 81 | |
| 82 | return <div className={className} ref={host} />; |
| 83 | } |
| 84 | |
| 85 | export interface MarkdownEditorViewProps extends MarkdownEditorOptions { |
| 86 | className?: string; |
| 87 | autoFocus?: boolean; |
| 88 | /** The editor handle (getValue/setValue/insertMention/…), for imperative access. */ |
| 89 | ref?: Ref<MarkdownEditor>; |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * The composer preset as a component. Options are read once at mount (callbacks stay live through |
| 94 | * refs); the editor is uncontrolled — read and write text through the handle. |
| 95 | */ |
| 96 | export function MarkdownEditorView({ |
| 97 | className, |
| 98 | autoFocus, |
| 99 | ref, |
| 100 | ...options |
| 101 | }: MarkdownEditorViewProps) { |
| 102 | const host = useRef<HTMLDivElement>(null); |
| 103 | const editorRef = useRef<MarkdownEditor>(null); |
| 104 | const optionsRef = useRef(options); |
| 105 | useLayoutEffect(() => { |
| 106 | optionsRef.current = options; |
| 107 | }); |
| 108 | |
| 109 | // oxlint-disable react/exhaustive-effect-dependencies -- Factory options seed one editor; live callbacks read the ref. |
| 110 | useLayoutEffect(() => { |
| 111 | const current = optionsRef.current; |
| 112 | const editor = createMarkdownEditor({ |
| 113 | ...current, |
| 114 | onChange: (value) => optionsRef.current.onChange?.(value), |
| 115 | onMention: (trigger) => optionsRef.current.onMention?.(trigger), |
| 116 | onSlash: (trigger) => optionsRef.current.onSlash?.(trigger), |
| 117 | onEmoji: (trigger) => optionsRef.current.onEmoji?.(trigger), |
| 118 | ...(current.onSubmit ? { onSubmit: () => optionsRef.current.onSubmit?.() } : {}), |
| 119 | ...(current.onArrow |
| 120 | ? { onArrow: (direction) => optionsRef.current.onArrow?.(direction) ?? false } |
| 121 | : {}), |
| 122 | ...(current.onAccept ? { onAccept: () => optionsRef.current.onAccept?.() ?? false } : {}), |
| 123 | ...(current.onEscape ? { onEscape: () => optionsRef.current.onEscape?.() ?? false } : {}), |
| 124 | ...(current.onFiles |
| 125 | ? { onFiles: (files) => optionsRef.current.onFiles?.(files) ?? false } |
| 126 | : {}), |
| 127 | }); |
| 128 | editorRef.current = editor; |
| 129 | host.current!.append(editor.dom); |
| 130 | if (autoFocus) editor.focus(); |
| 131 | return () => { |
| 132 | editorRef.current = null; |
| 133 | editor.destroy(); |
| 134 | }; |
| 135 | }, []); |
| 136 | // oxlint-enable react/exhaustive-effect-dependencies |
| 137 | |
| 138 | useImperativeHandle(ref, () => editorRef.current!, []); |
| 139 | |
| 140 | return <div className={className} ref={host} />; |
| 141 | } |