1// The composer-preset factory: a CodeMirror 6 markdown editor with live preview, mention chips,
2// and a submit-on-Enter keymap, handed back as a plain-TS handle the host mounts wherever it
3// likes. Consumer-specific behavior (extra decorations, paste chips, app keymaps) arrives through
4// `extensions` — the package never learns about it.
5import { history } from "@codemirror/commands";
6import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
7import type { Language, LanguageDescription } from "@codemirror/language";
8import { EditorState, type Extension, Prec } from "@codemirror/state";
9import { EditorView, keymap, placeholder as placeholderExt } from "@codemirror/view";
10
11import { codeBlockEnter, continueList, insertNewline } from "./commands.ts";
12import { baseKeymap, markdownKeymap } from "./keymap.ts";
13import { livePreview } from "./live-preview.ts";
14import {
15 createMentionRegistry,
16 hydrateMentions,
17 insertMention as insertMentionChip,
18 type MentionCandidate,
19 mentionsExtension,
20 mentionTriggerAt,
21 serializeMentions,
22} from "./mentions.ts";
23import {
24 type EmojiTrigger,
25 emojiTriggerAt,
26 type MentionTrigger,
27 type SlashTrigger,
28 slashTriggerAt,
29} from "./triggers.ts";
30
31export interface MarkdownEditor {
32 /** The CodeMirror root; the caller mounts it where the input used to sit. */
33 dom: HTMLElement;
34 /** The underlying view, for consumer extensions that dispatch their own effects. */
35 view: EditorView;
36 /** The serialized text: mention chips expanded to their `@token`. */
37 getValue(): string;
38 setValue(value: string): void;
39 clear(): void;
40 focus(): void;
41 insertText(text: string): void;
42 replaceText(text: string, range: { from: number; to: number }): void;
43 /** Insert an atomic mention chip, replacing `range` (the `@query` being completed) when given. */
44 insertMention(mention: MentionCandidate, range?: { from: number; to: number }): void;
45 destroy(): void;
46}
47
48const LARGE_PASTE_THRESHOLD = 10_000;
49
50/** The image files carried by a paste or drop, if any. */
51function imageFiles(data: DataTransfer | null): File[] {
52 if (!data) return [];
53 return [...data.files].filter((file) => file.type.startsWith("image/"));
54}
55
56const composerTheme = EditorView.theme({
57 "&": { fontSize: "14px", color: "var(--markdown-text, var(--text))" },
58 "&.cm-focused": { outline: "none" },
59 ".cm-content": { padding: "9px 12px", caretColor: "var(--markdown-text, var(--text))" },
60 ".cm-line": { padding: "0" },
61 ".cm-scroller": {
62 fontFamily: "inherit",
63 lineHeight: "1.55",
64 maxHeight: "var(--composer-max, 200px)",
65 overflowY: "auto",
66 scrollbarWidth: "thin",
67 },
68 ".cm-placeholder": { color: "var(--markdown-muted, var(--muted))" },
69});
70
71const documentTheme = EditorView.theme({
72 "&": { fontSize: "14px", color: "var(--markdown-text, var(--text))" },
73 "&.cm-focused": { outline: "none" },
74 ".cm-content": { padding: "12px 14px", caretColor: "var(--markdown-text, var(--text))" },
75 ".cm-line": { padding: "0" },
76 ".cm-scroller": {
77 fontFamily: "inherit",
78 lineHeight: "1.6",
79 overflowY: "auto",
80 scrollbarWidth: "thin",
81 },
82 ".cm-placeholder": { color: "var(--markdown-muted, var(--muted))" },
83});
84
85export interface MarkdownEditorOptions {
86 /**
87 * `composer` (default): chat-input behavior — Enter submits, block sources stay text-shaped.
88 * `document`: full-document behavior — Enter is a plain newline (fence-aware), and tables,
89 * images, and horizontal rules render as widgets while the caret is elsewhere.
90 */
91 preset?: "composer" | "document";
92 placeholder?: string;
93 /** Use host-owned chips instead of the built-in `@path` registry. */
94 mentions?: boolean;
95 /** Use a host-owned live-preview compartment when false. */
96 preview?: boolean;
97 /** Initial text; `@path` tokens hydrate to mention chips. */
98 doc?: string;
99 onChange?: (value: string) => void;
100 onSubmit?: () => void;
101 onMention?: (trigger: MentionTrigger | null) => void;
102 onSlash?: (trigger: SlashTrigger | null) => void;
103 onEmoji?: (trigger: EmojiTrigger | null) => void;
104 onArrow?: (direction: -1 | 1) => boolean;
105 onAccept?: () => boolean;
106 onEscape?: () => boolean;
107 /** Image files pasted or dropped onto the editor; returning true claims the event. */
108 onFiles?: (files: File[]) => boolean;
109 /** Maps relative image URLs to something fetchable (host seam); identity when omitted. */
110 resolveUrl?: (url: string) => string;
111 /** Consumer-owned fenced-code parsers; the package bundles no language data. */
112 codeLanguages?:
113 | readonly LanguageDescription[]
114 | ((info: string) => Language | LanguageDescription | null);
115 /** Consumer extensions: extra decorations, chips, keymaps. Appended after the built-ins. */
116 extensions?: Extension[];
117}
118
119/**
120 * A composer editor. Enter submits (a newline inside a code block instead); Shift-Enter always
121 * inserts a newline; Mod-b/Mod-i toggle bold/italic; a bare `` ` ``/`*` surrounds a selection.
122 * `onChange` fires on every edit so the caller can sync dependent state; `onSubmit` fires on a
123 * submitting Enter and reads the current value off the returned editor.
124 *
125 * The `onMention`/`onSlash`/`onEmoji`/`onArrow`/`onAccept`/`onEscape` hooks let a picker claim keys
126 * first: as the caret's trigger run changes the trigger hooks fire (with null when it closes);
127 * while the picker is open the arrows move its selection, Enter accepts instead of submitting, and
128 * Escape closes it — each returns true when it consumed the key.
129 */
130export function createMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
131 const registry = createMentionRegistry();
132 const documentPreset = opts.preset === "document";
133 const hydrate = (value: string) =>
134 opts.mentions === false ? value : hydrateMentions(registry, value);
135 const serialize = (value: string) =>
136 opts.mentions === false ? value : serializeMentions(registry, value);
137
138 const submitKeymap = Prec.highest(
139 keymap.of([
140 {
141 key: "Enter",
142 run: (view) => {
143 // Structural editing claims Enter before the composer submit action. An empty list or
144 // quote item terminates its structure; the following Enter can then submit normally.
145 if (opts.onAccept?.()) return true;
146 if (codeBlockEnter(view)) return true;
147 if (continueList(view)) return true;
148 if (!opts.onSubmit) return insertNewline(view);
149 opts.onSubmit();
150 return true;
151 },
152 },
153 { key: "ArrowDown", run: () => opts.onArrow?.(1) ?? false },
154 { key: "ArrowUp", run: () => opts.onArrow?.(-1) ?? false },
155 { key: "Escape", run: () => opts.onEscape?.() ?? false },
156 // The composer's newline key continues lists too, so multi-line drafts behave like a
157 // document even though plain Enter submits.
158 {
159 key: "Shift-Enter",
160 run: (view) => codeBlockEnter(view) || continueList(view) || insertNewline(view),
161 },
162 ]),
163 );
164
165 const extensions: Extension[] = [
166 history(),
167 baseKeymap(),
168 markdownKeymap(),
169 markdown({
170 base: markdownLanguage,
171 completeHTMLTags: false,
172 ...(opts.codeLanguages ? { codeLanguages: opts.codeLanguages } : {}),
173 extensions: [
174 {
175 parseBlock: [
176 {
177 name: "ExplicitBlockquotes",
178 before: "LinkReference",
179 leaf(context) {
180 let quoteDepth = 0;
181 for (let depth = 0; depth < context.depth; depth++) {
182 if (context.parentType(depth).name === "Blockquote") quoteDepth++;
183 }
184 if (quoteDepth === 0) return null;
185
186 return {
187 nextLine(context, line, block) {
188 const marks = line.markers.filter(
189 (mark) => context.parser.nodeSet.types[mark.type]?.name === "QuoteMark",
190 );
191 if (marks.length >= quoteDepth) return false;
192
193 // A missing > ends the quote, including CommonMark's lazy continuation lines.
194 if (!block.parsers.some((parser) => parser.finish(context, block))) {
195 context.addLeafElement(
196 block,
197 context.elt(
198 "Paragraph",
199 block.start,
200 block.start + block.content.length,
201 context.parser.parseInline(block.content, block.start),
202 ),
203 );
204 }
205 return true;
206 },
207 finish: () => false,
208 };
209 },
210 },
211 ],
212 },
213 // The composer stays HTML-free; the document preset previews sanitized readme HTML.
214 documentPreset
215 ? {}
216 : {
217 remove: ["HTMLBlock", "HTMLTag"],
218 parseBlock: [
219 {
220 name: "ComposerSetextHeading",
221 after: "SetextHeading",
222 leaf(_, leaf) {
223 // Preserve Lezer's Setext parser identity for precedence over horizontal rules.
224 const setext = leaf.parsers.at(-1)!;
225 const nextLine = setext.nextLine.bind(setext);
226 setext.nextLine = (context, line, block) => {
227 if (/^(?:-{1,2}|={1,2})[ \t]*$/.test(line.text.slice(line.pos))) {
228 return false;
229 }
230
231 return nextLine(context, line, block);
232 };
233 return null;
234 },
235 },
236 ],
237 },
238 ],
239 }),
240 EditorView.lineWrapping,
241 ...(opts.preview === false
242 ? []
243 : [
244 livePreview({
245 blockWidgets: documentPreset,
246 html: documentPreset,
247 ...(opts.resolveUrl ? { resolveUrl: opts.resolveUrl } : {}),
248 }),
249 ]),
250 ...(opts.mentions === false ? [] : [mentionsExtension(registry)]),
251 ...(opts.extensions ?? []),
252 EditorView.domEventHandlers({
253 paste: (event) => {
254 const files = imageFiles(event.clipboardData);
255 if (files.length !== 0) {
256 const handled = opts.onFiles?.(files) ?? false;
257 if (handled) event.preventDefault();
258 return handled;
259 }
260 const text = event.clipboardData?.getData("text/plain");
261 if (!text || text.length < LARGE_PASTE_THRESHOLD) return false;
262 event.preventDefault();
263 setTimeout(() => {
264 view.dispatch({
265 ...view.state.replaceSelection(hydrate(text)),
266 scrollIntoView: true,
267 userEvent: "input",
268 });
269 }, 0);
270 return true;
271 },
272 drop: (event) => {
273 const files = imageFiles(event.dataTransfer);
274 if (files.length === 0) return false;
275 const handled = opts.onFiles?.(files) ?? false;
276 if (handled) event.preventDefault();
277 return handled;
278 },
279 }),
280 submitKeymap,
281 ...(opts.placeholder === undefined ? [] : [placeholderExt(opts.placeholder)]),
282 documentPreset ? documentTheme : composerTheme,
283 EditorView.updateListener.of((update) => {
284 if (update.docChanged) opts.onChange?.(serialize(update.state.doc.toString()));
285 // A caret move (no doc edit) also opens/closes the picker, so track the trigger on either.
286 if (update.docChanged || update.selectionSet) {
287 opts.onMention?.(mentionTriggerAt(update.state));
288 opts.onSlash?.(slashTriggerAt(update.state));
289 opts.onEmoji?.(emojiTriggerAt(update.state));
290 }
291 }),
292 ];
293
294 const view = new EditorView({
295 state: EditorState.create({
296 doc: opts.doc === undefined ? "" : hydrate(opts.doc),
297 extensions,
298 }),
299 });
300
301 return {
302 dom: view.dom,
303 view,
304 getValue: () => serialize(view.state.doc.toString()),
305 setValue(value) {
306 view.dispatch({
307 changes: { from: 0, to: view.state.doc.length, insert: hydrate(value) },
308 });
309 },
310 clear() {
311 view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "" } });
312 },
313 focus: () => view.focus(),
314 insertText(text) {
315 const range = view.state.selection.main;
316 const needsLead =
317 range.from > 0 && !/\s/.test(view.state.sliceDoc(range.from - 1, range.from));
318 const insert = `${needsLead ? " " : ""}${text}`;
319 view.dispatch({
320 changes: { from: range.from, to: range.to, insert },
321 selection: { anchor: range.from + insert.length },
322 scrollIntoView: true,
323 userEvent: "input",
324 });
325 view.focus();
326 },
327 replaceText(text, range) {
328 view.dispatch({
329 changes: { from: range.from, to: range.to, insert: text },
330 selection: { anchor: range.from + text.length },
331 scrollIntoView: true,
332 userEvent: "input",
333 });
334 view.focus();
335 },
336 insertMention: (mention, range) => insertMentionChip(view, registry, mention, range),
337 destroy: () => view.destroy(),
338 };
339}