| 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. |
| 24 | const 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.) |
| 29 | export 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 | |
| 41 | export 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. |
| 48 | export 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 | |
| 55 | const FMT_KEY = "lchat_fmt_as_you_type"; |
| 56 | const CLASSIC_KEY = "lchat_classic_composer"; |
| 57 | |
| 58 | // Default ON: absent means enabled. |
| 59 | export function formatAsYouTypePref() { |
| 60 | return localStorage.getItem(FMT_KEY) !== "0"; |
| 61 | } |
| 62 | export 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). |
| 77 | export function classicComposerPref() { |
| 78 | return localStorage.getItem(CLASSIC_KEY) === "1"; |
| 79 | } |
| 80 | export 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). |
| 86 | function 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 | |