@intentface/chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ask-user/ask-user.d.ts +104 -0
  4. package/dist/ask-user/ask-user.js +226 -0
  5. package/dist/ask-user/index.d.ts +3 -0
  6. package/dist/ask-user/index.js +3 -0
  7. package/dist/ask-user/index.parts.d.ts +1 -0
  8. package/dist/ask-user/index.parts.js +5 -0
  9. package/dist/attachments/attachments.d.ts +41 -0
  10. package/dist/attachments/attachments.js +91 -0
  11. package/dist/attachments/index.d.ts +3 -0
  12. package/dist/attachments/index.js +3 -0
  13. package/dist/attachments/index.parts.d.ts +1 -0
  14. package/dist/attachments/index.parts.js +5 -0
  15. package/dist/chip/chip.d.ts +25 -0
  16. package/dist/chip/chip.js +25 -0
  17. package/dist/chip/index.d.ts +2 -0
  18. package/dist/chip/index.js +2 -0
  19. package/dist/chip/index.parts.d.ts +1 -0
  20. package/dist/chip/index.parts.js +5 -0
  21. package/dist/chip-markdown.d.ts +25 -0
  22. package/dist/chip-markdown.js +52 -0
  23. package/dist/composer/actions.d.ts +35 -0
  24. package/dist/composer/actions.js +75 -0
  25. package/dist/composer/ask-user-machine.d.ts +52 -0
  26. package/dist/composer/ask-user-machine.js +164 -0
  27. package/dist/composer/attachments-machine.d.ts +34 -0
  28. package/dist/composer/attachments-machine.js +91 -0
  29. package/dist/composer/attachments.d.ts +16 -0
  30. package/dist/composer/attachments.js +32 -0
  31. package/dist/composer/command-list.d.ts +49 -0
  32. package/dist/composer/command-list.js +357 -0
  33. package/dist/composer/commands.d.ts +28 -0
  34. package/dist/composer/commands.js +56 -0
  35. package/dist/composer/container.d.ts +3 -0
  36. package/dist/composer/container.js +32 -0
  37. package/dist/composer/controller.d.ts +3 -0
  38. package/dist/composer/controller.js +6 -0
  39. package/dist/composer/editor-dom.d.ts +55 -0
  40. package/dist/composer/editor-dom.js +431 -0
  41. package/dist/composer/fuzzy.d.ts +4 -0
  42. package/dist/composer/fuzzy.js +49 -0
  43. package/dist/composer/index.d.ts +25 -0
  44. package/dist/composer/index.js +13 -0
  45. package/dist/composer/index.parts.d.ts +11 -0
  46. package/dist/composer/index.parts.js +15 -0
  47. package/dist/composer/internals.d.ts +29 -0
  48. package/dist/composer/internals.js +102 -0
  49. package/dist/composer/keyboard.d.ts +61 -0
  50. package/dist/composer/keyboard.js +75 -0
  51. package/dist/composer/panel.d.ts +28 -0
  52. package/dist/composer/panel.js +80 -0
  53. package/dist/composer/placeholder.d.ts +10 -0
  54. package/dist/composer/placeholder.js +8 -0
  55. package/dist/composer/popover.d.ts +12 -0
  56. package/dist/composer/popover.js +63 -0
  57. package/dist/composer/prefix-detection.d.ts +23 -0
  58. package/dist/composer/prefix-detection.js +58 -0
  59. package/dist/composer/root.d.ts +21 -0
  60. package/dist/composer/root.js +107 -0
  61. package/dist/composer/segments.d.ts +108 -0
  62. package/dist/composer/segments.js +366 -0
  63. package/dist/composer/store.d.ts +97 -0
  64. package/dist/composer/store.js +451 -0
  65. package/dist/composer/textarea.d.ts +39 -0
  66. package/dist/composer/textarea.js +64 -0
  67. package/dist/composer/trigger-tracker.d.ts +12 -0
  68. package/dist/composer/trigger-tracker.js +90 -0
  69. package/dist/composer/types.d.ts +126 -0
  70. package/dist/composer/types.js +4 -0
  71. package/dist/composer/use-composer-editor.d.ts +43 -0
  72. package/dist/composer/use-composer-editor.js +649 -0
  73. package/dist/internal/collapsible.d.ts +22 -0
  74. package/dist/internal/collapsible.js +90 -0
  75. package/dist/internal/primitive-props.d.ts +4 -0
  76. package/dist/internal/primitive-props.js +4 -0
  77. package/dist/internal/render/baseTypes.d.ts +18 -0
  78. package/dist/internal/render/baseTypes.js +4 -0
  79. package/dist/internal/render/empty.d.ts +3 -0
  80. package/dist/internal/render/empty.js +5 -0
  81. package/dist/internal/render/getReactElementRef.d.ts +5 -0
  82. package/dist/internal/render/getReactElementRef.js +15 -0
  83. package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
  84. package/dist/internal/render/getStateAttributesProps.js +23 -0
  85. package/dist/internal/render/mergeObjects.d.ts +1 -0
  86. package/dist/internal/render/mergeObjects.js +14 -0
  87. package/dist/internal/render/mergeProps.d.ts +55 -0
  88. package/dist/internal/render/mergeProps.js +183 -0
  89. package/dist/internal/render/positioning.d.ts +22 -0
  90. package/dist/internal/render/positioning.js +78 -0
  91. package/dist/internal/render/reactVersion.d.ts +3 -0
  92. package/dist/internal/render/reactVersion.js +7 -0
  93. package/dist/internal/render/resolveClassName.d.ts +8 -0
  94. package/dist/internal/render/resolveClassName.js +12 -0
  95. package/dist/internal/render/resolveStyle.d.ts +8 -0
  96. package/dist/internal/render/resolveStyle.js +12 -0
  97. package/dist/internal/render/transition.d.ts +48 -0
  98. package/dist/internal/render/transition.js +203 -0
  99. package/dist/internal/render/types.d.ts +74 -0
  100. package/dist/internal/render/types.js +3 -0
  101. package/dist/internal/render/useMergedRefs.d.ts +21 -0
  102. package/dist/internal/render/useMergedRefs.js +102 -0
  103. package/dist/internal/render/useRefWithInit.d.ts +10 -0
  104. package/dist/internal/render/useRefWithInit.js +12 -0
  105. package/dist/internal/render/useRender.d.ts +71 -0
  106. package/dist/internal/render/useRender.js +12 -0
  107. package/dist/internal/render/useRenderElement.d.ts +62 -0
  108. package/dist/internal/render/useRenderElement.js +157 -0
  109. package/dist/internal/render/warn.d.ts +1 -0
  110. package/dist/internal/render/warn.js +15 -0
  111. package/dist/internal/state-mappings.d.ts +8 -0
  112. package/dist/internal/state-mappings.js +17 -0
  113. package/dist/message/index.d.ts +3 -0
  114. package/dist/message/index.js +3 -0
  115. package/dist/message/index.parts.d.ts +1 -0
  116. package/dist/message/index.parts.js +5 -0
  117. package/dist/message/message.d.ts +43 -0
  118. package/dist/message/message.js +78 -0
  119. package/dist/message-utils.d.ts +55 -0
  120. package/dist/message-utils.js +79 -0
  121. package/dist/reasoning/index.d.ts +3 -0
  122. package/dist/reasoning/index.js +3 -0
  123. package/dist/reasoning/index.parts.d.ts +1 -0
  124. package/dist/reasoning/index.parts.js +5 -0
  125. package/dist/reasoning/reasoning.d.ts +20 -0
  126. package/dist/reasoning/reasoning.js +49 -0
  127. package/dist/steps/index.d.ts +2 -0
  128. package/dist/steps/index.js +2 -0
  129. package/dist/steps/index.parts.d.ts +1 -0
  130. package/dist/steps/index.parts.js +5 -0
  131. package/dist/steps/steps.d.ts +53 -0
  132. package/dist/steps/steps.js +79 -0
  133. package/dist/thread/geometry.d.ts +18 -0
  134. package/dist/thread/geometry.js +59 -0
  135. package/dist/thread/index.d.ts +4 -0
  136. package/dist/thread/index.js +3 -0
  137. package/dist/thread/index.parts.d.ts +1 -0
  138. package/dist/thread/index.parts.js +5 -0
  139. package/dist/thread/stores.d.ts +21 -0
  140. package/dist/thread/stores.js +63 -0
  141. package/dist/thread/thread.d.ts +66 -0
  142. package/dist/thread/thread.js +639 -0
  143. package/dist/types.d.ts +58 -0
  144. package/dist/types.js +17 -0
  145. package/package.json +106 -0
