@dxos/react-ui-feed 0.11.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/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,2228 @@
|
|
|
1
|
+
import { createContext, memo, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { EditorState, StateEffect, StateField } from "@codemirror/state";
|
|
4
|
+
import { Decoration, EditorView } from "@codemirror/view";
|
|
5
|
+
import DOMPurify from "dompurify";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
7
|
+
import { Column, Popover, ScrollArea, composable, composableProps, setRef, useThemeContext } from "@dxos/react-ui";
|
|
8
|
+
import { createBasicExtensions, createMarkdownExtensions, createThemeExtensions, decorateMarkdown, extendedMarkdown, xmlBlockDecoration, xmlFormatting, xmlTags } from "@dxos/ui-editor";
|
|
9
|
+
import { createContext as createContext$1 } from "@radix-ui/react-context";
|
|
10
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
11
|
+
import * as Registry from "effect/unstable/reactivity/AtomRegistry";
|
|
12
|
+
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
|
+
//#region src/hooks/useDecorations.tsx
|
|
177
|
+
var DecorationsContext = createContext(/* @__PURE__ */ new Map());
|
|
178
|
+
/**
|
|
179
|
+
* THE PATTERN for cross-cutting per-item data (SPEC §Aspects), not a feed feature: a provider at
|
|
180
|
+
* the host carries what producers made, and an item asks for its own by id. The list neither knows
|
|
181
|
+
* nor routes — `hits` left its API entirely. Grouped once per change rather than filtered per item,
|
|
182
|
+
* so a long feed stays O(decorations).
|
|
183
|
+
*/
|
|
184
|
+
var DecorationsProvider = ({ decorations, children }) => {
|
|
185
|
+
const byId = useMemo(() => {
|
|
186
|
+
const map = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const decoration of decorations ?? []) {
|
|
188
|
+
const list = map.get(decoration.id) ?? [];
|
|
189
|
+
list.push(decoration);
|
|
190
|
+
map.set(decoration.id, list);
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
}, [decorations]);
|
|
194
|
+
return /* @__PURE__ */ jsx(DecorationsContext.Provider, {
|
|
195
|
+
value: byId,
|
|
196
|
+
children
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
var NONE = [];
|
|
200
|
+
/** An item's own decorations. Items work without a provider — they are simply undecorated. */
|
|
201
|
+
var useDecorations = (id) => {
|
|
202
|
+
return useContext(DecorationsContext).get(id) ?? NONE;
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/hooks/useFeedNavigation.ts
|
|
206
|
+
/**
|
|
207
|
+
* The one navigation seam (SPEC: useFeedNavigation): toolbar buttons, arrow keys, the outline rail
|
|
208
|
+
* and the minimap all call this, so they cannot disagree about what a step is.
|
|
209
|
+
*
|
|
210
|
+
* Stepping is from the row containing the scroll offset. The stop above it begins strictly higher
|
|
211
|
+
* and the stop below strictly lower, so every press moves the viewport — an index compared against
|
|
212
|
+
* a position cannot promise that while positions are still being measured. Steps glide (corrections
|
|
213
|
+
* move the window, not the scroll, so nothing cancels the animation); far jumps are instant, since
|
|
214
|
+
* a glide across unmounted rows is a journey over a blank screen.
|
|
215
|
+
*
|
|
216
|
+
* Distinct from react-ui-list's `useListNavigation`, which is focus traversal over fully-mounted
|
|
217
|
+
* rows — a different problem in the same naming family.
|
|
218
|
+
*/
|
|
219
|
+
var useFeedNavigation = ({ controller, stops, current, count, scrollPastEnd, onNavigate }) => {
|
|
220
|
+
const jumpTo = useCallback((index, behavior = "auto") => {
|
|
221
|
+
onNavigate?.(index);
|
|
222
|
+
const align = !scrollPastEnd && index >= count() - 1 ? "end" : "start";
|
|
223
|
+
controller.current?.scrollToIndex(index, align, behavior);
|
|
224
|
+
}, [
|
|
225
|
+
controller,
|
|
226
|
+
count,
|
|
227
|
+
scrollPastEnd,
|
|
228
|
+
onNavigate
|
|
229
|
+
]);
|
|
230
|
+
return {
|
|
231
|
+
step: useCallback((delta) => {
|
|
232
|
+
const all = stops();
|
|
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
|
+
}, [
|
|
291
|
+
count,
|
|
292
|
+
axis,
|
|
293
|
+
placement,
|
|
294
|
+
scrollerRef.current
|
|
295
|
+
]);
|
|
296
|
+
const follower = useMemo(() => {
|
|
297
|
+
const scroller = scrollerRef.current;
|
|
298
|
+
if (!scroller || axis !== "block") return;
|
|
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);
|
|
355
|
+
};
|
|
356
|
+
}, [
|
|
357
|
+
scrollerRef.current,
|
|
358
|
+
enabled,
|
|
359
|
+
axis,
|
|
360
|
+
reserve,
|
|
361
|
+
follower
|
|
362
|
+
]);
|
|
363
|
+
const positioned = useRef(false);
|
|
364
|
+
useLayoutEffect(() => {
|
|
365
|
+
const scroller = scrollerRef.current;
|
|
366
|
+
if (!enabled || !following.current || !count || !scroller) return;
|
|
367
|
+
const target = endTarget();
|
|
368
|
+
const gap = target - (axis === "block" ? scroller.scrollTop : scroller.scrollLeft);
|
|
369
|
+
if (Math.abs(gap) <= 1) {
|
|
370
|
+
positioned.current = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const viewport = axis === "block" ? scroller.clientHeight : scroller.clientWidth;
|
|
374
|
+
if (positioned.current && glide && follower && gap > 0 && gap <= viewport) {
|
|
375
|
+
follower.start();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
follower?.cancel();
|
|
379
|
+
placement.scrollTo(target);
|
|
380
|
+
lastOffset.current = target;
|
|
381
|
+
if (axis === "block") scroller.scrollTop = target;
|
|
382
|
+
else scroller.scrollLeft = target;
|
|
383
|
+
}, [
|
|
384
|
+
enabled,
|
|
385
|
+
count,
|
|
386
|
+
extent,
|
|
387
|
+
placement,
|
|
388
|
+
axis,
|
|
389
|
+
glide,
|
|
390
|
+
follower,
|
|
391
|
+
endTarget,
|
|
392
|
+
scrollerRef.current
|
|
393
|
+
]);
|
|
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
|
+
};
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/hooks/useItemSelection.tsx
|
|
406
|
+
var ItemSelectionContext = createContext({});
|
|
407
|
+
/**
|
|
408
|
+
* The item-side hook of the decoration pattern applied to selection (SPEC: useItemSelection): an
|
|
409
|
+
* item asks for its own `{ selected, toggle }` by id instead of the list threading `selectedIds`
|
|
410
|
+
* through props. Selection is model-space ids, so virtualization is untouched: an unmounted item is
|
|
411
|
+
* still selected, and mounts knowing it.
|
|
412
|
+
*
|
|
413
|
+
* The provider is deliberately the thinnest thing that works — a set and a callback. Mode policies
|
|
414
|
+
* (single | multi | range) belong to `react-ui-list`'s `useListSelection`, which a host composes
|
|
415
|
+
* *above* this provider; teaching this package about modes would be rebuilding that hook.
|
|
416
|
+
*/
|
|
417
|
+
var ItemSelectionProvider = ({ selectedIds, onSelect, children }) => /* @__PURE__ */ jsx(ItemSelectionContext.Provider, {
|
|
418
|
+
value: {
|
|
419
|
+
selectedIds,
|
|
420
|
+
onSelect
|
|
421
|
+
},
|
|
422
|
+
children
|
|
423
|
+
});
|
|
424
|
+
/**
|
|
425
|
+
* The whole selection value, for a component that renders many items in one pass.
|
|
426
|
+
*
|
|
427
|
+
* A row loop cannot call `useItemSelection` per row — hooks do not run in loops — so the viewport
|
|
428
|
+
* reads the value once and derives per-row state; item-shaped components use the per-id hook.
|
|
429
|
+
*/
|
|
430
|
+
var useItemSelectionValue = () => useContext(ItemSelectionContext);
|
|
431
|
+
var useItemSelection = (id) => {
|
|
432
|
+
const { selectedIds, onSelect } = useContext(ItemSelectionContext);
|
|
433
|
+
const toggle = useCallback((additive = true) => onSelect?.(id, additive), [id, onSelect]);
|
|
434
|
+
return {
|
|
435
|
+
selected: selectedIds?.has(id) ?? false,
|
|
436
|
+
toggle
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
/** Reads the additive intent off a gesture, so callers do not each invent the modifier rule. */
|
|
440
|
+
var isAdditive = (event) => event.metaKey || event.ctrlKey || event.shiftKey;
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/components/Block/selection-group.ts
|
|
443
|
+
/** Creates a group; one per feed, provided to its items by `MessageList.Root`. */
|
|
444
|
+
var createSelectionGroup = () => {
|
|
445
|
+
const views = /* @__PURE__ */ new Set();
|
|
446
|
+
return {
|
|
447
|
+
register: (view) => {
|
|
448
|
+
views.add(view);
|
|
449
|
+
return () => views.delete(view);
|
|
450
|
+
},
|
|
451
|
+
claim: (view) => {
|
|
452
|
+
for (const other of views) {
|
|
453
|
+
if (other === view || other.state.selection.main.empty) continue;
|
|
454
|
+
other.dispatch({ selection: { anchor: 0 } });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
var SelectionGroupContext = createContext(void 0);
|
|
460
|
+
/** The enclosing feed's selection group, or a private one when an item is used standalone. */
|
|
461
|
+
var useSelectionGroup = () => {
|
|
462
|
+
const provided = useContext(SelectionGroupContext);
|
|
463
|
+
const fallback = useMemo(createSelectionGroup, []);
|
|
464
|
+
return provided ?? fallback;
|
|
465
|
+
};
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/components/Block/highlight.ts
|
|
468
|
+
var setHighlights = StateEffect.define();
|
|
469
|
+
var mark = Decoration.mark({ class: "dx-feed-hit" });
|
|
470
|
+
var clamp = (offset, length) => Math.max(0, Math.min(offset, length));
|
|
471
|
+
/**
|
|
472
|
+
* Highlights pushed in from the model.
|
|
473
|
+
*
|
|
474
|
+
* Search runs over the feed's projection rather than per-view (most messages are never mounted), so
|
|
475
|
+
* the item receives ranges instead of owning search state.
|
|
476
|
+
*/
|
|
477
|
+
var highlights = StateField.define({
|
|
478
|
+
create: () => Decoration.none,
|
|
479
|
+
update: (value, tr) => {
|
|
480
|
+
for (const effect of tr.effects) if (effect.is(setHighlights)) {
|
|
481
|
+
const length = tr.state.doc.length;
|
|
482
|
+
return Decoration.set(effect.value.map(([from, to]) => [clamp(from, length), clamp(to, length)]).filter(([from, to]) => from < to).map(([from, to]) => mark.range(from, to)), true);
|
|
483
|
+
}
|
|
484
|
+
return tr.docChanged ? value.map(tr.changes) : value;
|
|
485
|
+
},
|
|
486
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
487
|
+
});
|
|
488
|
+
var highlightTheme = EditorView.baseTheme({ ".dx-feed-hit": {
|
|
489
|
+
backgroundColor: "var(--dx-accentFill)",
|
|
490
|
+
color: "var(--dx-accentFillText)",
|
|
491
|
+
borderRadius: "2px"
|
|
492
|
+
} });
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/components/Block/widget-state.tsx
|
|
495
|
+
var WidgetStateContext = createContext(void 0);
|
|
496
|
+
/** Scopes a widget's key to its message, so two messages' widgets cannot collide. */
|
|
497
|
+
var WidgetScopeContext = createContext("");
|
|
498
|
+
var WidgetStateProvider = ({ store, children }) => /* @__PURE__ */ jsx(WidgetStateContext.Provider, {
|
|
499
|
+
value: store,
|
|
500
|
+
children
|
|
501
|
+
});
|
|
502
|
+
var WidgetScopeProvider = ({ scope, children }) => /* @__PURE__ */ jsx(WidgetScopeContext.Provider, {
|
|
503
|
+
value: scope,
|
|
504
|
+
children
|
|
505
|
+
});
|
|
506
|
+
/** Creates the store a feed's widgets share. One per `MessageList.Root`. */
|
|
507
|
+
var createWidgetStateStore = () => {
|
|
508
|
+
const values = /* @__PURE__ */ new Map();
|
|
509
|
+
return {
|
|
510
|
+
read: (key) => values.get(key),
|
|
511
|
+
write: (key, value) => {
|
|
512
|
+
values.set(key, value);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
/**
|
|
517
|
+
* `useState` for a widget, keyed so it survives the row being destroyed and rebuilt.
|
|
518
|
+
*
|
|
519
|
+
* The key must identify the widget within its message — its tag and position in the document, say —
|
|
520
|
+
* and is scoped to the message automatically. Outside a feed this degrades to plain `useState`, so a
|
|
521
|
+
* widget can still be rendered on its own.
|
|
522
|
+
*/
|
|
523
|
+
var useWidgetState = (key, initial) => {
|
|
524
|
+
const store = useContext(WidgetStateContext);
|
|
525
|
+
const scope = useContext(WidgetScopeContext);
|
|
526
|
+
const scoped = useMemo(() => `${scope}:${key}`, [scope, key]);
|
|
527
|
+
const [value, setValue] = useState(() => {
|
|
528
|
+
const stored = store?.read(scoped);
|
|
529
|
+
return stored === void 0 ? initial : stored;
|
|
530
|
+
});
|
|
531
|
+
return [value, useCallback((next) => {
|
|
532
|
+
store?.write(scoped, next);
|
|
533
|
+
setValue(next);
|
|
534
|
+
}, [store, scoped])];
|
|
535
|
+
};
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/components/Block/HtmlBlock.tsx
|
|
538
|
+
/**
|
|
539
|
+
* A message rendered as sanitized HTML — the email case, which is not markdown and cannot be a
|
|
540
|
+
* CodeMirror document.
|
|
541
|
+
*
|
|
542
|
+
* Its presence is the reason the item renderer is generalized rather than markdown-only: the
|
|
543
|
+
* engine owns virtualization, chrome and selection anchors, not what a message looks like inside.
|
|
544
|
+
* As plain (non-contenteditable) DOM it is also the control case for cross-item selection.
|
|
545
|
+
*/
|
|
546
|
+
var HtmlBlock = memo(({ html }) => {
|
|
547
|
+
const sanitized = useMemo(() => DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }), [html]);
|
|
548
|
+
return /* @__PURE__ */ jsx("div", {
|
|
549
|
+
className: "dx-feed-html",
|
|
550
|
+
dangerouslySetInnerHTML: { __html: sanitized }
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
HtmlBlock.displayName = "HtmlBlock";
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/components/Block/extensions.ts
|
|
556
|
+
/**
|
|
557
|
+
* The extension set one item's document is built from.
|
|
558
|
+
*
|
|
559
|
+
* Everything that does not depend on the item is **built once and shared**, and that is the point of
|
|
560
|
+
* this module rather than a tidiness. `EditorView.theme()` mints a new `StyleModule` and a new
|
|
561
|
+
* generated class on every call, so a set built per item injects one stylesheet per row and
|
|
562
|
+
* invalidates the whole document's style each time it does. Measured at ~14ms per row against ~2ms
|
|
563
|
+
* for the identical set shared (`baseline/mount`) — six times the cost of the first fill, for
|
|
564
|
+
* stylesheets that are all the same.
|
|
565
|
+
*
|
|
566
|
+
* Extensions are descriptors, not instances: a `StateField` or `ViewPlugin` in a shared array is
|
|
567
|
+
* still instantiated per view, so sharing changes nothing but the identity CodeMirror dedupes on.
|
|
568
|
+
*/
|
|
569
|
+
var createBlockExtensions = ({ registry, editable = false, themeMode = "light", setWidgets } = {}) => [...sharedExtensions(registry, editable, themeMode), ...registry ? [xmlTags({
|
|
570
|
+
registry,
|
|
571
|
+
setWidgets: setWidgets ?? (() => {}),
|
|
572
|
+
bookmarks: ["prompt"]
|
|
573
|
+
})] : []];
|
|
574
|
+
/** Registries are compared by identity, so a feed's single registry is a single cache scope. */
|
|
575
|
+
var NO_REGISTRY = {};
|
|
576
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
577
|
+
var sharedExtensions = (registry, editable, themeMode) => {
|
|
578
|
+
const scope = registry ?? NO_REGISTRY;
|
|
579
|
+
let byVariant = cache.get(scope);
|
|
580
|
+
if (!byVariant) {
|
|
581
|
+
byVariant = /* @__PURE__ */ new Map();
|
|
582
|
+
cache.set(scope, byVariant);
|
|
583
|
+
}
|
|
584
|
+
const key = `${editable}:${themeMode}`;
|
|
585
|
+
let extensions = byVariant.get(key);
|
|
586
|
+
if (!extensions) {
|
|
587
|
+
extensions = build(registry, editable, themeMode);
|
|
588
|
+
byVariant.set(key, extensions);
|
|
589
|
+
}
|
|
590
|
+
return extensions;
|
|
591
|
+
};
|
|
592
|
+
var build = (registry, editable, themeMode) => [
|
|
593
|
+
createBasicExtensions({
|
|
594
|
+
readOnly: !editable,
|
|
595
|
+
editable,
|
|
596
|
+
lineWrapping: true
|
|
597
|
+
}),
|
|
598
|
+
createThemeExtensions({ themeMode }),
|
|
599
|
+
registry ? extendedMarkdown({ registry }) : createMarkdownExtensions(),
|
|
600
|
+
registry && xmlFormatting({ skip: ["prompt"] }),
|
|
601
|
+
decorateMarkdown(),
|
|
602
|
+
registry?.prompt && xmlBlockDecoration({
|
|
603
|
+
tag: "prompt",
|
|
604
|
+
hideTags: true
|
|
605
|
+
}),
|
|
606
|
+
highlights,
|
|
607
|
+
highlightTheme
|
|
608
|
+
].filter(Boolean);
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/components/Block/MarkdownBlock.tsx
|
|
611
|
+
/**
|
|
612
|
+
* One message as its own markdown document.
|
|
613
|
+
*
|
|
614
|
+
* Each item owns a document, so streaming appends and per-message editing stay local — where a
|
|
615
|
+
* single thread-wide document needs a cursor and a range table to know which message it is touching.
|
|
616
|
+
*/
|
|
617
|
+
var MarkdownBlock = memo(({ text, editable = false, registry, hits, onWidgetsChange }) => {
|
|
618
|
+
const { themeMode } = useThemeContext();
|
|
619
|
+
const [view, setView] = useState(null);
|
|
620
|
+
const [widgets, setWidgets] = useState([]);
|
|
621
|
+
const rootRef = useRef(null);
|
|
622
|
+
const selectionGroup = useSelectionGroup();
|
|
623
|
+
const selectionGroupRef = useRef(selectionGroup);
|
|
624
|
+
selectionGroupRef.current = selectionGroup;
|
|
625
|
+
const extensions = useMemo(() => [...createBlockExtensions({
|
|
626
|
+
registry,
|
|
627
|
+
editable,
|
|
628
|
+
themeMode,
|
|
629
|
+
setWidgets
|
|
630
|
+
}), EditorView.updateListener.of((update) => {
|
|
631
|
+
if (update.selectionSet && !update.state.selection.main.empty) selectionGroupRef.current.claim(update.view);
|
|
632
|
+
})], [
|
|
633
|
+
editable,
|
|
634
|
+
themeMode,
|
|
635
|
+
registry
|
|
636
|
+
]);
|
|
637
|
+
const initialTextRef = useRef(text);
|
|
638
|
+
useLayoutEffect(() => {
|
|
639
|
+
const parent = rootRef.current;
|
|
640
|
+
if (!parent) return;
|
|
641
|
+
const instance = new EditorView({
|
|
642
|
+
parent,
|
|
643
|
+
state: EditorState.create({
|
|
644
|
+
doc: initialTextRef.current,
|
|
645
|
+
extensions
|
|
646
|
+
})
|
|
647
|
+
});
|
|
648
|
+
const unregister = selectionGroupRef.current.register(instance);
|
|
649
|
+
setView(instance);
|
|
650
|
+
return () => {
|
|
651
|
+
unregister();
|
|
652
|
+
instance.destroy();
|
|
653
|
+
setView(null);
|
|
654
|
+
};
|
|
655
|
+
}, [extensions]);
|
|
656
|
+
const currentRef = useRef(text);
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
if (!view || currentRef.current === text) return;
|
|
659
|
+
const previous = currentRef.current;
|
|
660
|
+
currentRef.current = text;
|
|
661
|
+
initialTextRef.current = text;
|
|
662
|
+
view.dispatch(text.startsWith(previous) ? { changes: {
|
|
663
|
+
from: previous.length,
|
|
664
|
+
insert: text.slice(previous.length)
|
|
665
|
+
} } : { changes: {
|
|
666
|
+
from: 0,
|
|
667
|
+
to: view.state.doc.length,
|
|
668
|
+
insert: text
|
|
669
|
+
} });
|
|
670
|
+
}, [view, text]);
|
|
671
|
+
const highlighted = useRef(false);
|
|
672
|
+
useEffect(() => {
|
|
673
|
+
if (!view || !hits?.length && !highlighted.current) return;
|
|
674
|
+
highlighted.current = Boolean(hits?.length);
|
|
675
|
+
view.dispatch({ effects: setHighlights.of(hits ?? []) });
|
|
676
|
+
}, [view, hits]);
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
onWidgetsChange?.(widgets.length);
|
|
679
|
+
return () => onWidgetsChange?.(0);
|
|
680
|
+
}, [widgets.length, onWidgetsChange]);
|
|
681
|
+
useEffect(() => {
|
|
682
|
+
if (!view || !widgets.length) return;
|
|
683
|
+
const observer = new ResizeObserver(() => view.requestMeasure());
|
|
684
|
+
for (const { root } of widgets) observer.observe(root);
|
|
685
|
+
return () => observer.disconnect();
|
|
686
|
+
}, [view, widgets]);
|
|
687
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", { ref: rootRef }), widgets.map(({ Component, root, id, props }) => /* @__PURE__ */ jsx("div", { children: createPortal(/* @__PURE__ */ jsx(Component, {
|
|
688
|
+
view: view ?? void 0,
|
|
689
|
+
...props
|
|
690
|
+
}), root) }, id))] });
|
|
691
|
+
});
|
|
692
|
+
MarkdownBlock.displayName = "MarkdownBlock";
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/model/feed-model.ts
|
|
695
|
+
/**
|
|
696
|
+
* Default renderer: an `text/html` block wins (email), otherwise every block is flattened to
|
|
697
|
+
* markdown. Block kinds the engine does not know become fenced JSON so nothing renders as blank.
|
|
698
|
+
*/
|
|
699
|
+
var defaultRenderer = (message) => createContent(message, false);
|
|
700
|
+
/**
|
|
701
|
+
* Assistant chat: as {@link defaultRenderer}, plus the reader's own turns marked with `<prompt>`.
|
|
702
|
+
*
|
|
703
|
+
* Separate because the tag is only meaningful where a widget registry knows it — a feed rendered
|
|
704
|
+
* without one would show the reader their own angle brackets, which is what a human chat, a
|
|
705
|
+
* transcript and a comment list all are.
|
|
706
|
+
*/
|
|
707
|
+
var chatRenderer = (message) => createContent(message, true);
|
|
708
|
+
var createContent = (message, prompts) => {
|
|
709
|
+
const html = message.blocks.filter((block) => block._tag === "text").find((block) => block.mimeType === "text/html");
|
|
710
|
+
if (html) return {
|
|
711
|
+
kind: "html",
|
|
712
|
+
html: html.text
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
kind: "markdown",
|
|
716
|
+
text: message.blocks.map((block) => blockToMarkdown(message, block, prompts)).filter(Boolean).join("\n\n")
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* One block as markdown, with anything that is not prose emitted as an XML tag for a widget to
|
|
721
|
+
* render. A message is the concatenation of these, which is what makes an item a document rather
|
|
722
|
+
* than a component tree: the blocks are content, and the chrome around them belongs to the host.
|
|
723
|
+
*/
|
|
724
|
+
var blockToMarkdown = (message, block, prompts) => {
|
|
725
|
+
switch (block._tag) {
|
|
726
|
+
case "text": return prompts && message.sender.role === "user" ? tag("prompt", block.text, block) : block.text.trim();
|
|
727
|
+
case "reasoning": return tag("reasoning", block.reasoningText ?? block.redactedText ?? "", block);
|
|
728
|
+
case "status": return tag("status", block.statusText, block);
|
|
729
|
+
case "summary": return tag("summary", block.content, block);
|
|
730
|
+
case "suggestion": return tag("suggestion", block.text, block);
|
|
731
|
+
case "select": return block.options.length ? `<select>${block.options.map((option) => `<option>${escapeXml(option)}</option>`).join("")}</select>` : "";
|
|
732
|
+
case "toolCall": return `<toolCall id="${escapeXml(block.toolCallId)}" name="${escapeXml(block.name)}"${block.pending ? " pending=\"true\"" : ""} />`;
|
|
733
|
+
case "toolResult": return tag("toolResult", String(block.result ?? block.error ?? ""), block, `for="${escapeXml(block.name)}"`);
|
|
734
|
+
default: return tag("json", JSON.stringify(block), block);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* An XML tag holding block content.
|
|
739
|
+
*
|
|
740
|
+
* A pending block is emitted **unclosed**, which is the streaming contract: the item reconciles by
|
|
741
|
+
* appending, so a closing tag written before the content is complete would have to be rewritten on
|
|
742
|
+
* every chunk — and rewriting the document is what discards decorations and scroll position.
|
|
743
|
+
*
|
|
744
|
+
* Paragraph breaks inside the content are collapsed, since a blank line would end the enclosing
|
|
745
|
+
* markdown block and leave the rest of the tag parsing as prose.
|
|
746
|
+
*/
|
|
747
|
+
var tag = (name, content, block, attributes) => {
|
|
748
|
+
const text = escapeXml(content.replace(/\n\n+/g, " ").trim());
|
|
749
|
+
if (!text.length && !block.pending) return "";
|
|
750
|
+
const open = `<${name}${attributes ? ` ${attributes}` : ""}>`;
|
|
751
|
+
return block.pending ? `${open}${text}` : `${open}${text}</${name}>`;
|
|
752
|
+
};
|
|
753
|
+
var escapeXml = (raw) => raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
754
|
+
/** Plain text of a message, used by search and copy so both read the same projection. */
|
|
755
|
+
var messageText = (message, renderer) => {
|
|
756
|
+
const content = renderer(message);
|
|
757
|
+
switch (content.kind) {
|
|
758
|
+
case "markdown": return content.text;
|
|
759
|
+
case "html": return stripHtml(content.html);
|
|
760
|
+
case "custom": return "";
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
/** Crude tag strip — the engine only needs searchable/copyable text, not a parse tree. */
|
|
764
|
+
var stripHtml = (html) => html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
765
|
+
/**
|
|
766
|
+
* Search the model rather than the DOM.
|
|
767
|
+
*
|
|
768
|
+
* Virtualization means most messages are not mounted, and a `viewType` filter can hide blocks that
|
|
769
|
+
* are still in the model — neither is reachable by a per-view CodeMirror search, so thread-wide find
|
|
770
|
+
* has to run over the projection.
|
|
771
|
+
*/
|
|
772
|
+
var searchFeed = (messages, renderer, query) => {
|
|
773
|
+
const hits = [];
|
|
774
|
+
if (!query.length) return hits;
|
|
775
|
+
const needle = query.toLowerCase();
|
|
776
|
+
messages.forEach((message, index) => {
|
|
777
|
+
const text = messageText(message, renderer).toLowerCase();
|
|
778
|
+
let offset = text.indexOf(needle);
|
|
779
|
+
while (offset !== -1) {
|
|
780
|
+
hits.push({
|
|
781
|
+
messageId: message.id,
|
|
782
|
+
index,
|
|
783
|
+
offset,
|
|
784
|
+
length: query.length
|
|
785
|
+
});
|
|
786
|
+
offset = text.indexOf(needle, offset + needle.length);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return hits;
|
|
790
|
+
};
|
|
791
|
+
/**
|
|
792
|
+
* Text between two anchors, spanning message boundaries.
|
|
793
|
+
*
|
|
794
|
+
* Native selection cannot span separate editor instances, and unmounted messages are not in the DOM
|
|
795
|
+
* at all, so cross-message copy is reconstructed from the model.
|
|
796
|
+
*/
|
|
797
|
+
var sliceFeed = (messages, renderer, range) => {
|
|
798
|
+
const fromIndex = messages.findIndex((message) => message.id === range.from.messageId);
|
|
799
|
+
const toIndex = messages.findIndex((message) => message.id === range.to.messageId);
|
|
800
|
+
if (fromIndex === -1 || toIndex === -1) return "";
|
|
801
|
+
const [start, end] = fromIndex < toIndex || fromIndex === toIndex && range.from.offset <= range.to.offset ? [range.from, range.to] : [range.to, range.from];
|
|
802
|
+
const [startIndex, endIndex] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
|
803
|
+
const parts = [];
|
|
804
|
+
for (let index = startIndex; index <= endIndex; index++) {
|
|
805
|
+
const text = messageText(messages[index], renderer);
|
|
806
|
+
const from = index === startIndex ? start.offset : 0;
|
|
807
|
+
const to = index === endIndex ? end.offset : text.length;
|
|
808
|
+
parts.push(text.slice(from, to));
|
|
809
|
+
}
|
|
810
|
+
return parts.join("\n\n");
|
|
811
|
+
};
|
|
812
|
+
//#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
|
+
//#region src/model/feed.ts
|
|
915
|
+
/**
|
|
916
|
+
* The native case, not an add-on (SPEC: FeedModel): a ListModel of messages carrying the feed's
|
|
917
|
+
* own semantics — the stops policy, the streaming tail, and iteration. The list consumes this and
|
|
918
|
+
* nothing else; `hits`, `isAnchor` and `streamingId` all left the props API and live here or in
|
|
919
|
+
* the decoration provider.
|
|
920
|
+
*
|
|
921
|
+
* ECHO/queue bindings adapt to this outside the package; `fromMessages` adapts the plain case.
|
|
922
|
+
*/
|
|
923
|
+
var FeedModel = class extends ListModel {
|
|
924
|
+
#stops;
|
|
925
|
+
#streamingId;
|
|
926
|
+
#loading = false;
|
|
927
|
+
#loadBefore;
|
|
928
|
+
#loadAfter;
|
|
929
|
+
constructor({ messages = [], stops = "message", loadBefore, loadAfter } = {}) {
|
|
930
|
+
super({
|
|
931
|
+
items: messages,
|
|
932
|
+
getId: (message) => message.id
|
|
933
|
+
});
|
|
934
|
+
this.#stops = stops;
|
|
935
|
+
this.#loadBefore = loadBefore;
|
|
936
|
+
this.#loadAfter = loadAfter;
|
|
937
|
+
}
|
|
938
|
+
get messages() {
|
|
939
|
+
return this.items;
|
|
940
|
+
}
|
|
941
|
+
/** The tail message reconciling by delta; its item must not remount per chunk. */
|
|
942
|
+
get streamingId() {
|
|
943
|
+
return this.#streamingId;
|
|
944
|
+
}
|
|
945
|
+
setStreaming(id) {
|
|
946
|
+
const previous = this.#streamingId;
|
|
947
|
+
this.#streamingId = id;
|
|
948
|
+
for (const changed of [previous, id]) if (changed) this.update(changed);
|
|
949
|
+
}
|
|
950
|
+
/** A chunk arrived for the streaming tail: same identity, longer content. */
|
|
951
|
+
stream(id) {
|
|
952
|
+
this.update(id);
|
|
953
|
+
}
|
|
954
|
+
setStops(stops) {
|
|
955
|
+
this.#stops = stops;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* The positions navigation can land on, under the current policy.
|
|
959
|
+
*
|
|
960
|
+
* Recomputed on demand rather than cached: a policy is a predicate over the model, and the model
|
|
961
|
+
* knows when it changed — callers that want reactivity derive from `rowsAtom`.
|
|
962
|
+
*/
|
|
963
|
+
stops() {
|
|
964
|
+
const predicate = this.#stops === "message" ? () => true : this.#stops === "prompt" ? (message) => message.sender.role === "user" : this.#stops;
|
|
965
|
+
const stops = [];
|
|
966
|
+
this.messages.forEach((message, index) => {
|
|
967
|
+
if (predicate(message, index)) stops.push({
|
|
968
|
+
index,
|
|
969
|
+
id: message.id
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
return stops;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* The window reached the start: ask the source for history.
|
|
976
|
+
*
|
|
977
|
+
* The model owns iteration (SPEC F-7.2) — the pages arrive as a prepend, which the virtualizer
|
|
978
|
+
* is told about, so nothing on screen moves (F-1.1). Serialized, because edges are reached once
|
|
979
|
+
* per frame while the reader sits on them.
|
|
980
|
+
*/
|
|
981
|
+
async more(edge) {
|
|
982
|
+
const load = edge === "start" ? this.#loadBefore : this.#loadAfter;
|
|
983
|
+
if (!load || this.#loading) return false;
|
|
984
|
+
this.#loading = true;
|
|
985
|
+
try {
|
|
986
|
+
const page = await load();
|
|
987
|
+
if (!page.length) return false;
|
|
988
|
+
if (edge === "start") this.prepend(page);
|
|
989
|
+
else this.append(page);
|
|
990
|
+
return true;
|
|
991
|
+
} finally {
|
|
992
|
+
this.#loading = false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/** Model-level search, since virtualization hides most items from any DOM search. */
|
|
996
|
+
search(query, renderer = defaultRenderer) {
|
|
997
|
+
return searchFeed(this.messages, renderer, query);
|
|
998
|
+
}
|
|
999
|
+
/** The rendered text of one message, for previews and rails. */
|
|
1000
|
+
textOf(id, renderer = defaultRenderer) {
|
|
1001
|
+
const index = this.indexOf(id);
|
|
1002
|
+
const message = this.at(index);
|
|
1003
|
+
return message && messageText(message, renderer);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
/** The plain case: a feed over an array the host already has. */
|
|
1007
|
+
var fromMessages = (messages, options = {}) => new FeedModel({
|
|
1008
|
+
messages,
|
|
1009
|
+
...options
|
|
1010
|
+
});
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/model/useListModel.ts
|
|
1013
|
+
/**
|
|
1014
|
+
* A model over an array the host re-creates per render — the React-prop case.
|
|
1015
|
+
*
|
|
1016
|
+
* The model is stable; the array is folded into it with `replace`, which is where the prepend
|
|
1017
|
+
* inference lives (and the only place — SPEC F-7.1). In an effect rather than during render: the
|
|
1018
|
+
* model publishes synchronously to its subscribers, and a subscriber invalidating itself while the
|
|
1019
|
+
* host is still rendering is a cross-component update React rejects. The window it opens is one
|
|
1020
|
+
* 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
|
+
*
|
|
1035
|
+
* Streaming through this adapter is a `replace` whose length does not change; the model still
|
|
1036
|
+
* publishes, so the window still re-renders, and the streaming item reconciles by document delta
|
|
1037
|
+
* exactly as it would under a told update.
|
|
1038
|
+
*/
|
|
1039
|
+
var useFeedModel = (messages, options = {}) => {
|
|
1040
|
+
const model = useMemo(() => new FeedModel({
|
|
1041
|
+
messages,
|
|
1042
|
+
...options
|
|
1043
|
+
}), []);
|
|
1044
|
+
useEffect(() => {
|
|
1045
|
+
if (model.messages !== messages) model.replace(messages);
|
|
1046
|
+
}, [model, messages]);
|
|
1047
|
+
return model;
|
|
1048
|
+
};
|
|
1049
|
+
//#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
|
+
//#region src/components/MessageList/position-log.ts
|
|
1559
|
+
/** Movement below this is sub-pixel rounding, not a row changing place. */
|
|
1560
|
+
var EPSILON = 1;
|
|
1561
|
+
/** Recent shifts kept for inspection — enough to see whether one row type moves or all of them. */
|
|
1562
|
+
var TRACE_LIMIT = 40;
|
|
1563
|
+
var EMPTY = {
|
|
1564
|
+
shifts: 0,
|
|
1565
|
+
breaks: 0
|
|
1566
|
+
};
|
|
1567
|
+
/**
|
|
1568
|
+
* Records where each row was placed, and reports when a row moves.
|
|
1569
|
+
*
|
|
1570
|
+
* A virtualized list is only still if a row's offset, once laid out, does not change: rows above the
|
|
1571
|
+
* reader are measured as they mount, and any correction there pushes everything below it. Scrolling
|
|
1572
|
+
* **up** is where this shows, because that is the direction in which unmeasured rows enter — going
|
|
1573
|
+
* down, the rows above have already been measured.
|
|
1574
|
+
*
|
|
1575
|
+
* Positions are recorded **relative to the viewport** (`start - scrollOffset`), not as absolute
|
|
1576
|
+
* document offsets, because those two answer different questions. Measuring a row for the first time
|
|
1577
|
+
* re-lays every row below it, and the virtualizer compensates by moving `scrollTop` the same
|
|
1578
|
+
* distance — the offsets all change and the reader sees nothing. What a reader sees is a row landing
|
|
1579
|
+
* somewhere else on screen, which is what this counts.
|
|
1580
|
+
*
|
|
1581
|
+
* Two invariants:
|
|
1582
|
+
*
|
|
1583
|
+
* 1. a row keyed by message id holds its place on screen for as long as it stays mounted;
|
|
1584
|
+
* 2. positions within the mounted window increase strictly with index.
|
|
1585
|
+
*
|
|
1586
|
+
* The count is what a reader means by "flicker". Deliberately kept as a count rather than a log:
|
|
1587
|
+
* a scroll produces thousands of samples, and the number is the signal.
|
|
1588
|
+
*/
|
|
1589
|
+
/**
|
|
1590
|
+
* Watches for jumps in what was actually painted, once per animation frame.
|
|
1591
|
+
*
|
|
1592
|
+
* The counting above is done per React render, which cannot separate a row moving from the sampling
|
|
1593
|
+
* happening at a different moment than the layout. This reads the scroll and every row's box in the
|
|
1594
|
+
* **same** frame and compares each row's travel against the scroll's: a row that keeps its place in
|
|
1595
|
+
* the document travels exactly as far as the scroll did, so anything else is the reader seeing a
|
|
1596
|
+
* jump. It is the in-page form of measuring a screen recording frame by frame, and it is what a
|
|
1597
|
+
* developer should have running while working on the list.
|
|
1598
|
+
*/
|
|
1599
|
+
var useJumpDetector = (viewport, enabled = true) => {
|
|
1600
|
+
const [jumps, setJumps] = useState({
|
|
1601
|
+
count: 0,
|
|
1602
|
+
worst: 0
|
|
1603
|
+
});
|
|
1604
|
+
useEffect(() => {
|
|
1605
|
+
if (!viewport || !enabled) return;
|
|
1606
|
+
let handle = 0;
|
|
1607
|
+
let previous = /* @__PURE__ */ new Map();
|
|
1608
|
+
let previousScroll = viewport.scrollTop;
|
|
1609
|
+
let count = 0;
|
|
1610
|
+
let worst = 0;
|
|
1611
|
+
const tick = () => {
|
|
1612
|
+
const scroll = viewport.scrollTop;
|
|
1613
|
+
const travelled = previousScroll - scroll;
|
|
1614
|
+
const current = /* @__PURE__ */ new Map();
|
|
1615
|
+
for (const row of viewport.querySelectorAll("[data-index]")) {
|
|
1616
|
+
const top = row.getBoundingClientRect().top;
|
|
1617
|
+
const index = row.dataset.index;
|
|
1618
|
+
current.set(index, top);
|
|
1619
|
+
const before = previous.get(index);
|
|
1620
|
+
if (before !== void 0) {
|
|
1621
|
+
const residual = Math.abs(top - (before + travelled));
|
|
1622
|
+
if (residual > EPSILON) {
|
|
1623
|
+
count++;
|
|
1624
|
+
worst = Math.max(worst, Math.round(residual));
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
previous = current;
|
|
1629
|
+
previousScroll = scroll;
|
|
1630
|
+
handle = requestAnimationFrame(tick);
|
|
1631
|
+
};
|
|
1632
|
+
handle = requestAnimationFrame(tick);
|
|
1633
|
+
const timer = setInterval(() => setJumps({
|
|
1634
|
+
count,
|
|
1635
|
+
worst
|
|
1636
|
+
}), 250);
|
|
1637
|
+
return () => {
|
|
1638
|
+
cancelAnimationFrame(handle);
|
|
1639
|
+
clearInterval(timer);
|
|
1640
|
+
};
|
|
1641
|
+
}, [viewport, enabled]);
|
|
1642
|
+
return jumps;
|
|
1643
|
+
};
|
|
1644
|
+
var usePositionLog = () => {
|
|
1645
|
+
const positions = useRef(/* @__PURE__ */ new Map());
|
|
1646
|
+
const trace = useRef([]);
|
|
1647
|
+
const [stats, setStats] = useState(EMPTY);
|
|
1648
|
+
const pending = useRef(EMPTY);
|
|
1649
|
+
const reset = useCallback(() => {
|
|
1650
|
+
positions.current.clear();
|
|
1651
|
+
trace.current = [];
|
|
1652
|
+
pending.current = EMPTY;
|
|
1653
|
+
setStats(EMPTY);
|
|
1654
|
+
}, []);
|
|
1655
|
+
const record = useCallback((entries, { scrollOffset }) => {
|
|
1656
|
+
let { shifts, breaks, last } = pending.current;
|
|
1657
|
+
let previousStart = -Infinity;
|
|
1658
|
+
for (const { key, index, start: absolute } of entries) {
|
|
1659
|
+
const start = absolute - scrollOffset;
|
|
1660
|
+
const recorded = positions.current.get(key);
|
|
1661
|
+
if (recorded !== void 0 && Math.abs(recorded - start) > EPSILON) {
|
|
1662
|
+
shifts++;
|
|
1663
|
+
last = {
|
|
1664
|
+
index,
|
|
1665
|
+
from: Math.round(recorded),
|
|
1666
|
+
to: Math.round(start),
|
|
1667
|
+
delta: Math.round(start - recorded)
|
|
1668
|
+
};
|
|
1669
|
+
trace.current.push(last);
|
|
1670
|
+
if (trace.current.length > TRACE_LIMIT) trace.current.shift();
|
|
1671
|
+
}
|
|
1672
|
+
positions.current.set(key, start);
|
|
1673
|
+
if (start < previousStart) breaks++;
|
|
1674
|
+
previousStart = start;
|
|
1675
|
+
}
|
|
1676
|
+
const mounted = new Set(entries.map(({ key }) => key));
|
|
1677
|
+
for (const key of positions.current.keys()) if (!mounted.has(key)) positions.current.delete(key);
|
|
1678
|
+
const next = {
|
|
1679
|
+
shifts,
|
|
1680
|
+
breaks,
|
|
1681
|
+
last
|
|
1682
|
+
};
|
|
1683
|
+
pending.current = next;
|
|
1684
|
+
setStats((previous) => previous.shifts === next.shifts && previous.breaks === next.breaks ? previous : { ...next });
|
|
1685
|
+
}, []);
|
|
1686
|
+
return {
|
|
1687
|
+
...stats,
|
|
1688
|
+
trace,
|
|
1689
|
+
record,
|
|
1690
|
+
reset
|
|
1691
|
+
};
|
|
1692
|
+
};
|
|
1693
|
+
//#endregion
|
|
1694
|
+
//#region src/components/MessageList/MessageList.tsx
|
|
1695
|
+
var [MessageListProvider, useMessageListContext] = createContext$1("MessageList");
|
|
1696
|
+
/**
|
|
1697
|
+
* The list's state and scroll controls, for parts that live outside the viewport — a toolbar's
|
|
1698
|
+
* find-next, a statusbar's range readout.
|
|
1699
|
+
*/
|
|
1700
|
+
var useMessageList = (consumerName = "useMessageList") => {
|
|
1701
|
+
const { range, currentIndex, navigation, mountedRows, mountedWidgets, jumps, shifts, breaks, resetShifts, model, scrollToIndex, scrollToBottom } = useMessageListContext(consumerName);
|
|
1702
|
+
return {
|
|
1703
|
+
range,
|
|
1704
|
+
currentIndex,
|
|
1705
|
+
navigation,
|
|
1706
|
+
mountedRows,
|
|
1707
|
+
mountedWidgets,
|
|
1708
|
+
jumps,
|
|
1709
|
+
shifts,
|
|
1710
|
+
breaks,
|
|
1711
|
+
resetShifts,
|
|
1712
|
+
model,
|
|
1713
|
+
count: model.count,
|
|
1714
|
+
scrollToIndex,
|
|
1715
|
+
scrollToBottom
|
|
1716
|
+
};
|
|
1717
|
+
};
|
|
1718
|
+
var DefaultChrome = ({ children }) => /* @__PURE__ */ jsx(Fragment$1, { children });
|
|
1719
|
+
/** Row height assumed before anything has been measured. */
|
|
1720
|
+
var DEFAULT_ESTIMATE = 120;
|
|
1721
|
+
/**
|
|
1722
|
+
* Headless root of a feed: owns the placement, the selection group and the model-to-item mapping.
|
|
1723
|
+
* Renders no DOM of its own, so a toolbar or statusbar that reads its state can sit outside the
|
|
1724
|
+
* scroll container — the scrolling part is `MessageList.Viewport`.
|
|
1725
|
+
*/
|
|
1726
|
+
var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry, Chrome = DefaultChrome, Custom, debug, estimateSize = DEFAULT_ESTIMATE, stickyBottom = false, scrollPastEnd = false, overscan = 8, onRangeChange }) => {
|
|
1727
|
+
const widgetStore = useMemo(createWidgetStateStore, []);
|
|
1728
|
+
const [viewport, setViewportState] = useState(null);
|
|
1729
|
+
const scrollerRef = useRef(null);
|
|
1730
|
+
const setViewport = useCallback((element) => {
|
|
1731
|
+
scrollerRef.current = element;
|
|
1732
|
+
setViewportState(element);
|
|
1733
|
+
}, []);
|
|
1734
|
+
const [range, setRange] = useState(void 0);
|
|
1735
|
+
const [mounted, setMounted] = useState(0);
|
|
1736
|
+
const nominalSize = typeof estimateSize === "number" ? estimateSize : DEFAULT_ESTIMATE;
|
|
1737
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
1738
|
+
const currentIndexRef = useRef(0);
|
|
1739
|
+
const setCurrent = useCallback((index) => {
|
|
1740
|
+
currentIndexRef.current = index;
|
|
1741
|
+
setCurrentIndex(index);
|
|
1742
|
+
}, []);
|
|
1743
|
+
const widgetCounts = useRef(/* @__PURE__ */ new Map());
|
|
1744
|
+
const [mountedWidgets, setMountedWidgets] = useState(0);
|
|
1745
|
+
const reportWidgets = useCallback((id, count) => {
|
|
1746
|
+
if (count) widgetCounts.current.set(id, count);
|
|
1747
|
+
else widgetCounts.current.delete(id);
|
|
1748
|
+
setMountedWidgets([...widgetCounts.current.values()].reduce((total, value) => total + value, 0));
|
|
1749
|
+
}, []);
|
|
1750
|
+
const extents = useMemo(() => ({ of: (index) => {
|
|
1751
|
+
const message = model.at(index);
|
|
1752
|
+
return typeof estimateSize === "function" && message ? estimateSize(message, index) : nominalSize;
|
|
1753
|
+
} }), [
|
|
1754
|
+
model,
|
|
1755
|
+
estimateSize,
|
|
1756
|
+
nominalSize
|
|
1757
|
+
]);
|
|
1758
|
+
const reserve = useMemo(() => scrollPastEnd && viewport && model.count ? Math.max(0, viewport.clientHeight - nominalSize) : 0, [
|
|
1759
|
+
scrollPastEnd,
|
|
1760
|
+
viewport,
|
|
1761
|
+
model.count,
|
|
1762
|
+
nominalSize
|
|
1763
|
+
]);
|
|
1764
|
+
const controller = useRef(null);
|
|
1765
|
+
const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
|
|
1766
|
+
scrollerRef,
|
|
1767
|
+
model,
|
|
1768
|
+
extents,
|
|
1769
|
+
overscan,
|
|
1770
|
+
reserve,
|
|
1771
|
+
onChange: useCallback((state) => {
|
|
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
|
+
]),
|
|
1789
|
+
controllerRef: controller
|
|
1790
|
+
});
|
|
1791
|
+
const follow = useFollow({
|
|
1792
|
+
scrollerRef,
|
|
1793
|
+
placement,
|
|
1794
|
+
extent: sizerExtent,
|
|
1795
|
+
count: model.count,
|
|
1796
|
+
reserve,
|
|
1797
|
+
enabled: stickyBottom
|
|
1798
|
+
});
|
|
1799
|
+
const jumps = useJumpDetector(viewport, debug);
|
|
1800
|
+
const { shifts, breaks, trace: shiftTrace, record: recordPositions, reset: resetShifts } = usePositionLog();
|
|
1801
|
+
useEffect(() => {
|
|
1802
|
+
const rows = [];
|
|
1803
|
+
for (let index = first; index <= last; index++) rows.push({
|
|
1804
|
+
key: model.getId(index),
|
|
1805
|
+
index,
|
|
1806
|
+
start: placement.positionOf(index)
|
|
1807
|
+
});
|
|
1808
|
+
recordPositions(rows, { scrollOffset: placement.scroll });
|
|
1809
|
+
}, [
|
|
1810
|
+
first,
|
|
1811
|
+
last,
|
|
1812
|
+
offset,
|
|
1813
|
+
sizerExtent,
|
|
1814
|
+
model,
|
|
1815
|
+
placement,
|
|
1816
|
+
recordPositions
|
|
1817
|
+
]);
|
|
1818
|
+
const scrollToIndex = useCallback((index, { align = "start", behavior = "auto" } = {}) => {
|
|
1819
|
+
follow.onNavigate(index);
|
|
1820
|
+
controller.current?.scrollToIndex(index, align === "center" ? "start" : align, behavior);
|
|
1821
|
+
}, [follow]);
|
|
1822
|
+
const scrollToBottom = useCallback((options) => {
|
|
1823
|
+
if (model.count) scrollToIndex(model.count - 1, {
|
|
1824
|
+
align: "end",
|
|
1825
|
+
...options
|
|
1826
|
+
});
|
|
1827
|
+
}, [scrollToIndex, model]);
|
|
1828
|
+
useEffect(() => {
|
|
1829
|
+
if (viewport) viewport.__feed = {
|
|
1830
|
+
placement,
|
|
1831
|
+
get layout() {
|
|
1832
|
+
return placement.layout();
|
|
1833
|
+
},
|
|
1834
|
+
get shifted() {
|
|
1835
|
+
return {
|
|
1836
|
+
shifts,
|
|
1837
|
+
breaks,
|
|
1838
|
+
recent: shiftTrace.current
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
}, [
|
|
1843
|
+
viewport,
|
|
1844
|
+
placement,
|
|
1845
|
+
shifts,
|
|
1846
|
+
breaks,
|
|
1847
|
+
shiftTrace
|
|
1848
|
+
]);
|
|
1849
|
+
const navigation = useFeedNavigation({
|
|
1850
|
+
controller,
|
|
1851
|
+
stops: () => model.stops(),
|
|
1852
|
+
current: () => currentIndexRef.current,
|
|
1853
|
+
count: () => model.count,
|
|
1854
|
+
scrollPastEnd,
|
|
1855
|
+
onNavigate: follow.onNavigate
|
|
1856
|
+
});
|
|
1857
|
+
useEffect(() => {
|
|
1858
|
+
if (!viewport) return;
|
|
1859
|
+
viewport.tabIndex = 0;
|
|
1860
|
+
const onKeyDown = (event) => {
|
|
1861
|
+
if (event.altKey || event.shiftKey) return;
|
|
1862
|
+
const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
1863
|
+
if (!delta) return;
|
|
1864
|
+
event.preventDefault();
|
|
1865
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1866
|
+
if (delta > 0) navigation.last();
|
|
1867
|
+
else navigation.first();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
navigation.step(delta);
|
|
1871
|
+
};
|
|
1872
|
+
viewport.addEventListener("keydown", onKeyDown);
|
|
1873
|
+
return () => viewport.removeEventListener("keydown", onKeyDown);
|
|
1874
|
+
}, [viewport, navigation]);
|
|
1875
|
+
const selectionGroup = useMemo(createSelectionGroup, []);
|
|
1876
|
+
return /* @__PURE__ */ jsx(SelectionGroupContext.Provider, {
|
|
1877
|
+
value: selectionGroup,
|
|
1878
|
+
children: /* @__PURE__ */ jsx(WidgetStateProvider, {
|
|
1879
|
+
store: widgetStore,
|
|
1880
|
+
children: /* @__PURE__ */ jsx(MessageListProvider, {
|
|
1881
|
+
model,
|
|
1882
|
+
renderer,
|
|
1883
|
+
registry,
|
|
1884
|
+
Chrome,
|
|
1885
|
+
Custom,
|
|
1886
|
+
debug,
|
|
1887
|
+
range,
|
|
1888
|
+
currentIndex,
|
|
1889
|
+
navigation,
|
|
1890
|
+
mountedRows: mounted,
|
|
1891
|
+
mountedWidgets,
|
|
1892
|
+
reportWidgets,
|
|
1893
|
+
jumps,
|
|
1894
|
+
shifts,
|
|
1895
|
+
breaks,
|
|
1896
|
+
resetShifts,
|
|
1897
|
+
windowRef,
|
|
1898
|
+
offset,
|
|
1899
|
+
sizerExtent,
|
|
1900
|
+
first,
|
|
1901
|
+
last,
|
|
1902
|
+
setViewport,
|
|
1903
|
+
scrollToIndex,
|
|
1904
|
+
scrollToBottom,
|
|
1905
|
+
children
|
|
1906
|
+
})
|
|
1907
|
+
})
|
|
1908
|
+
});
|
|
1909
|
+
};
|
|
1910
|
+
MessageListRoot.displayName = "MessageList.Root";
|
|
1911
|
+
var MESSAGE_LIST_VIEWPORT_NAME = "MessageList.Viewport";
|
|
1912
|
+
/**
|
|
1913
|
+
* The scroll container and the mounted window of rows.
|
|
1914
|
+
*
|
|
1915
|
+
* Scrolling belongs to `ScrollArea`, which owns the overlay thumbs and the padding tokens; the
|
|
1916
|
+
* placement needs only the element being scrolled, which `ScrollArea.Viewport` publishes.
|
|
1917
|
+
*/
|
|
1918
|
+
var MessageListViewport = composable(({ autoHide, centered, native, padding, scrollbars, thin, gutter, ...props }, forwardedRef) => {
|
|
1919
|
+
const { model, Chrome, windowRef, offset, sizerExtent, first, last, setViewport } = useMessageListContext(MESSAGE_LIST_VIEWPORT_NAME);
|
|
1920
|
+
const { selectedIds, onSelect } = useItemSelectionValue();
|
|
1921
|
+
const handleViewportRef = useCallback((element) => {
|
|
1922
|
+
setViewport(element);
|
|
1923
|
+
setRef(forwardedRef, element);
|
|
1924
|
+
}, [setViewport, forwardedRef]);
|
|
1925
|
+
const rows = [];
|
|
1926
|
+
for (let index = first; index <= last; index++) {
|
|
1927
|
+
const message = model.at(index);
|
|
1928
|
+
if (!message) continue;
|
|
1929
|
+
rows.push(/* @__PURE__ */ jsx("div", {
|
|
1930
|
+
"data-index": index,
|
|
1931
|
+
"data-object-id": message.id,
|
|
1932
|
+
children: /* @__PURE__ */ jsx(Column.Root, {
|
|
1933
|
+
gutter,
|
|
1934
|
+
children: /* @__PURE__ */ jsx(Column.Center, { children: /* @__PURE__ */ jsx(Chrome, {
|
|
1935
|
+
message,
|
|
1936
|
+
index,
|
|
1937
|
+
selected: selectedIds?.has(message.id) ?? false,
|
|
1938
|
+
onSelect: (id, additive) => onSelect?.(id, additive),
|
|
1939
|
+
children: /* @__PURE__ */ jsx(MessageListItem, { message })
|
|
1940
|
+
}) })
|
|
1941
|
+
})
|
|
1942
|
+
}, message.id));
|
|
1943
|
+
}
|
|
1944
|
+
return /* @__PURE__ */ jsx(ScrollArea.Root, {
|
|
1945
|
+
...composableProps(props),
|
|
1946
|
+
orientation: "vertical",
|
|
1947
|
+
autoHide,
|
|
1948
|
+
centered,
|
|
1949
|
+
native,
|
|
1950
|
+
padding,
|
|
1951
|
+
scrollbars,
|
|
1952
|
+
thin,
|
|
1953
|
+
children: /* @__PURE__ */ jsxs(ScrollArea.Viewport, {
|
|
1954
|
+
"data-testid": "feed.viewport",
|
|
1955
|
+
style: {
|
|
1956
|
+
position: "relative",
|
|
1957
|
+
overflowAnchor: "none"
|
|
1958
|
+
},
|
|
1959
|
+
ref: handleViewportRef,
|
|
1960
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1961
|
+
style: { height: sizerExtent },
|
|
1962
|
+
"data-testid": "feed.sizer"
|
|
1963
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1964
|
+
ref: windowRef,
|
|
1965
|
+
className: "absolute top-0 left-0 flex flex-col w-full",
|
|
1966
|
+
style: { transform: `translateY(${offset}px)` },
|
|
1967
|
+
"data-testid": "feed.window",
|
|
1968
|
+
children: rows
|
|
1969
|
+
})]
|
|
1970
|
+
})
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1973
|
+
MessageListViewport.displayName = MESSAGE_LIST_VIEWPORT_NAME;
|
|
1974
|
+
var MESSAGE_LIST_ITEM_NAME = "MessageList.Item";
|
|
1975
|
+
/**
|
|
1976
|
+
* One message, rendered by the kind its renderer resolves. Exposed so a host can render a message
|
|
1977
|
+
* outside the scrolling window — a pinned message, a preview — through the same path.
|
|
1978
|
+
*/
|
|
1979
|
+
var MessageListItem = composable(({ message, ...props }, forwardedRef) => {
|
|
1980
|
+
const { renderer, registry, Custom, debug, reportWidgets } = useMessageListContext(MESSAGE_LIST_ITEM_NAME);
|
|
1981
|
+
const content = renderer(message);
|
|
1982
|
+
const decorations = useDecorations(message.id);
|
|
1983
|
+
const hits = useMemo(() => decorations.length ? decorations.map(({ range }) => [range.offset, range.offset + range.length]) : void 0, [decorations]);
|
|
1984
|
+
const handleWidgetsChange = useCallback((count) => reportWidgets(message.id, count), [reportWidgets, message.id]);
|
|
1985
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1986
|
+
...composableProps(props, { classNames: debug ? "outline outline-1 outline-dashed outline-primary-500/50 [&_.cm-content>*]:outline [&_.cm-content>*]:outline-1 [&_.cm-content>*]:outline-dashed [&_.cm-content>*]:outline-neutral-500/40" : void 0 }),
|
|
1987
|
+
ref: forwardedRef,
|
|
1988
|
+
children: [
|
|
1989
|
+
content.kind === "markdown" && /* @__PURE__ */ jsx(WidgetScopeProvider, {
|
|
1990
|
+
scope: message.id,
|
|
1991
|
+
children: /* @__PURE__ */ jsx(MarkdownBlock, {
|
|
1992
|
+
text: content.text,
|
|
1993
|
+
registry,
|
|
1994
|
+
hits,
|
|
1995
|
+
onWidgetsChange: handleWidgetsChange
|
|
1996
|
+
})
|
|
1997
|
+
}),
|
|
1998
|
+
content.kind === "html" && /* @__PURE__ */ jsx(HtmlBlock, { html: content.html }),
|
|
1999
|
+
content.kind === "custom" && Custom && /* @__PURE__ */ jsx(Custom, {
|
|
2000
|
+
content,
|
|
2001
|
+
message
|
|
2002
|
+
})
|
|
2003
|
+
]
|
|
2004
|
+
});
|
|
2005
|
+
});
|
|
2006
|
+
MessageListItem.displayName = MESSAGE_LIST_ITEM_NAME;
|
|
2007
|
+
var MessageList = {
|
|
2008
|
+
Root: MessageListRoot,
|
|
2009
|
+
Viewport: MessageListViewport,
|
|
2010
|
+
Item: MessageListItem
|
|
2011
|
+
};
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region src/components/Minimap/Minimap.tsx
|
|
2014
|
+
/** 2rem, matching the outline rail it usually sits opposite. */
|
|
2015
|
+
var DEFAULT_WIDTH$1 = 32;
|
|
2016
|
+
var Minimap = ({ classNames, state, width = DEFAULT_WIDTH$1, onSelect }) => {
|
|
2017
|
+
const total = state?.geometry.total ?? 0;
|
|
2018
|
+
const scale = (value) => total > 0 ? `${Math.max(0, Math.min(100, value / total * 100))}%` : "0%";
|
|
2019
|
+
return /* @__PURE__ */ jsx("div", {
|
|
2020
|
+
className: mx("relative h-full bg-input-surface overflow-hidden", onSelect && "cursor-pointer", classNames),
|
|
2021
|
+
style: { width },
|
|
2022
|
+
title: total ? `${Math.round(total)}px over ${state?.count} rows` : "empty",
|
|
2023
|
+
"data-testid": "minimap",
|
|
2024
|
+
onClick: onSelect && ((event) => {
|
|
2025
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
2026
|
+
onSelect(Math.max(0, Math.min(1, (event.clientY - box.top) / box.height)));
|
|
2027
|
+
}),
|
|
2028
|
+
children: state && total > 0 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2029
|
+
/* @__PURE__ */ jsx("div", {
|
|
2030
|
+
className: "absolute inset-x-0 bg-accent-fill/30",
|
|
2031
|
+
style: {
|
|
2032
|
+
top: scale(state.geometry.window.start),
|
|
2033
|
+
height: scale(state.geometry.window.extent)
|
|
2034
|
+
},
|
|
2035
|
+
"data-testid": "minimap.mounted"
|
|
2036
|
+
}),
|
|
2037
|
+
Array.from({ length: 9 }, (_, step) => /* @__PURE__ */ jsx("div", {
|
|
2038
|
+
className: "absolute inset-x-0 border-t border-separator/40",
|
|
2039
|
+
style: { top: `${(step + 1) / 10 * 100}%` }
|
|
2040
|
+
}, step)),
|
|
2041
|
+
/* @__PURE__ */ jsx("div", {
|
|
2042
|
+
className: "absolute inset-x-0 border-y border-accent-bg bg-accent-fill/70",
|
|
2043
|
+
style: {
|
|
2044
|
+
top: scale(state.geometry.scroll),
|
|
2045
|
+
height: scale(state.geometry.viewport)
|
|
2046
|
+
},
|
|
2047
|
+
"data-testid": "minimap.viewport"
|
|
2048
|
+
})
|
|
2049
|
+
] })
|
|
2050
|
+
});
|
|
2051
|
+
};
|
|
2052
|
+
//#endregion
|
|
2053
|
+
//#region src/components/Outline/Outline.tsx
|
|
2054
|
+
var REST_WIDTH = 8;
|
|
2055
|
+
/** 2rem, matching the debug minimap it usually sits opposite. */
|
|
2056
|
+
var DEFAULT_WIDTH = 32;
|
|
2057
|
+
/** Height of one tick, and so the pitch the rail thins its markers to. */
|
|
2058
|
+
var TICK_SIZE = 8;
|
|
2059
|
+
var intersects = (range, visible) => range.from < visible.to && range.to > visible.from;
|
|
2060
|
+
/**
|
|
2061
|
+
* Evenly spreads `markers` over at most `capacity` rows, keeping the first and last.
|
|
2062
|
+
*
|
|
2063
|
+
* Each rendered tick then stands for the span up to the next one, so a thinned rail still covers
|
|
2064
|
+
* the whole document — a position between two ticks belongs to the one above it, rather than to
|
|
2065
|
+
* nothing.
|
|
2066
|
+
*/
|
|
2067
|
+
var thin = (markers, capacity) => {
|
|
2068
|
+
const step = capacity > 1 ? (markers.length - 1) / (capacity - 1) : 0;
|
|
2069
|
+
const indexes = markers.length <= capacity ? markers.map((_, index) => index) : Array.from({ length: capacity }, (_, row) => Math.round(row * step));
|
|
2070
|
+
return indexes.map((index, row) => ({
|
|
2071
|
+
marker: markers[index],
|
|
2072
|
+
index,
|
|
2073
|
+
span: {
|
|
2074
|
+
from: markers[index].range.from,
|
|
2075
|
+
to: markers[indexes[row + 1]]?.range.from ?? markers[index].range.to
|
|
2076
|
+
}
|
|
2077
|
+
}));
|
|
2078
|
+
};
|
|
2079
|
+
var Outline = ({ classNames, markers, visibleRange, tickSize = TICK_SIZE, width = DEFAULT_WIDTH, onSelect, onNavigate }) => {
|
|
2080
|
+
const containerRef = useRef(null);
|
|
2081
|
+
const rowsRef = useRef([]);
|
|
2082
|
+
const [pointer, setPointer] = useState(null);
|
|
2083
|
+
const [navigated, setNavigated] = useState(null);
|
|
2084
|
+
const [available, setAvailable] = useState(0);
|
|
2085
|
+
useEffect(() => {
|
|
2086
|
+
const parent = containerRef.current?.parentElement;
|
|
2087
|
+
if (!parent) return;
|
|
2088
|
+
const observer = new ResizeObserver(([entry]) => setAvailable(entry.contentRect.height));
|
|
2089
|
+
observer.observe(parent);
|
|
2090
|
+
setAvailable(parent.getBoundingClientRect().height);
|
|
2091
|
+
return () => observer.disconnect();
|
|
2092
|
+
}, []);
|
|
2093
|
+
const natural = markers.length * tickSize;
|
|
2094
|
+
const bounded = available >= tickSize * 2;
|
|
2095
|
+
const rows = useMemo(() => thin(markers, bounded ? Math.min(markers.length, Math.floor(available / tickSize)) : markers.length), [
|
|
2096
|
+
markers,
|
|
2097
|
+
bounded,
|
|
2098
|
+
available,
|
|
2099
|
+
tickSize
|
|
2100
|
+
]);
|
|
2101
|
+
const isActive = useCallback((row) => visibleRange ? intersects(row.span, visibleRange) : true, [visibleRange]);
|
|
2102
|
+
const [keyboard, setKeyboard] = useState(false);
|
|
2103
|
+
const shown = keyboard ? navigated ?? pointer : pointer ?? navigated;
|
|
2104
|
+
useEffect(() => {
|
|
2105
|
+
if (pointer == null) return;
|
|
2106
|
+
const rail = containerRef.current;
|
|
2107
|
+
const owner = rail?.ownerDocument;
|
|
2108
|
+
if (!rail || !owner) return;
|
|
2109
|
+
const onOver = (event) => {
|
|
2110
|
+
if (!rail.contains(event.target)) setPointer(null);
|
|
2111
|
+
};
|
|
2112
|
+
owner.addEventListener("pointerover", onOver);
|
|
2113
|
+
return () => owner.removeEventListener("pointerover", onOver);
|
|
2114
|
+
}, [pointer]);
|
|
2115
|
+
const [anchorOffset, setAnchorOffset] = useState(0);
|
|
2116
|
+
useLayoutEffect(() => {
|
|
2117
|
+
const row = shown == null ? null : rowsRef.current[shown];
|
|
2118
|
+
if (row) setAnchorOffset(row.offsetTop + row.offsetHeight / 2);
|
|
2119
|
+
}, [shown]);
|
|
2120
|
+
const widthFor = useCallback((index) => {
|
|
2121
|
+
if (shown == null) return REST_WIDTH;
|
|
2122
|
+
const distance = index - shown;
|
|
2123
|
+
const falloff = Math.exp(-(distance * distance) / 8);
|
|
2124
|
+
return REST_WIDTH + (width - REST_WIDTH) * falloff;
|
|
2125
|
+
}, [shown, width]);
|
|
2126
|
+
const handleKeyDown = useCallback((event) => {
|
|
2127
|
+
const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
2128
|
+
if (!delta) return;
|
|
2129
|
+
if (onNavigate) {
|
|
2130
|
+
event.preventDefault();
|
|
2131
|
+
setKeyboard(true);
|
|
2132
|
+
onNavigate(delta);
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
const current = rowsRef.current.findIndex((element) => element === event.target);
|
|
2136
|
+
if (current < 0) return;
|
|
2137
|
+
const next = Math.min(Math.max(current + delta, 0), rows.length - 1);
|
|
2138
|
+
event.preventDefault();
|
|
2139
|
+
rowsRef.current[next]?.focus();
|
|
2140
|
+
}, [rows.length, onNavigate]);
|
|
2141
|
+
const visibleFrom = visibleRange?.from;
|
|
2142
|
+
useEffect(() => {
|
|
2143
|
+
if (!keyboard || visibleFrom == null) {
|
|
2144
|
+
setNavigated(null);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
const index = rows.findIndex((row) => visibleFrom < row.span.to);
|
|
2148
|
+
setNavigated(index < 0 ? rows.length - 1 : index);
|
|
2149
|
+
}, [
|
|
2150
|
+
keyboard,
|
|
2151
|
+
visibleFrom,
|
|
2152
|
+
rows
|
|
2153
|
+
]);
|
|
2154
|
+
const hoveredMarker = shown == null ? void 0 : rows[shown]?.marker;
|
|
2155
|
+
return /* @__PURE__ */ jsxs(Popover.Root, {
|
|
2156
|
+
open: hoveredMarker != null,
|
|
2157
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2158
|
+
role: "navigation",
|
|
2159
|
+
className: mx("relative flex flex-col justify-center overflow-hidden", classNames),
|
|
2160
|
+
style: {
|
|
2161
|
+
width: `${width}px`,
|
|
2162
|
+
height: bounded ? "100%" : `${natural}px`
|
|
2163
|
+
},
|
|
2164
|
+
"data-pointer": pointer ?? "",
|
|
2165
|
+
"data-navigated": navigated ?? "",
|
|
2166
|
+
"data-shown": shown ?? "",
|
|
2167
|
+
onPointerDown: () => setKeyboard(false),
|
|
2168
|
+
onBlurCapture: (event) => {
|
|
2169
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
|
|
2170
|
+
},
|
|
2171
|
+
onKeyDown: handleKeyDown,
|
|
2172
|
+
ref: containerRef,
|
|
2173
|
+
children: [rows.map((row, index) => {
|
|
2174
|
+
const marker = row.marker;
|
|
2175
|
+
const active = isActive(row);
|
|
2176
|
+
return /* @__PURE__ */ jsx("button", {
|
|
2177
|
+
type: "button",
|
|
2178
|
+
"aria-label": marker.title,
|
|
2179
|
+
className: "flex items-center w-full shrink-0 cursor-pointer outline-none",
|
|
2180
|
+
style: { height: tickSize },
|
|
2181
|
+
ref: (element) => {
|
|
2182
|
+
rowsRef.current[index] = element;
|
|
2183
|
+
},
|
|
2184
|
+
onPointerEnter: () => {
|
|
2185
|
+
setPointer(index);
|
|
2186
|
+
setKeyboard(false);
|
|
2187
|
+
},
|
|
2188
|
+
onPointerLeave: () => setPointer((current) => current === index ? null : current),
|
|
2189
|
+
onFocus: () => keyboard && setNavigated(index),
|
|
2190
|
+
onClick: () => onSelect?.(marker, row.index),
|
|
2191
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
2192
|
+
className: mx("h-[3px] rounded-full transition-all duration-200 ease-out", shown === index ? "bg-neutral-800 dark:bg-neutral-200" : "bg-neutral-400 dark:bg-neutral-600", active ? "opacity-100" : "opacity-30"),
|
|
2193
|
+
style: { width: widthFor(index) }
|
|
2194
|
+
})
|
|
2195
|
+
}, marker.id);
|
|
2196
|
+
}), /* @__PURE__ */ jsx(Popover.Anchor, {
|
|
2197
|
+
asChild: true,
|
|
2198
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
2199
|
+
className: "absolute left-0",
|
|
2200
|
+
style: {
|
|
2201
|
+
top: anchorOffset,
|
|
2202
|
+
width,
|
|
2203
|
+
height: 0
|
|
2204
|
+
}
|
|
2205
|
+
})
|
|
2206
|
+
})]
|
|
2207
|
+
}), hoveredMarker && /* @__PURE__ */ jsxs(Popover.Content, {
|
|
2208
|
+
side: "right",
|
|
2209
|
+
align: "center",
|
|
2210
|
+
avoidCollisions: false,
|
|
2211
|
+
onOpenAutoFocus: (event) => event.preventDefault(),
|
|
2212
|
+
children: [/* @__PURE__ */ jsx(Popover.Viewport, { children: /* @__PURE__ */ jsxs("div", {
|
|
2213
|
+
className: "px-2 py-1 max-w-[24rem] w-[24rem]",
|
|
2214
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
2215
|
+
className: "truncate font-medium",
|
|
2216
|
+
children: hoveredMarker.title
|
|
2217
|
+
}), hoveredMarker.description && /* @__PURE__ */ jsx("p", {
|
|
2218
|
+
className: "mt-1 text-sm text-description line-clamp-3",
|
|
2219
|
+
children: hoveredMarker.description
|
|
2220
|
+
})]
|
|
2221
|
+
}) }), /* @__PURE__ */ jsx(Popover.Arrow, {})]
|
|
2222
|
+
}, hoveredMarker.id)]
|
|
2223
|
+
});
|
|
2224
|
+
};
|
|
2225
|
+
//#endregion
|
|
2226
|
+
export { ItemSelectionProvider as A, useWidgetState as C, SelectionGroupContext as D, setHighlights as E, useFeedNavigation as F, DecorationsProvider as I, useDecorations as L, useItemSelection as M, useItemSelectionValue as N, createSelectionGroup as O, useFollow as P, ScrollFollower as R, createWidgetStateStore as S, highlights as T, sliceFeed as _, useJumpDetector as a, WidgetScopeProvider as b, useFeedModel as c, fromMessages as d, ListModel as f, searchFeed as g, messageText as h, useMessageList as i, isAdditive as j, useSelectionGroup as k, useListModel as l, defaultRenderer as m, Minimap as n, usePositionLog as o, chatRenderer as p, MessageList as r, Window as s, Outline as t, FeedModel as u, MarkdownBlock as v, highlightTheme as w, WidgetStateProvider as x, HtmlBlock as y, stepVelocity as z };
|
|
2227
|
+
|
|
2228
|
+
//# sourceMappingURL=chunk-src.mjs.map
|