@intentface/chat 0.1.2 → 0.2.1
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/README.md +2 -2
- package/dist/composer/editor-dom.d.ts +8 -6
- package/dist/composer/editor-dom.js +265 -191
- package/dist/internal/collapsible.js +7 -4
- package/dist/internal/render/transition.d.ts +0 -6
- package/dist/internal/render/transition.js +52 -9
- package/dist/thread/geometry.d.ts +15 -10
- package/dist/thread/geometry.js +16 -27
- package/dist/thread/thread.d.ts +1 -10
- package/dist/thread/thread.js +26 -14
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ for building AI chat interfaces, with no styling of their own.
|
|
|
5
5
|
|
|
6
6
|
This is the Base UI model applied to chat: **the package owns behavior, you own
|
|
7
7
|
every class.** There is no pre-styled `@intentface/chat` package. The demos on
|
|
8
|
-
each [docs](https://intentface.
|
|
8
|
+
each [docs](https://ui.intentface.com) page show how the parts fit together
|
|
9
9
|
and are meant to be copied and restyled.
|
|
10
10
|
|
|
11
11
|
## Status
|
|
@@ -101,7 +101,7 @@ directive and import fine anywhere.
|
|
|
101
101
|
## Docs
|
|
102
102
|
|
|
103
103
|
Full guides, live demos, and the API reference at
|
|
104
|
-
[intentface.
|
|
104
|
+
[ui.intentface.com](https://ui.intentface.com).
|
|
105
105
|
|
|
106
106
|
## License
|
|
107
107
|
|
|
@@ -9,12 +9,19 @@ export type ReadableNode = {
|
|
|
9
9
|
getAttribute?: (name: string) => string | null;
|
|
10
10
|
};
|
|
11
11
|
export declare const toReadable: (node: Node) => ReadableNode;
|
|
12
|
+
/** The writer marks its padding <br>; the reader recognizes it structurally. */
|
|
13
|
+
export declare const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
|
|
12
14
|
export declare const readDocumentFromDom: (root: ReadableNode, resolveChip: (id: string) => ChipData | null, makeId?: () => string) => {
|
|
13
15
|
doc: SegmentDoc;
|
|
14
16
|
dirty: boolean;
|
|
15
17
|
};
|
|
16
18
|
/** Logical position of (node, offset), or null when the point isn't inside root. */
|
|
17
19
|
export declare const logicalRangeFromDom: (root: ReadableNode, targetNode: ReadableNode, targetOffset: number) => number | null;
|
|
20
|
+
/** DOM point for a logical position, preferring text-node points. */
|
|
21
|
+
export declare const domPointFromLogical: (root: HTMLElement, position: number) => {
|
|
22
|
+
node: Node;
|
|
23
|
+
offset: number;
|
|
24
|
+
};
|
|
18
25
|
export type DomNodeSpec = {
|
|
19
26
|
kind: "text";
|
|
20
27
|
text: string;
|
|
@@ -32,16 +39,12 @@ export declare const documentToDomSpec: (doc: SegmentDoc) => DomNodeSpec[];
|
|
|
32
39
|
* chip content. Returns the live id → element map for the portal layer.
|
|
33
40
|
*/
|
|
34
41
|
export declare const renderDocumentToDom: (root: HTMLElement, doc: SegmentDoc) => Map<string, HTMLElement>;
|
|
35
|
-
/** DOM point for a logical position, preferring text-node points. */
|
|
36
|
-
export declare const domPointFromLogical: (root: HTMLElement, position: number) => {
|
|
37
|
-
node: Node;
|
|
38
|
-
offset: number;
|
|
39
|
-
};
|
|
40
42
|
export declare const readSelectionRange: (root: HTMLElement) => {
|
|
41
43
|
start: number;
|
|
42
44
|
end: number;
|
|
43
45
|
} | null;
|
|
44
46
|
export declare const writeCaretToDom: (root: HTMLElement, start: number, end?: number) => void;
|
|
47
|
+
export declare const scrollCaretIntoView: (root: HTMLElement) => void;
|
|
45
48
|
export type BadgeToken = {
|
|
46
49
|
start: number;
|
|
47
50
|
end: number;
|
|
@@ -52,4 +55,3 @@ export type BadgeToken = {
|
|
|
52
55
|
* changed — the caller must restore the caret afterwards.
|
|
53
56
|
*/
|
|
54
57
|
export declare const syncBadge: (root: HTMLElement, token: BadgeToken | null) => boolean;
|
|
55
|
-
export declare const scrollCaretIntoView: (root: HTMLElement) => void;
|
|
@@ -3,14 +3,33 @@
|
|
|
3
3
|
// minimal structural node view, so tests need no DOM); the materializers at
|
|
4
4
|
// the bottom are thin real-DOM wrappers over the pure spec builder.
|
|
5
5
|
//
|
|
6
|
+
// One taxonomy, three walkers: classifyNode below is the single answer to
|
|
7
|
+
// "what is this node?", and leafWidth the single answer to "how many logical
|
|
8
|
+
// positions does it occupy?". The reader, logicalRangeFromDom and
|
|
9
|
+
// domPointFromLogical all defer to them — when they disagree the caret and the
|
|
10
|
+
// model desync, so the rule lives in exactly one place.
|
|
11
|
+
//
|
|
6
12
|
// Writer/reader pact for line breaks: every "\n" renders as <br>, plus one
|
|
7
13
|
// padding <br> when the document is empty or ends with "\n" (an unpadded
|
|
8
|
-
// trailing line is unreachable/zero-height). The
|
|
9
|
-
//
|
|
10
|
-
//
|
|
14
|
+
// trailing line is unreachable/zero-height). The padding carries
|
|
15
|
+
// data-padding-break, so the reader identifies it structurally rather than
|
|
16
|
+
// inferring it from position — a native edit that strands it mid-document then
|
|
17
|
+
// reads as scaffolding (dropped, dirty) instead of a phantom newline. Unmarked
|
|
18
|
+
// trailing <br>s are the browser's own (Chrome keeps an emptied line box
|
|
19
|
+
// alive); the trailing trim covers those too.
|
|
20
|
+
// Round-trips: "" ↔ <br·pad>, "a\n" ↔ a<br><br·pad>, "a\n\n" ↔ a<br><br><br·pad>.
|
|
11
21
|
import { nextChipId } from "./segments.js";
|
|
12
22
|
const TEXT_NODE = 3;
|
|
13
23
|
const ELEMENT_NODE = 1;
|
|
24
|
+
export const toReadable = (node) => node;
|
|
25
|
+
const childrenOf = (node) => Array.from(node.childNodes);
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Node taxonomy — the shared vocabulary. Every walker in this file switches on
|
|
28
|
+
// a NodeClass rather than re-deriving node kinds from attributes, so they
|
|
29
|
+
// cannot drift apart.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** The writer marks its padding <br>; the reader recognizes it structurally. */
|
|
32
|
+
export const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
|
|
14
33
|
// Elements whose appearance inside the editable means something rewrote our
|
|
15
34
|
// flat structure (paste leftovers, extensions) — content still reads, but the
|
|
16
35
|
// document gets renormalized from the model afterwards.
|
|
@@ -30,26 +49,94 @@ const BLOCK_NODE_NAMES = new Set([
|
|
|
30
49
|
"H6",
|
|
31
50
|
"TABLE",
|
|
32
51
|
]);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
// Precedence is load-bearing: a chip span is also contenteditable="false", so
|
|
53
|
+
// the chip test must come before the presentation test.
|
|
54
|
+
const classifyNode = (node) => {
|
|
55
|
+
if (node.nodeType === TEXT_NODE)
|
|
56
|
+
return { kind: "text", text: node.data ?? "" };
|
|
57
|
+
if (node.nodeType !== ELEMENT_NODE)
|
|
58
|
+
return { kind: "ignored" };
|
|
59
|
+
if (node.nodeName === "BR") {
|
|
60
|
+
const marked = (node.getAttribute?.(PADDING_BREAK_ATTRIBUTE) ?? null) !== null;
|
|
61
|
+
return marked ? { kind: "padding" } : { kind: "break" };
|
|
62
|
+
}
|
|
63
|
+
const chipId = node.getAttribute?.("data-chip-id") ?? null;
|
|
64
|
+
if (chipId !== null)
|
|
65
|
+
return { kind: "chip", id: chipId };
|
|
66
|
+
if (node.getAttribute?.("contenteditable") === "false")
|
|
67
|
+
return { kind: "presentation" };
|
|
68
|
+
if (BLOCK_NODE_NAMES.has(node.nodeName))
|
|
69
|
+
return { kind: "block" };
|
|
70
|
+
return { kind: "wrapper" };
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Logical positions a leaf occupies. Containers are the sum of their children,
|
|
74
|
+
* so they are not leaves and answer 0 here — the walkers descend instead.
|
|
75
|
+
*/
|
|
76
|
+
const leafWidth = (classified) => {
|
|
77
|
+
switch (classified.kind) {
|
|
78
|
+
case "text":
|
|
79
|
+
return classified.text.length;
|
|
80
|
+
case "break":
|
|
81
|
+
case "chip":
|
|
82
|
+
return 1;
|
|
83
|
+
default:
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const tokenizeDom = (root) => {
|
|
88
|
+
const tokens = [];
|
|
89
|
+
let dirty = false;
|
|
90
|
+
const descend = (node) => {
|
|
91
|
+
for (const child of childrenOf(node))
|
|
92
|
+
visit(child);
|
|
93
|
+
};
|
|
94
|
+
const visit = (node) => {
|
|
95
|
+
const classified = classifyNode(node);
|
|
96
|
+
switch (classified.kind) {
|
|
97
|
+
case "text":
|
|
98
|
+
// Empty text nodes are editing residue — dropping them keeps the
|
|
99
|
+
// padding rules below looking at real neighbours.
|
|
100
|
+
if (classified.text.length > 0)
|
|
101
|
+
tokens.push(classified);
|
|
102
|
+
return;
|
|
103
|
+
case "break":
|
|
104
|
+
case "padding":
|
|
105
|
+
tokens.push({ kind: classified.kind });
|
|
106
|
+
return;
|
|
107
|
+
case "chip":
|
|
108
|
+
tokens.push(classified);
|
|
109
|
+
return;
|
|
110
|
+
case "presentation":
|
|
111
|
+
case "ignored":
|
|
112
|
+
return;
|
|
113
|
+
case "block":
|
|
114
|
+
dirty = true;
|
|
115
|
+
descend(node);
|
|
116
|
+
return;
|
|
117
|
+
case "wrapper":
|
|
118
|
+
descend(node);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
descend(root);
|
|
123
|
+
return { tokens, dirty };
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Drop the writer's trailing line-box scaffolding: our own marked padding, or
|
|
127
|
+
* an unmarked <br> the browser added to keep an emptied last line alive. Any
|
|
128
|
+
* padding token that survives this was stranded mid-document.
|
|
129
|
+
*/
|
|
130
|
+
const trimTrailingPadding = (tokens) => {
|
|
131
|
+
const last = tokens.at(-1);
|
|
132
|
+
if (last?.kind === "padding" || last?.kind === "break")
|
|
133
|
+
return tokens.slice(0, -1);
|
|
134
|
+
return tokens;
|
|
135
|
+
};
|
|
136
|
+
const foldTokens = (tokens, resolveChip, makeId) => {
|
|
49
137
|
const doc = [];
|
|
50
138
|
let dirty = false;
|
|
51
139
|
let pendingText = "";
|
|
52
|
-
let lastWasBreak = false;
|
|
53
140
|
const seenChipIds = new Set();
|
|
54
141
|
const flushText = () => {
|
|
55
142
|
if (pendingText.length === 0)
|
|
@@ -63,140 +150,156 @@ export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
|
|
|
63
150
|
}
|
|
64
151
|
pendingText = "";
|
|
65
152
|
};
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
lastWasBreak = true;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const chipId = chipIdOf(node);
|
|
83
|
-
if (chipId !== null) {
|
|
84
|
-
// Chip span content is presentation — never read. Unknown ids are
|
|
85
|
-
// leftovers from DOM the model doesn't know (mark dirty, skip);
|
|
86
|
-
// duplicated ids (clone paths) get a fresh identity.
|
|
87
|
-
const chip = resolveChip(chipId);
|
|
88
|
-
if (!chip) {
|
|
153
|
+
for (const token of trimTrailingPadding(tokens)) {
|
|
154
|
+
switch (token.kind) {
|
|
155
|
+
case "text":
|
|
156
|
+
// Adjacent text concatenates logically, never via root.normalize() —
|
|
157
|
+
// WebKit collapses the caret when the selection's node gets merged.
|
|
158
|
+
pendingText += token.text.replace(/\u00A0/g, " ");
|
|
159
|
+
break;
|
|
160
|
+
case "break":
|
|
161
|
+
pendingText += "\n";
|
|
162
|
+
break;
|
|
163
|
+
case "padding":
|
|
164
|
+
// Survived the trim, so it is not the trailing scaffolding: a native
|
|
165
|
+
// edit stranded it mid-document. Not content — repaint from the model.
|
|
89
166
|
dirty = true;
|
|
90
|
-
|
|
167
|
+
break;
|
|
168
|
+
case "chip": {
|
|
169
|
+
// Unknown ids are leftovers from DOM the model doesn't know (mark
|
|
170
|
+
// dirty, skip); duplicated ids (clone paths) get a fresh identity.
|
|
171
|
+
const chip = resolveChip(token.id);
|
|
172
|
+
if (!chip) {
|
|
173
|
+
dirty = true;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
flushText();
|
|
177
|
+
const id = seenChipIds.has(token.id) ? makeId() : token.id;
|
|
178
|
+
seenChipIds.add(token.id);
|
|
179
|
+
doc.push({ type: "chip", id, chip });
|
|
180
|
+
break;
|
|
91
181
|
}
|
|
92
|
-
flushText();
|
|
93
|
-
const id = seenChipIds.has(chipId) ? makeId() : chipId;
|
|
94
|
-
seenChipIds.add(chipId);
|
|
95
|
-
doc.push({ type: "chip", id, chip });
|
|
96
|
-
lastWasBreak = false;
|
|
97
|
-
return;
|
|
98
182
|
}
|
|
99
|
-
// Presentation-only elements (the badge's hint span) never reach the model.
|
|
100
|
-
if (isPresentationOnly(node))
|
|
101
|
-
return;
|
|
102
|
-
// Badge span / unknown inline: transparent wrapper. Block elements also
|
|
103
|
-
// read through, but flag the document for renormalization.
|
|
104
|
-
if (BLOCK_NODE_NAMES.has(node.nodeName))
|
|
105
|
-
dirty = true;
|
|
106
|
-
for (const child of Array.from(node.childNodes))
|
|
107
|
-
visit(child);
|
|
108
|
-
};
|
|
109
|
-
for (const child of Array.from(root.childNodes))
|
|
110
|
-
visit(child);
|
|
111
|
-
// Invert the writer's padding <br>: one trailing "\n" is the padding, not
|
|
112
|
-
// content — but only when a <br> was actually the last rendered node.
|
|
113
|
-
if (lastWasBreak && pendingText.endsWith("\n")) {
|
|
114
|
-
pendingText = pendingText.slice(0, -1);
|
|
115
183
|
}
|
|
116
184
|
flushText();
|
|
117
185
|
return { doc, dirty };
|
|
118
186
|
};
|
|
187
|
+
export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
|
|
188
|
+
const { tokens, dirty: structureDirty } = tokenizeDom(root);
|
|
189
|
+
const { doc, dirty: contentDirty } = foldTokens(tokens, resolveChip, makeId);
|
|
190
|
+
return { doc, dirty: structureDirty || contentDirty };
|
|
191
|
+
};
|
|
119
192
|
// ---------------------------------------------------------------------------
|
|
120
|
-
// Position mapping — DOM points ↔ logical positions
|
|
121
|
-
// counts 1 (it renders a "\n"),
|
|
193
|
+
// Position mapping — DOM points ↔ logical positions, inverses of each other
|
|
194
|
+
// over leafWidth. Chips count 1, a real <br> counts 1 (it renders a "\n"),
|
|
195
|
+
// padding and presentation count 0, wrappers are transparent.
|
|
122
196
|
// ---------------------------------------------------------------------------
|
|
123
197
|
/** Logical position of (node, offset), or null when the point isn't inside root. */
|
|
124
198
|
export const logicalRangeFromDom = (root, targetNode, targetOffset) => {
|
|
125
199
|
let position = 0;
|
|
126
200
|
let found = null;
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
for (const child of children) {
|
|
133
|
-
if (measure(child))
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
found = position;
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
if (node.nodeType === TEXT_NODE) {
|
|
140
|
-
if (node === targetNode) {
|
|
141
|
-
found = position + Math.min(targetOffset, (node.data ?? "").length);
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
position += (node.data ?? "").length;
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
if (node.nodeType !== ELEMENT_NODE)
|
|
148
|
-
return false;
|
|
149
|
-
if (node.nodeName === "BR") {
|
|
150
|
-
position += 1;
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
if (chipIdOf(node) !== null) {
|
|
154
|
-
// A point inside a chip clamps to the chip's start.
|
|
155
|
-
if (node === targetNode || contains(node, targetNode)) {
|
|
156
|
-
found = position;
|
|
157
|
-
return true;
|
|
158
|
-
}
|
|
159
|
-
position += 1;
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
// Presentation-only elements are zero-width; a point inside one clamps to
|
|
163
|
-
// its boundary.
|
|
164
|
-
if (isPresentationOnly(node)) {
|
|
165
|
-
if (node === targetNode || contains(node, targetNode)) {
|
|
166
|
-
found = position;
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
for (const child of Array.from(node.childNodes)) {
|
|
201
|
+
const contains = (parent, target) => childrenOf(parent).some((child) => child === target || contains(child, target));
|
|
202
|
+
// Element point: the offset is a child index — advance past that many
|
|
203
|
+
// children, then record where we landed.
|
|
204
|
+
const measureUpTo = (node, childIndex) => {
|
|
205
|
+
for (const child of childrenOf(node).slice(0, childIndex)) {
|
|
172
206
|
if (visit(child))
|
|
173
207
|
return true;
|
|
174
208
|
}
|
|
175
209
|
return false;
|
|
176
210
|
};
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (child === target || contains(child, target))
|
|
211
|
+
const visit = (node) => {
|
|
212
|
+
const classified = classifyNode(node);
|
|
213
|
+
if (node === targetNode && classified.kind !== "text" && classified.kind !== "chip") {
|
|
214
|
+
if (measureUpTo(node, targetOffset))
|
|
182
215
|
return true;
|
|
216
|
+
found = position;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
switch (classified.kind) {
|
|
220
|
+
case "text":
|
|
221
|
+
if (node === targetNode) {
|
|
222
|
+
found = position + Math.min(targetOffset, classified.text.length);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
position += leafWidth(classified);
|
|
226
|
+
return false;
|
|
227
|
+
case "break":
|
|
228
|
+
case "padding":
|
|
229
|
+
position += leafWidth(classified);
|
|
230
|
+
return false;
|
|
231
|
+
// A point inside a chip or a hint span clamps to that node's start; they
|
|
232
|
+
// differ only in width.
|
|
233
|
+
case "chip":
|
|
234
|
+
case "presentation":
|
|
235
|
+
if (node === targetNode || contains(node, targetNode)) {
|
|
236
|
+
found = position;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
position += leafWidth(classified);
|
|
240
|
+
return false;
|
|
241
|
+
case "wrapper":
|
|
242
|
+
case "block":
|
|
243
|
+
for (const child of childrenOf(node)) {
|
|
244
|
+
if (visit(child))
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
case "ignored":
|
|
249
|
+
return false;
|
|
183
250
|
}
|
|
184
|
-
return false;
|
|
185
251
|
};
|
|
186
252
|
if (root === targetNode) {
|
|
187
|
-
|
|
188
|
-
for (const child of children) {
|
|
189
|
-
if (measure(child))
|
|
190
|
-
return found;
|
|
191
|
-
}
|
|
192
|
-
return position;
|
|
253
|
+
return measureUpTo(root, targetOffset) ? found : position;
|
|
193
254
|
}
|
|
194
|
-
for (const child of
|
|
255
|
+
for (const child of childrenOf(root)) {
|
|
195
256
|
if (visit(child))
|
|
196
257
|
return found;
|
|
197
258
|
}
|
|
198
259
|
return found;
|
|
199
260
|
};
|
|
261
|
+
/** DOM point for a logical position, preferring text-node points. */
|
|
262
|
+
export const domPointFromLogical = (root, position) => {
|
|
263
|
+
let remaining = position;
|
|
264
|
+
const walk = (parent) => {
|
|
265
|
+
for (let index = 0; index < parent.childNodes.length; index++) {
|
|
266
|
+
const child = parent.childNodes[index];
|
|
267
|
+
if (!child)
|
|
268
|
+
continue;
|
|
269
|
+
const classified = classifyNode(toReadable(child));
|
|
270
|
+
switch (classified.kind) {
|
|
271
|
+
case "text": {
|
|
272
|
+
const width = leafWidth(classified);
|
|
273
|
+
if (remaining <= width)
|
|
274
|
+
return { node: child, offset: remaining };
|
|
275
|
+
remaining -= width;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// Atomic children: the point is the gap before them. Padding is
|
|
279
|
+
// zero-width, so the caret can never be written past the model's end.
|
|
280
|
+
case "break":
|
|
281
|
+
case "padding":
|
|
282
|
+
case "chip":
|
|
283
|
+
if (remaining === 0)
|
|
284
|
+
return { node: parent, offset: index };
|
|
285
|
+
remaining -= leafWidth(classified);
|
|
286
|
+
continue;
|
|
287
|
+
case "presentation":
|
|
288
|
+
case "ignored":
|
|
289
|
+
continue;
|
|
290
|
+
case "wrapper":
|
|
291
|
+
case "block": {
|
|
292
|
+
const inner = walk(child);
|
|
293
|
+
if (inner)
|
|
294
|
+
return inner;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return remaining === 0 ? { node: parent, offset: parent.childNodes.length } : null;
|
|
300
|
+
};
|
|
301
|
+
return walk(root) ?? { node: root, offset: root.childNodes.length };
|
|
302
|
+
};
|
|
200
303
|
export const documentToDomSpec = (doc) => {
|
|
201
304
|
const specs = [];
|
|
202
305
|
for (const segment of doc) {
|
|
@@ -236,6 +339,12 @@ const createChipSpan = (id) => {
|
|
|
236
339
|
span.style.whiteSpace = "normal";
|
|
237
340
|
return span;
|
|
238
341
|
};
|
|
342
|
+
const createBreak = (padding) => {
|
|
343
|
+
const br = document.createElement("br");
|
|
344
|
+
if (padding)
|
|
345
|
+
br.setAttribute(PADDING_BREAK_ATTRIBUTE, "");
|
|
346
|
+
return br;
|
|
347
|
+
};
|
|
239
348
|
/**
|
|
240
349
|
* Render the canonical child list. Existing chip spans are reused by id —
|
|
241
350
|
* moving a node preserves its React portal; recreating it would remount the
|
|
@@ -254,7 +363,7 @@ export const renderDocumentToDom = (root, doc) => {
|
|
|
254
363
|
if (spec.kind === "text")
|
|
255
364
|
return document.createTextNode(spec.text);
|
|
256
365
|
if (spec.kind === "br")
|
|
257
|
-
return
|
|
366
|
+
return createBreak(spec.padding);
|
|
258
367
|
const span = existingSpans.get(spec.id) ?? createChipSpan(spec.id);
|
|
259
368
|
// Without an accessible boundary the chip reads as bare prose inside the
|
|
260
369
|
// textbox — label + type gives AT an atomic token ("Rasmus, @ mention").
|
|
@@ -267,42 +376,10 @@ export const renderDocumentToDom = (root, doc) => {
|
|
|
267
376
|
root.replaceChildren(...children);
|
|
268
377
|
return chipElements;
|
|
269
378
|
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
for (let index = 0; index < parent.childNodes.length; index++) {
|
|
275
|
-
const child = parent.childNodes[index];
|
|
276
|
-
if (!child)
|
|
277
|
-
continue;
|
|
278
|
-
if (child.nodeType === TEXT_NODE) {
|
|
279
|
-
const length = child.data.length;
|
|
280
|
-
if (remaining <= length)
|
|
281
|
-
return { node: child, offset: remaining };
|
|
282
|
-
remaining -= length;
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (child.nodeType !== ELEMENT_NODE)
|
|
286
|
-
continue;
|
|
287
|
-
const element = child;
|
|
288
|
-
if (element.nodeName === "BR" || element.hasAttribute("data-chip-id")) {
|
|
289
|
-
if (remaining === 0)
|
|
290
|
-
return { node: parent, offset: index };
|
|
291
|
-
remaining -= 1;
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
// Presentation-only (the badge's hint span): zero width, never a target.
|
|
295
|
-
if (isPresentationOnly(toReadable(element)))
|
|
296
|
-
continue;
|
|
297
|
-
// Transparent wrapper (badge span): descend.
|
|
298
|
-
const inner = walk(element);
|
|
299
|
-
if (inner)
|
|
300
|
-
return inner;
|
|
301
|
-
}
|
|
302
|
-
return remaining === 0 ? { node: parent, offset: parent.childNodes.length } : null;
|
|
303
|
-
};
|
|
304
|
-
return walk(root) ?? { node: root, offset: root.childNodes.length };
|
|
305
|
-
};
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Selection — reading the caret out of the document and writing it back, plus
|
|
381
|
+
// keeping it visible after a programmatic write.
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
306
383
|
export const readSelectionRange = (root) => {
|
|
307
384
|
const selection = root.ownerDocument.getSelection();
|
|
308
385
|
if (!selection || selection.rangeCount === 0)
|
|
@@ -324,6 +401,30 @@ export const writeCaretToDom = (root, start, end = start) => {
|
|
|
324
401
|
const focus = end === start ? anchor : domPointFromLogical(root, end);
|
|
325
402
|
selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
|
|
326
403
|
};
|
|
404
|
+
// Native typing auto-scrolls; programmatic writes don't. Nudge only the nearest
|
|
405
|
+
// scrollable ancestor (never scrollIntoView — it yanks the whole page).
|
|
406
|
+
export const scrollCaretIntoView = (root) => {
|
|
407
|
+
const selection = root.ownerDocument.getSelection();
|
|
408
|
+
if (!selection || selection.rangeCount === 0)
|
|
409
|
+
return;
|
|
410
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
411
|
+
let scroller = root;
|
|
412
|
+
while (scroller && scroller !== root.ownerDocument.body) {
|
|
413
|
+
const { overflowY } = getComputedStyle(scroller);
|
|
414
|
+
if ((overflowY === "auto" || overflowY === "scroll") &&
|
|
415
|
+
scroller.scrollHeight > scroller.clientHeight) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
scroller = scroller.parentElement;
|
|
419
|
+
}
|
|
420
|
+
if (!scroller || scroller === root.ownerDocument.body)
|
|
421
|
+
return;
|
|
422
|
+
const view = scroller.getBoundingClientRect();
|
|
423
|
+
if (rect.top < view.top)
|
|
424
|
+
scroller.scrollTop -= view.top - rect.top;
|
|
425
|
+
else if (rect.bottom > view.bottom)
|
|
426
|
+
scroller.scrollTop += rect.bottom - view.bottom;
|
|
427
|
+
};
|
|
327
428
|
const badgeSpansOf = (root) => [
|
|
328
429
|
...root.querySelectorAll("[data-command-badge]"),
|
|
329
430
|
];
|
|
@@ -334,7 +435,7 @@ const unwrapBadgeSpan = (span) => {
|
|
|
334
435
|
// Presentation children (the hint span) are the badge's own chrome — they
|
|
335
436
|
// die with it rather than spilling into content.
|
|
336
437
|
for (const child of [...span.children]) {
|
|
337
|
-
if (
|
|
438
|
+
if (classifyNode(toReadable(child)).kind === "presentation")
|
|
338
439
|
child.remove();
|
|
339
440
|
}
|
|
340
441
|
while (span.firstChild)
|
|
@@ -402,30 +503,3 @@ export const syncBadge = (root, token) => {
|
|
|
402
503
|
range.insertNode(span);
|
|
403
504
|
return true;
|
|
404
505
|
};
|
|
405
|
-
// ---------------------------------------------------------------------------
|
|
406
|
-
// Caret visibility — native typing auto-scrolls; programmatic writes don't.
|
|
407
|
-
// Nudge only the nearest scrollable ancestor (never scrollIntoView — it
|
|
408
|
-
// yanks the whole page).
|
|
409
|
-
// ---------------------------------------------------------------------------
|
|
410
|
-
export const scrollCaretIntoView = (root) => {
|
|
411
|
-
const selection = root.ownerDocument.getSelection();
|
|
412
|
-
if (!selection || selection.rangeCount === 0)
|
|
413
|
-
return;
|
|
414
|
-
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
415
|
-
let scroller = root;
|
|
416
|
-
while (scroller && scroller !== root.ownerDocument.body) {
|
|
417
|
-
const { overflowY } = getComputedStyle(scroller);
|
|
418
|
-
if ((overflowY === "auto" || overflowY === "scroll") &&
|
|
419
|
-
scroller.scrollHeight > scroller.clientHeight) {
|
|
420
|
-
break;
|
|
421
|
-
}
|
|
422
|
-
scroller = scroller.parentElement;
|
|
423
|
-
}
|
|
424
|
-
if (!scroller || scroller === root.ownerDocument.body)
|
|
425
|
-
return;
|
|
426
|
-
const view = scroller.getBoundingClientRect();
|
|
427
|
-
if (rect.top < view.top)
|
|
428
|
-
scroller.scrollTop -= view.top - rect.top;
|
|
429
|
-
else if (rect.bottom > view.bottom)
|
|
430
|
-
scroller.scrollTop += rect.bottom - view.bottom;
|
|
431
|
-
};
|
|
@@ -6,7 +6,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
// presence attributes (data-open / data-closed) generated from state, plus
|
|
7
7
|
// the animation contract: data-starting-style on the panel's first open
|
|
8
8
|
// frame, data-ending-style while its exit animations run (hide/unmount waits
|
|
9
|
-
// for them), and the panel's natural height as --
|
|
9
|
+
// for them), and the panel's natural height as --panel-height. That value is
|
|
10
|
+
// released once the open transition settles, so an open panel falls back to
|
|
11
|
+
// `auto` and tracks content that grows underneath it.
|
|
10
12
|
// Every part supports the Base UI render prop.
|
|
11
13
|
import { createContext, use, useCallback, useId, useRef, useState, } from "react";
|
|
12
14
|
import { useOpenTransition } from "./render/transition.js";
|
|
@@ -76,9 +78,10 @@ const CollapsiblePanel = ({ keepMounted = false, className, render, style, ...el
|
|
|
76
78
|
{
|
|
77
79
|
id: panelId,
|
|
78
80
|
hidden,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
// Once the open transition settles the height is released back to null, so the
|
|
82
|
+
// property stops being written and a `height: var(…)` consumer falls back to auto
|
|
83
|
+
// — that is what lets an open panel track content that grows.
|
|
84
|
+
style: height !== null ? { "--panel-height": `${height}px` } : undefined,
|
|
82
85
|
},
|
|
83
86
|
elementProps,
|
|
84
87
|
],
|
|
@@ -35,12 +35,6 @@ export type UseOpenTransitionOptions = {
|
|
|
35
35
|
/** Called when the close animation completes, alongside the internal unmount. */
|
|
36
36
|
onClosed?: () => void;
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
39
|
-
* Drives a Base UI-style open/close for an element: keeps it `mounted` through the exit,
|
|
40
|
-
* reports `transitionStatus` (feed it to a stateAttributesMapping for data-open/closed/
|
|
41
|
-
* starting-style/ending-style), optionally measures its natural `height`, and unmounts once
|
|
42
|
-
* `element.getAnimations()` resolve. Composes useTransitionStatus + useOpenChangeComplete.
|
|
43
|
-
*/
|
|
44
38
|
export declare const useOpenTransition: (open: boolean, ref: RefObject<HTMLElement | null>, { measureHeight, onClosed }?: UseOpenTransitionOptions) => {
|
|
45
39
|
mounted: boolean;
|
|
46
40
|
transitionStatus: TransitionStatus;
|
|
@@ -176,24 +176,67 @@ export const useOpenChangeComplete = ({ enabled = true, open, ref, onComplete: o
|
|
|
176
176
|
* starting-style/ending-style), optionally measures its natural `height`, and unmounts once
|
|
177
177
|
* `element.getAnimations()` resolve. Composes useTransitionStatus + useOpenChangeComplete.
|
|
178
178
|
*/
|
|
179
|
+
// Inline alignment can distort a scroll-based measurement, so it is neutralized for the read
|
|
180
|
+
// and restored immediately after (Base UI does the same before measuring a collapsible).
|
|
181
|
+
const ALIGNMENT_PROPERTIES = ["justify-content", "align-items", "align-content", "justify-items"];
|
|
182
|
+
const measureNaturalHeight = (element) => {
|
|
183
|
+
const saved = ALIGNMENT_PROPERTIES.map((property) => [
|
|
184
|
+
property,
|
|
185
|
+
element.style.getPropertyValue(property),
|
|
186
|
+
element.style.getPropertyPriority(property),
|
|
187
|
+
]);
|
|
188
|
+
for (const [property] of saved) {
|
|
189
|
+
element.style.setProperty(property, "initial", "important");
|
|
190
|
+
}
|
|
191
|
+
const naturalHeight = element.scrollHeight;
|
|
192
|
+
for (const [property, value, priority] of saved) {
|
|
193
|
+
if (value)
|
|
194
|
+
element.style.setProperty(property, value, priority);
|
|
195
|
+
else
|
|
196
|
+
element.style.removeProperty(property);
|
|
197
|
+
}
|
|
198
|
+
return naturalHeight;
|
|
199
|
+
};
|
|
179
200
|
export const useOpenTransition = (open, ref, { measureHeight = false, onClosed } = {}) => {
|
|
180
|
-
|
|
201
|
+
// Both flags on: `idle` is the settled-open status the height release gates on, and
|
|
202
|
+
// deferring `ending` by a frame leaves one frame where a closing panel is still at its
|
|
203
|
+
// open size — which is where the close has to be measured.
|
|
204
|
+
const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, true, true);
|
|
181
205
|
const [height, setHeight] = useState(null);
|
|
182
|
-
// Measure on the transitional frames (before the CSS transition runs). scrollHeight is the
|
|
183
|
-
// natural content height regardless of any clamp the data-starting/ending-style sets.
|
|
184
206
|
useIsoLayoutEffect(() => {
|
|
185
207
|
if (!measureHeight)
|
|
186
208
|
return;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
209
|
+
const element = ref.current;
|
|
210
|
+
if (!element)
|
|
211
|
+
return;
|
|
212
|
+
// Closing: measure on the deferred frame. By the `ending` frame the consumer's closed
|
|
213
|
+
// styles have landed, so we would measure the clamped box instead of the natural one.
|
|
214
|
+
if (!open && mounted && (transitionStatus === "idle" || transitionStatus === "starting")) {
|
|
215
|
+
setHeight(measureNaturalHeight(element));
|
|
216
|
+
return;
|
|
191
217
|
}
|
|
192
|
-
|
|
218
|
+
// Opening: measure on the first frame, before the transition runs.
|
|
219
|
+
if (open && transitionStatus === "starting") {
|
|
220
|
+
setHeight(measureNaturalHeight(element));
|
|
221
|
+
}
|
|
222
|
+
}, [measureHeight, open, mounted, transitionStatus, ref]);
|
|
223
|
+
// Release the measurement once the open transition settles, so the panel tracks content
|
|
224
|
+
// that grows while it is open. With `height` null the custom property is never written, so
|
|
225
|
+
// a consumer's `height: var(--…)` is invalid at computed-value time and falls back to
|
|
226
|
+
// `auto`. The close re-measures a pixel value first, so it still animates from a number.
|
|
227
|
+
useOpenChangeComplete({
|
|
228
|
+
open: true,
|
|
229
|
+
ref,
|
|
230
|
+
enabled: measureHeight && open && mounted && transitionStatus === "idle",
|
|
231
|
+
onComplete: () => setHeight(null),
|
|
232
|
+
});
|
|
193
233
|
useOpenChangeComplete({
|
|
194
234
|
open,
|
|
195
235
|
ref,
|
|
196
|
-
|
|
236
|
+
// Gated on `ending`, not merely `!open`: with the ending state deferred by a frame, an
|
|
237
|
+
// earlier check would call getAnimations() before the closed styles applied, find
|
|
238
|
+
// nothing running, and cut the exit off.
|
|
239
|
+
enabled: !open && mounted && transitionStatus === "ending",
|
|
197
240
|
onComplete: () => {
|
|
198
241
|
setMounted(false);
|
|
199
242
|
onClosed?.();
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
export declare const prefersReducedMotion: () => boolean;
|
|
2
2
|
export declare const resolveScrollBehavior: (behavior: ScrollBehavior) => ScrollBehavior;
|
|
3
3
|
export declare const scrollContainerTo: (el: HTMLElement, top: number, behavior: ScrollBehavior) => void;
|
|
4
|
+
export type ViewportBox = {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const readViewportBox: (el: HTMLElement | null) => ViewportBox;
|
|
9
|
+
export declare const sameViewportBox: (a: ViewportBox, b: ViewportBox) => boolean;
|
|
4
10
|
export declare const wasPrepended: (mutations: MutationRecord[], previousFirst: Element | null, content: HTMLElement) => boolean;
|
|
5
11
|
export declare const DEFAULT_BOTTOM_OFFSET = 128;
|
|
6
|
-
export declare const
|
|
7
|
-
export declare const
|
|
12
|
+
export declare const DOCK_SELECTOR = "[data-thread-composer]";
|
|
13
|
+
export declare const queryDock: (root: HTMLElement) => HTMLElement | null;
|
|
8
14
|
/**
|
|
9
|
-
* Height (px) to reserve at the bottom for the dock
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* mounted yet.
|
|
15
|
+
* Height (px) to reserve at the bottom for the dock: its top edge down to the
|
|
16
|
+
* root's bottom, plus the content gap. Measured against the root's bottom
|
|
17
|
+
* rather than the slot's own height, so a dock that floats above the bottom
|
|
18
|
+
* edge still reserves the space beneath it. Parts that must NOT reserve space
|
|
19
|
+
* — the command-list / ask-user panel, the scroll button — are positioned out
|
|
20
|
+
* of the slot's flow. Returns null when the slot isn't mounted yet.
|
|
16
21
|
*/
|
|
17
|
-
export declare const measureDockInset: (root: HTMLElement
|
|
22
|
+
export declare const measureDockInset: (root: HTMLElement) => number | null;
|
|
18
23
|
export declare const measureTopInset: (root: HTMLElement) => number;
|
package/dist/thread/geometry.js
CHANGED
|
@@ -11,6 +11,8 @@ export const resolveScrollBehavior = (behavior) => behavior === "smooth" && pref
|
|
|
11
11
|
export const scrollContainerTo = (el, top, behavior) => {
|
|
12
12
|
el.scrollTo({ top, behavior: resolveScrollBehavior(behavior) });
|
|
13
13
|
};
|
|
14
|
+
export const readViewportBox = (el) => el ? { width: el.clientWidth, height: el.clientHeight } : { width: 0, height: 0 };
|
|
15
|
+
export const sameViewportBox = (a, b) => a.width === b.width && a.height === b.height;
|
|
14
16
|
// A prepend = rows were added, nothing removed, and the previously-first row
|
|
15
17
|
// is still connected but no longer first (older history loading in above).
|
|
16
18
|
export const wasPrepended = (mutations, previousFirst, content) => previousFirst?.isConnected === true &&
|
|
@@ -22,36 +24,23 @@ export const DEFAULT_BOTTOM_OFFSET = 128;
|
|
|
22
24
|
// Breathing room between the last line of content and the composer dock. The
|
|
23
25
|
// bottom overlay spans it in the styled layer.
|
|
24
26
|
const COMPOSER_GAP = 32;
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export const queryDockParts = (root, dockSelector) => {
|
|
30
|
-
try {
|
|
31
|
-
return [...root.querySelectorAll(dockSelector)];
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return [];
|
|
35
|
-
}
|
|
36
|
-
};
|
|
27
|
+
// The dock is the Thread.Composer slot — the one element the thread reserves
|
|
28
|
+
// space for. Null until it mounts.
|
|
29
|
+
export const DOCK_SELECTOR = "[data-thread-composer]";
|
|
30
|
+
export const queryDock = (root) => root.querySelector(DOCK_SELECTOR);
|
|
37
31
|
/**
|
|
38
|
-
* Height (px) to reserve at the bottom for the dock
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* mounted yet.
|
|
32
|
+
* Height (px) to reserve at the bottom for the dock: its top edge down to the
|
|
33
|
+
* root's bottom, plus the content gap. Measured against the root's bottom
|
|
34
|
+
* rather than the slot's own height, so a dock that floats above the bottom
|
|
35
|
+
* edge still reserves the space beneath it. Parts that must NOT reserve space
|
|
36
|
+
* — the command-list / ask-user panel, the scroll button — are positioned out
|
|
37
|
+
* of the slot's flow. Returns null when the slot isn't mounted yet.
|
|
45
38
|
*/
|
|
46
|
-
export const measureDockInset = (root
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const top = part.getBoundingClientRect().top;
|
|
50
|
-
if (dockTop === null || top > dockTop)
|
|
51
|
-
dockTop = top;
|
|
52
|
-
}
|
|
53
|
-
if (dockTop === null)
|
|
39
|
+
export const measureDockInset = (root) => {
|
|
40
|
+
const dock = queryDock(root);
|
|
41
|
+
if (!dock)
|
|
54
42
|
return null;
|
|
43
|
+
const dockTop = dock.getBoundingClientRect().top;
|
|
55
44
|
return Math.round(root.getBoundingClientRect().bottom - dockTop + COMPOSER_GAP);
|
|
56
45
|
};
|
|
57
46
|
// Top inset reserved by the top overlay, measured straight off the rendered
|
package/dist/thread/thread.d.ts
CHANGED
|
@@ -37,17 +37,8 @@ export type ThreadRootProps = PrimitiveProps<"div"> & {
|
|
|
37
37
|
* current, so leave it off unless older content actually loads in above.
|
|
38
38
|
*/
|
|
39
39
|
preserveScrollOnPrepend?: boolean;
|
|
40
|
-
/**
|
|
41
|
-
* CSS selector for the bottom-docked parts the thread reserves space for.
|
|
42
|
-
* Every match is observed for resize, but the reserved inset is measured
|
|
43
|
-
* from a single reference — the bottom-most match's top edge, not a sum of
|
|
44
|
-
* matches — so a taller part stacked above it must fit within the content
|
|
45
|
-
* gap. Must be a valid CSS selector. Defaults to the styled composer's dock
|
|
46
|
-
* slots.
|
|
47
|
-
*/
|
|
48
|
-
dockSelector?: string;
|
|
49
40
|
};
|
|
50
|
-
export declare const ThreadRoot: ({ autoScroll, preserveScrollOnPrepend,
|
|
41
|
+
export declare const ThreadRoot: ({ autoScroll, preserveScrollOnPrepend, className, render, style, ...elementProps }: ThreadRootProps) => import("react").JSX.Element;
|
|
51
42
|
export type ThreadOverlayState = {
|
|
52
43
|
/** Which edge this overlay marks, surfaced as data-thread-overlay. */
|
|
53
44
|
direction: "top" | "bottom";
|
package/dist/thread/thread.js
CHANGED
|
@@ -12,7 +12,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
12
12
|
import { createContext, memo, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
13
13
|
import { useRefWithInit } from "../internal/render/useRefWithInit.js";
|
|
14
14
|
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
15
|
-
import { DEFAULT_BOTTOM_OFFSET,
|
|
15
|
+
import { DEFAULT_BOTTOM_OFFSET, measureDockInset, measureTopInset, queryDock, readViewportBox, resolveScrollBehavior, sameViewportBox, scrollContainerTo, wasPrepended, } from "./geometry.js";
|
|
16
16
|
import { createEdgeStore, createVisibilityStore, EMPTY_VISIBILITY, } from "./stores.js";
|
|
17
17
|
// Latest-ref: read the current value from long-lived effects/callbacks without
|
|
18
18
|
// re-subscribing them when it changes.
|
|
@@ -299,14 +299,24 @@ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
|
|
|
299
299
|
// late, so a position check here would scroll on stale "at bottom" the
|
|
300
300
|
// instant after the user wheels up, hijacking their scroll and re-arming
|
|
301
301
|
// the follow in a loop they can't escape.
|
|
302
|
+
//
|
|
303
|
+
// A resize alone can't say whether content grew or the viewport did: the
|
|
304
|
+
// reserve tracks --thread-turn-area, so a container animating open resizes
|
|
305
|
+
// `content` every frame. Only a viewport change moves the scroller's own
|
|
306
|
+
// box — re-pin those instantly instead of smooth-scrolling a moving target
|
|
307
|
+
// for the length of the animation.
|
|
308
|
+
let viewport = readViewportBox(scrollRef.current);
|
|
302
309
|
const follow = () => {
|
|
310
|
+
const previous = viewport;
|
|
311
|
+
viewport = readViewportBox(scrollRef.current);
|
|
312
|
+
const resized = !sameViewportBox(previous, viewport);
|
|
303
313
|
if (skipNextResize) {
|
|
304
314
|
skipNextResize = false;
|
|
305
315
|
return;
|
|
306
316
|
}
|
|
307
|
-
if (followingRef.current)
|
|
308
|
-
|
|
309
|
-
|
|
317
|
+
if (!followingRef.current)
|
|
318
|
+
return;
|
|
319
|
+
scrollToBottom(resized ? "instant" : "smooth");
|
|
310
320
|
};
|
|
311
321
|
land();
|
|
312
322
|
const turns = new MutationObserver(land);
|
|
@@ -517,32 +527,34 @@ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
|
|
|
517
527
|
* (--thread-turn-min-height) to it; otherwise the reserve falls back to 0.
|
|
518
528
|
* Recomputes only on root (window) / composer-dock resize — never per token.
|
|
519
529
|
*/
|
|
520
|
-
const useThreadInsets = (rootRef
|
|
530
|
+
const useThreadInsets = (rootRef) => {
|
|
521
531
|
useLayoutEffect(() => {
|
|
522
532
|
const root = rootRef.current;
|
|
523
533
|
if (!root)
|
|
524
534
|
return;
|
|
535
|
+
// Read both insets before writing either property: a write followed by a forced
|
|
536
|
+
// layout read dips scrollHeight for a frame, and that scrollTop clamp is final.
|
|
525
537
|
const apply = () => {
|
|
526
|
-
const bottomInset = measureDockInset(root
|
|
538
|
+
const bottomInset = measureDockInset(root);
|
|
539
|
+
const topInset = measureTopInset(root);
|
|
540
|
+
const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
|
|
527
541
|
if (bottomInset !== null) {
|
|
528
542
|
root.style.setProperty("--thread-overlay-bottom-height", `${bottomInset}px`);
|
|
529
543
|
}
|
|
530
|
-
const topInset = measureTopInset(root);
|
|
531
|
-
const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
|
|
532
544
|
root.style.setProperty("--thread-turn-area", `${area}px`);
|
|
533
545
|
};
|
|
534
546
|
apply();
|
|
535
547
|
const observer = new ResizeObserver(apply);
|
|
536
548
|
observer.observe(root);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
549
|
+
const dock = queryDock(root);
|
|
550
|
+
if (dock)
|
|
551
|
+
observer.observe(dock);
|
|
540
552
|
return () => observer.disconnect();
|
|
541
|
-
}, [
|
|
553
|
+
}, [rootRef]);
|
|
542
554
|
};
|
|
543
|
-
export const ThreadRoot = ({ autoScroll = "follow", preserveScrollOnPrepend = false,
|
|
555
|
+
export const ThreadRoot = ({ autoScroll = "follow", preserveScrollOnPrepend = false, className, render, style, ...elementProps }) => {
|
|
544
556
|
const rootRef = useRef(null);
|
|
545
|
-
useThreadInsets(rootRef
|
|
557
|
+
useThreadInsets(rootRef);
|
|
546
558
|
const scroll = useThreadScroll(rootRef, autoScroll, preserveScrollOnPrepend);
|
|
547
559
|
const element = useRenderElement("div", { className, render, style }, {
|
|
548
560
|
ref: rootRef,
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentface/chat",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Headless chat UI primitives — unstyled compound components, hooks, and wire formats for building AI chat interfaces.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"author": "Intentface",
|
|
9
|
-
"homepage": "https://intentface.
|
|
9
|
+
"homepage": "https://ui.intentface.com",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/Intentface/intentface-chat.git",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/add-dist-extensions.mjs && node scripts/smoke-dist.mjs",
|
|
81
81
|
"test": "bun test",
|
|
82
82
|
"typecheck": "tsc --noEmit",
|
|
83
|
-
"
|
|
84
|
-
"
|
|
83
|
+
"verify:release": "bun run typecheck && bun test && bun run build && publint",
|
|
84
|
+
"prepack": "bun run build"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
87
|
"@floating-ui/dom": "^1.7.6",
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
"@types/react": "^19",
|
|
99
99
|
"@types/react-dom": "^19",
|
|
100
100
|
"axe-core": "^4.12.1",
|
|
101
|
+
"publint": "0.3.23",
|
|
101
102
|
"typescript": "^5"
|
|
102
103
|
},
|
|
103
104
|
"publishConfig": {
|