| 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. |
| 5 | import { history } from "@codemirror/commands"; |
| 6 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; |
| 7 | import type { Language, LanguageDescription } from "@codemirror/language"; |
| 8 | import { EditorState, type Extension, Prec } from "@codemirror/state"; |
| 9 | import { EditorView, keymap, placeholder as placeholderExt } from "@codemirror/view"; |
| 10 | |
| 11 | import { codeBlockEnter, continueList, insertNewline } from "./commands.ts"; |
| 12 | import { baseKeymap, markdownKeymap } from "./keymap.ts"; |
| 13 | import { livePreview } from "./live-preview.ts"; |
| 14 | import { |
| 15 | createMentionRegistry, |
| 16 | hydrateMentions, |
| 17 | insertMention as insertMentionChip, |
| 18 | type MentionCandidate, |
| 19 | mentionsExtension, |
| 20 | mentionTriggerAt, |
| 21 | serializeMentions, |
| 22 | } from "./mentions.ts"; |
| 23 | import { |
| 24 | type EmojiTrigger, |
| 25 | emojiTriggerAt, |
| 26 | type MentionTrigger, |
| 27 | type SlashTrigger, |
| 28 | slashTriggerAt, |
| 29 | } from "./triggers.ts"; |
| 30 | |
| 31 | export 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 | |
| 48 | const LARGE_PASTE_THRESHOLD = 10_000; |
| 49 | |
| 50 | /** The image files carried by a paste or drop, if any. */ |
| 51 | function imageFiles(data: DataTransfer | null): File[] { |
| 52 | if (!data) return []; |
| 53 | return [...data.files].filter((file) => file.type.startsWith("image/")); |
| 54 | } |
| 55 | |
| 56 | const 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 | |
| 71 | const 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 | |
| 85 | export 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 | */ |
| 130 | export 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 | } |