@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +226 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +41 -0
- package/dist/attachments/attachments.js +91 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +25 -0
- package/dist/chip/chip.js +25 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +52 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +75 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +357 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +55 -0
- package/dist/composer/editor-dom.js +431 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +29 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +80 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +63 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +58 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +107 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +97 -0
- package/dist/composer/store.js +451 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +126 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +649 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +90 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +4 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +22 -0
- package/dist/internal/render/positioning.js +78 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +48 -0
- package/dist/internal/render/transition.js +203 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +43 -0
- package/dist/message/message.js +78 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +20 -0
- package/dist/reasoning/reasoning.js +49 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +53 -0
- package/dist/steps/steps.js +79 -0
- package/dist/thread/geometry.d.ts +18 -0
- package/dist/thread/geometry.js +59 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +66 -0
- package/dist/thread/thread.js +639 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- 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
|
+
};
|