@dxos/react-ui-feed 0.11.1 → 0.12.0-next-73daef452f1464b62efdbca75fb0067c1066f264
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/dist/lib/chunk-src.mjs +270 -1020
- package/dist/lib/chunk-src.mjs.map +1 -1
- package/dist/lib/debug.mjs +296 -0
- package/dist/lib/debug.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -2
- package/dist/lib/testing.mjs +60 -266
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +8 -1
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -1
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -1
- package/dist/types/src/components/MessageList/MessageList.d.ts +40 -25
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -1
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -1
- package/dist/types/src/components/Minimap/Minimap.d.ts +1 -1
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -1
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -1
- package/dist/types/src/components/Outline/Outline.stories.d.ts +4 -3
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -1
- package/dist/types/src/debug/index.d.ts +1 -1
- package/dist/types/src/debug/index.d.ts.map +1 -1
- package/dist/types/src/debug/{use-debug.d.ts → useDebug.d.ts} +1 -1
- package/dist/types/src/debug/useDebug.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +0 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useFeedNavigation.d.ts +2 -4
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -1
- package/dist/types/src/model/feed-model.d.ts +6 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -1
- package/dist/types/src/model/feed.d.ts +1 -1
- package/dist/types/src/model/feed.d.ts.map +1 -1
- package/dist/types/src/model/index.d.ts +0 -1
- package/dist/types/src/model/index.d.ts.map +1 -1
- package/dist/types/src/model/useListModel.d.ts +1 -6
- package/dist/types/src/model/useListModel.d.ts.map +1 -1
- package/dist/types/src/stories/bridge.stories.d.ts +5 -10
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -1
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -1
- package/dist/types/src/stories/navigation.stories.d.ts +9 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -1
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -1
- package/dist/types/src/stories/widget.stories.d.ts +10 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -1
- package/dist/types/src/testing/FeedStory.d.ts +3 -3
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -1
- package/dist/types/src/testing/MessageWindow.d.ts +1 -1
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -16
- package/src/components/Block/MarkdownBlock.tsx +195 -117
- package/src/components/Block/block.stories.tsx +11 -11
- package/src/components/MessageList/MessageList.stories.tsx +6 -3
- package/src/components/MessageList/MessageList.tsx +217 -50
- package/src/components/Minimap/Minimap.stories.tsx +1 -2
- package/src/components/Minimap/Minimap.tsx +1 -2
- package/src/debug/Debug.tsx +2 -2
- package/src/debug/index.ts +1 -1
- package/src/hooks/index.ts +0 -2
- package/src/hooks/useFeedNavigation.ts +36 -11
- package/src/model/feed-model.ts +9 -0
- package/src/model/feed.ts +6 -8
- package/src/model/index.ts +0 -1
- package/src/model/useListModel.ts +1 -17
- package/src/stories/baseline.stories.tsx +2 -2
- package/src/stories/bridge.stories.tsx +18 -34
- package/src/stories/fill.stories.tsx +2 -2
- package/src/stories/horizontal.stories.tsx +1 -2
- package/src/stories/navigation.stories.tsx +41 -0
- package/src/stories/streaming.stories.tsx +1 -1
- package/src/stories/tail.stories.tsx +18 -6
- package/src/stories/widget.stories.tsx +34 -0
- package/src/testing/FeedStory.tsx +6 -58
- package/src/testing/MessageWindow.tsx +1 -2
- package/src/testing/scenarios.tsx +40 -17
- package/dist/types/src/debug/use-debug.d.ts.map +0 -1
- package/dist/types/src/hooks/follow.d.ts +0 -89
- package/dist/types/src/hooks/follow.d.ts.map +0 -1
- package/dist/types/src/hooks/follow.stories.d.ts +0 -25
- package/dist/types/src/hooks/follow.stories.d.ts.map +0 -1
- package/dist/types/src/hooks/follow.test.d.ts +0 -2
- package/dist/types/src/hooks/follow.test.d.ts.map +0 -1
- package/dist/types/src/hooks/useFollow.d.ts +0 -46
- package/dist/types/src/hooks/useFollow.d.ts.map +0 -1
- package/dist/types/src/model/list-model.d.ts +0 -61
- package/dist/types/src/model/list-model.d.ts.map +0 -1
- package/dist/types/src/model/list-model.test.d.ts +0 -2
- package/dist/types/src/model/list-model.test.d.ts.map +0 -1
- package/dist/types/src/stories/assistant.stories.d.ts +0 -38
- package/dist/types/src/stories/assistant.stories.d.ts.map +0 -1
- package/dist/types/src/virtualizer/Window.d.ts +0 -137
- package/dist/types/src/virtualizer/Window.d.ts.map +0 -1
- package/dist/types/src/virtualizer/Window.stories.d.ts +0 -112
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +0 -1
- package/dist/types/src/virtualizer/index.d.ts +0 -3
- package/dist/types/src/virtualizer/index.d.ts.map +0 -1
- package/dist/types/src/virtualizer/placement.d.ts +0 -188
- package/dist/types/src/virtualizer/placement.d.ts.map +0 -1
- package/dist/types/src/virtualizer/placement.test.d.ts +0 -2
- package/dist/types/src/virtualizer/placement.test.d.ts.map +0 -1
- package/src/hooks/follow.stories.tsx +0 -200
- package/src/hooks/follow.test.ts +0 -105
- package/src/hooks/follow.ts +0 -241
- package/src/hooks/useFollow.ts +0 -258
- package/src/model/list-model.test.ts +0 -71
- package/src/model/list-model.ts +0 -152
- package/src/stories/assistant.stories.tsx +0 -425
- package/src/virtualizer/Window.stories.tsx +0 -683
- package/src/virtualizer/Window.tsx +0 -435
- package/src/virtualizer/index.ts +0 -6
- package/src/virtualizer/placement.test.ts +0 -312
- package/src/virtualizer/placement.ts +0 -403
- /package/src/debug/{use-debug.tsx → useDebug.tsx} +0 -0
package/dist/lib/chunk-src.mjs
CHANGED
|
@@ -1,178 +1,14 @@
|
|
|
1
|
-
import { createContext, memo, useCallback, useContext, useEffect,
|
|
1
|
+
import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { EditorState, StateEffect, StateField } from "@codemirror/state";
|
|
4
4
|
import { Decoration, EditorView } from "@codemirror/view";
|
|
5
5
|
import DOMPurify from "dompurify";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
|
-
import { Column, Popover, ScrollArea, composable, composableProps, setRef, useThemeContext } from "@dxos/react-ui";
|
|
7
|
+
import { Column, IconButton, Popover, ScrollArea, composable, composableProps, setRef, useThemeContext } from "@dxos/react-ui";
|
|
8
8
|
import { createBasicExtensions, createMarkdownExtensions, createThemeExtensions, decorateMarkdown, extendedMarkdown, xmlBlockDecoration, xmlFormatting, xmlTags } from "@dxos/ui-editor";
|
|
9
9
|
import { createContext as createContext$1 } from "@radix-ui/react-context";
|
|
10
|
-
import
|
|
11
|
-
import * as Registry from "effect/unstable/reactivity/AtomRegistry";
|
|
10
|
+
import { ListModel, useFollow, useWindow } from "@dxos/react-ui-virtual";
|
|
12
11
|
import { mx } from "@dxos/ui-theme";
|
|
13
|
-
//#region src/hooks/follow.ts
|
|
14
|
-
var DEFAULT_MAX_SPEED = 2;
|
|
15
|
-
var DEFAULT_ACCELERATION = 12;
|
|
16
|
-
var DEFAULT_DECELERATION = 6;
|
|
17
|
-
/**
|
|
18
|
-
* Gentler than the ramp up, and deliberately so.
|
|
19
|
-
*
|
|
20
|
-
* Braking distance is `v²/2d`, so matching the two rates sheds full speed in three rows — a travel
|
|
21
|
-
* of any length then cruises to within a few rows of the target and stops, which reads as an abrupt
|
|
22
|
-
* halt however correct the curve is. At a third of the acceleration the landing occupies nine rows
|
|
23
|
-
* and a second and a half, which is long enough to see.
|
|
24
|
-
*/
|
|
25
|
-
var DEFAULT_ROW_HEIGHT = 120;
|
|
26
|
-
/** Within this distance the follow has arrived; below it the animation would only jitter. */
|
|
27
|
-
var ARRIVED = .5;
|
|
28
|
-
/**
|
|
29
|
-
* Speed for the next frame of a follow.
|
|
30
|
-
*
|
|
31
|
-
* Ramps up to `maxSpeed` and back down so the travel starts and ends gently rather than jumping.
|
|
32
|
-
* The cap is whichever is lower: the ceiling, or the speed from which the remaining distance can
|
|
33
|
-
* still be shed at `acceleration` (`v = sqrt(2·a·d)`) — that second term is what turns the arrival
|
|
34
|
-
* into a deceleration instead of a stop, and what keeps a growing target at cruise speed.
|
|
35
|
-
*
|
|
36
|
-
* Acceleration is rate-limited so a start is gradual; **deceleration is not**, because the braking
|
|
37
|
-
* curve is already a smooth ramp to zero and easing onto it instead leaves the speed above the
|
|
38
|
-
* curve for the whole approach — the follow then runs out of distance while still moving and the
|
|
39
|
-
* arrival reads as a stop rather than a landing.
|
|
40
|
-
*/
|
|
41
|
-
var stepVelocity = ({ velocity, distance, dt, maxSpeed, acceleration, deceleration = acceleration }) => {
|
|
42
|
-
const braking = Math.sqrt(2 * deceleration * Math.max(distance, 0));
|
|
43
|
-
const desired = Math.min(maxSpeed, braking);
|
|
44
|
-
return velocity < desired ? Math.min(desired, velocity + acceleration * dt) : desired;
|
|
45
|
-
};
|
|
46
|
-
/**
|
|
47
|
-
* **Not wired into `MessageList` at present.** The placement engine follows the tail by holding an
|
|
48
|
-
* intent and correcting the offset, which arrives instantly; this class is the animated travel the
|
|
49
|
-
* old engine used, kept with its tests because the glide is worth having back and nothing about it
|
|
50
|
-
* was wrong. Wiring it is a decision about feel, not a repair.
|
|
51
|
-
*/
|
|
52
|
-
/**
|
|
53
|
-
* Keeps an element scrolled to its bottom while its content grows.
|
|
54
|
-
*
|
|
55
|
-
* Native `scrollTo({ behavior: 'smooth' })` restarts its easing on every call, so a target that
|
|
56
|
-
* moves every chunk never leaves the start of the curve — the motion reads as a stutter. This
|
|
57
|
-
* carries velocity across frames instead: it accelerates as a stream begins, cruises while content
|
|
58
|
-
* keeps arriving, and decelerates onto the tail when it stops.
|
|
59
|
-
*
|
|
60
|
-
* `@dxos/ui-editor`'s `createCrawler` follows a streaming CodeMirror document the same way, with a
|
|
61
|
-
* critically-damped spring. The two are deliberately separate: that one is bound to `view.scrollDOM`
|
|
62
|
-
* and driven by editor effects, where a feed owns its own scroll container. The models differ in
|
|
63
|
-
* feel — a spring's speed falls off with distance, where this holds a ceiling and brakes onto the
|
|
64
|
-
* target, which keeps a fast stream at a constant rate rather than an ever-widening lag.
|
|
65
|
-
*/
|
|
66
|
-
var ScrollFollower = class {
|
|
67
|
-
_element;
|
|
68
|
-
#maxSpeed;
|
|
69
|
-
#acceleration;
|
|
70
|
-
#deceleration;
|
|
71
|
-
#rowHeight;
|
|
72
|
-
#trailing;
|
|
73
|
-
#frame;
|
|
74
|
-
#velocity = 0;
|
|
75
|
-
#timestamp = 0;
|
|
76
|
-
#braking = false;
|
|
77
|
-
constructor(_element, { maxSpeed = DEFAULT_MAX_SPEED, acceleration = DEFAULT_ACCELERATION, deceleration = DEFAULT_DECELERATION, rowHeight = () => DEFAULT_ROW_HEIGHT, trailing = () => 0 } = {}) {
|
|
78
|
-
this._element = _element;
|
|
79
|
-
this.#maxSpeed = maxSpeed;
|
|
80
|
-
this.#acceleration = acceleration;
|
|
81
|
-
this.#deceleration = deceleration;
|
|
82
|
-
this.#rowHeight = rowHeight;
|
|
83
|
-
this.#trailing = trailing;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* The bottom of the last row, which is not the bottom of the document.
|
|
87
|
-
*
|
|
88
|
-
* A feed may reserve empty space below its last row so that row can be brought to the top of the
|
|
89
|
-
* viewport. That space is somewhere the reader may go and nowhere the follow should: chasing the
|
|
90
|
-
* element's own maximum walks the feed off the end of the conversation and keeps going.
|
|
91
|
-
*/
|
|
92
|
-
get #bottom() {
|
|
93
|
-
return this._element.scrollHeight - this.#trailing() - this._element.clientHeight;
|
|
94
|
-
}
|
|
95
|
-
get running() {
|
|
96
|
-
return this.#frame !== void 0;
|
|
97
|
-
}
|
|
98
|
-
/** Current speed, rows/s. Exposed so a harness can show the ramp. */
|
|
99
|
-
get velocity() {
|
|
100
|
-
return this.#velocity / Math.max(this.#rowHeight(), 1);
|
|
101
|
-
}
|
|
102
|
-
/** Starts (or continues) travelling towards the bottom. Safe to call on every content change. */
|
|
103
|
-
start() {
|
|
104
|
-
this.#braking = false;
|
|
105
|
-
if (this.#frame !== void 0) return;
|
|
106
|
-
this.#timestamp = performance.now();
|
|
107
|
-
this.#frame = requestAnimationFrame(this.#tick);
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Coasts to a halt, shedding speed at `deceleration` — what a stream ending or a stop control
|
|
111
|
-
* means. Cutting the animation instead would halt mid-glide, which is the one motion the reader
|
|
112
|
-
* cannot have caused and so reads as a fault.
|
|
113
|
-
*/
|
|
114
|
-
stop() {
|
|
115
|
-
if (this.#frame === void 0) {
|
|
116
|
-
this.#velocity = 0;
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
this.#braking = true;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Halts immediately, discarding the velocity — the reader has taken over, or the list is going
|
|
123
|
-
* away. Coasting here would fight a hand already on the scrollbar.
|
|
124
|
-
*/
|
|
125
|
-
cancel() {
|
|
126
|
-
if (this.#frame !== void 0) {
|
|
127
|
-
cancelAnimationFrame(this.#frame);
|
|
128
|
-
this.#frame = void 0;
|
|
129
|
-
}
|
|
130
|
-
this.#braking = false;
|
|
131
|
-
this.#velocity = 0;
|
|
132
|
-
}
|
|
133
|
-
/** Jumps to the bottom with no animation. */
|
|
134
|
-
jump() {
|
|
135
|
-
this.cancel();
|
|
136
|
-
this._element.scrollTop = Math.max(0, this.#bottom);
|
|
137
|
-
}
|
|
138
|
-
#tick = (timestamp) => {
|
|
139
|
-
const dt = Math.min((timestamp - this.#timestamp) / 1e3, .05);
|
|
140
|
-
this.#timestamp = timestamp;
|
|
141
|
-
const target = Math.max(0, this.#bottom);
|
|
142
|
-
const distance = target - this._element.scrollTop;
|
|
143
|
-
if (this.#braking) {
|
|
144
|
-
const rowHeight = Math.max(this.#rowHeight(), 1);
|
|
145
|
-
this.#velocity = Math.max(0, this.#velocity - this.#deceleration * rowHeight * dt);
|
|
146
|
-
if (this.#velocity <= 0 || distance <= ARRIVED) {
|
|
147
|
-
this.#frame = void 0;
|
|
148
|
-
this.#velocity = 0;
|
|
149
|
-
this.#braking = false;
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
|
|
153
|
-
this.#frame = requestAnimationFrame(this.#tick);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (distance <= ARRIVED) {
|
|
157
|
-
this._element.scrollTop = target;
|
|
158
|
-
this.#frame = void 0;
|
|
159
|
-
this.#velocity = 0;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const rowHeight = Math.max(this.#rowHeight(), 1);
|
|
163
|
-
this.#velocity = stepVelocity({
|
|
164
|
-
velocity: this.#velocity,
|
|
165
|
-
distance,
|
|
166
|
-
dt,
|
|
167
|
-
maxSpeed: this.#maxSpeed * rowHeight,
|
|
168
|
-
acceleration: this.#acceleration * rowHeight,
|
|
169
|
-
deceleration: this.#deceleration * rowHeight
|
|
170
|
-
});
|
|
171
|
-
this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
|
|
172
|
-
this.#frame = requestAnimationFrame(this.#tick);
|
|
173
|
-
};
|
|
174
|
-
};
|
|
175
|
-
//#endregion
|
|
176
12
|
//#region src/hooks/useDecorations.tsx
|
|
177
13
|
var DecorationsContext = createContext(/* @__PURE__ */ new Map());
|
|
178
14
|
/**
|
|
@@ -203,6 +39,8 @@ var useDecorations = (id) => {
|
|
|
203
39
|
};
|
|
204
40
|
//#endregion
|
|
205
41
|
//#region src/hooks/useFeedNavigation.ts
|
|
42
|
+
/** How long a commanded destination outranks the scroll offset as "where the reader is". */
|
|
43
|
+
var PENDING_WINDOW = 1500;
|
|
206
44
|
/**
|
|
207
45
|
* The one navigation seam (SPEC: useFeedNavigation): toolbar buttons, arrow keys, the outline rail
|
|
208
46
|
* and the minimap all call this, so they cannot disagree about what a step is.
|
|
@@ -216,190 +54,55 @@ var useDecorations = (id) => {
|
|
|
216
54
|
* Distinct from react-ui-list's `useListNavigation`, which is focus traversal over fully-mounted
|
|
217
55
|
* rows — a different problem in the same naming family.
|
|
218
56
|
*/
|
|
219
|
-
var useFeedNavigation = ({ controller, stops, current, count,
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}, [
|
|
225
|
-
controller,
|
|
57
|
+
var useFeedNavigation = ({ controller, stops, current, count, onNavigate }) => {
|
|
58
|
+
const pending = useRef(void 0);
|
|
59
|
+
const optionsRef = useRef({
|
|
60
|
+
stops,
|
|
61
|
+
current,
|
|
226
62
|
count,
|
|
227
|
-
scrollPastEnd,
|
|
228
63
|
onNavigate
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (!all.length) return;
|
|
234
|
-
const at = current();
|
|
235
|
-
const next = delta > 0 ? all.find(({ index }) => index > at) ?? all[all.length - 1] : [...all].reverse().find(({ index }) => index < at) ?? all[0];
|
|
236
|
-
jumpTo(next.index, "smooth");
|
|
237
|
-
}, [
|
|
238
|
-
stops,
|
|
239
|
-
current,
|
|
240
|
-
jumpTo
|
|
241
|
-
]),
|
|
242
|
-
jumpTo,
|
|
243
|
-
first: useCallback(() => jumpTo(0), [jumpTo]),
|
|
244
|
-
last: useCallback(() => jumpTo(count() - 1), [jumpTo, count])
|
|
245
|
-
};
|
|
246
|
-
};
|
|
247
|
-
//#endregion
|
|
248
|
-
//#region src/hooks/useFollow.ts
|
|
249
|
-
/** Distance from the end within which the reader counts as being at it. */
|
|
250
|
-
var STICKY_THRESHOLD = 32;
|
|
251
|
-
/** How long after a gesture a scroll is still attributable to the reader. */
|
|
252
|
-
var GESTURE_WINDOW = 300;
|
|
253
|
-
/**
|
|
254
|
-
* The sticky tail as an intent (SPEC: useFollow) — a policy over the window, not part of it.
|
|
255
|
-
*
|
|
256
|
-
* Withdrawn by one thing only: a scroll that moves *backwards*. That is the reader; a correction
|
|
257
|
-
* never moves the offset back, it moves the end away. Re-deriving the intent from proximity on
|
|
258
|
-
* every commit cannot survive its own corrections — measuring the tail grows the document under a
|
|
259
|
-
* reader who has not moved, the gap opens past any threshold worth having, and the follow reads
|
|
260
|
-
* that as the reader leaving and disengages for good (`bridge/Tail`, the second defect).
|
|
261
|
-
*
|
|
262
|
-
* Reusable for any streaming container — transcripts, logs — because nothing here knows about
|
|
263
|
-
* messages: it needs a scroller, a placement, and the two numbers that say the content grew.
|
|
264
|
-
*/
|
|
265
|
-
var useFollow = ({ scrollerRef, placement, extent, count, axis = "block", reserve = 0, enabled, glide = true }) => {
|
|
266
|
-
const following = useRef(!!enabled);
|
|
267
|
-
const wasEnabled = useRef(enabled);
|
|
268
|
-
if (wasEnabled.current !== enabled) {
|
|
269
|
-
wasEnabled.current = enabled;
|
|
270
|
-
following.current = !!enabled;
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Where the reader has to be for the last row's rendered edge to rest on the viewport's.
|
|
274
|
-
*
|
|
275
|
-
* From the row's own rectangle when it is mounted, and from the arithmetic only when it is not:
|
|
276
|
-
* measured extents are rounded to integers and rendered rows are fractional, so over a mounted
|
|
277
|
-
* window the sum drifts from the layout by tens of pixels — an "arrived" that the reader can see
|
|
278
|
-
* is short. The rect already includes the window's transform, so the delta is exact.
|
|
279
|
-
*/
|
|
280
|
-
const endTarget = useCallback(() => {
|
|
281
|
-
const scroller = scrollerRef.current;
|
|
282
|
-
if (!scroller) return 0;
|
|
283
|
-
const last = scroller.querySelector(`[data-index="${count - 1}"]`);
|
|
284
|
-
if (last) {
|
|
285
|
-
const current = axis === "block" ? scroller.scrollTop : scroller.scrollLeft;
|
|
286
|
-
const delta = axis === "block" ? last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom : last.getBoundingClientRect().right - scroller.getBoundingClientRect().right;
|
|
287
|
-
return Math.max(0, Math.round(current + delta));
|
|
288
|
-
}
|
|
289
|
-
return placement.endOffset();
|
|
290
|
-
}, [
|
|
64
|
+
});
|
|
65
|
+
optionsRef.current = {
|
|
66
|
+
stops,
|
|
67
|
+
current,
|
|
291
68
|
count,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return new ScrollFollower(scroller, {
|
|
300
|
-
maxSpeed: 8,
|
|
301
|
-
acceleration: 32,
|
|
302
|
-
deceleration: 10,
|
|
303
|
-
trailing: () => scroller.scrollHeight - scroller.clientHeight - endTarget(),
|
|
304
|
-
rowHeight: () => {
|
|
305
|
-
const { first, last } = placement.layout();
|
|
306
|
-
const sizes = [];
|
|
307
|
-
for (let index = first; index <= last; index++) sizes.push(placement.extentOf(index));
|
|
308
|
-
sizes.sort((a, b) => a - b);
|
|
309
|
-
const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 100;
|
|
310
|
-
return Math.min(median, scroller.clientHeight || median);
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
}, [
|
|
314
|
-
scrollerRef.current,
|
|
315
|
-
axis,
|
|
316
|
-
placement,
|
|
317
|
-
endTarget
|
|
318
|
-
]);
|
|
319
|
-
useLayoutEffect(() => () => follower?.cancel(), [follower]);
|
|
320
|
-
const lastOffset = useRef(0);
|
|
321
|
-
const gestureAt = useRef(0);
|
|
322
|
-
useLayoutEffect(() => {
|
|
323
|
-
const scroller = scrollerRef.current;
|
|
324
|
-
if (!scroller || !enabled) return;
|
|
325
|
-
const onGesture = () => {
|
|
326
|
-
gestureAt.current = performance.now();
|
|
327
|
-
};
|
|
328
|
-
const onScroll = () => {
|
|
329
|
-
const current = axis === "block" ? scroller.scrollTop : scroller.scrollLeft;
|
|
330
|
-
const viewport = axis === "block" ? scroller.clientHeight : scroller.clientWidth;
|
|
331
|
-
const total = axis === "block" ? scroller.scrollHeight : scroller.scrollWidth;
|
|
332
|
-
const back = current < lastOffset.current - 1;
|
|
333
|
-
lastOffset.current = current;
|
|
334
|
-
if (total - reserve - current - viewport <= STICKY_THRESHOLD) following.current = true;
|
|
335
|
-
else if (back && performance.now() - gestureAt.current < GESTURE_WINDOW) {
|
|
336
|
-
following.current = false;
|
|
337
|
-
follower?.cancel();
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
341
|
-
for (const event of [
|
|
342
|
-
"wheel",
|
|
343
|
-
"touchmove",
|
|
344
|
-
"keydown",
|
|
345
|
-
"pointerdown"
|
|
346
|
-
]) scroller.addEventListener(event, onGesture, { passive: true });
|
|
347
|
-
return () => {
|
|
348
|
-
scroller.removeEventListener("scroll", onScroll);
|
|
349
|
-
for (const event of [
|
|
350
|
-
"wheel",
|
|
351
|
-
"touchmove",
|
|
352
|
-
"keydown",
|
|
353
|
-
"pointerdown"
|
|
354
|
-
]) scroller.removeEventListener(event, onGesture);
|
|
69
|
+
onNavigate
|
|
70
|
+
};
|
|
71
|
+
const jumpTo = useCallback((index, behavior = "auto") => {
|
|
72
|
+
const { count, onNavigate } = optionsRef.current;
|
|
73
|
+
pending.current = {
|
|
74
|
+
index,
|
|
75
|
+
at: performance.now()
|
|
355
76
|
};
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
enabled,
|
|
385
|
-
count,
|
|
386
|
-
extent,
|
|
387
|
-
placement,
|
|
388
|
-
axis,
|
|
389
|
-
glide,
|
|
390
|
-
follower,
|
|
391
|
-
endTarget,
|
|
392
|
-
scrollerRef.current
|
|
77
|
+
onNavigate?.(index);
|
|
78
|
+
const align = index >= count() - 1 ? "end" : "start";
|
|
79
|
+
controller.current?.scrollToIndex(index, align, behavior);
|
|
80
|
+
}, [controller]);
|
|
81
|
+
const step = useCallback((delta) => {
|
|
82
|
+
const { stops, current } = optionsRef.current;
|
|
83
|
+
const all = stops();
|
|
84
|
+
if (!all.length) return;
|
|
85
|
+
const flight = pending.current;
|
|
86
|
+
const at = flight && performance.now() - flight.at < PENDING_WINDOW && flight.index !== current() ? flight.index : current();
|
|
87
|
+
const next = delta > 0 ? all.find(({ index }) => index > at) ?? all[all.length - 1] : [...all].reverse().find(({ index }) => index < at) ?? all[0];
|
|
88
|
+
jumpTo(next.index, "smooth");
|
|
89
|
+
}, [jumpTo]);
|
|
90
|
+
const first = useCallback(() => jumpTo(0), [jumpTo]);
|
|
91
|
+
const last = useCallback(() => {
|
|
92
|
+
const { count } = optionsRef.current;
|
|
93
|
+
jumpTo(count() - 1);
|
|
94
|
+
}, [jumpTo]);
|
|
95
|
+
return useMemo(() => ({
|
|
96
|
+
step,
|
|
97
|
+
jumpTo,
|
|
98
|
+
first,
|
|
99
|
+
last
|
|
100
|
+
}), [
|
|
101
|
+
step,
|
|
102
|
+
jumpTo,
|
|
103
|
+
first,
|
|
104
|
+
last
|
|
393
105
|
]);
|
|
394
|
-
return { onNavigate: useCallback((index) => {
|
|
395
|
-
const wants = index >= count - 1;
|
|
396
|
-
following.current = !!enabled && wants;
|
|
397
|
-
if (!wants) follower?.cancel();
|
|
398
|
-
}, [
|
|
399
|
-
enabled,
|
|
400
|
-
count,
|
|
401
|
-
follower
|
|
402
|
-
]) };
|
|
403
106
|
};
|
|
404
107
|
//#endregion
|
|
405
108
|
//#region src/hooks/useItemSelection.tsx
|
|
@@ -614,7 +317,7 @@ var build = (registry, editable, themeMode) => [
|
|
|
614
317
|
* Each item owns a document, so streaming appends and per-message editing stay local — where a
|
|
615
318
|
* single thread-wide document needs a cursor and a range table to know which message it is touching.
|
|
616
319
|
*/
|
|
617
|
-
var MarkdownBlock = memo(({ text, editable = false, registry, hits, onWidgetsChange }) => {
|
|
320
|
+
var MarkdownBlock = memo(({ text, stream, editable = false, registry, hits, onWidgetsChange }) => {
|
|
618
321
|
const { themeMode } = useThemeContext();
|
|
619
322
|
const [view, setView] = useState(null);
|
|
620
323
|
const [widgets, setWidgets] = useState([]);
|
|
@@ -653,21 +356,74 @@ var MarkdownBlock = memo(({ text, editable = false, registry, hits, onWidgetsCha
|
|
|
653
356
|
setView(null);
|
|
654
357
|
};
|
|
655
358
|
}, [extensions]);
|
|
656
|
-
const
|
|
359
|
+
const targetRef = useRef(text);
|
|
360
|
+
const dispatchedRef = useRef(text);
|
|
361
|
+
const dripRaf = useRef(0);
|
|
657
362
|
useEffect(() => {
|
|
658
|
-
if (!view
|
|
659
|
-
|
|
660
|
-
currentRef.current = text;
|
|
363
|
+
if (!view) return;
|
|
364
|
+
targetRef.current = text;
|
|
661
365
|
initialTextRef.current = text;
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
366
|
+
if (dispatchedRef.current === text) return;
|
|
367
|
+
if (!text.startsWith(dispatchedRef.current)) {
|
|
368
|
+
cancelAnimationFrame(dripRaf.current);
|
|
369
|
+
dripRaf.current = 0;
|
|
370
|
+
dispatchedRef.current = text;
|
|
371
|
+
view.dispatch({ changes: {
|
|
372
|
+
from: 0,
|
|
373
|
+
to: view.state.doc.length,
|
|
374
|
+
insert: text
|
|
375
|
+
} });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!stream) {
|
|
379
|
+
cancelAnimationFrame(dripRaf.current);
|
|
380
|
+
dripRaf.current = 0;
|
|
381
|
+
const from = dispatchedRef.current.length;
|
|
382
|
+
dispatchedRef.current = text;
|
|
383
|
+
view.dispatch({ changes: {
|
|
384
|
+
from,
|
|
385
|
+
insert: text.slice(from)
|
|
386
|
+
} });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (dripRaf.current) return;
|
|
390
|
+
const tick = () => {
|
|
391
|
+
const target = targetRef.current;
|
|
392
|
+
const dispatched = dispatchedRef.current;
|
|
393
|
+
if (!target.startsWith(dispatched)) {
|
|
394
|
+
dripRaf.current = 0;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const backlog = target.slice(dispatched.length);
|
|
398
|
+
if (!backlog.length) {
|
|
399
|
+
dripRaf.current = 0;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
let take = Math.max(2, Math.ceil(backlog.length / 30));
|
|
403
|
+
if (backlog[0] === "<") {
|
|
404
|
+
const close = backlog.indexOf(">");
|
|
405
|
+
take = close === -1 ? 0 : Math.max(take, close + 1);
|
|
406
|
+
} else {
|
|
407
|
+
const tag = backlog.indexOf("<");
|
|
408
|
+
if (tag !== -1) take = Math.min(take, tag);
|
|
409
|
+
}
|
|
410
|
+
if (take > 0) {
|
|
411
|
+
const insert = backlog.slice(0, take);
|
|
412
|
+
dispatchedRef.current = dispatched + insert;
|
|
413
|
+
view.dispatch({ changes: {
|
|
414
|
+
from: dispatched.length,
|
|
415
|
+
insert
|
|
416
|
+
} });
|
|
417
|
+
}
|
|
418
|
+
dripRaf.current = requestAnimationFrame(tick);
|
|
419
|
+
};
|
|
420
|
+
dripRaf.current = requestAnimationFrame(tick);
|
|
421
|
+
}, [
|
|
422
|
+
view,
|
|
423
|
+
text,
|
|
424
|
+
stream
|
|
425
|
+
]);
|
|
426
|
+
useEffect(() => () => cancelAnimationFrame(dripRaf.current), []);
|
|
671
427
|
const highlighted = useRef(false);
|
|
672
428
|
useEffect(() => {
|
|
673
429
|
if (!view || !hits?.length && !highlighted.current) return;
|
|
@@ -751,6 +507,12 @@ var tag = (name, content, block, attributes) => {
|
|
|
751
507
|
return block.pending ? `${open}${text}` : `${open}${text}</${name}>`;
|
|
752
508
|
};
|
|
753
509
|
var escapeXml = (raw) => raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
510
|
+
/**
|
|
511
|
+
* Whether a message is the reader's own prompt. Role alone cannot say: tool results travel back as
|
|
512
|
+
* `user`-role messages (the AI protocol convention), and a synthetic turn is system-generated input
|
|
513
|
+
* — neither is the reader speaking, so neither gets the prompt frame or counts as a stop.
|
|
514
|
+
*/
|
|
515
|
+
var isPrompt = (message) => message.sender.role === "user" && message.blocks.some((block) => block._tag === "text" && block.disposition !== "synthetic");
|
|
754
516
|
/** Plain text of a message, used by search and copy so both read the same projection. */
|
|
755
517
|
var messageText = (message, renderer) => {
|
|
756
518
|
const content = renderer(message);
|
|
@@ -810,107 +572,6 @@ var sliceFeed = (messages, renderer, range) => {
|
|
|
810
572
|
return parts.join("\n\n");
|
|
811
573
|
};
|
|
812
574
|
//#endregion
|
|
813
|
-
//#region src/model/list-model.ts
|
|
814
|
-
/**
|
|
815
|
-
* The low-level reactive collection the virtualizer binds to (SPEC: ListModel).
|
|
816
|
-
*
|
|
817
|
-
* Two faces on one state, deliberately: a plain synchronous face (`count`, `at`, `subscribe`) that
|
|
818
|
-
* the virtualizer consumes without dragging an atom registry into every story and test, and an
|
|
819
|
-
* atom face (`rowsAtom`) for hosts that live in atom-land — derivations (stops, decorations,
|
|
820
|
-
* readouts) hang off the atom rather than becoming parallel props. Both are the same array; the
|
|
821
|
-
* subscribe face is not a fallback but the binding's contract.
|
|
822
|
-
*/
|
|
823
|
-
var ListModel = class {
|
|
824
|
-
#items;
|
|
825
|
-
#getId;
|
|
826
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
827
|
-
/** The rows as an atom, for derivations. Registry-managed by whoever consumes it. */
|
|
828
|
-
rowsAtom;
|
|
829
|
-
#registry;
|
|
830
|
-
constructor({ items = [], getId }) {
|
|
831
|
-
this.#items = [...items];
|
|
832
|
-
this.#getId = getId;
|
|
833
|
-
this.rowsAtom = Atom.make(this.#items);
|
|
834
|
-
this.#registry = Registry.make();
|
|
835
|
-
}
|
|
836
|
-
get count() {
|
|
837
|
-
return this.#items.length;
|
|
838
|
-
}
|
|
839
|
-
get items() {
|
|
840
|
-
return this.#items;
|
|
841
|
-
}
|
|
842
|
-
getId = (index) => {
|
|
843
|
-
const item = this.#items[index];
|
|
844
|
-
return item === void 0 ? `missing-${index}` : this.#getId(item);
|
|
845
|
-
};
|
|
846
|
-
at = (index) => this.#items[index];
|
|
847
|
-
indexOf(id) {
|
|
848
|
-
return this.#items.findIndex((item) => this.#getId(item) === id);
|
|
849
|
-
}
|
|
850
|
-
subscribe(listener) {
|
|
851
|
-
this.#listeners.add(listener);
|
|
852
|
-
return () => this.#listeners.delete(listener);
|
|
853
|
-
}
|
|
854
|
-
append(items) {
|
|
855
|
-
if (!items.length) return;
|
|
856
|
-
this.#items = [...this.#items, ...items];
|
|
857
|
-
this.#publish({ appended: items.length });
|
|
858
|
-
}
|
|
859
|
-
prepend(items) {
|
|
860
|
-
if (!items.length) return;
|
|
861
|
-
this.#items = [...items, ...this.#items];
|
|
862
|
-
this.#publish({ prepended: items.length });
|
|
863
|
-
}
|
|
864
|
-
/** An item changed in place — same identity, new content. */
|
|
865
|
-
update(id) {
|
|
866
|
-
this.#publish({ updated: [id] });
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Replace one item with a successor of the same identity — the streaming case for *frozen*
|
|
870
|
-
* items. An ECHO object mutates and `update` announces it; a schema-made message is frozen, so
|
|
871
|
-
* the chunk arrives as a fresh value under the old id. Same event either way: `updated`.
|
|
872
|
-
*/
|
|
873
|
-
patch(id, next) {
|
|
874
|
-
const index = this.#items.findIndex((item) => this.#getId(item) === id);
|
|
875
|
-
if (index < 0 || this.#getId(next) !== id) return;
|
|
876
|
-
this.#items = [
|
|
877
|
-
...this.#items.slice(0, index),
|
|
878
|
-
next,
|
|
879
|
-
...this.#items.slice(index + 1)
|
|
880
|
-
];
|
|
881
|
-
this.#publish({ updated: [id] });
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* The adapter for hosts that hand over a whole new array (a React prop, a query result).
|
|
885
|
-
*
|
|
886
|
-
* The one place the prepend inference survives, because this is the one caller that genuinely
|
|
887
|
-
* does not know what changed: the old first item is found in the new array, and everything before
|
|
888
|
-
* it was prepended. The virtualizer itself is always told (SPEC F-7.1).
|
|
889
|
-
*/
|
|
890
|
-
replace(items) {
|
|
891
|
-
const previous = this.#items;
|
|
892
|
-
const previousFirst = previous.length ? this.#getId(previous[0]) : void 0;
|
|
893
|
-
this.#items = [...items];
|
|
894
|
-
let prepended = 0;
|
|
895
|
-
if (previousFirst !== void 0 && items.length > previous.length) {
|
|
896
|
-
const grew = items.length - previous.length;
|
|
897
|
-
for (let index = 1; index <= grew; index++) if (items[index] !== void 0 && this.#getId(items[index]) === previousFirst) {
|
|
898
|
-
prepended = index;
|
|
899
|
-
break;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
const appended = prepended ? 0 : Math.max(0, items.length - previous.length);
|
|
903
|
-
this.#publish({
|
|
904
|
-
prepended: prepended || void 0,
|
|
905
|
-
appended: appended || void 0
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
#publish(change) {
|
|
909
|
-
this.#registry.set(this.rowsAtom, this.#items);
|
|
910
|
-
for (const listener of this.#listeners) listener(change);
|
|
911
|
-
}
|
|
912
|
-
};
|
|
913
|
-
//#endregion
|
|
914
575
|
//#region src/model/feed.ts
|
|
915
576
|
/**
|
|
916
577
|
* The native case, not an add-on (SPEC: FeedModel): a ListModel of messages carrying the feed's
|
|
@@ -944,6 +605,7 @@ var FeedModel = class extends ListModel {
|
|
|
944
605
|
}
|
|
945
606
|
setStreaming(id) {
|
|
946
607
|
const previous = this.#streamingId;
|
|
608
|
+
if (previous === id) return;
|
|
947
609
|
this.#streamingId = id;
|
|
948
610
|
for (const changed of [previous, id]) if (changed) this.update(changed);
|
|
949
611
|
}
|
|
@@ -961,7 +623,7 @@ var FeedModel = class extends ListModel {
|
|
|
961
623
|
* knows when it changed — callers that want reactivity derive from `rowsAtom`.
|
|
962
624
|
*/
|
|
963
625
|
stops() {
|
|
964
|
-
const predicate = this.#stops === "message" ? () => true : this.#stops === "prompt" ?
|
|
626
|
+
const predicate = this.#stops === "message" ? () => true : this.#stops === "prompt" ? isPrompt : this.#stops;
|
|
965
627
|
const stops = [];
|
|
966
628
|
this.messages.forEach((message, index) => {
|
|
967
629
|
if (predicate(message, index)) stops.push({
|
|
@@ -1011,26 +673,13 @@ var fromMessages = (messages, options = {}) => new FeedModel({
|
|
|
1011
673
|
//#endregion
|
|
1012
674
|
//#region src/model/useListModel.ts
|
|
1013
675
|
/**
|
|
1014
|
-
* A model over
|
|
676
|
+
* A feed model over a message array the host re-creates per render — the React-prop case.
|
|
1015
677
|
*
|
|
1016
678
|
* The model is stable; the array is folded into it with `replace`, which is where the prepend
|
|
1017
679
|
* inference lives (and the only place — SPEC F-7.1). In an effect rather than during render: the
|
|
1018
680
|
* model publishes synchronously to its subscribers, and a subscriber invalidating itself while the
|
|
1019
681
|
* host is still rendering is a cross-component update React rejects. The window it opens is one
|
|
1020
682
|
* frame, and the window's own subscription closes it.
|
|
1021
|
-
*/
|
|
1022
|
-
var useListModel = (items, getId) => {
|
|
1023
|
-
const model = useMemo(() => new ListModel({
|
|
1024
|
-
items,
|
|
1025
|
-
getId
|
|
1026
|
-
}), []);
|
|
1027
|
-
useEffect(() => {
|
|
1028
|
-
if (model.items !== items) model.replace(items);
|
|
1029
|
-
}, [model, items]);
|
|
1030
|
-
return model;
|
|
1031
|
-
};
|
|
1032
|
-
/**
|
|
1033
|
-
* The feed-shaped twin, for a host that re-creates its message array per render.
|
|
1034
683
|
*
|
|
1035
684
|
* Streaming through this adapter is a `replace` whose length does not change; the model still
|
|
1036
685
|
* publishes, so the window still re-renders, and the streaming item reconciles by document delta
|
|
@@ -1047,514 +696,6 @@ var useFeedModel = (messages, options = {}) => {
|
|
|
1047
696
|
return model;
|
|
1048
697
|
};
|
|
1049
698
|
//#endregion
|
|
1050
|
-
//#region src/virtualizer/placement.ts
|
|
1051
|
-
var DEFAULT_OVERSCAN = 8;
|
|
1052
|
-
/**
|
|
1053
|
-
*
|
|
1054
|
-
*/
|
|
1055
|
-
var Placement = class {
|
|
1056
|
-
#count;
|
|
1057
|
-
#getId;
|
|
1058
|
-
#extents;
|
|
1059
|
-
#viewport;
|
|
1060
|
-
#overscan;
|
|
1061
|
-
#reserve;
|
|
1062
|
-
/** Measured extents by message id, so that reordering the model cannot invalidate them. */
|
|
1063
|
-
#measured = /* @__PURE__ */ new Map();
|
|
1064
|
-
/** The one authoritative position: this row starts exactly here. */
|
|
1065
|
-
#anchor;
|
|
1066
|
-
#scroll = 0;
|
|
1067
|
-
constructor({ count, getId, extents, viewport, overscan = DEFAULT_OVERSCAN, reserve = 0 }) {
|
|
1068
|
-
this.#count = count;
|
|
1069
|
-
this.#getId = getId;
|
|
1070
|
-
this.#extents = extents;
|
|
1071
|
-
this.#viewport = viewport;
|
|
1072
|
-
this.#overscan = overscan;
|
|
1073
|
-
this.#reserve = reserve;
|
|
1074
|
-
this.#anchor = {
|
|
1075
|
-
id: count ? getId(0) : "",
|
|
1076
|
-
index: 0,
|
|
1077
|
-
start: 0
|
|
1078
|
-
};
|
|
1079
|
-
}
|
|
1080
|
-
get scroll() {
|
|
1081
|
-
return this.#scroll;
|
|
1082
|
-
}
|
|
1083
|
-
get count() {
|
|
1084
|
-
return this.#count;
|
|
1085
|
-
}
|
|
1086
|
-
/** The viewport is the binding's to know: it is the one thing here that comes from the DOM. */
|
|
1087
|
-
setViewport(viewport) {
|
|
1088
|
-
this.#viewport = viewport;
|
|
1089
|
-
}
|
|
1090
|
-
/**
|
|
1091
|
-
* The host's answer about extents can change — a row is edited, a panel opens, a story flips a
|
|
1092
|
-
* fixture — and it is a *function*, so keeping the one handed over at construction quietly pins
|
|
1093
|
-
* the layout to whatever was true then. Measured extents survive: they are keyed by id and this
|
|
1094
|
-
* only replaces what the host would say about a row nobody has measured.
|
|
1095
|
-
*/
|
|
1096
|
-
setExtents(extents) {
|
|
1097
|
-
this.#extents = extents;
|
|
1098
|
-
}
|
|
1099
|
-
setReserve(reserve) {
|
|
1100
|
-
this.#reserve = reserve;
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* The host's identity function, which is a closure over the host's own list.
|
|
1104
|
-
*
|
|
1105
|
-
* Appending a message gives a *new* `getId`, and a placement holding the one it was constructed
|
|
1106
|
-
* with resolves the new row's index to an id nobody will ever measure. The binding then stores
|
|
1107
|
-
* that row's measurement under the id the DOM carries and reads it back under the stale one, so
|
|
1108
|
-
* the row is measured, found to disagree, measured again — every commit, for ever. It presents as
|
|
1109
|
-
* a render loop rather than as a wrong size, which is what made `bridge/Tail` look like a tail
|
|
1110
|
-
* defect (§8: measurement is only a correction if it can be read back).
|
|
1111
|
-
*/
|
|
1112
|
-
setGetId(getId) {
|
|
1113
|
-
this.#getId = getId;
|
|
1114
|
-
}
|
|
1115
|
-
get anchor() {
|
|
1116
|
-
return this.#anchor;
|
|
1117
|
-
}
|
|
1118
|
-
/** Extent of one row: what was measured, or what the host says. */
|
|
1119
|
-
extentOf(index) {
|
|
1120
|
-
return this.#measured.get(this.#getId(index)) ?? this.#extents.of(index);
|
|
1121
|
-
}
|
|
1122
|
-
/**
|
|
1123
|
-
* Absolute position of a row, derived from the anchor rather than summed from the start.
|
|
1124
|
-
*
|
|
1125
|
-
* Rows between here and the anchor are the only ones whose extents matter (§5): everything before
|
|
1126
|
-
* the window is described by the anchor's position, not by adding up guesses about it.
|
|
1127
|
-
*/
|
|
1128
|
-
positionOf(index) {
|
|
1129
|
-
let position = this.#anchor.start;
|
|
1130
|
-
if (index >= this.#anchor.index) for (let row = this.#anchor.index; row < index; row++) position += this.extentOf(row);
|
|
1131
|
-
else for (let row = index; row < this.#anchor.index; row++) position -= this.extentOf(row);
|
|
1132
|
-
return position;
|
|
1133
|
-
}
|
|
1134
|
-
/** The reader moved. Nothing is corrected here — only the mounted range changes. */
|
|
1135
|
-
scrollTo(offset) {
|
|
1136
|
-
this.#scroll = offset;
|
|
1137
|
-
this.#reanchor();
|
|
1138
|
-
}
|
|
1139
|
-
/**
|
|
1140
|
-
* A row turned out to be a different size than assumed.
|
|
1141
|
-
*
|
|
1142
|
-
* Rows at or after the anchor push the ones after them and leave the anchor alone. A row *before*
|
|
1143
|
-
* the anchor moves the rows between it and the anchor, which is correct and unavoidable: the
|
|
1144
|
-
* anchor is the thing being held still, and it is still being held.
|
|
1145
|
-
*/
|
|
1146
|
-
measure(id, extent) {
|
|
1147
|
-
if (this.#extents.exact) return;
|
|
1148
|
-
this.#measured.set(id, extent);
|
|
1149
|
-
}
|
|
1150
|
-
/** The model changed length. `prepended` rows arrived before the anchor and shift its index. */
|
|
1151
|
-
setCount(count, { prepended = 0 } = {}) {
|
|
1152
|
-
this.#count = count;
|
|
1153
|
-
if (prepended) this.#anchor = {
|
|
1154
|
-
...this.#anchor,
|
|
1155
|
-
index: this.#anchor.index + prepended
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
/**
|
|
1159
|
-
* Move to a row that may be nowhere near the window.
|
|
1160
|
-
*
|
|
1161
|
-
* The only operation that positions from estimates rather than from measurement, because there is
|
|
1162
|
-
* no measured path to somewhere the reader has never been. A jump is a discontinuity the reader
|
|
1163
|
-
* asked for, so being approximately right is what it can be.
|
|
1164
|
-
*/
|
|
1165
|
-
jumpTo(index, align = "start") {
|
|
1166
|
-
const clamped = Math.max(0, Math.min(index, this.#count - 1));
|
|
1167
|
-
let start = 0;
|
|
1168
|
-
for (let row = 0; row < clamped; row++) start += this.extentOf(row);
|
|
1169
|
-
this.#anchor = {
|
|
1170
|
-
id: this.#getId(clamped),
|
|
1171
|
-
index: clamped,
|
|
1172
|
-
start
|
|
1173
|
-
};
|
|
1174
|
-
const end = clamped === this.#count - 1 ? this.layout().sizerExtent - this.#reserve : start + this.extentOf(clamped);
|
|
1175
|
-
this.#scroll = align === "end" ? end - this.#viewport : start;
|
|
1176
|
-
this.#scroll = Math.max(0, this.#scroll);
|
|
1177
|
-
this.#reanchor();
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Where the reader has to be for `index` to sit at the start (or end) of the viewport.
|
|
1181
|
-
*
|
|
1182
|
-
* Reports; does not move. `jumpTo` re-anchors as well, which is right for a discontinuity and
|
|
1183
|
-
* wrong for a glide: the anchor would arrive at the destination while the element was still
|
|
1184
|
-
* travelling, so the reader would watch the rows they were scrolling *through* unmount and the
|
|
1185
|
-
* journey happen over nothing.
|
|
1186
|
-
*/
|
|
1187
|
-
offsetOf(index, align = "start") {
|
|
1188
|
-
const clamped = Math.max(0, Math.min(index, this.#count - 1));
|
|
1189
|
-
const start = this.positionOf(clamped);
|
|
1190
|
-
return Math.max(0, align === "end" ? start + this.extentOf(clamped) - this.#viewport : start);
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Where the reader has to be for the last row to rest against the end of the viewport.
|
|
1194
|
-
*
|
|
1195
|
-
* Derived from the anchor, like every other position, and deliberately **not** from a sum over the
|
|
1196
|
-
* model: a follow asks this again on every commit, and each answer mounts rows whose measurement
|
|
1197
|
-
* replaces the estimates that sum is over — so a sum-based end moves every time it is consulted,
|
|
1198
|
-
* and consulting it is what moves it. That is not slow convergence, it is a feedback loop, and in
|
|
1199
|
-
* React it terminates by exceeding the update limit rather than by settling (`bridge/Tail`).
|
|
1200
|
-
*
|
|
1201
|
-
* Anchor-relative, the answer only changes as the rows *between the anchor and the last row* are
|
|
1202
|
-
* measured — each of them once — so a follow settles in a couple of commits and rows the reader
|
|
1203
|
-
* has already passed cannot disturb it.
|
|
1204
|
-
*/
|
|
1205
|
-
endOffset() {
|
|
1206
|
-
if (!this.#count) return 0;
|
|
1207
|
-
const last = this.#count - 1;
|
|
1208
|
-
return Math.max(0, this.positionOf(last) + this.extentOf(last) - this.#viewport);
|
|
1209
|
-
}
|
|
1210
|
-
/** The mounted range, the parent's offset, and the extent the thumb is computed from. */
|
|
1211
|
-
layout() {
|
|
1212
|
-
if (!this.#count) return {
|
|
1213
|
-
first: 0,
|
|
1214
|
-
last: -1,
|
|
1215
|
-
visible: {
|
|
1216
|
-
first: 0,
|
|
1217
|
-
last: -1
|
|
1218
|
-
},
|
|
1219
|
-
offset: 0,
|
|
1220
|
-
sizerExtent: 0
|
|
1221
|
-
};
|
|
1222
|
-
const { first, last, visible } = this.#range();
|
|
1223
|
-
const offset = this.positionOf(first);
|
|
1224
|
-
let windowExtent = 0;
|
|
1225
|
-
for (let row = first; row <= last; row++) windowExtent += this.extentOf(row);
|
|
1226
|
-
let after = 0;
|
|
1227
|
-
for (let row = last + 1; row < this.#count; row++) after += this.extentOf(row);
|
|
1228
|
-
return {
|
|
1229
|
-
first,
|
|
1230
|
-
last,
|
|
1231
|
-
visible,
|
|
1232
|
-
offset,
|
|
1233
|
-
sizerExtent: offset + windowExtent + after + this.#reserve
|
|
1234
|
-
};
|
|
1235
|
-
}
|
|
1236
|
-
/**
|
|
1237
|
-
* Repay the start edge's drift: shift every position so row 0 starts at exactly zero, and move
|
|
1238
|
-
* the scroll by the same amount so nothing the reader sees moves.
|
|
1239
|
-
*
|
|
1240
|
-
* The first row starting at zero is the one absolute fact of content space, and estimates being
|
|
1241
|
-
* replaced above the anchor break it: a feed opened at its tail whose rows measure taller than
|
|
1242
|
-
* assumed pushes row 0 *negative*, where no scroll can reach it — the reader arrives at the top
|
|
1243
|
-
* and the first messages simply are not there. This is the single correction that must touch the
|
|
1244
|
-
* scroll (§7's rule is about corrections and readers sharing a channel; here both the content and
|
|
1245
|
-
* the offset move by the same delta in the same commit, so the reader sees nothing at all).
|
|
1246
|
-
* Returns the shift for the binding to apply to the element.
|
|
1247
|
-
*/
|
|
1248
|
-
rebaseStart() {
|
|
1249
|
-
if (!this.#count) return 0;
|
|
1250
|
-
const shift = -this.positionOf(0);
|
|
1251
|
-
if (shift === 0) return 0;
|
|
1252
|
-
this.#anchor = {
|
|
1253
|
-
...this.#anchor,
|
|
1254
|
-
start: this.#anchor.start + shift
|
|
1255
|
-
};
|
|
1256
|
-
this.#scroll = Math.max(0, this.#scroll + shift);
|
|
1257
|
-
return shift;
|
|
1258
|
-
}
|
|
1259
|
-
/**
|
|
1260
|
-
* What an edge revealed, if the window has reached one.
|
|
1261
|
-
*
|
|
1262
|
-
* The edges are where estimate meets ground truth: the first row starts at zero and the last one
|
|
1263
|
-
* ends at the content's end, by definition, so a computed position that says otherwise is the
|
|
1264
|
-
* measure of how wrong the estimates were. Reported rather than silently applied — absorbing it
|
|
1265
|
-
* into the anchor would move what the reader is looking at, and at an edge they are looking at
|
|
1266
|
-
* exactly the thing that would move.
|
|
1267
|
-
*/
|
|
1268
|
-
drift() {
|
|
1269
|
-
if (!this.#count) return;
|
|
1270
|
-
const { first, last } = this.#range();
|
|
1271
|
-
if (first === 0 && this.positionOf(0) !== 0) return {
|
|
1272
|
-
edge: "start",
|
|
1273
|
-
delta: this.positionOf(0)
|
|
1274
|
-
};
|
|
1275
|
-
if (last === this.#count - 1) {
|
|
1276
|
-
const end = this.positionOf(last) + this.extentOf(last);
|
|
1277
|
-
const { sizerExtent } = this.layout();
|
|
1278
|
-
const content = sizerExtent - this.#reserve;
|
|
1279
|
-
if (end !== content) return {
|
|
1280
|
-
edge: "end",
|
|
1281
|
-
delta: content - end
|
|
1282
|
-
};
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
/** Visible rows, plus overscan, clamped to the model. */
|
|
1286
|
-
#range() {
|
|
1287
|
-
let first = this.#anchor.index;
|
|
1288
|
-
while (first > 0 && this.positionOf(first) > this.#scroll) first--;
|
|
1289
|
-
while (first < this.#count - 1 && this.positionOf(first) + this.extentOf(first) <= this.#scroll) first++;
|
|
1290
|
-
let last = first;
|
|
1291
|
-
let filled = this.positionOf(first) + this.extentOf(first);
|
|
1292
|
-
while (last < this.#count - 1 && filled < this.#scroll + this.#viewport) {
|
|
1293
|
-
last++;
|
|
1294
|
-
filled += this.extentOf(last);
|
|
1295
|
-
}
|
|
1296
|
-
return {
|
|
1297
|
-
first: Math.max(0, first - this.#overscan),
|
|
1298
|
-
last: Math.min(this.#count - 1, last + this.#overscan),
|
|
1299
|
-
visible: {
|
|
1300
|
-
first,
|
|
1301
|
-
last
|
|
1302
|
-
}
|
|
1303
|
-
};
|
|
1304
|
-
}
|
|
1305
|
-
/**
|
|
1306
|
-
* The anchor is the first row the reader can see — always, not merely when the old one leaves
|
|
1307
|
-
* the window.
|
|
1308
|
-
*
|
|
1309
|
-
* The anchor is the row held still while others are measured, so it must be the reader's
|
|
1310
|
-
* reference point. Clamping the old anchor into the mounted range let it drift to the window's
|
|
1311
|
-
* *far* edge after an upward scroll — below everything on screen — and a widget toggled open
|
|
1312
|
-
* above it then pushed the whole viewport up by its own growth: the engine held a row the reader
|
|
1313
|
-
* could not see still, at the expense of every row they could. Re-anchoring preserves the new
|
|
1314
|
-
* anchor's computed position exactly, so the move itself shifts nothing.
|
|
1315
|
-
*/
|
|
1316
|
-
#reanchor() {
|
|
1317
|
-
if (!this.#count) return;
|
|
1318
|
-
const { visible } = this.#range();
|
|
1319
|
-
if (this.#anchor.index === visible.first) return;
|
|
1320
|
-
const index = visible.first;
|
|
1321
|
-
this.#anchor = {
|
|
1322
|
-
id: this.#getId(index),
|
|
1323
|
-
index,
|
|
1324
|
-
start: this.positionOf(index)
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
};
|
|
1328
|
-
//#endregion
|
|
1329
|
-
//#region src/virtualizer/Window.tsx
|
|
1330
|
-
/** Extent of the mounted rows, which is the parent's own size in content space. */
|
|
1331
|
-
var windowExtentOf = (placement, first, last) => {
|
|
1332
|
-
let extent = 0;
|
|
1333
|
-
for (let index = first; index <= last; index++) extent += placement.extentOf(index);
|
|
1334
|
-
return extent;
|
|
1335
|
-
};
|
|
1336
|
-
var useWindow = ({ scrollerRef, model, extents, axis = "block", overscan, reserve = 0, onEdge, onMismatch, onChange, controllerRef }) => {
|
|
1337
|
-
const windowRef = useRef(null);
|
|
1338
|
-
const [, render] = useState(0);
|
|
1339
|
-
const invalidate = useCallback(() => render((value) => value + 1), []);
|
|
1340
|
-
const count = model.count;
|
|
1341
|
-
const getId = model.getId;
|
|
1342
|
-
const placement = useMemo(() => new Placement({
|
|
1343
|
-
count,
|
|
1344
|
-
getId,
|
|
1345
|
-
extents,
|
|
1346
|
-
viewport: 0,
|
|
1347
|
-
overscan
|
|
1348
|
-
}), []);
|
|
1349
|
-
const reported = useRef(/* @__PURE__ */ new Set());
|
|
1350
|
-
placement.setGetId(getId);
|
|
1351
|
-
useEffect(() => {
|
|
1352
|
-
const unsubscribe = model.subscribe((change) => {
|
|
1353
|
-
placement.setCount(model.count, { prepended: change.prepended ?? 0 });
|
|
1354
|
-
invalidate();
|
|
1355
|
-
});
|
|
1356
|
-
placement.setCount(model.count, {});
|
|
1357
|
-
invalidate();
|
|
1358
|
-
return unsubscribe;
|
|
1359
|
-
}, [
|
|
1360
|
-
model,
|
|
1361
|
-
placement,
|
|
1362
|
-
invalidate
|
|
1363
|
-
]);
|
|
1364
|
-
if (placement.count !== model.count) placement.setCount(model.count, {});
|
|
1365
|
-
useImperativeHandle(controllerRef, () => ({ scrollToIndex: (index, align, behavior = "auto") => {
|
|
1366
|
-
const scroller = scrollerRef.current;
|
|
1367
|
-
const { first, last } = placement.layout();
|
|
1368
|
-
if (behavior === "smooth" && scroller && index >= first && index <= last) {
|
|
1369
|
-
const target = placement.offsetOf(index, align);
|
|
1370
|
-
scroller.scrollTo(axis === "block" ? {
|
|
1371
|
-
top: target,
|
|
1372
|
-
behavior
|
|
1373
|
-
} : {
|
|
1374
|
-
left: target,
|
|
1375
|
-
behavior
|
|
1376
|
-
});
|
|
1377
|
-
return;
|
|
1378
|
-
}
|
|
1379
|
-
placement.jumpTo(index, align);
|
|
1380
|
-
if (scroller) {
|
|
1381
|
-
if (axis === "block") scroller.scrollTop = placement.scroll;
|
|
1382
|
-
else scroller.scrollLeft = placement.scroll;
|
|
1383
|
-
}
|
|
1384
|
-
invalidate();
|
|
1385
|
-
} }), [
|
|
1386
|
-
placement,
|
|
1387
|
-
axis,
|
|
1388
|
-
invalidate
|
|
1389
|
-
]);
|
|
1390
|
-
placement.setExtents(extents);
|
|
1391
|
-
placement.setReserve(reserve);
|
|
1392
|
-
const { first, last, visible, offset, sizerExtent } = placement.layout();
|
|
1393
|
-
useLayoutEffect(() => {
|
|
1394
|
-
const scroller = scrollerRef.current;
|
|
1395
|
-
if (!scroller) return;
|
|
1396
|
-
const read = () => axis === "block" ? scroller.clientHeight : scroller.clientWidth;
|
|
1397
|
-
placement.setViewport(read());
|
|
1398
|
-
const observer = new ResizeObserver(() => {
|
|
1399
|
-
placement.setViewport(read());
|
|
1400
|
-
invalidate();
|
|
1401
|
-
});
|
|
1402
|
-
observer.observe(scroller);
|
|
1403
|
-
const onScroll = () => {
|
|
1404
|
-
placement.scrollTo(axis === "block" ? scroller.scrollTop : scroller.scrollLeft);
|
|
1405
|
-
invalidate();
|
|
1406
|
-
};
|
|
1407
|
-
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
1408
|
-
invalidate();
|
|
1409
|
-
return () => {
|
|
1410
|
-
observer.disconnect();
|
|
1411
|
-
scroller.removeEventListener("scroll", onScroll);
|
|
1412
|
-
};
|
|
1413
|
-
}, [
|
|
1414
|
-
placement,
|
|
1415
|
-
axis,
|
|
1416
|
-
invalidate
|
|
1417
|
-
]);
|
|
1418
|
-
useLayoutEffect(() => {
|
|
1419
|
-
const parent = windowRef.current;
|
|
1420
|
-
if (!parent) return;
|
|
1421
|
-
let changed = false;
|
|
1422
|
-
for (const element of parent.children) {
|
|
1423
|
-
const row = element;
|
|
1424
|
-
const index = Number(row.dataset.index);
|
|
1425
|
-
const id = row.dataset.objectId;
|
|
1426
|
-
const actual = Math.round(axis === "block" ? row.offsetHeight : row.offsetWidth);
|
|
1427
|
-
const declared = placement.extentOf(index);
|
|
1428
|
-
if (extents.exact) {
|
|
1429
|
-
const key = `${id}:${declared}:${actual}`;
|
|
1430
|
-
if (actual !== declared && !reported.current.has(key)) {
|
|
1431
|
-
reported.current.add(key);
|
|
1432
|
-
onMismatch?.({
|
|
1433
|
-
index,
|
|
1434
|
-
id,
|
|
1435
|
-
declared,
|
|
1436
|
-
actual
|
|
1437
|
-
});
|
|
1438
|
-
}
|
|
1439
|
-
continue;
|
|
1440
|
-
}
|
|
1441
|
-
if (actual && actual !== declared) {
|
|
1442
|
-
placement.measure(id, actual);
|
|
1443
|
-
changed = true;
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
if (placement.rebaseStart() !== 0) {
|
|
1447
|
-
const scroller = scrollerRef.current;
|
|
1448
|
-
if (scroller) {
|
|
1449
|
-
if (axis === "block") scroller.scrollTop = placement.scroll;
|
|
1450
|
-
else scroller.scrollLeft = placement.scroll;
|
|
1451
|
-
}
|
|
1452
|
-
changed = true;
|
|
1453
|
-
}
|
|
1454
|
-
const drift = placement.drift();
|
|
1455
|
-
const key = drift && `${drift.edge}:${drift.delta}`;
|
|
1456
|
-
if (drift && key && !reported.current.has(key)) {
|
|
1457
|
-
reported.current.add(key);
|
|
1458
|
-
onEdge?.(drift);
|
|
1459
|
-
}
|
|
1460
|
-
if (changed) invalidate();
|
|
1461
|
-
});
|
|
1462
|
-
const announced = useRef("");
|
|
1463
|
-
const state = `${visible.first}:${visible.last}:${first}:${last}:${count}`;
|
|
1464
|
-
if (onChange && announced.current !== state) {
|
|
1465
|
-
announced.current = state;
|
|
1466
|
-
const scroller = scrollerRef.current;
|
|
1467
|
-
const viewport = (axis === "block" ? scroller?.clientHeight : scroller?.clientWidth) ?? 0;
|
|
1468
|
-
const extent = windowExtentOf(placement, first, last);
|
|
1469
|
-
queueMicrotask(() => onChange({
|
|
1470
|
-
index: visible.first,
|
|
1471
|
-
visible,
|
|
1472
|
-
mounted: {
|
|
1473
|
-
first,
|
|
1474
|
-
last
|
|
1475
|
-
},
|
|
1476
|
-
count,
|
|
1477
|
-
geometry: {
|
|
1478
|
-
scroll: placement.scroll,
|
|
1479
|
-
viewport,
|
|
1480
|
-
total: sizerExtent,
|
|
1481
|
-
window: {
|
|
1482
|
-
start: offset,
|
|
1483
|
-
extent
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
}));
|
|
1487
|
-
}
|
|
1488
|
-
return {
|
|
1489
|
-
placement,
|
|
1490
|
-
layout: {
|
|
1491
|
-
first,
|
|
1492
|
-
last,
|
|
1493
|
-
visible,
|
|
1494
|
-
offset,
|
|
1495
|
-
sizerExtent
|
|
1496
|
-
},
|
|
1497
|
-
windowRef,
|
|
1498
|
-
sizerExtent,
|
|
1499
|
-
offset,
|
|
1500
|
-
first,
|
|
1501
|
-
last
|
|
1502
|
-
};
|
|
1503
|
-
};
|
|
1504
|
-
/**
|
|
1505
|
-
* The DOM shape, and nothing else.
|
|
1506
|
-
*
|
|
1507
|
-
* Three elements: a **sizer** that holds no rows and exists only to give the thumb something to
|
|
1508
|
-
* measure, a **window** holding the mounted rows in normal flow, and the scroller around them.
|
|
1509
|
-
*/
|
|
1510
|
-
var Window = ({ classNames, children, controllerRef, ...options }) => {
|
|
1511
|
-
const scrollerRef = useRef(null);
|
|
1512
|
-
const inner = useRef(null);
|
|
1513
|
-
const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
|
|
1514
|
-
...options,
|
|
1515
|
-
scrollerRef,
|
|
1516
|
-
controllerRef: inner
|
|
1517
|
-
});
|
|
1518
|
-
const axis = options.axis ?? "block";
|
|
1519
|
-
const main = axis === "block" ? "height" : "width";
|
|
1520
|
-
const { getId } = options.model;
|
|
1521
|
-
const follow = useFollow({
|
|
1522
|
-
scrollerRef,
|
|
1523
|
-
placement,
|
|
1524
|
-
extent: sizerExtent,
|
|
1525
|
-
count: options.model.count,
|
|
1526
|
-
axis,
|
|
1527
|
-
reserve: options.reserve,
|
|
1528
|
-
enabled: options.sticky
|
|
1529
|
-
});
|
|
1530
|
-
useImperativeHandle(controllerRef, () => ({ scrollToIndex: (index, align, behavior) => {
|
|
1531
|
-
follow.onNavigate(index);
|
|
1532
|
-
inner.current?.scrollToIndex(index, align, behavior);
|
|
1533
|
-
} }), [follow]);
|
|
1534
|
-
const rows = [];
|
|
1535
|
-
for (let index = first; index <= last; index++) rows.push(/* @__PURE__ */ jsx("div", {
|
|
1536
|
-
"data-index": index,
|
|
1537
|
-
"data-object-id": getId(index),
|
|
1538
|
-
children: children(index, getId(index))
|
|
1539
|
-
}, getId(index)));
|
|
1540
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
1541
|
-
ref: scrollerRef,
|
|
1542
|
-
className: mx("relative", axis === "block" ? "overflow-y-auto" : "overflow-x-auto", classNames),
|
|
1543
|
-
style: { overflowAnchor: "none" },
|
|
1544
|
-
"data-testid": "window.scroller",
|
|
1545
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
1546
|
-
style: { [main]: sizerExtent },
|
|
1547
|
-
"data-testid": "window.sizer"
|
|
1548
|
-
}), /* @__PURE__ */ jsx("div", {
|
|
1549
|
-
ref: windowRef,
|
|
1550
|
-
className: mx("absolute top-0 left-0 flex", axis === "block" ? "flex-col w-full" : "flex-row h-full"),
|
|
1551
|
-
style: { transform: axis === "block" ? `translateY(${offset}px)` : `translateX(${offset}px)` },
|
|
1552
|
-
"data-testid": "window.window",
|
|
1553
|
-
children: rows
|
|
1554
|
-
})]
|
|
1555
|
-
});
|
|
1556
|
-
};
|
|
1557
|
-
//#endregion
|
|
1558
699
|
//#region src/components/MessageList/position-log.ts
|
|
1559
700
|
/** Movement below this is sub-pixel rounding, not a row changing place. */
|
|
1560
701
|
var EPSILON = 1;
|
|
@@ -1723,7 +864,7 @@ var DEFAULT_ESTIMATE = 120;
|
|
|
1723
864
|
* Renders no DOM of its own, so a toolbar or statusbar that reads its state can sit outside the
|
|
1724
865
|
* scroll container — the scrolling part is `MessageList.Viewport`.
|
|
1725
866
|
*/
|
|
1726
|
-
var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry, Chrome = DefaultChrome, Custom, debug, estimateSize = DEFAULT_ESTIMATE, stickyBottom = false,
|
|
867
|
+
var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry, Chrome = DefaultChrome, Custom, debug, estimateSize = DEFAULT_ESTIMATE, stickyBottom = false, tailLines = 0, overscan = 8, onRangeChange, controllerRef }) => {
|
|
1727
868
|
const widgetStore = useMemo(createWidgetStateStore, []);
|
|
1728
869
|
const [viewport, setViewportState] = useState(null);
|
|
1729
870
|
const scrollerRef = useRef(null);
|
|
@@ -1755,46 +896,53 @@ var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry,
|
|
|
1755
896
|
estimateSize,
|
|
1756
897
|
nominalSize
|
|
1757
898
|
]);
|
|
1758
|
-
const reserve =
|
|
1759
|
-
|
|
1760
|
-
viewport
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
899
|
+
const [reserve, setReserve] = useState(0);
|
|
900
|
+
useEffect(() => {
|
|
901
|
+
if (!viewport || !tailLines) {
|
|
902
|
+
setReserve(0);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const lineHeight = parseFloat(getComputedStyle(viewport).lineHeight) || 24;
|
|
906
|
+
setReserve(Math.round(tailLines * lineHeight));
|
|
907
|
+
}, [viewport, tailLines]);
|
|
1764
908
|
const controller = useRef(null);
|
|
909
|
+
const onWindowChange = useCallback((state) => {
|
|
910
|
+
const next = state.count ? {
|
|
911
|
+
startIndex: state.visible.first,
|
|
912
|
+
endIndex: state.visible.last
|
|
913
|
+
} : void 0;
|
|
914
|
+
setRange(next);
|
|
915
|
+
setMounted(state.mounted.last - state.mounted.first + 1);
|
|
916
|
+
if (next) {
|
|
917
|
+
onRangeChange?.(next);
|
|
918
|
+
setCurrent(next.startIndex);
|
|
919
|
+
if (next.startIndex === 0) model.more("start");
|
|
920
|
+
if (next.endIndex >= model.count - 1) model.more("end");
|
|
921
|
+
}
|
|
922
|
+
}, [
|
|
923
|
+
onRangeChange,
|
|
924
|
+
setCurrent,
|
|
925
|
+
model
|
|
926
|
+
]);
|
|
1765
927
|
const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
|
|
1766
928
|
scrollerRef,
|
|
1767
929
|
model,
|
|
1768
930
|
extents,
|
|
1769
931
|
overscan,
|
|
1770
932
|
reserve,
|
|
1771
|
-
onChange:
|
|
1772
|
-
const next = state.count ? {
|
|
1773
|
-
startIndex: state.visible.first,
|
|
1774
|
-
endIndex: state.visible.last
|
|
1775
|
-
} : void 0;
|
|
1776
|
-
setRange(next);
|
|
1777
|
-
setMounted(state.mounted.last - state.mounted.first + 1);
|
|
1778
|
-
if (next) {
|
|
1779
|
-
onRangeChange?.(next);
|
|
1780
|
-
setCurrent(next.startIndex);
|
|
1781
|
-
if (next.startIndex === 0) model.more("start");
|
|
1782
|
-
if (next.endIndex >= model.count - 1) model.more("end");
|
|
1783
|
-
}
|
|
1784
|
-
}, [
|
|
1785
|
-
onRangeChange,
|
|
1786
|
-
setCurrent,
|
|
1787
|
-
model
|
|
1788
|
-
]),
|
|
933
|
+
onChange: onWindowChange,
|
|
1789
934
|
controllerRef: controller
|
|
1790
935
|
});
|
|
936
|
+
const changedAtRef = useRef(performance.now());
|
|
937
|
+
useEffect(() => model.subscribe(() => changedAtRef.current = performance.now()), [model]);
|
|
1791
938
|
const follow = useFollow({
|
|
1792
939
|
scrollerRef,
|
|
1793
940
|
placement,
|
|
1794
941
|
extent: sizerExtent,
|
|
1795
942
|
count: model.count,
|
|
1796
943
|
reserve,
|
|
1797
|
-
enabled: stickyBottom
|
|
944
|
+
enabled: stickyBottom,
|
|
945
|
+
changedAt: () => changedAtRef.current
|
|
1798
946
|
});
|
|
1799
947
|
const jumps = useJumpDetector(viewport, debug);
|
|
1800
948
|
const { shifts, breaks, trace: shiftTrace, record: recordPositions, reset: resetShifts } = usePositionLog();
|
|
@@ -1817,14 +965,24 @@ var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry,
|
|
|
1817
965
|
]);
|
|
1818
966
|
const scrollToIndex = useCallback((index, { align = "start", behavior = "auto" } = {}) => {
|
|
1819
967
|
follow.onNavigate(index);
|
|
1820
|
-
controller.current?.scrollToIndex(index, align
|
|
968
|
+
controller.current?.scrollToIndex(index, align, behavior);
|
|
1821
969
|
}, [follow]);
|
|
1822
970
|
const scrollToBottom = useCallback((options) => {
|
|
1823
|
-
if (model.count)
|
|
971
|
+
if (!model.count) return;
|
|
972
|
+
const scroller = scrollerRef.current;
|
|
973
|
+
if (scroller && scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 32) {
|
|
974
|
+
follow.onNavigate(model.count - 1);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
scrollToIndex(model.count - 1, {
|
|
1824
978
|
align: "end",
|
|
1825
979
|
...options
|
|
1826
980
|
});
|
|
1827
|
-
}, [
|
|
981
|
+
}, [
|
|
982
|
+
scrollToIndex,
|
|
983
|
+
model,
|
|
984
|
+
follow
|
|
985
|
+
]);
|
|
1828
986
|
useEffect(() => {
|
|
1829
987
|
if (viewport) viewport.__feed = {
|
|
1830
988
|
placement,
|
|
@@ -1851,7 +1009,6 @@ var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry,
|
|
|
1851
1009
|
stops: () => model.stops(),
|
|
1852
1010
|
current: () => currentIndexRef.current,
|
|
1853
1011
|
count: () => model.count,
|
|
1854
|
-
scrollPastEnd,
|
|
1855
1012
|
onNavigate: follow.onNavigate
|
|
1856
1013
|
});
|
|
1857
1014
|
useEffect(() => {
|
|
@@ -1873,6 +1030,23 @@ var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry,
|
|
|
1873
1030
|
return () => viewport.removeEventListener("keydown", onKeyDown);
|
|
1874
1031
|
}, [viewport, navigation]);
|
|
1875
1032
|
const selectionGroup = useMemo(createSelectionGroup, []);
|
|
1033
|
+
useEffect(() => {
|
|
1034
|
+
setRef(controllerRef, {
|
|
1035
|
+
model,
|
|
1036
|
+
scrollToBottom,
|
|
1037
|
+
scrollToIndex,
|
|
1038
|
+
navigation
|
|
1039
|
+
});
|
|
1040
|
+
return () => {
|
|
1041
|
+
setRef(controllerRef, null);
|
|
1042
|
+
};
|
|
1043
|
+
}, [
|
|
1044
|
+
controllerRef,
|
|
1045
|
+
model,
|
|
1046
|
+
scrollToBottom,
|
|
1047
|
+
scrollToIndex,
|
|
1048
|
+
navigation
|
|
1049
|
+
]);
|
|
1876
1050
|
return /* @__PURE__ */ jsx(SelectionGroupContext.Provider, {
|
|
1877
1051
|
value: selectionGroup,
|
|
1878
1052
|
children: /* @__PURE__ */ jsx(WidgetStateProvider, {
|
|
@@ -1915,8 +1089,15 @@ var MESSAGE_LIST_VIEWPORT_NAME = "MessageList.Viewport";
|
|
|
1915
1089
|
* Scrolling belongs to `ScrollArea`, which owns the overlay thumbs and the padding tokens; the
|
|
1916
1090
|
* placement needs only the element being scrolled, which `ScrollArea.Viewport` publishes.
|
|
1917
1091
|
*/
|
|
1918
|
-
|
|
1919
|
-
|
|
1092
|
+
/**
|
|
1093
|
+
* Whether a message renders to nothing under the current renderer — e.g. a stats-only turn, whose
|
|
1094
|
+
* block the renderer deliberately maps to no output. The message stays in the model (identity,
|
|
1095
|
+
* stops, search all see it); only its chrome is withheld, so the reader is not shown an empty row
|
|
1096
|
+
* with a toolbar.
|
|
1097
|
+
*/
|
|
1098
|
+
var isEmptyContent = (content, hasCustomRenderer) => content.kind === "markdown" && !content.text.trim() || content.kind === "html" && !content.html.trim() || content.kind === "custom" && !hasCustomRenderer;
|
|
1099
|
+
var MessageListViewport = composable(({ autoHide, centered, native, padding, scrollbars, thin, gutter = "md", ...props }, forwardedRef) => {
|
|
1100
|
+
const { model, renderer, Chrome, Custom, windowRef, offset, sizerExtent, first, last, setViewport } = useMessageListContext(MESSAGE_LIST_VIEWPORT_NAME);
|
|
1920
1101
|
const { selectedIds, onSelect } = useItemSelectionValue();
|
|
1921
1102
|
const handleViewportRef = useCallback((element) => {
|
|
1922
1103
|
setViewport(element);
|
|
@@ -1926,10 +1107,11 @@ var MessageListViewport = composable(({ autoHide, centered, native, padding, scr
|
|
|
1926
1107
|
for (let index = first; index <= last; index++) {
|
|
1927
1108
|
const message = model.at(index);
|
|
1928
1109
|
if (!message) continue;
|
|
1110
|
+
const empty = isEmptyContent(renderer(message), Boolean(Custom));
|
|
1929
1111
|
rows.push(/* @__PURE__ */ jsx("div", {
|
|
1930
1112
|
"data-index": index,
|
|
1931
1113
|
"data-object-id": message.id,
|
|
1932
|
-
children: /* @__PURE__ */ jsx(Column.Root, {
|
|
1114
|
+
children: !empty && /* @__PURE__ */ jsx(Column.Root, {
|
|
1933
1115
|
gutter,
|
|
1934
1116
|
children: /* @__PURE__ */ jsx(Column.Center, { children: /* @__PURE__ */ jsx(Chrome, {
|
|
1935
1117
|
message,
|
|
@@ -1956,6 +1138,7 @@ var MessageListViewport = composable(({ autoHide, centered, native, padding, scr
|
|
|
1956
1138
|
position: "relative",
|
|
1957
1139
|
overflowAnchor: "none"
|
|
1958
1140
|
},
|
|
1141
|
+
classNames: "dx-document",
|
|
1959
1142
|
ref: handleViewportRef,
|
|
1960
1143
|
children: [/* @__PURE__ */ jsx("div", {
|
|
1961
1144
|
style: { height: sizerExtent },
|
|
@@ -1977,7 +1160,7 @@ var MESSAGE_LIST_ITEM_NAME = "MessageList.Item";
|
|
|
1977
1160
|
* outside the scrolling window — a pinned message, a preview — through the same path.
|
|
1978
1161
|
*/
|
|
1979
1162
|
var MessageListItem = composable(({ message, ...props }, forwardedRef) => {
|
|
1980
|
-
const { renderer, registry, Custom, debug, reportWidgets } = useMessageListContext(MESSAGE_LIST_ITEM_NAME);
|
|
1163
|
+
const { model, renderer, registry, Custom, debug, reportWidgets } = useMessageListContext(MESSAGE_LIST_ITEM_NAME);
|
|
1981
1164
|
const content = renderer(message);
|
|
1982
1165
|
const decorations = useDecorations(message.id);
|
|
1983
1166
|
const hits = useMemo(() => decorations.length ? decorations.map(({ range }) => [range.offset, range.offset + range.length]) : void 0, [decorations]);
|
|
@@ -1990,6 +1173,7 @@ var MessageListItem = composable(({ message, ...props }, forwardedRef) => {
|
|
|
1990
1173
|
scope: message.id,
|
|
1991
1174
|
children: /* @__PURE__ */ jsx(MarkdownBlock, {
|
|
1992
1175
|
text: content.text,
|
|
1176
|
+
stream: model.streamingId === message.id,
|
|
1993
1177
|
registry,
|
|
1994
1178
|
hits,
|
|
1995
1179
|
onWidgetsChange: handleWidgetsChange
|
|
@@ -2004,10 +1188,76 @@ var MessageListItem = composable(({ message, ...props }, forwardedRef) => {
|
|
|
2004
1188
|
});
|
|
2005
1189
|
});
|
|
2006
1190
|
MessageListItem.displayName = MESSAGE_LIST_ITEM_NAME;
|
|
1191
|
+
var MESSAGE_LIST_NAV_NAME = "MessageList.Nav";
|
|
1192
|
+
/**
|
|
1193
|
+
* The step and jump controls, on the same seam as the arrow keys and the rails (SPEC F-3.2), so a
|
|
1194
|
+
* reader who switches between them does not change places. Lives outside the viewport — Root is
|
|
1195
|
+
* headless precisely so chrome like this can sit in a toolbar.
|
|
1196
|
+
*
|
|
1197
|
+
* While any of its buttons has focus, ArrowUp/Down step and Meta(Ctrl)+Arrow jumps to the ends —
|
|
1198
|
+
* the same keymap the viewport itself carries, so focus landing on the toolbar does not change
|
|
1199
|
+
* what the keys mean.
|
|
1200
|
+
*/
|
|
1201
|
+
var MessageListNav = composable(({ ends = true, ...props }, forwardedRef) => {
|
|
1202
|
+
const { navigation } = useMessageListContext(MESSAGE_LIST_NAV_NAME);
|
|
1203
|
+
const onKeyDown = useCallback((event) => {
|
|
1204
|
+
const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
1205
|
+
if (!delta || event.altKey || event.shiftKey) return;
|
|
1206
|
+
event.preventDefault();
|
|
1207
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1208
|
+
if (delta > 0) navigation.last();
|
|
1209
|
+
else navigation.first();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
navigation.step(delta);
|
|
1213
|
+
}, [navigation]);
|
|
1214
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1215
|
+
role: "group",
|
|
1216
|
+
...composableProps(props),
|
|
1217
|
+
onKeyDown,
|
|
1218
|
+
ref: forwardedRef,
|
|
1219
|
+
children: [
|
|
1220
|
+
ends && /* @__PURE__ */ jsx(IconButton, {
|
|
1221
|
+
icon: "ph--arrow-line-up--regular",
|
|
1222
|
+
iconOnly: true,
|
|
1223
|
+
label: "First message",
|
|
1224
|
+
variant: "ghost",
|
|
1225
|
+
"data-testid": "feed.nav.top",
|
|
1226
|
+
onClick: () => navigation.first()
|
|
1227
|
+
}),
|
|
1228
|
+
/* @__PURE__ */ jsx(IconButton, {
|
|
1229
|
+
icon: "ph--caret-up--regular",
|
|
1230
|
+
iconOnly: true,
|
|
1231
|
+
label: "Previous message",
|
|
1232
|
+
variant: "ghost",
|
|
1233
|
+
"data-testid": "feed.nav.back",
|
|
1234
|
+
onClick: () => navigation.step(-1)
|
|
1235
|
+
}),
|
|
1236
|
+
/* @__PURE__ */ jsx(IconButton, {
|
|
1237
|
+
icon: "ph--caret-down--regular",
|
|
1238
|
+
iconOnly: true,
|
|
1239
|
+
label: "Next message",
|
|
1240
|
+
variant: "ghost",
|
|
1241
|
+
"data-testid": "feed.nav.forward",
|
|
1242
|
+
onClick: () => navigation.step(1)
|
|
1243
|
+
}),
|
|
1244
|
+
ends && /* @__PURE__ */ jsx(IconButton, {
|
|
1245
|
+
icon: "ph--arrow-line-down--regular",
|
|
1246
|
+
iconOnly: true,
|
|
1247
|
+
label: "Last message",
|
|
1248
|
+
variant: "ghost",
|
|
1249
|
+
"data-testid": "feed.nav.bottom",
|
|
1250
|
+
onClick: () => navigation.last()
|
|
1251
|
+
})
|
|
1252
|
+
]
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
MessageListNav.displayName = MESSAGE_LIST_NAV_NAME;
|
|
2007
1256
|
var MessageList = {
|
|
2008
1257
|
Root: MessageListRoot,
|
|
2009
1258
|
Viewport: MessageListViewport,
|
|
2010
|
-
Item: MessageListItem
|
|
1259
|
+
Item: MessageListItem,
|
|
1260
|
+
Nav: MessageListNav
|
|
2011
1261
|
};
|
|
2012
1262
|
//#endregion
|
|
2013
1263
|
//#region src/components/Minimap/Minimap.tsx
|
|
@@ -2223,6 +1473,6 @@ var Outline = ({ classNames, markers, visibleRange, tickSize = TICK_SIZE, width
|
|
|
2223
1473
|
});
|
|
2224
1474
|
};
|
|
2225
1475
|
//#endregion
|
|
2226
|
-
export {
|
|
1476
|
+
export { useItemSelection as A, highlights as C, useSelectionGroup as D, createSelectionGroup as E, useFeedNavigation as M, DecorationsProvider as N, ItemSelectionProvider as O, useDecorations as P, highlightTheme as S, SelectionGroupContext as T, HtmlBlock as _, useJumpDetector as a, createWidgetStateStore as b, FeedModel as c, defaultRenderer as d, isPrompt as f, MarkdownBlock as g, sliceFeed as h, useMessageList as i, useItemSelectionValue as j, isAdditive as k, fromMessages as l, searchFeed as m, Minimap as n, usePositionLog as o, messageText as p, MessageList as r, useFeedModel as s, Outline as t, chatRenderer as u, WidgetScopeProvider as v, setHighlights as w, useWidgetState as x, WidgetStateProvider as y };
|
|
2227
1477
|
|
|
2228
1478
|
//# sourceMappingURL=chunk-src.mjs.map
|