web: the WYSIWYG composer — ProseMirror backend, format-as-you-type (#531) (#888)
The rich backend behind the PR #885 facade: composer-pm.js mounts a ProseMirror EditorView wearing the textarea's exact skin (auto-grow is pure CSS — no JS measuring to go stale). Format-as-you-type input rules (**bold**, *i*, `code`, ~s~, lists, quotes, fences) with Backspace-undo; ⌘B/⌘I/⌘E/⌘⇧X/⌘⇧7/8/9 shortcuts; desktop Enter sends everywhere except inside a code fence; Shift+Enter (and touch Enter) stay structural — the keyboard never splits paragraphs, so wire bytes never drift. Huge pastes skip coercion past 100KB and refuse past 1MB. chat.js: primed dynamic import (never in native webviews; plain fallback if it fails), commands read plaintext while the wire carries serialized markdown, edit loads parse markdown and ↓-undo compares a captured baseline. Theme sheet gains 'Format as you type' (default on, live reconfigure) and 'Classic plain-text composer' (the escape hatch).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+457
−20
changelog/2026-07-14-rich-composer.md+7 −0web/static/apps/chat.js+45 −15web/static/composer-pm.js+271 −0web/static/composer.js+38 −1web/static/shell.js+33 −1web/static/style.css+63 −3
diff --git a/changelog/2026-07-14-rich-composer.md b/changelog/2026-07-14-rich-composer.md| new file mode 100644 |
| index 0000000..32c1c19 |
| --- /dev/null |
| +++ b/changelog/2026-07-14-rich-composer.md |
| @@ -0,0 +1,7 @@ |
| +🤖 The message box now formats as you type. Type `**bold**` and it turns bold |
| +the moment you close the stars — same for *italics*, `code`, ~strikethrough~, |
| +"- " lists, "> " quotes and ``` code blocks — or use the shortcuts you already |
| +know: ⌘B, ⌘I, and friends. Backspace right after undoes the formatting, and |
| +what you send is exactly what everyone sees, on every device. Prefer typing |
| +the markers and seeing them stay put? Turn off "Format as you type" in the |
| +Theme sheet — or switch back to the classic plain-text box entirely. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 562c9ac..06e2b0f 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -25,7 +25,18 @@ |
| import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js"; |
| import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { parseMessage, renderTree } from "../markdown.js"; |
| -import { createComposer } from "../composer.js"; |
| +import { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js"; |
| + |
| +// The rich (ProseMirror) composer backend loads via a primed dynamic import — |
| +// fetching starts at module load, mounts long after — and never in the native |
| +// shells' webviews (they draw their own composer; the hidden one stays the |
| +// cheap textarea). If the import fails, the factory quietly falls back to the |
| +// plain backend: chat always works. |
| +let pmModule = null; |
| +// (typeof guard: the event-vector tests import this module under Node.) |
| +if (typeof document !== "undefined" && !document.documentElement.classList.contains("native")) { |
| + import("../composer-pm.js").then((m) => (pmModule = m)).catch(() => null); |
| +} |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js"; |
| @@ -2660,8 +2671,10 @@ function buildSkeleton(el, ctx) { |
| // can slot in behind the same handle. The handler bodies are function |
| // declarations further down (hoisted; they run long after the skeleton is |
| // fully built, so the consts they close over are all live by then). |
| - const composer = createComposer({ |
| + const inEmbed = !!(host && host.embed); |
| + const composerOpts = { |
| placeholder: composerHints.get(ctx.threadId) || basePlaceholder(), |
| + formatAsYouType: formatAsYouTypePref(), |
| onInput: () => onComposerInput(), |
| onKeydown: (e) => onComposerKeydown(e), |
| // Caret moves (click / arrows) can enter or leave a token too — re-check. |
| @@ -2677,7 +2690,15 @@ function buildSkeleton(el, ctx) { |
| setTimeout(closeTypeahead, 100); |
| }, |
| onPaste: (e) => onComposerPaste(e), |
| - }); |
| + // Rich-backend file routes (ProseMirror owns paste/drop on its element). |
| + onPasteFiles: (files) => stageFiles(files), |
| + onDropFiles: (files) => stageFiles(files), |
| + onOversizePaste: () => stageError("That's too long for a message — attach it as a file instead."), |
| + }; |
| + const composer = |
| + pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref() |
| + ? adoptComposer(pmModule.createRichComposer(composerOpts)) |
| + : createComposer(composerOpts); |
| const applyFieldLabel = () => { |
| const label = composerLabels.get(ctx.threadId) || ""; |
| fieldLabel.textContent = label; |
| @@ -2753,6 +2774,7 @@ function buildSkeleton(el, ctx) { |
| // ---- composer state (per mount; a fresh open rebuilds it clean) ---- |
| let replyTo = null; // { id, name, snippet } — the original payload shape |
| let editing = null; // the item being edited |
| + let editBaseline = ""; // getMarkdown() right after edit-load (the ↓-undo comparator) |
| let pending = []; // staged attachments: { att | null (uploading), name, isImage } |
| let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish |
| let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch |
| @@ -2804,7 +2826,6 @@ function buildSkeleton(el, ctx) { |
| replyBanner.classList.remove("hidden"); |
| } |
| }; |
| - const inEmbed = !!(host && host.embed); |
| if (inEmbed) root.classList.add("embed"); // the native shell draws the composer |
| // The owning app declared its chat a read-only stream — no composer to draw. |
| if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly"); |
| @@ -2943,7 +2964,8 @@ function buildSkeleton(el, ctx) { |
| startEdit(m) { |
| replyTo = null; |
| editing = m; |
| - composer.setText(m.text, { notify: true }); // resize + reveal send |
| + composer.setMarkdown(m.text, { notify: true }); // resize + reveal send |
| + editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes |
| refreshBanners(); |
| if (inEmbed) host.native({ editing: { text: m.text } }); |
| if (!inEmbed) composer.focus(); |
| @@ -3201,7 +3223,7 @@ function buildSkeleton(el, ctx) { |
| // Blockquote continuation: pasting multi-line text onto a bare "> " line |
| // prefixes every line after the first with "> " so the whole quote stays a |
| // quote — matches the original composer's paste behaviour. |
| - if (!files.length) { |
| + if (!files.length && composer.kind === "plain") { |
| const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain"); |
| if (textItem) { |
| const start = composer.getCaret(); |
| @@ -3435,8 +3457,12 @@ function buildSkeleton(el, ctx) { |
| if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead(); |
| if (e.key === "Escape") return e.preventDefault(), closeTypeahead(); |
| } |
| - // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines. |
| - if (shouldSend(e, coarse)) { |
| + // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines. A |
| + // code fence is the one context that explicitly means "multi-line |
| + // content", so Enter newlines there too. The isComposing guard stops a |
| + // Japanese/Korean composition-confirm from sending early (a pre-existing |
| + // textarea quirk, fixed while we're here). |
| + if (shouldSend(e, coarse) && !composer.inCodeBlock() && !e.isComposing) { |
| e.preventDefault(); |
| compose.requestSubmit(); |
| return; |
| @@ -3482,7 +3508,7 @@ function buildSkeleton(el, ctx) { |
| // below the cursor to navigate to (the original rule). |
| if (e.key === "ArrowDown" && editing) { |
| const afterCursor = composer.getText().slice(composer.getSelectionEnd()); |
| - if (composer.getText() === editing.text && !afterCursor.includes("\n")) { |
| + if (composer.getMarkdown() === editBaseline && !afterCursor.includes("\n")) { |
| e.preventDefault(); |
| composer.setText(""); |
| cancelEdit(); |
| @@ -3497,7 +3523,11 @@ function buildSkeleton(el, ctx) { |
| setSendQueued(true); // still uploading — send as soon as it lands |
| return; |
| } |
| - const text = emojify(composer.getText().trim()); // :tada: → 🎉, like the original composer |
| + // Two readings of the composer: commands parse the PLAIN text (a command |
| + // is not prose), the wire carries the serialized markdown. On the plain |
| + // backend the two are identical bytes, exactly as before. |
| + const plain = emojify(composer.getText().trim()); |
| + const text = emojify(composer.getMarkdown().trim()); // :tada: → 🎉, like the original composer |
| const resetInput = () => { |
| composer.clear(); |
| refreshSend(); |
| @@ -3508,7 +3538,7 @@ function buildSkeleton(el, ctx) { |
| // line ("Alice waves"), not a bubble — instead of answering privately, so |
| // it's handled here rather than through runCommand (see parseMeCommand). |
| if (!editing) { |
| - const action = parseMeCommand(text); |
| + const action = parseMeCommand(plain); |
| if (action !== null) { |
| if (!action) return resetInput(); // bare "/me" — nothing to say |
| ctx.emit({ text: `${ctx.meName || "Someone"} ${action}`, action: true }).then((sent) => { |
| @@ -3523,7 +3553,7 @@ function buildSkeleton(el, ctx) { |
| // instead of sending — it answers in place with a private "only visible to you" |
| // reply that stacks in the feed and persists (addLocalNotice). Skipped while |
| // editing, like the original composer. |
| - if (!editing && runCommand(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| + if (!editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| resetInput(); |
| return; |
| } |
| @@ -3531,7 +3561,7 @@ function buildSkeleton(el, ctx) { |
| // app, the text runs its default command — an EVENT into this thread (shown |
| // as an action line), not a message. Leading-@ only; a mention mid-text is |
| // ordinary conversation. See runAppMention. |
| - if (!editing && !replyTo && !pending.length && text && text[0] === "@" && runAppMention(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| + if (!editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| resetInput(); |
| return; |
| } |
| @@ -3539,10 +3569,10 @@ function buildSkeleton(el, ctx) { |
| // instead of sending a message nobody reads — typing "buy milk" into To-do |
| // just adds it (defaultCommandFor gates this to app-threads with no bot; |
| // slash text never falls through here, a "/" always means command). |
| - if (!editing && !replyTo && text && text[0] !== "/" && !pending.length) { |
| + if (!editing && !replyTo && plain && plain[0] !== "/" && !pending.length) { |
| const dc = defaultCmd(); |
| if (dc) { |
| - const typed = text; |
| + const typed = plain; |
| runCommand(dc.name + " " + typed, ctx.threadId, (r) => { |
| addLocalNotice(ctx, r); |
| // A reply with no `short` is the command PUSHING BACK ("when should |
diff --git a/web/static/composer-pm.js b/web/static/composer-pm.js| new file mode 100644 |
| index 0000000..f049576 |
| --- /dev/null |
| +++ b/web/static/composer-pm.js |
| @@ -0,0 +1,271 @@ |
| +// 🤖 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, wrappingInputRule, textblockTypeInputRule, undoInputRule, |
| + chainCommands, toggleMark, newlineInCode, wrapIn, lift, |
| + wrapInList, splitListItem, liftListItem, |
| +} 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() { |
| + return inputRules({ |
| + rules: [ |
| + // strong before em, so the ** closer never half-fires the * rule |
| + markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong), |
| + markInputRule(/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/, schema.marks.em), |
| + markInputRule(/`([^`\s](?:[^`]*[^`\s])?)`$/, schema.marks.code), |
| + markInputRule(/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/, schema.marks.strike), |
| + wrappingInputRule(/^\s*([-*])\s$/, schema.nodes.bullet_list), |
| + wrappingInputRule( |
| + /^(\d+)\.\s$/, |
| + schema.nodes.ordered_list, |
| + (match) => ({ order: +match[1] }), |
| + (match, node) => node.childCount + node.attrs.order === +match[1], |
| + ), |
| + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), |
| + textblockTypeInputRule(/^```$/, schema.nodes.code_block), |
| + ], |
| + }); |
| +} |
| + |
| +// --- the backend ---------------------------------------------------------------- |
| + |
| +export function createRichComposer(opts = {}) { |
| + const insertHardBreak = (state, dispatch) => { |
| + 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-Shift-7": wrapInList(schema.nodes.ordered_list), |
| + "Mod-Shift-8": wrapInList(schema.nodes.bullet_list), |
| + "Mod-Shift-9": chainCommands(wrapIn(schema.nodes.blockquote), lift), |
| + "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() === ""; |
| + const refreshEmpty = () => view.dom.classList.toggle("pm-empty", isEmptyDoc()); |
| + 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() { |
| + return serializeDoc(view.state.doc); |
| + }, |
| + 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(); |
| + }, |
| + }; |
| +} |
diff --git a/web/static/composer.js b/web/static/composer.js| index 64e9b78..44d9189 100644 |
| --- a/web/static/composer.js |
| +++ b/web/static/composer.js |
| @@ -39,11 +39,48 @@ export function anyComposerHasText() { |
| } |
| export function createComposer(opts = {}) { |
| - const handle = createPlainComposer(opts); |
| + return adoptComposer(createPlainComposer(opts)); |
| +} |
| + |
| +// The rich backend (composer-pm.js) is dynamically imported by chat.js — it |
| +// registers its handles here so the draft guard and live settings see every |
| +// composer regardless of backend. |
| +export function adoptComposer(handle) { |
| live.add(new WeakRef(handle)); |
| return handle; |
| } |
| +// --- composer preferences (localStorage, like the theme prefs in shell.js) --- |
| + |
| +const FMT_KEY = "lchat_fmt_as_you_type"; |
| +const CLASSIC_KEY = "lchat_classic_composer"; |
| + |
| +// Default ON: absent means enabled. |
| +export function formatAsYouTypePref() { |
| + return localStorage.getItem(FMT_KEY) !== "0"; |
| +} |
| +export function setFormatAsYouType(on) { |
| + localStorage.setItem(FMT_KEY, on ? "1" : "0"); |
| + // Live-reconfigure every mounted composer — no remount, history survives. |
| + for (const ref of live) { |
| + const c = ref.deref(); |
| + if (!c) { |
| + live.delete(ref); |
| + continue; |
| + } |
| + c.setFormatAsYouType(on); |
| + } |
| +} |
| + |
| +// The escape hatch: force the plain textarea backend (picked up on the next |
| +// composer mount — shell.js reopens the pane when toggled). |
| +export function classicComposerPref() { |
| + return localStorage.getItem(CLASSIC_KEY) === "1"; |
| +} |
| +export function setClassicComposer(on) { |
| + localStorage.setItem(CLASSIC_KEY, on ? "1" : "0"); |
| +} |
| + |
| // The plain backend — today's <textarea>, behaviour preserved to the letter |
| // (auto-grow to 140px, input/keydown wiring, splice-based typeahead inserts). |
| function createPlainComposer(opts) { |
diff --git a/web/static/shell.js b/web/static/shell.js| index 4927f8e..f4f20bf 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -175,7 +175,7 @@ import { |
| getBackfillMark as storeBackfillMark, |
| runBackfill as storeRunBackfill, |
| } from "/static/store.js"; |
| -import { anyComposerHasText } from "/static/composer.js"; |
| +import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js"; |
| const SESSION_KEY = "lchat_session"; |
| const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it |
| @@ -539,6 +539,38 @@ function showThemePicker() { |
| card.appendChild(row); |
| } |
| + // Composer preferences (#531): "Format as you type" applies live to every |
| + // mounted composer; "Classic composer" (the plain-textarea escape hatch) |
| + // takes effect on the next thread open — reopening the current one here so |
| + // the choice is visible immediately. |
| + const composerLabel = document.createElement("label"); |
| + composerLabel.className = "field-label"; |
| + composerLabel.textContent = "Composer"; |
| + composerLabel.style.marginTop = "14px"; |
| + card.appendChild(composerLabel); |
| + const toggleRow = (labelText, checked, onchange) => { |
| + const row = document.createElement("label"); |
| + row.className = "composer-pref-row"; |
| + const box = document.createElement("input"); |
| + box.type = "checkbox"; |
| + box.checked = checked; |
| + box.onchange = () => onchange(box.checked); |
| + const txt = document.createElement("span"); |
| + txt.textContent = labelText; |
| + row.append(box, txt); |
| + card.appendChild(row); |
| + return row; |
| + }; |
| + toggleRow("Format as you type (**bold** becomes bold)", formatAsYouTypePref(), (on) => setFormatAsYouType(on)); |
| + toggleRow("Classic plain-text composer", classicComposerPref(), (on) => { |
| + setClassicComposer(on); |
| + // Remount the open thread's pane so the switch is immediate. Parked panes |
| + // rebuild on their next open (appPaneCache entries are dropped). |
| + for (const k of Object.keys(appPaneCache)) delete appPaneCache[k]; |
| + const active = localStorage.getItem(ACTIVE_KEY); |
| + if (active && typeof openThreadById === "function") openThreadById(active); |
| + }); |
| + |
| const foot = document.createElement("div"); |
| foot.className = "sheet-foot"; |
| const done = document.createElement("button"); |
diff --git a/web/static/style.css b/web/static/style.css| index 632a438..0e3bc3f 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -340,7 +340,7 @@ input, textarea { |
| input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } |
| /* the message box doesn't get the bright focus ring (chat-app convention) — the |
| coloured caret is the cue. */ |
| -.composer textarea:focus { outline: none; border-color: var(--border); } |
| +.composer .composer-input:focus { outline: none; border-color: var(--border); } |
| /* ---- centered card layout (join / fallback) ---- */ |
| .center { |
| @@ -1551,7 +1551,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| variable from the base rule; the two declarations after it restore what |
| the shorthand resets. Higher-specificity sizes (.jumbo-emoji) still win. */ |
| .msg .bubble { font: -apple-system-body; font-family: inherit; line-height: 1.35; } |
| - .composer textarea { font-size: 17px; } |
| + .composer .composer-input { font-size: 17px; } |
| .thread-row-name { font-size: 17px; } |
| .thread-row-preview { font-size: 14px; } |
| } |
| @@ -1590,7 +1590,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| @media (prefers-reduced-motion: reduce) { |
| .composer-label { transition: none; } |
| } |
| -.composer textarea { |
| +.composer .composer-input { |
| flex: 1; |
| resize: none; |
| max-height: 140px; |
| @@ -1612,6 +1612,66 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* The shared lifted-panel input fill (see --input-bg / .eleven-input). */ |
| background: var(--input-bg); |
| } |
| +/* The rich composer (composer-pm.js): a ProseMirror contenteditable wearing |
| + the same skin. Auto-grow is pure CSS here — min/max-height + overflow — so |
| + there is no JS measuring to go stale (the lesson from the reverted native |
| + attempt). The textarea's implicit border comes back explicitly. */ |
| +.composer .composer-input[contenteditable] { |
| + position: relative; |
| + border: 1px solid var(--border); |
| + white-space: pre-wrap; |
| + overflow-wrap: break-word; |
| + color: var(--ink); |
| + caret-color: var(--accent); |
| +} |
| +.composer .composer-input[contenteditable] p { margin: 0; } |
| +/* In-composer blocks: the bubble treatments at composer scale. */ |
| +.composer .composer-input[contenteditable] ul, |
| +.composer .composer-input[contenteditable] ol { margin: 2px 0; padding-left: 22px; } |
| +.composer .composer-input[contenteditable] blockquote { |
| + margin: 2px 0; |
| + padding: 1px 0 1px 10px; |
| + border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent); |
| + color: color-mix(in srgb, var(--ink) 72%, transparent); |
| +} |
| +.composer .composer-input[contenteditable] pre { |
| + margin: 2px 0; |
| + padding: 6px 8px; |
| + border-radius: 8px; |
| + background: color-mix(in srgb, var(--ink) 8%, transparent); |
| + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| + font-size: 0.92em; |
| + overflow-x: auto; |
| + white-space: pre; |
| +} |
| +.composer .composer-input[contenteditable] code { |
| + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| + font-size: 0.92em; |
| + background: color-mix(in srgb, var(--ink) 8%, transparent); |
| + padding: 1px 5px; |
| + border-radius: 5px; |
| +} |
| +.composer .composer-input[contenteditable] pre code { background: none; padding: 0; font-size: 1em; } |
| +/* Placeholder: contenteditable has no native one — the pm-empty class is |
| + maintained by the backend; float keeps the caret on the first line. */ |
| +/* Theme-sheet composer preference rows (#531) */ |
| +.composer-pref-row { |
| + display: flex; |
| + align-items: center; |
| + gap: 10px; |
| + margin: 8px 0; |
| + font-size: 14px; |
| + color: var(--ink); |
| + cursor: pointer; |
| +} |
| +.composer-pref-row input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; margin: 0; } |
| +.composer .composer-input.pm-empty::before { |
| + content: attr(data-placeholder); |
| + color: var(--muted); |
| + float: left; |
| + height: 0; |
| + pointer-events: none; |
| +} |
| /* eleven-input — the reusable text-input surface: the same lifted-panel fill the |
| chat composer uses, so inputs across the app and its mini-apps read as one. |