@@ -0,0 +1,649 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // useComposerEditor — the hand-rolled contenteditable engine. Owns the flat
4
+ // segment document, the caret, the trigger-token state, and every editing
5
+ // event; registers itself with the composer store as a RegisteredEditor.
6
+ //
7
+ // Two invariants:
8
+ // 1. The browser owns text editing (native typing, IME, autocorrect); the
9
+ // model syncs FROM the DOM on input via read → diff → commit. Programmatic
10
+ // operations (chips, paste, setText, intercepted deletes) are the only
11
+ // imperative DOM writes, always a full render + caret restore.
12
+ // 2. Engine state lives outside React — createEditorEngine below is a plain
13
+ // closure factory (the createAskUserKeydownHandler pattern). Renders are
14
+ // driven by a version counter through useSyncExternalStore; chip visuals
15
+ // mount through portals into engine-owned spans, so React never
16
+ // reconciles the editable's children.
17
+ import { useSyncExternalStore, } from "react";
18
+ import { createPortal } from "react-dom";
19
+ import { Chip } from "../chip/index.js";
20
+ import { parseChipSegments } from "../chip-markdown.js";
21
+ import { useRefWithInit } from "../internal/render/useRefWithInit.js";
22
+ import { documentToDomSpec, logicalRangeFromDom, readDocumentFromDom, readSelectionRange, renderDocumentToDom, scrollCaretIntoView, syncBadge, toReadable, writeCaretToDom, } from "./editor-dom.js";
23
+ import { useAsRef, useComposerInternals, useIsomorphicLayoutEffect } from "./internals.js";
24
+ import { interpretEditorKey } from "./keyboard.js";
25
+ import { CLOSED_COMMAND_STATE } from "./prefix-detection.js";
26
+ import { diffFlatText, documentLength, getPlainText, nextChipId, planBeforeInput, segmentsFromChipSegments, segmentsFromParagraphJSON, segmentsFromText, segmentsToParagraphJSON, serializeSegments, sliceSegments, spliceSegments, toFlatText, toScanText, truncateToFit, } from "./segments.js";
27
+ import { useComposer, useComposerContextStore } from "./store.js";
28
+ import { closeActiveToken, trackActiveToken } from "./trigger-tracker.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Engine factory — all state and behavior in one plain closure. The hook
31
+ // below is only the React shell (subscription, portals, controlled value).
32
+ // ---------------------------------------------------------------------------
33
+ const createEditorEngine = (getDependencies) => {
34
+ // --- State
35
+ let doc = [];
36
+ let caret = { start: 0, end: 0 };
37
+ let commandState = CLOSED_COMMAND_STATE;
38
+ let isComposing = false;
39
+ let chipRegistry = new Map();
40
+ let chipElements = new Map();
41
+ let root = null;
42
+ let version = 0;
43
+ const listeners = new Set();
44
+ let detachSelectionListener = null;
45
+ const bumpVersion = () => {
46
+ version += 1;
47
+ for (const listener of listeners)
48
+ listener();
49
+ };
50
+ // --- Commit — the single funnel every state change flows through: tracker,
51
+ // badge, store mirrors, value callbacks, version bump.
52
+ const commit = (nextDoc, nextCaret, change, docChanged) => {
53
+ const { store, getRegisteredPrefixes, reportEditorUpdate, options } = getDependencies();
54
+ doc = nextDoc;
55
+ caret = nextCaret;
56
+ chipRegistry = new Map(nextDoc.flatMap((segment) => segment.type === "chip" ? [[segment.id, segment.chip]] : []));
57
+ commandState = trackActiveToken(commandState, {
58
+ registered: getRegisteredPrefixes(),
59
+ scanText: toScanText(nextDoc),
60
+ caret: nextCaret.start,
61
+ change,
62
+ });
63
+ syncBadgeAndCaret();
64
+ const plainText = getPlainText(nextDoc);
65
+ refreshHasContent();
66
+ // Single-select questions clear their selection once the user starts
67
+ // typing a free-text answer.
68
+ if (docChanged && plainText.trim().length > 0) {
69
+ const { askUser } = store.getSnapshot();
70
+ const currentQuestion = askUser.questions?.[askUser.step];
71
+ if (askUser.questions && !currentQuestion?.multiSelect)
72
+ askUser.clearSelections();
73
+ askUser.optionsRef.current?.clearHighlight();
74
+ }
75
+ mirrorCommandState();
76
+ if (docChanged) {
77
+ options.onValueChange?.(plainText);
78
+ reportEditorUpdate();
79
+ bumpVersion();
80
+ }
81
+ };
82
+ // hasContent from the model — the placeholder overlay and data-filled key
83
+ // off this through the store. Any content counts, including whitespace-only
84
+ // docs: a lone "\n" (Shift+Enter on an empty editor) renders two line boxes,
85
+ // and a trim-based check would paint the placeholder over them. This is
86
+ // also legacy parity — its `|| !instance.isEmpty` half made structure count
87
+ // — and native-textarea parity: the placeholder hides on any character.
88
+ const refreshHasContent = () => {
89
+ getDependencies().store.setHasContent(documentLength(doc) > 0);
90
+ };
91
+ // Make the badge span match the tracker state; a DOM change moves the
92
+ // selection, so the logical caret is rewritten afterwards. Never during
93
+ // composition — reparenting the composition's text node aborts the IME
94
+ // session.
95
+ const syncBadgeAndCaret = () => {
96
+ if (!root || isComposing)
97
+ return;
98
+ const changed = syncBadge(root, commandState.isOpen
99
+ ? {
100
+ start: commandState.triggerStartPosition,
101
+ end: commandState.triggerEndPosition,
102
+ showPlaceholder: commandState.query === "",
103
+ }
104
+ : null);
105
+ if (changed)
106
+ writeCaretToDom(root, caret.start, caret.end);
107
+ };
108
+ const mirrorCommandState = () => {
109
+ getDependencies().store.setCommands({
110
+ active: commandState.isOpen,
111
+ trigger: commandState.trigger,
112
+ query: commandState.query,
113
+ });
114
+ };
115
+ // --- Programmatic pipeline — every model-initiated edit renders the
116
+ // canonical DOM, restores the caret, and commits.
117
+ const applyDocument = (nextDoc, caretPosition, change) => {
118
+ // The registry must know new chips before the next DOM read resolves them.
119
+ chipRegistry = new Map(nextDoc.flatMap((segment) => segment.type === "chip" ? [[segment.id, segment.chip]] : []));
120
+ if (root) {
121
+ chipElements = renderDocumentToDom(root, nextDoc);
122
+ writeCaretToDom(root, caretPosition);
123
+ }
124
+ commit(nextDoc, { start: caretPosition, end: caretPosition }, change, true);
125
+ if (root)
126
+ scrollCaretIntoView(root);
127
+ };
128
+ const spliceAndApply = (from, to, insert) => {
129
+ const insertedLength = documentLength(insert);
130
+ applyDocument(spliceSegments(doc, from, to, insert), from + insertedLength, {
131
+ rangeStart: from,
132
+ rangeEnd: to,
133
+ insertedLength,
134
+ });
135
+ };
136
+ const insertPlainText = (text) => {
137
+ const { maxLength } = getDependencies().options;
138
+ const { start, end } = caret;
139
+ let insertedText = text;
140
+ if (maxLength !== undefined) {
141
+ const room = maxLength - (documentLength(doc) - (end - start));
142
+ if (room <= 0)
143
+ return;
144
+ insertedText = insertedText.slice(0, room);
145
+ }
146
+ if (insertedText.length === 0)
147
+ return;
148
+ spliceAndApply(start, end, segmentsFromText(insertedText));
149
+ };
150
+ const setTextContent = (text) => {
151
+ const previousLength = documentLength(doc);
152
+ const nextDoc = segmentsFromText(text);
153
+ applyDocument(nextDoc, documentLength(nextDoc), {
154
+ rangeStart: 0,
155
+ rangeEnd: previousLength,
156
+ insertedLength: documentLength(nextDoc),
157
+ });
158
+ };
159
+ // Renormalize after a dirty read: re-render the committed model and restore
160
+ // the caret (extension leftovers, unknown spans, block wrappers).
161
+ const renormalizeDom = () => {
162
+ if (!root || isComposing)
163
+ return;
164
+ chipElements = renderDocumentToDom(root, doc);
165
+ writeCaretToDom(root, caret.start, caret.end);
166
+ syncBadgeAndCaret();
167
+ bumpVersion();
168
+ };
169
+ // --- Read-back — DOM → model after a native edit. Idempotent: a no-diff,
170
+ // same-caret sync is free, which makes the Chrome (input before
171
+ // compositionend) and Safari (compositionend before input) orderings both
172
+ // safe to double-fire.
173
+ const syncFromDom = () => {
174
+ if (!root)
175
+ return;
176
+ const { doc: readDoc, dirty } = readDocumentFromDom(toReadable(root), (id) => chipRegistry.get(id) ?? null);
177
+ const selection = readSelectionRange(root) ?? caret;
178
+ const change = diffFlatText(toFlatText(doc), toFlatText(readDoc), selection.start);
179
+ if (change !== null) {
180
+ commit(readDoc, selection, change, true);
181
+ }
182
+ else if (selection.start !== caret.start || selection.end !== caret.end) {
183
+ commit(doc, selection, null, false);
184
+ }
185
+ // Line-break parity invariant: browsers add and remove <br>s freely during
186
+ // native edits — emptying can leave no <br> at all (the unpadded editable
187
+ // collapses to zero height, hiding the caret and the placeholder overlay),
188
+ // and typing before the padding <br> strands it mid-content, where later
189
+ // reads count it as a phantom newline. Whenever the DOM's <br> count
190
+ // diverges from the canonical rendering, repaint from the model. Plain
191
+ // typing never diverges, so the native fast path stays untouched.
192
+ const domBreakCount = root.querySelectorAll("br").length;
193
+ const canonicalBreakCount = documentToDomSpec(doc).filter((spec) => spec.kind === "br").length;
194
+ if (dirty || domBreakCount !== canonicalBreakCount)
195
+ renormalizeDom();
196
+ };
197
+ // --- Command closing — Escape / Dismiss / blur / post-selection all funnel
198
+ // here; the tracker records the dismissal memory itself.
199
+ const closeCommands = () => {
200
+ if (!commandState.isOpen && commandState.dismissedAt === null)
201
+ return;
202
+ commandState = closeActiveToken(commandState);
203
+ syncBadgeAndCaret();
204
+ mirrorCommandState();
205
+ };
206
+ const focusEditor = () => {
207
+ if (!root)
208
+ return;
209
+ root.focus();
210
+ writeCaretToDom(root, caret.start, caret.end);
211
+ };
212
+ // --- Event handlers (attached as native listeners — React synthetics
213
+ // polyfill beforeinput and lose getTargetRanges timing).
214
+ const handleBeforeInput = (event) => {
215
+ if (isComposing || !root)
216
+ return;
217
+ let targetRange = null;
218
+ const nativeRange = event.getTargetRanges()[0];
219
+ if (nativeRange) {
220
+ const editable = root;
221
+ const start = logicalRangeFromDom(toReadable(editable), toReadable(nativeRange.startContainer), nativeRange.startOffset);
222
+ const end = logicalRangeFromDom(toReadable(editable), toReadable(nativeRange.endContainer), nativeRange.endOffset);
223
+ if (start !== null && end !== null) {
224
+ targetRange = { start: Math.min(start, end), end: Math.max(start, end) };
225
+ }
226
+ }
227
+ // Fallback for synthetic deletes without target ranges: expand a collapsed
228
+ // caret one unit in the delete direction so chip adjacency is still caught.
229
+ if (!targetRange && caret.start === caret.end) {
230
+ if (event.inputType === "deleteContentBackward" && caret.start > 0) {
231
+ targetRange = { start: caret.start - 1, end: caret.start };
232
+ }
233
+ else if (event.inputType === "deleteContentForward") {
234
+ targetRange = { start: caret.start, end: caret.start + 1 };
235
+ }
236
+ }
237
+ const plan = planBeforeInput({
238
+ inputType: event.inputType,
239
+ data: event.data,
240
+ targetRange,
241
+ doc,
242
+ caret,
243
+ maxLength: getDependencies().options.maxLength,
244
+ });
245
+ if (plan.kind === "native")
246
+ return;
247
+ event.preventDefault();
248
+ if (plan.kind === "block")
249
+ return;
250
+ spliceAndApply(plan.from, plan.to, plan.insert);
251
+ };
252
+ const handleInput = (event) => {
253
+ if (isComposing || event.isComposing)
254
+ return;
255
+ syncFromDom();
256
+ };
257
+ // Composition flips are version-bumped so React re-renders: the textarea
258
+ // gates its placeholder overlay on !isComposing (the browser paints marked
259
+ // text without any commit, so hasContent alone would lag until
260
+ // compositionend). hasContent itself stays single-writer, model-derived.
261
+ const handleCompositionStart = () => {
262
+ isComposing = true;
263
+ bumpVersion();
264
+ };
265
+ const handleCompositionEnd = () => {
266
+ isComposing = false;
267
+ syncFromDom();
268
+ // A cancelled composition leaves no diff → no commit → no bump; bump
269
+ // explicitly so the overlay can return on an empty editor.
270
+ bumpVersion();
271
+ // insertCompositionText is not cancelable — the cap is enforced at commit:
272
+ // the one sanctioned post-IME rewrite.
273
+ const { maxLength } = getDependencies().options;
274
+ if (maxLength !== undefined && documentLength(doc) > maxLength) {
275
+ const overflow = documentLength(doc);
276
+ applyDocument(sliceSegments(doc, 0, maxLength), Math.min(caret.start, maxLength), {
277
+ rangeStart: maxLength,
278
+ rangeEnd: overflow,
279
+ insertedLength: 0,
280
+ });
281
+ }
282
+ };
283
+ const handleKeyDown = (event) => {
284
+ // An IME owns Enter (conversion confirm) and the arrows (candidate
285
+ // window) during composition — 229 covers engines that omit isComposing.
286
+ if (event.isComposing || event.keyCode === 229)
287
+ return;
288
+ const { store, options } = getDependencies();
289
+ const action = interpretEditorKey({ key: event.key, shiftKey: event.shiftKey }, {
290
+ isCommandListOpen: commandState.isOpen,
291
+ hasActiveAskUser: (store.getSnapshot().askUser.questions?.length ?? 0) > 0,
292
+ isEditorEmpty: getPlainText(doc) === "",
293
+ hasAttachments: store.getSnapshot().attachments.items.length > 0,
294
+ submitOn: options.submitOn,
295
+ });
296
+ if (!action)
297
+ return;
298
+ switch (action.type) {
299
+ case "command-select": {
300
+ event.preventDefault();
301
+ store.commandSelectRef.current?.();
302
+ return;
303
+ }
304
+ case "command-close": {
305
+ event.preventDefault();
306
+ closeCommands();
307
+ return;
308
+ }
309
+ case "command-navigate": {
310
+ event.preventDefault();
311
+ store.moveHighlight(action.direction);
312
+ return;
313
+ }
314
+ case "command-caret": {
315
+ // Trap the caret inside the active token: clamp Left/Right to the
316
+ // token range so it can't leave while the popup is open (Escape /
317
+ // Dismiss is the only way out).
318
+ event.preventDefault();
319
+ if (commandState.isOpen && root) {
320
+ const current = caret.start;
321
+ const target = Math.min(Math.max(current + action.direction, commandState.triggerStartPosition), commandState.triggerEndPosition);
322
+ if (target !== current) {
323
+ caret = { start: target, end: target };
324
+ writeCaretToDom(root, target);
325
+ }
326
+ }
327
+ return;
328
+ }
329
+ case "ask-user-arrow": {
330
+ // navigate() moves DOM focus onto the newly highlighted option
331
+ // (roving tabindex) — never blur to <body>. At the list boundary
332
+ // (null) focus stays in the editor.
333
+ event.preventDefault();
334
+ store.getSnapshot().askUser.optionsRef.current?.navigate(action.direction);
335
+ return;
336
+ }
337
+ case "ask-user-dismiss": {
338
+ event.preventDefault();
339
+ store.getSnapshot().askUser.dismissStep();
340
+ return;
341
+ }
342
+ case "remove-last-attachment": {
343
+ event.preventDefault();
344
+ const { items, remove } = store.getSnapshot().attachments;
345
+ const lastItem = items.at(-1);
346
+ if (lastItem)
347
+ remove(lastItem.id);
348
+ return;
349
+ }
350
+ case "submit-form": {
351
+ event.preventDefault();
352
+ event.target.closest("form")?.requestSubmit();
353
+ return;
354
+ }
355
+ case "soft-break": {
356
+ // The browser's own insertParagraph must be blocked — it would split
357
+ // the editable into block children the flat model doesn't represent.
358
+ event.preventDefault();
359
+ // A line break is a deliberate exit from an active trigger token —
360
+ // dismiss it like Escape (with re-entry suppression) before inserting,
361
+ // so the popup doesn't linger under the new line.
362
+ if (commandState.isOpen)
363
+ closeCommands();
364
+ insertPlainText("\n");
365
+ return;
366
+ }
367
+ }
368
+ };
369
+ // Caret-only moves don't fire input — this is the engine's equivalent of
370
+ // selection-only transactions. Attached on focus, detached on blur, so only
371
+ // the focused composer ever listens. Reentry-safe by idempotence: the
372
+ // engine's own writeCaretToDom calls update `caret` first, so the resulting
373
+ // event compares equal and dies.
374
+ const handleSelectionChange = () => {
375
+ if (isComposing || !root)
376
+ return;
377
+ const selection = readSelectionRange(root);
378
+ if (!selection)
379
+ return;
380
+ if (selection.start === caret.start && selection.end === caret.end)
381
+ return;
382
+ commit(doc, selection, null, false);
383
+ };
384
+ const handleFocus = () => {
385
+ getDependencies().store.getSnapshot().askUser.optionsRef.current?.clearHighlight();
386
+ if (!root)
387
+ return;
388
+ const ownerDocument = root.ownerDocument;
389
+ ownerDocument.addEventListener("selectionchange", handleSelectionChange);
390
+ detachSelectionListener = () => ownerDocument.removeEventListener("selectionchange", handleSelectionChange);
391
+ };
392
+ // Losing focus dismisses an open command list (click-outside, tab-away).
393
+ // Command items and the popover chrome preventDefault their mousedown, so
394
+ // interacting with the list keeps focus and never triggers this.
395
+ const handleBlur = () => {
396
+ detachSelectionListener?.();
397
+ detachSelectionListener = null;
398
+ if (commandState.isOpen)
399
+ closeCommands();
400
+ };
401
+ const handlePaste = (event) => {
402
+ const items = event.clipboardData?.items;
403
+ if (items) {
404
+ const files = [...items]
405
+ .filter((item) => item.kind === "file")
406
+ .map((item) => item.getAsFile())
407
+ .filter((file) => Boolean(file));
408
+ if (files.length) {
409
+ event.preventDefault();
410
+ getDependencies().store.getSnapshot().attachments.add(files);
411
+ return;
412
+ }
413
+ }
414
+ // The engine owns all text paste — arbitrary clipboard markup must never
415
+ // reach the contenteditable.
416
+ event.preventDefault();
417
+ const pastedText = event.clipboardData?.getData("text/plain")?.replace(/\r\n?/g, "\n");
418
+ if (!pastedText)
419
+ return;
420
+ let insert = pastedText.includes("(chip:")
421
+ ? segmentsFromChipSegments(parseChipSegments(pastedText))
422
+ : segmentsFromText(pastedText);
423
+ const { maxLength } = getDependencies().options;
424
+ const { start, end } = caret;
425
+ if (maxLength !== undefined) {
426
+ const room = maxLength - (documentLength(doc) - (end - start));
427
+ insert = truncateToFit(insert, room);
428
+ if (documentLength(insert) === 0)
429
+ return;
430
+ }
431
+ spliceAndApply(start, end, insert);
432
+ };
433
+ // Copy/cut emit chip markdown (the wire format) — native copy would degrade
434
+ // chips to their visual label text and silently lose the chip data.
435
+ const handleCopy = (event) => {
436
+ const { start, end } = caret;
437
+ if (start === end || !event.clipboardData)
438
+ return;
439
+ event.preventDefault();
440
+ event.clipboardData.setData("text/plain", serializeSegments(sliceSegments(doc, start, end)).text);
441
+ };
442
+ const handleCut = (event) => {
443
+ const { start, end } = caret;
444
+ if (start === end || !event.clipboardData)
445
+ return;
446
+ handleCopy(event);
447
+ if (!getDependencies().options.disabled)
448
+ spliceAndApply(start, end, []);
449
+ };
450
+ // --- RegisteredEditor — the seam the store, controller, and shared command
451
+ // list drive.
452
+ const registeredEditor = {
453
+ focus: focusEditor,
454
+ blur: () => root?.blur(),
455
+ clear: () => setTextContent(""),
456
+ insertText: insertPlainText,
457
+ insertChip: (chip) => {
458
+ spliceAndApply(caret.start, caret.end, [{ type: "chip", id: nextChipId(), chip }]);
459
+ },
460
+ getText: () => getPlainText(doc),
461
+ setText: setTextContent,
462
+ serialize: () => serializeSegments(doc),
463
+ isFocused: () => root !== null && root.ownerDocument.activeElement === root,
464
+ getRootElement: () => root,
465
+ getSnapshot: () => ({
466
+ __doc: { type: "doc", content: segmentsToParagraphJSON(doc) },
467
+ __brand: "ComposerSnapshot",
468
+ }),
469
+ applySnapshot: (snapshot) => {
470
+ const snapshotDoc = snapshot.__doc;
471
+ const nextDoc = segmentsFromParagraphJSON(snapshotDoc.content ?? []);
472
+ const previousLength = documentLength(doc);
473
+ applyDocument(nextDoc, documentLength(nextDoc), {
474
+ rangeStart: 0,
475
+ rangeEnd: previousLength,
476
+ insertedLength: documentLength(nextDoc),
477
+ });
478
+ },
479
+ // Replace the active trigger token with the chip, plus a trailing space
480
+ // so the user can keep typing — unless one already follows.
481
+ insertChipAtTrigger: (chip) => {
482
+ const from = commandState.triggerStartPosition;
483
+ const to = commandState.isOpen ? commandState.triggerEndPosition : caret.start;
484
+ const chipSegment = { type: "chip", id: nextChipId(), chip };
485
+ const afterChip = spliceSegments(doc, from, to, [chipSegment]);
486
+ const charAfter = toScanText(afterChip).slice(from + 1, from + 2);
487
+ const needsSpace = charAfter !== " ";
488
+ const nextDoc = needsSpace
489
+ ? spliceSegments(afterChip, from + 1, from + 1, segmentsFromText(" "))
490
+ : afterChip;
491
+ const { maxLength } = getDependencies().options;
492
+ if (maxLength !== undefined && documentLength(nextDoc) > maxLength)
493
+ return;
494
+ focusEditor();
495
+ applyDocument(nextDoc, from + (needsSpace ? 2 : 1), {
496
+ rangeStart: from,
497
+ rangeEnd: to,
498
+ insertedLength: needsSpace ? 2 : 1,
499
+ });
500
+ },
501
+ deleteTrigger: () => {
502
+ if (!commandState.isOpen)
503
+ return;
504
+ focusEditor();
505
+ spliceAndApply(commandState.triggerStartPosition, commandState.triggerEndPosition, []);
506
+ },
507
+ closeCommands,
508
+ dismissCommands: () => {
509
+ root?.focus();
510
+ closeCommands();
511
+ },
512
+ };
513
+ // --- Mount — the input-pipeline listeners stay native (getTargetRanges
514
+ // must be read off the real beforeinput event; composition timing is
515
+ // synthetic-hostile). The interactive events (keydown, focus/blur,
516
+ // clipboard) are exposed below and attached as React handlers so consumer
517
+ // callbacks compose with native-textarea override semantics.
518
+ const attach = (node) => {
519
+ if (!node)
520
+ return undefined;
521
+ root = node;
522
+ chipElements = renderDocumentToDom(node, doc);
523
+ node.addEventListener("beforeinput", handleBeforeInput);
524
+ node.addEventListener("input", handleInput);
525
+ node.addEventListener("compositionstart", handleCompositionStart);
526
+ node.addEventListener("compositionend", handleCompositionEnd);
527
+ const unregister = getDependencies().store.registerEditor(registeredEditor);
528
+ if (getDependencies().options.autoFocus)
529
+ focusEditor();
530
+ return () => {
531
+ node.removeEventListener("beforeinput", handleBeforeInput);
532
+ node.removeEventListener("input", handleInput);
533
+ node.removeEventListener("compositionstart", handleCompositionStart);
534
+ node.removeEventListener("compositionend", handleCompositionEnd);
535
+ detachSelectionListener?.();
536
+ detachSelectionListener = null;
537
+ unregister();
538
+ root = null;
539
+ };
540
+ };
541
+ // Controlled value → engine. The getText-equality check both swallows the
542
+ // onValueChange echo and protects chips from being wiped by a chip-less
543
+ // round-tripped string (legacy parity).
544
+ const applyControlledText = (value) => {
545
+ if (value === undefined || isComposing)
546
+ return;
547
+ if (value === getPlainText(doc))
548
+ return;
549
+ setTextContent(value);
550
+ };
551
+ return {
552
+ subscribe: (listener) => {
553
+ listeners.add(listener);
554
+ return () => {
555
+ listeners.delete(listener);
556
+ };
557
+ },
558
+ getVersion: () => version,
559
+ getDoc: () => doc,
560
+ getChipElement: (id) => chipElements.get(id),
561
+ getIsComposing: () => isComposing,
562
+ attach,
563
+ applyControlledText,
564
+ // Interactive handlers, composed with consumer callbacks by the hook.
565
+ handleKeyDown,
566
+ handleFocus,
567
+ handleBlur,
568
+ handlePaste,
569
+ handleCopy,
570
+ handleCut,
571
+ };
572
+ };
573
+ // ---------------------------------------------------------------------------
574
+ // Hook — the React shell over the engine.
575
+ // ---------------------------------------------------------------------------
576
+ export const useComposerEditor = (options) => {
577
+ const store = useComposerContextStore();
578
+ const hasContent = useComposer((composer) => composer.textarea.hasContent);
579
+ const { getRegisteredPrefixes, reportEditorUpdate } = useComposerInternals();
580
+ const dependenciesRef = useAsRef({
581
+ store,
582
+ getRegisteredPrefixes,
583
+ reportEditorUpdate,
584
+ options,
585
+ });
586
+ const engine = useRefWithInit(() => createEditorEngine(() => dependenciesRef.current)).current;
587
+ useSyncExternalStore(engine.subscribe, engine.getVersion, engine.getVersion);
588
+ // Controlled value → engine: pushing a prop into a non-React external
589
+ // system is what effects are for (the useComposerSnapshot precedent).
590
+ useIsomorphicLayoutEffect(() => {
591
+ engine.applyControlledText(options.value);
592
+ }, [engine, options.value]);
593
+ // Consumer-then-engine composition — native-textarea override semantics:
594
+ // preventDefault in the consumer's handler stops the engine (and, for
595
+ // keydown, the browser default) exactly like it would on a real textarea.
596
+ // Focus/blur engine handling is bookkeeping (selectionchange lifecycle,
597
+ // command-list close) and always runs.
598
+ const editableProps = {
599
+ onFocus: (event) => {
600
+ dependenciesRef.current.options.onFocus?.(event);
601
+ engine.handleFocus();
602
+ },
603
+ onBlur: (event) => {
604
+ dependenciesRef.current.options.onBlur?.(event);
605
+ engine.handleBlur();
606
+ },
607
+ onKeyDown: (event) => {
608
+ dependenciesRef.current.options.onKeyDown?.(event);
609
+ if (!event.defaultPrevented)
610
+ engine.handleKeyDown(event.nativeEvent);
611
+ },
612
+ onKeyUp: (event) => {
613
+ dependenciesRef.current.options.onKeyUp?.(event);
614
+ },
615
+ onPaste: (event) => {
616
+ dependenciesRef.current.options.onPaste?.(event);
617
+ if (!event.defaultPrevented)
618
+ engine.handlePaste(event.nativeEvent);
619
+ },
620
+ onCopy: (event) => {
621
+ dependenciesRef.current.options.onCopy?.(event);
622
+ if (!event.defaultPrevented)
623
+ engine.handleCopy(event.nativeEvent);
624
+ },
625
+ onCut: (event) => {
626
+ dependenciesRef.current.options.onCut?.(event);
627
+ if (!event.defaultPrevented)
628
+ engine.handleCut(event.nativeEvent);
629
+ },
630
+ };
631
+ const chipPortals = engine.getDoc().flatMap((segment) => {
632
+ if (segment.type !== "chip")
633
+ return [];
634
+ const element = engine.getChipElement(segment.id);
635
+ if (!element)
636
+ return [];
637
+ return [
638
+ createPortal(options.renderChip?.(segment.chip) ?? (_jsx(Chip.Root, { children: _jsx(Chip.Label, { children: segment.chip.label }) })), element, segment.id),
639
+ ];
640
+ });
641
+ return {
642
+ attachRoot: engine.attach,
643
+ editableProps,
644
+ chipPortals,
645
+ hasContent,
646
+ isComposing: engine.getIsComposing(),
647
+ serializedText: serializeSegments(engine.getDoc()).text,
648
+ };
649
+ };
@@ -0,0 +1,22 @@
1
+ import type { PrimitiveProps } from "./primitive-props.js";
2
+ import { type TransitionStatus } from "./render/transition.js";
3
+ export type CollapsibleState = {
4
+ open: boolean;
5
+ };
6
+ export type CollapsibleRootProps = PrimitiveProps<"div", CollapsibleState> & {
7
+ open?: boolean;
8
+ defaultOpen?: boolean;
9
+ onOpenChange?: (open: boolean) => void;
10
+ };
11
+ export type CollapsibleTriggerProps = PrimitiveProps<"button", CollapsibleState>;
12
+ export type CollapsiblePanelProps = PrimitiveProps<"div", CollapsiblePanelState> & {
13
+ /** Keep the panel in the DOM (hidden) when closed. */
14
+ keepMounted?: boolean;
15
+ };
16
+ export type CollapsiblePanelState = CollapsibleState & {
17
+ transitionStatus: TransitionStatus;
18
+ };
19
+ export declare const Collapsible: (({ open: controlledOpen, defaultOpen, onOpenChange, className, render, style, ...elementProps }: CollapsibleRootProps) => import("react").JSX.Element) & {
20
+ Trigger: ({ className, render, style, ...elementProps }: CollapsibleTriggerProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
21
+ Panel: ({ keepMounted, className, render, style, ...elementProps }: CollapsiblePanelProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
22
+ };