eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The rich composer backend (#531): a ProseMirror EditorView behind the
2// same facade contract as composer.js's plain textarea. chat.js cannot tell
3// the two apart β€” the handle speaks plain-string+caret for the typeahead,
4// markdown for the wire (composer-md.js owns both mappings). This module is
5// loaded via a primed dynamic import (never in embed mode), so the native
6// shells' webviews and the classic-composer escape hatch never pay for it.
7//
8// Behaviour rules carried over from the design:
9// - Desktop Enter sends EVERYWHERE except inside a code fence (the one
10// context that explicitly means "multi-line content"); Shift+Enter β€” and
11// touch Enter β€” are structural: newline in a fence, next item in a list,
12// hard_break in a paragraph. The keyboard NEVER splits paragraphs (wire
13// bytes: hard_break is "\n"; "\n\n" only ever comes from edit-loads).
14// - "Format as you type" (input rules) is a default-ON setting; OFF still
15// leaves ⌘B/⌘I working and literal markdown rendering in the feed. The
16// toggle reconfigures plugins live β€” history and doc survive.
17// - Backspace runs undoInputRule first: typing *x* then Backspace restores
18// the literal text instead of leaving surprise styling.
19
20import {
21 EditorState, EditorView, TextSelection,
22 history, undo, redo, keymap, baseKeymap,
23 inputRules, InputRule, undoInputRule,
24 chainCommands, toggleMark, newlineInCode,
25 splitListItem,
26} from "./vendor/prosemirror.js";
27import {
28 composerSchema as schema,
29 parseToDoc, serializeDoc, docText, textOffsetFromPos, posFromTextOffset,
30} from "./composer-md.js";
31
32// Paste guards (the reverted-native-attempt lesson): a giant paste must never
33// wedge the input. Past 100KB we skip schema/HTML coercion and insert plain
34// text in one transaction; past 1MB we refuse and point at attachments.
35const PASTE_PLAIN_BYTES = 100_000;
36const PASTE_MAX_BYTES = 1_000_000;
37
38// --- input rules ("format as you type") ---------------------------------------
39
40// ProseMirror has no built-in mark input rule β€” the standard shape: fire on
41// the closing marker, swap the matched text for a marked run, and clear the
42// stored mark so the next keystroke types unstyled (Slack behaviour).
43function markInputRule(regexp, markType) {
44 return new InputRule(regexp, (state, match, start, end) => {
45 const content = match[1];
46 if (!content) return null;
47 const $start = state.doc.resolve(start);
48 if ($start.parent.type.spec.code) return null; // never inside a fence
49 if (state.doc.rangeHasMark(start, end, schema.marks.code)) return null;
50 return state.tr
51 .replaceWith(start, end, schema.text(content, [markType.create()]))
52 .removeStoredMark(markType);
53 });
54}
55
56function buildInputRules() {
57 // Inline marks ONLY β€” block conversion (lists/quotes/fences as you type)
58 // was deliberately removed after the native attempt showed the input-side
59 // jank isn't worth it: the composers stick to the basics, and block
60 // markdown stays a render-side ability (bots, history, hand-typed).
61 return inputRules({
62 rules: [
63 // strong before em, so the ** closer never half-fires the * rule
64 markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong),
65 markInputRule(/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/, schema.marks.em),
66 // _underscores_ italicize too β€” never intraword (snake_case stays put)
67 markInputRule(/(?<![\w_])_([^_\s](?:[^_]*[^_\s])?)_$/, schema.marks.em),
68 markInputRule(/`([^`\s](?:[^`]*[^`\s])?)`$/, schema.marks.code),
69 markInputRule(/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/, schema.marks.strike),
70 ],
71 });
72}
73
74// --- the backend ----------------------------------------------------------------
75
76export function createRichComposer(opts = {}) {
77 const insertHardBreak = (state, dispatch) => {
78 if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
79 return true;
80 };
81 // Structural Enter: newline in a fence, next item in a list (an empty item
82 // lifts out), hard_break everywhere else β€” splitBlock is unreachable, so
83 // the keyboard can never mint a "\n\n".
84 const structuralEnter = chainCommands(newlineInCode, splitListItem(schema.nodes.list_item), insertHardBreak);
85
86 const chatKeymap = keymap({
87 Enter: structuralEnter, // desktop send was already taken upstream (handleKeyDown)
88 "Shift-Enter": structuralEnter,
89 "Mod-b": toggleMark(schema.marks.strong),
90 "Mod-i": toggleMark(schema.marks.em),
91 "Mod-e": toggleMark(schema.marks.code),
92 "Mod-Shift-x": toggleMark(schema.marks.strike),
93 "Mod-z": undo,
94 "Mod-Shift-z": redo,
95 "Mod-y": redo,
96 Backspace: chainCommands(undoInputRule, baseKeymap["Backspace"]),
97 });
98
99 let rulesOn = opts.formatAsYouType !== false;
100 const rulesPlugin = buildInputRules();
101
102 // Plugin instances are created ONCE and shared by both configurations, so
103 // the format-as-you-type toggle's reconfigure preserves history state.
104 const basePlugins = [history(), chatKeymap, keymap(baseKeymap)];
105 const pluginsFor = (on) => (on ? [rulesPlugin, ...basePlugins] : basePlugins);
106
107 const freshState = (doc) =>
108 EditorState.create({
109 schema,
110 doc: doc || undefined,
111 plugins: pluginsFor(rulesOn),
112 selection: doc ? TextSelection.atEnd(doc) : undefined,
113 });
114
115 const view = new EditorView(null, {
116 state: freshState(),
117 dispatchTransaction(tr) {
118 view.updateState(view.state.apply(tr));
119 refreshEmpty();
120 if (tr.docChanged && opts.onInput) opts.onInput();
121 },
122 // DIRECT view props run before every plugin: chat.js's keydown layer
123 // (typeahead nav, shouldSend β†’ submit, Escape layering, ↑/↓ edit) sees
124 // keys first; anything it preventDefaulted is consumed, everything else
125 // falls through to the keymaps above.
126 handleKeyDown: (view, e) => {
127 if (opts.onKeydown) opts.onKeydown(e);
128 return e.defaultPrevented;
129 },
130 handlePaste: (view, e) => {
131 const files = [...((e.clipboardData && e.clipboardData.files) || [])];
132 if (files.length) {
133 if (opts.onPasteFiles) opts.onPasteFiles(files);
134 return true;
135 }
136 const text = e.clipboardData && e.clipboardData.getData("text/plain");
137 if (text && text.length > PASTE_MAX_BYTES) {
138 if (opts.onOversizePaste) opts.onOversizePaste();
139 return true;
140 }
141 if (text && text.length > PASTE_PLAIN_BYTES) {
142 // One plain-text transaction β€” no HTML parse, no schema coercion.
143 view.dispatch(view.state.tr.insertText(text).scrollIntoView());
144 return true;
145 }
146 return false; // ProseMirror's paste (rich HTML degrades through the schema)
147 },
148 handleDrop: (view, e) => {
149 const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
150 if (files.length) {
151 if (opts.onDropFiles) opts.onDropFiles(files);
152 return true;
153 }
154 return false;
155 },
156 attributes: {
157 class: "composer-input",
158 role: "textbox",
159 "aria-multiline": "true",
160 },
161 });
162 // Caret moves (click / arrows) can enter or leave a typeahead token β€”
163 // ProseMirror leaves these DOM events alone, so plain listeners match the
164 // textarea backend's wiring exactly.
165 if (opts.onKeyup) view.dom.addEventListener("keyup", opts.onKeyup);
166 if (opts.onClick) view.dom.addEventListener("click", opts.onClick);
167 view.dom.addEventListener("focus", () => opts.onFocus && opts.onFocus());
168 view.dom.addEventListener("blur", () => opts.onBlur && opts.onBlur());
169
170 const isEmptyDoc = () => docText(view.state.doc).trim() === "";
171 // The placeholder shows only over a bare empty paragraph β€” an empty list
172 // item or fence is already visible structure, and the two would overlap.
173 const showsPlaceholder = () => {
174 const doc = view.state.doc;
175 return (
176 isEmptyDoc() &&
177 doc.childCount === 1 &&
178 doc.firstChild.type.name === "paragraph" &&
179 doc.firstChild.childCount === 0
180 );
181 };
182 const refreshEmpty = () => view.dom.classList.toggle("pm-empty", showsPlaceholder());
183 if (opts.placeholder) view.dom.setAttribute("data-placeholder", opts.placeholder);
184 refreshEmpty();
185
186 // Silent state swaps (no dispatchTransaction β†’ no onInput) for programmatic
187 // writes; { notify: true } fires the same change pipeline typing does.
188 const swapDoc = (doc, o = {}) => {
189 view.updateState(freshState(doc));
190 refreshEmpty();
191 if (o.notify && opts.onInput) opts.onInput();
192 };
193
194 // Plain text in (native pushback etc.): literal paragraph + hard_breaks β€”
195 // NOT markdown-parsed, so predictable for text that came from getText().
196 const plainDoc = (text) => {
197 const parts = String(text || "").split("\n");
198 const children = [];
199 parts.forEach((line, i) => {
200 if (i > 0) children.push(schema.nodes.hard_break.create());
201 if (line) children.push(schema.text(line));
202 });
203 return schema.node("doc", null, [schema.node("paragraph", null, children)]);
204 };
205
206 return {
207 kind: "rich",
208 el: view.dom,
209 view, // exposed for the live settings toggle (composer.js registry)
210 focus() {
211 view.focus();
212 },
213 hasFocus() {
214 return view.hasFocus();
215 },
216 isEmpty() {
217 return isEmptyDoc();
218 },
219 getText() {
220 return docText(view.state.doc);
221 },
222 getCaret() {
223 return textOffsetFromPos(view.state.doc, view.state.selection.from);
224 },
225 getSelectionEnd() {
226 return textOffsetFromPos(view.state.doc, view.state.selection.to);
227 },
228 setText(v, o = {}) {
229 swapDoc(plainDoc(v), o);
230 },
231 replaceRange(start, end, insert) {
232 const doc = view.state.doc;
233 const from = posFromTextOffset(doc, start);
234 const to = posFromTextOffset(doc, end);
235 let tr = view.state.tr.insertText(insert, from, to);
236 tr = tr.setSelection(TextSelection.create(tr.doc, from + insert.length));
237 view.dispatch(tr.scrollIntoView());
238 view.focus();
239 },
240 insertAtCaret(text) {
241 view.dispatch(view.state.tr.insertText(text).scrollIntoView());
242 view.focus();
243 },
244 getMarkdown() {
245 // Opted out of format-as-you-type = typed markers are literal text and
246 // must RENDER literally: escape them at send, so only WYSIWYG-applied
247 // marks format. Opted in keeps the zero-escape wire (what you type is
248 // what sends).
249 return serializeDoc(view.state.doc, { escapeLiterals: !rulesOn });
250 },
251 setMarkdown(v, o = {}) {
252 swapDoc(parseToDoc(v), o);
253 },
254 clear() {
255 swapDoc(null); // fresh state, fresh history β€” ⌘Z can't resurrect a sent message
256 },
257 setPlaceholder(s) {
258 view.dom.setAttribute("data-placeholder", s || "");
259 },
260 setAriaLabel(s) {
261 view.dom.setAttribute("aria-label", s || "");
262 },
263 setIndent(px) {
264 view.dom.style.paddingLeft = px ? `${px}px` : "";
265 },
266 inCodeBlock() {
267 return view.state.selection.$from.parent.type.name === "code_block";
268 },
269 setFormatAsYouType(on) {
270 if (on === rulesOn) return;
271 rulesOn = on;
272 // reconfigure keeps state for plugins present on both sides β€” the doc
273 // and undo history survive the toggle.
274 view.updateState(view.state.reconfigure({ plugins: pluginsFor(on) }));
275 },
276 destroy() {
277 view.destroy();
278 },
279 };
280}
281