eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The composer facade (#531, the WYSIWYG groundwork): chat.js talks to its
2// message input ONLY through the handle this module returns, never to a DOM
3// element's .value directly. Today there is one backend β€” "plain", the same
4// <textarea> as always, byte-identical behaviour β€” and the rich (ProseMirror)
5// backend slots in behind the same contract without chat.js changing again.
6// The plain backend is permanent, not transitional: embed mode (the native
7// shells' hidden composer) and the "classic composer" escape hatch keep it
8// exercised forever.
9//
10// Contract notes that both backends must honour:
11// - getText()/getCaret()/replaceRange() speak plain string + caret offset β€”
12// the typeahead contract (ui.js composerToken and its Swift twin) is frozen.
13// - Programmatic writes never fire callbacks unless asked ({ notify: true }):
14// the native bridge sets text right before submitting, and a surprise
15// onInput would emit a spurious typing beat.
16// - getMarkdown()/setMarkdown() are the wire-format pair; for plain they're
17// just the value (what you type is what sends, exactly as today).
18
19// Live composers, weakly held: panes park in shell.js's appPaneCache (the
20// composer survives, detached) and are sometimes dropped without a teardown
21// hook, so strong references would pin dead composers forever. Callers filter
22// by connectedness β€” matching the old ".composer textarea" DOM query, which
23// only ever saw the mounted pane.
24const live = new Set();
25
26// Does any MOUNTED composer hold unsent text? (shell.js gates the auto-update
27// reload on this β€” same semantics as the old document-wide selector: parked
28// panes didn't block then and don't now.)
29export function anyComposerHasText() {
30 for (const ref of live) {
31 const c = ref.deref();
32 if (!c) {
33 live.delete(ref);
34 continue;
35 }
36 if (c.el.isConnected && !c.isEmpty()) return true;
37 }
38 return false;
39}
40
41export function createComposer(opts = {}) {
42 return adoptComposer(createPlainComposer(opts));
43}
44
45// The rich backend (composer-pm.js) is dynamically imported by chat.js β€” it
46// registers its handles here so the draft guard and live settings see every
47// composer regardless of backend.
48export function adoptComposer(handle) {
49 live.add(new WeakRef(handle));
50 return handle;
51}
52
53// --- composer preferences (localStorage, like the theme prefs in shell.js) ---
54
55const FMT_KEY = "lchat_fmt_as_you_type";
56const CLASSIC_KEY = "lchat_classic_composer";
57
58// Default ON: absent means enabled.
59export function formatAsYouTypePref() {
60 return localStorage.getItem(FMT_KEY) !== "0";
61}
62export function setFormatAsYouType(on) {
63 localStorage.setItem(FMT_KEY, on ? "1" : "0");
64 // Live-reconfigure every mounted composer β€” no remount, history survives.
65 for (const ref of live) {
66 const c = ref.deref();
67 if (!c) {
68 live.delete(ref);
69 continue;
70 }
71 c.setFormatAsYouType(on);
72 }
73}
74
75// The escape hatch: force the plain textarea backend (picked up on the next
76// composer mount β€” shell.js reopens the pane when toggled).
77export function classicComposerPref() {
78 return localStorage.getItem(CLASSIC_KEY) === "1";
79}
80export function setClassicComposer(on) {
81 localStorage.setItem(CLASSIC_KEY, on ? "1" : "0");
82}
83
84// The plain backend β€” today's <textarea>, behaviour preserved to the letter
85// (auto-grow to 140px, input/keydown wiring, splice-based typeahead inserts).
86function createPlainComposer(opts) {
87 const el = document.createElement("textarea");
88 el.className = "composer-input";
89 el.rows = 1;
90 el.autocomplete = "off";
91 if (opts.placeholder) el.placeholder = opts.placeholder;
92
93 const notify = () => el.dispatchEvent(new Event("input", { bubbles: true }));
94
95 el.addEventListener("input", () => {
96 // Auto-grow first, so the caller's onInput observes the post-grow layout
97 // (its feed re-pin reads scrollHeight synchronously).
98 el.style.height = "auto";
99 el.style.height = Math.min(el.scrollHeight, 140) + "px";
100 if (opts.onInput) opts.onInput();
101 });
102 if (opts.onKeydown) el.onkeydown = opts.onKeydown;
103 if (opts.onKeyup) el.addEventListener("keyup", opts.onKeyup);
104 if (opts.onClick) el.addEventListener("click", opts.onClick);
105 el.addEventListener("focus", () => opts.onFocus && opts.onFocus());
106 el.addEventListener("blur", () => opts.onBlur && opts.onBlur());
107 if (opts.onPaste) el.addEventListener("paste", opts.onPaste);
108
109 return {
110 kind: "plain",
111 el,
112 focus() {
113 el.focus();
114 },
115 hasFocus() {
116 return document.activeElement === el;
117 },
118 isEmpty() {
119 return el.value.trim() === "";
120 },
121 getText() {
122 return el.value;
123 },
124 getCaret() {
125 return el.selectionStart == null ? el.value.length : el.selectionStart;
126 },
127 getSelectionEnd() {
128 return el.selectionEnd == null ? this.getCaret() : el.selectionEnd;
129 },
130 setText(v, o = {}) {
131 el.value = v || "";
132 if (o.notify) notify();
133 },
134 // Typeahead splice: replace [start, end) in getText() coordinates, land
135 // the caret after the insert, notify (resize + send-visibility + token
136 // re-check) and keep focus β€” the original acceptTypeahead, verbatim.
137 replaceRange(start, end, insert) {
138 const before = el.value.slice(0, start);
139 el.value = before + insert + el.value.slice(end);
140 const pos = before.length + insert.length;
141 el.setSelectionRange(pos, pos);
142 notify();
143 el.focus();
144 },
145 // Panel insertText: at the caret (or replacing a selection), caret after.
146 insertAtCaret(text) {
147 const start = el.selectionStart == null ? el.value.length : el.selectionStart;
148 const end = el.selectionEnd == null ? start : el.selectionEnd;
149 el.value = el.value.slice(0, start) + text + el.value.slice(end);
150 el.selectionStart = el.selectionEnd = start + text.length;
151 notify();
152 el.focus();
153 },
154 getMarkdown() {
155 return el.value;
156 },
157 setMarkdown(v, o = {}) {
158 this.setText(v, o);
159 },
160 clear() {
161 el.value = "";
162 el.style.height = "auto";
163 },
164 setPlaceholder(s) {
165 el.placeholder = s || "";
166 },
167 setAriaLabel(s) {
168 el.setAttribute("aria-label", s || "");
169 },
170 // The inset field label's indent (applyFieldLabel measures the pill).
171 setIndent(px) {
172 el.style.paddingLeft = px ? `${px}px` : "";
173 },
174 inCodeBlock() {
175 return false;
176 },
177 setFormatAsYouType() {},
178 };
179}
180