// 🤖 The rich composer backend (#531): a ProseMirror EditorView behind the // same facade contract as composer.js's plain textarea. chat.js cannot tell // the two apart — the handle speaks plain-string+caret for the typeahead, // markdown for the wire (composer-md.js owns both mappings). This module is // loaded via a primed dynamic import (never in embed mode), so the native // shells' webviews and the classic-composer escape hatch never pay for it. // // Behaviour rules carried over from the design: // - Desktop Enter sends EVERYWHERE except inside a code fence (the one // context that explicitly means "multi-line content"); Shift+Enter — and // touch Enter — are structural: newline in a fence, next item in a list, // hard_break in a paragraph. The keyboard NEVER splits paragraphs (wire // bytes: hard_break is "\n"; "\n\n" only ever comes from edit-loads). // - "Format as you type" (input rules) is a default-ON setting; OFF still // leaves ⌘B/⌘I working and literal markdown rendering in the feed. The // toggle reconfigures plugins live — history and doc survive. // - Backspace runs undoInputRule first: typing *x* then Backspace restores // the literal text instead of leaving surprise styling. import { EditorState, EditorView, TextSelection, history, undo, redo, keymap, baseKeymap, inputRules, InputRule, undoInputRule, chainCommands, toggleMark, newlineInCode, splitListItem, } from "./vendor/prosemirror.js"; import { composerSchema as schema, parseToDoc, serializeDoc, docText, textOffsetFromPos, posFromTextOffset, } from "./composer-md.js"; // Paste guards (the reverted-native-attempt lesson): a giant paste must never // wedge the input. Past 100KB we skip schema/HTML coercion and insert plain // text in one transaction; past 1MB we refuse and point at attachments. const PASTE_PLAIN_BYTES = 100_000; const PASTE_MAX_BYTES = 1_000_000; // --- input rules ("format as you type") --------------------------------------- // ProseMirror has no built-in mark input rule — the standard shape: fire on // the closing marker, swap the matched text for a marked run, and clear the // stored mark so the next keystroke types unstyled (Slack behaviour). function markInputRule(regexp, markType) { return new InputRule(regexp, (state, match, start, end) => { const content = match[1]; if (!content) return null; const $start = state.doc.resolve(start); if ($start.parent.type.spec.code) return null; // never inside a fence if (state.doc.rangeHasMark(start, end, schema.marks.code)) return null; return state.tr .replaceWith(start, end, schema.text(content, [markType.create()])) .removeStoredMark(markType); }); } function buildInputRules() { // Inline marks ONLY — block conversion (lists/quotes/fences as you type) // was deliberately removed after the native attempt showed the input-side // jank isn't worth it: the composers stick to the basics, and block // markdown stays a render-side ability (bots, history, hand-typed). return inputRules({ rules: [ // strong before em, so the ** closer never half-fires the * rule markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong), markInputRule(/(? { if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()); return true; }; // Structural Enter: newline in a fence, next item in a list (an empty item // lifts out), hard_break everywhere else — splitBlock is unreachable, so // the keyboard can never mint a "\n\n". const structuralEnter = chainCommands(newlineInCode, splitListItem(schema.nodes.list_item), insertHardBreak); const chatKeymap = keymap({ Enter: structuralEnter, // desktop send was already taken upstream (handleKeyDown) "Shift-Enter": structuralEnter, "Mod-b": toggleMark(schema.marks.strong), "Mod-i": toggleMark(schema.marks.em), "Mod-e": toggleMark(schema.marks.code), "Mod-Shift-x": toggleMark(schema.marks.strike), "Mod-z": undo, "Mod-Shift-z": redo, "Mod-y": redo, Backspace: chainCommands(undoInputRule, baseKeymap["Backspace"]), }); let rulesOn = opts.formatAsYouType !== false; const rulesPlugin = buildInputRules(); // Plugin instances are created ONCE and shared by both configurations, so // the format-as-you-type toggle's reconfigure preserves history state. const basePlugins = [history(), chatKeymap, keymap(baseKeymap)]; const pluginsFor = (on) => (on ? [rulesPlugin, ...basePlugins] : basePlugins); const freshState = (doc) => EditorState.create({ schema, doc: doc || undefined, plugins: pluginsFor(rulesOn), selection: doc ? TextSelection.atEnd(doc) : undefined, }); const view = new EditorView(null, { state: freshState(), dispatchTransaction(tr) { view.updateState(view.state.apply(tr)); refreshEmpty(); if (tr.docChanged && opts.onInput) opts.onInput(); }, // DIRECT view props run before every plugin: chat.js's keydown layer // (typeahead nav, shouldSend → submit, Escape layering, ↑/↓ edit) sees // keys first; anything it preventDefaulted is consumed, everything else // falls through to the keymaps above. handleKeyDown: (view, e) => { if (opts.onKeydown) opts.onKeydown(e); return e.defaultPrevented; }, handlePaste: (view, e) => { const files = [...((e.clipboardData && e.clipboardData.files) || [])]; if (files.length) { if (opts.onPasteFiles) opts.onPasteFiles(files); return true; } const text = e.clipboardData && e.clipboardData.getData("text/plain"); if (text && text.length > PASTE_MAX_BYTES) { if (opts.onOversizePaste) opts.onOversizePaste(); return true; } if (text && text.length > PASTE_PLAIN_BYTES) { // One plain-text transaction — no HTML parse, no schema coercion. view.dispatch(view.state.tr.insertText(text).scrollIntoView()); return true; } return false; // ProseMirror's paste (rich HTML degrades through the schema) }, handleDrop: (view, e) => { const files = [...((e.dataTransfer && e.dataTransfer.files) || [])]; if (files.length) { if (opts.onDropFiles) opts.onDropFiles(files); return true; } return false; }, attributes: { class: "composer-input", role: "textbox", "aria-multiline": "true", }, }); // Caret moves (click / arrows) can enter or leave a typeahead token — // ProseMirror leaves these DOM events alone, so plain listeners match the // textarea backend's wiring exactly. if (opts.onKeyup) view.dom.addEventListener("keyup", opts.onKeyup); if (opts.onClick) view.dom.addEventListener("click", opts.onClick); view.dom.addEventListener("focus", () => opts.onFocus && opts.onFocus()); view.dom.addEventListener("blur", () => opts.onBlur && opts.onBlur()); const isEmptyDoc = () => docText(view.state.doc).trim() === ""; // The placeholder shows only over a bare empty paragraph — an empty list // item or fence is already visible structure, and the two would overlap. const showsPlaceholder = () => { const doc = view.state.doc; return ( isEmptyDoc() && doc.childCount === 1 && doc.firstChild.type.name === "paragraph" && doc.firstChild.childCount === 0 ); }; const refreshEmpty = () => view.dom.classList.toggle("pm-empty", showsPlaceholder()); if (opts.placeholder) view.dom.setAttribute("data-placeholder", opts.placeholder); refreshEmpty(); // Silent state swaps (no dispatchTransaction → no onInput) for programmatic // writes; { notify: true } fires the same change pipeline typing does. const swapDoc = (doc, o = {}) => { view.updateState(freshState(doc)); refreshEmpty(); if (o.notify && opts.onInput) opts.onInput(); }; // Plain text in (native pushback etc.): literal paragraph + hard_breaks — // NOT markdown-parsed, so predictable for text that came from getText(). const plainDoc = (text) => { const parts = String(text || "").split("\n"); const children = []; parts.forEach((line, i) => { if (i > 0) children.push(schema.nodes.hard_break.create()); if (line) children.push(schema.text(line)); }); return schema.node("doc", null, [schema.node("paragraph", null, children)]); }; return { kind: "rich", el: view.dom, view, // exposed for the live settings toggle (composer.js registry) focus() { view.focus(); }, hasFocus() { return view.hasFocus(); }, isEmpty() { return isEmptyDoc(); }, getText() { return docText(view.state.doc); }, getCaret() { return textOffsetFromPos(view.state.doc, view.state.selection.from); }, getSelectionEnd() { return textOffsetFromPos(view.state.doc, view.state.selection.to); }, setText(v, o = {}) { swapDoc(plainDoc(v), o); }, replaceRange(start, end, insert) { const doc = view.state.doc; const from = posFromTextOffset(doc, start); const to = posFromTextOffset(doc, end); let tr = view.state.tr.insertText(insert, from, to); tr = tr.setSelection(TextSelection.create(tr.doc, from + insert.length)); view.dispatch(tr.scrollIntoView()); view.focus(); }, insertAtCaret(text) { view.dispatch(view.state.tr.insertText(text).scrollIntoView()); view.focus(); }, getMarkdown() { // Opted out of format-as-you-type = typed markers are literal text and // must RENDER literally: escape them at send, so only WYSIWYG-applied // marks format. Opted in keeps the zero-escape wire (what you type is // what sends). return serializeDoc(view.state.doc, { escapeLiterals: !rulesOn }); }, setMarkdown(v, o = {}) { swapDoc(parseToDoc(v), o); }, clear() { swapDoc(null); // fresh state, fresh history — ⌘Z can't resurrect a sent message }, setPlaceholder(s) { view.dom.setAttribute("data-placeholder", s || ""); }, setAriaLabel(s) { view.dom.setAttribute("aria-label", s || ""); }, setIndent(px) { view.dom.style.paddingLeft = px ? `${px}px` : ""; }, inCodeBlock() { return view.state.selection.$from.parent.type.name === "code_block"; }, setFormatAsYouType(on) { if (on === rulesOn) return; rulesOn = on; // reconfigure keeps state for plugins present on both sides — the doc // and undo history survive the toggle. view.updateState(view.state.reconfigure({ plugins: pluginsFor(on) })); }, destroy() { view.destroy(); }, }; }