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.
5import { EditorState, Compartment, type Extension } from "@codemirror/state";
6import { EditorView } from "@codemirror/view";
7import { type Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react";
8
9import { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts";
10
11export 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
25const EMPTY_EXTENSIONS: Extension[] = [];
26
27/** A generic CodeMirror mount: one view per component instance, extensions hot-swappable. */
28export 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
85export 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 */
96export 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}