@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,241 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Speeds are expressed in **rows**, not pixels: a feed of tall messages and one of short replies
|
|
7
|
+
* should travel at the same perceived rate, and only a row-relative unit gives that.
|
|
8
|
+
*/
|
|
9
|
+
export type FollowOptions = {
|
|
10
|
+
/** Ceiling on travel speed, rows/s. */
|
|
11
|
+
maxSpeed?: number;
|
|
12
|
+
/** Rate the speed ramps up, rows/s². */
|
|
13
|
+
acceleration?: number;
|
|
14
|
+
/** Rate the speed sheds on approach, rows/s². Lower than `acceleration` gives a longer landing. */
|
|
15
|
+
deceleration?: number;
|
|
16
|
+
/** Height of a row in px, sampled each frame so a measured list can refine it. */
|
|
17
|
+
rowHeight?: () => number;
|
|
18
|
+
/** Empty space reserved below the last row, which the follow must stop short of. */
|
|
19
|
+
trailing?: () => number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_SPEED = 2;
|
|
23
|
+
const DEFAULT_ACCELERATION = 12;
|
|
24
|
+
const DEFAULT_DECELERATION = 6;
|
|
25
|
+
/**
|
|
26
|
+
* Gentler than the ramp up, and deliberately so.
|
|
27
|
+
*
|
|
28
|
+
* Braking distance is `v²/2d`, so matching the two rates sheds full speed in three rows — a travel
|
|
29
|
+
* of any length then cruises to within a few rows of the target and stops, which reads as an abrupt
|
|
30
|
+
* halt however correct the curve is. At a third of the acceleration the landing occupies nine rows
|
|
31
|
+
* and a second and a half, which is long enough to see.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_ROW_HEIGHT = 120;
|
|
34
|
+
|
|
35
|
+
/** Within this distance the follow has arrived; below it the animation would only jitter. */
|
|
36
|
+
const ARRIVED = 0.5;
|
|
37
|
+
|
|
38
|
+
/** The physics runs in pixels; rows are converted at the boundary by {@link ScrollFollower}. */
|
|
39
|
+
export type VelocityStep = {
|
|
40
|
+
/** Current speed, px/s. */
|
|
41
|
+
velocity: number;
|
|
42
|
+
/** Remaining distance to the target, px. */
|
|
43
|
+
distance: number;
|
|
44
|
+
/** Elapsed time since the last step, seconds. */
|
|
45
|
+
dt: number;
|
|
46
|
+
/** Ceiling, px/s. */
|
|
47
|
+
maxSpeed: number;
|
|
48
|
+
/** Ramp-up rate, px/s². */
|
|
49
|
+
acceleration: number;
|
|
50
|
+
/** Approach rate, px/s². Defaults to `acceleration`. */
|
|
51
|
+
deceleration?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Speed for the next frame of a follow.
|
|
56
|
+
*
|
|
57
|
+
* Ramps up to `maxSpeed` and back down so the travel starts and ends gently rather than jumping.
|
|
58
|
+
* The cap is whichever is lower: the ceiling, or the speed from which the remaining distance can
|
|
59
|
+
* still be shed at `acceleration` (`v = sqrt(2·a·d)`) — that second term is what turns the arrival
|
|
60
|
+
* into a deceleration instead of a stop, and what keeps a growing target at cruise speed.
|
|
61
|
+
*
|
|
62
|
+
* Acceleration is rate-limited so a start is gradual; **deceleration is not**, because the braking
|
|
63
|
+
* curve is already a smooth ramp to zero and easing onto it instead leaves the speed above the
|
|
64
|
+
* curve for the whole approach — the follow then runs out of distance while still moving and the
|
|
65
|
+
* arrival reads as a stop rather than a landing.
|
|
66
|
+
*/
|
|
67
|
+
export const stepVelocity = ({
|
|
68
|
+
velocity,
|
|
69
|
+
distance,
|
|
70
|
+
dt,
|
|
71
|
+
maxSpeed,
|
|
72
|
+
acceleration,
|
|
73
|
+
deceleration = acceleration,
|
|
74
|
+
}: VelocityStep): number => {
|
|
75
|
+
const braking = Math.sqrt(2 * deceleration * Math.max(distance, 0));
|
|
76
|
+
const desired = Math.min(maxSpeed, braking);
|
|
77
|
+
return velocity < desired ? Math.min(desired, velocity + acceleration * dt) : desired;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* **Not wired into `MessageList` at present.** The placement engine follows the tail by holding an
|
|
82
|
+
* intent and correcting the offset, which arrives instantly; this class is the animated travel the
|
|
83
|
+
* old engine used, kept with its tests because the glide is worth having back and nothing about it
|
|
84
|
+
* was wrong. Wiring it is a decision about feel, not a repair.
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Keeps an element scrolled to its bottom while its content grows.
|
|
88
|
+
*
|
|
89
|
+
* Native `scrollTo({ behavior: 'smooth' })` restarts its easing on every call, so a target that
|
|
90
|
+
* moves every chunk never leaves the start of the curve — the motion reads as a stutter. This
|
|
91
|
+
* carries velocity across frames instead: it accelerates as a stream begins, cruises while content
|
|
92
|
+
* keeps arriving, and decelerates onto the tail when it stops.
|
|
93
|
+
*
|
|
94
|
+
* `@dxos/ui-editor`'s `createCrawler` follows a streaming CodeMirror document the same way, with a
|
|
95
|
+
* critically-damped spring. The two are deliberately separate: that one is bound to `view.scrollDOM`
|
|
96
|
+
* and driven by editor effects, where a feed owns its own scroll container. The models differ in
|
|
97
|
+
* feel — a spring's speed falls off with distance, where this holds a ceiling and brakes onto the
|
|
98
|
+
* target, which keeps a fast stream at a constant rate rather than an ever-widening lag.
|
|
99
|
+
*/
|
|
100
|
+
export class ScrollFollower {
|
|
101
|
+
readonly #maxSpeed: number;
|
|
102
|
+
readonly #acceleration: number;
|
|
103
|
+
readonly #deceleration: number;
|
|
104
|
+
readonly #rowHeight: () => number;
|
|
105
|
+
readonly #trailing: () => number;
|
|
106
|
+
|
|
107
|
+
#frame: number | undefined;
|
|
108
|
+
#velocity = 0;
|
|
109
|
+
#timestamp = 0;
|
|
110
|
+
#braking = false;
|
|
111
|
+
|
|
112
|
+
constructor(
|
|
113
|
+
private readonly _element: HTMLElement,
|
|
114
|
+
{
|
|
115
|
+
maxSpeed = DEFAULT_MAX_SPEED,
|
|
116
|
+
acceleration = DEFAULT_ACCELERATION,
|
|
117
|
+
deceleration = DEFAULT_DECELERATION,
|
|
118
|
+
rowHeight = () => DEFAULT_ROW_HEIGHT,
|
|
119
|
+
trailing = () => 0,
|
|
120
|
+
}: FollowOptions = {},
|
|
121
|
+
) {
|
|
122
|
+
this.#maxSpeed = maxSpeed;
|
|
123
|
+
this.#acceleration = acceleration;
|
|
124
|
+
this.#deceleration = deceleration;
|
|
125
|
+
this.#rowHeight = rowHeight;
|
|
126
|
+
this.#trailing = trailing;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The bottom of the last row, which is not the bottom of the document.
|
|
131
|
+
*
|
|
132
|
+
* A feed may reserve empty space below its last row so that row can be brought to the top of the
|
|
133
|
+
* viewport. That space is somewhere the reader may go and nowhere the follow should: chasing the
|
|
134
|
+
* element's own maximum walks the feed off the end of the conversation and keeps going.
|
|
135
|
+
*/
|
|
136
|
+
get #bottom(): number {
|
|
137
|
+
return this._element.scrollHeight - this.#trailing() - this._element.clientHeight;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get running(): boolean {
|
|
141
|
+
return this.#frame !== undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Current speed, rows/s. Exposed so a harness can show the ramp. */
|
|
145
|
+
get velocity(): number {
|
|
146
|
+
return this.#velocity / Math.max(this.#rowHeight(), 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Starts (or continues) travelling towards the bottom. Safe to call on every content change. */
|
|
150
|
+
start(): void {
|
|
151
|
+
this.#braking = false;
|
|
152
|
+
if (this.#frame !== undefined) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.#timestamp = performance.now();
|
|
157
|
+
this.#frame = requestAnimationFrame(this.#tick);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Coasts to a halt, shedding speed at `deceleration` — what a stream ending or a stop control
|
|
162
|
+
* means. Cutting the animation instead would halt mid-glide, which is the one motion the reader
|
|
163
|
+
* cannot have caused and so reads as a fault.
|
|
164
|
+
*/
|
|
165
|
+
stop(): void {
|
|
166
|
+
if (this.#frame === undefined) {
|
|
167
|
+
this.#velocity = 0;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.#braking = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Halts immediately, discarding the velocity — the reader has taken over, or the list is going
|
|
176
|
+
* away. Coasting here would fight a hand already on the scrollbar.
|
|
177
|
+
*/
|
|
178
|
+
cancel(): void {
|
|
179
|
+
if (this.#frame !== undefined) {
|
|
180
|
+
cancelAnimationFrame(this.#frame);
|
|
181
|
+
this.#frame = undefined;
|
|
182
|
+
}
|
|
183
|
+
this.#braking = false;
|
|
184
|
+
this.#velocity = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Jumps to the bottom with no animation. */
|
|
188
|
+
jump(): void {
|
|
189
|
+
this.cancel();
|
|
190
|
+
this._element.scrollTop = Math.max(0, this.#bottom);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
readonly #tick = (timestamp: number): void => {
|
|
194
|
+
// Clamped because a backgrounded tab resumes with a large gap, which would otherwise apply one
|
|
195
|
+
// enormous step and teleport the viewport.
|
|
196
|
+
const dt = Math.min((timestamp - this.#timestamp) / 1_000, 0.05);
|
|
197
|
+
this.#timestamp = timestamp;
|
|
198
|
+
|
|
199
|
+
const target = Math.max(0, this.#bottom);
|
|
200
|
+
const distance = target - this._element.scrollTop;
|
|
201
|
+
|
|
202
|
+
// Coasting: the target no longer matters, only shedding what speed is left. Bounded by the
|
|
203
|
+
// bottom so a stop late in a travel still cannot run past it.
|
|
204
|
+
if (this.#braking) {
|
|
205
|
+
const rowHeight = Math.max(this.#rowHeight(), 1);
|
|
206
|
+
this.#velocity = Math.max(0, this.#velocity - this.#deceleration * rowHeight * dt);
|
|
207
|
+
if (this.#velocity <= 0 || distance <= ARRIVED) {
|
|
208
|
+
this.#frame = undefined;
|
|
209
|
+
this.#velocity = 0;
|
|
210
|
+
this.#braking = false;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
|
|
215
|
+
this.#frame = requestAnimationFrame(this.#tick);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (distance <= ARRIVED) {
|
|
220
|
+
this._element.scrollTop = target;
|
|
221
|
+
this.#frame = undefined;
|
|
222
|
+
this.#velocity = 0;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Rows are converted to pixels every frame rather than once, so a list whose measured rows grow
|
|
227
|
+
// (a streaming message) travels at the same rate in rows throughout.
|
|
228
|
+
const rowHeight = Math.max(this.#rowHeight(), 1);
|
|
229
|
+
this.#velocity = stepVelocity({
|
|
230
|
+
velocity: this.#velocity,
|
|
231
|
+
distance,
|
|
232
|
+
dt,
|
|
233
|
+
maxSpeed: this.#maxSpeed * rowHeight,
|
|
234
|
+
acceleration: this.#acceleration * rowHeight,
|
|
235
|
+
deceleration: this.#deceleration * rowHeight,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
|
|
239
|
+
this.#frame = requestAnimationFrame(this.#tick);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type PropsWithChildren, createContext, useContext, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A range of one item's rendered text that some producer wants painted (SPEC: Decoration).
|
|
9
|
+
*
|
|
10
|
+
* Search is ONE producer — mentions, lint results and diff spans are others — which is why this is
|
|
11
|
+
* not called a hit: the item painting the range does not care where it came from, and the list does
|
|
12
|
+
* not know it exists at all.
|
|
13
|
+
*/
|
|
14
|
+
export type Decoration = {
|
|
15
|
+
/** Item (message) id. */
|
|
16
|
+
id: string;
|
|
17
|
+
range: { offset: number; length: number };
|
|
18
|
+
/** Producer tag: 'search' | 'mention' | ... */
|
|
19
|
+
kind: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DecorationsContext = createContext<ReadonlyMap<string, readonly Decoration[]>>(new Map());
|
|
23
|
+
|
|
24
|
+
export type DecorationsProviderProps = PropsWithChildren<{
|
|
25
|
+
decorations?: readonly Decoration[];
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* THE PATTERN for cross-cutting per-item data (SPEC §Aspects), not a feed feature: a provider at
|
|
30
|
+
* the host carries what producers made, and an item asks for its own by id. The list neither knows
|
|
31
|
+
* nor routes — `hits` left its API entirely. Grouped once per change rather than filtered per item,
|
|
32
|
+
* so a long feed stays O(decorations).
|
|
33
|
+
*/
|
|
34
|
+
export const DecorationsProvider = ({ decorations, children }: DecorationsProviderProps) => {
|
|
35
|
+
const byId = useMemo(() => {
|
|
36
|
+
const map = new Map<string, Decoration[]>();
|
|
37
|
+
for (const decoration of decorations ?? []) {
|
|
38
|
+
const list = map.get(decoration.id) ?? [];
|
|
39
|
+
list.push(decoration);
|
|
40
|
+
map.set(decoration.id, list);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return map;
|
|
44
|
+
}, [decorations]);
|
|
45
|
+
|
|
46
|
+
return <DecorationsContext.Provider value={byId}>{children}</DecorationsContext.Provider>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const NONE: readonly Decoration[] = [];
|
|
50
|
+
|
|
51
|
+
/** An item's own decorations. Items work without a provider — they are simply undecorated. */
|
|
52
|
+
export const useDecorations = (id: string): readonly Decoration[] => {
|
|
53
|
+
return useContext(DecorationsContext).get(id) ?? NONE;
|
|
54
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type RefObject, useCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Stop } from '../model';
|
|
8
|
+
import { type WindowController } from '../virtualizer';
|
|
9
|
+
|
|
10
|
+
export type UseFeedNavigationOptions = {
|
|
11
|
+
controller: RefObject<WindowController | null>;
|
|
12
|
+
/** The stops policy's current answer; asked per press, so a policy change needs no rewiring. */
|
|
13
|
+
stops: () => readonly Stop[];
|
|
14
|
+
/** The row containing the scroll offset — the cursor, derived and never set beside the scroll. */
|
|
15
|
+
current: () => number;
|
|
16
|
+
count: () => number;
|
|
17
|
+
/** Align the last stop to the end unless space is reserved past it (the reserve is scrollable). */
|
|
18
|
+
scrollPastEnd?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Told before every jump, with the target index — the follow's `onNavigate` goes here. Without
|
|
21
|
+
* it a navigation races the follow's correction effect, which runs before the jump's scroll
|
|
22
|
+
* event can withdraw the intent and snaps the reader straight back to the tail they just left.
|
|
23
|
+
* The follow may move the view only while content is arriving AND the reader has pinned
|
|
24
|
+
* themselves to the bottom; navigating into the feed un-pins by definition.
|
|
25
|
+
*/
|
|
26
|
+
onNavigate?: (index: number) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FeedNavigation = {
|
|
30
|
+
/** Move by `delta` stops. Every press travels: see below. */
|
|
31
|
+
step: (delta: number) => void;
|
|
32
|
+
/** Jump to a stop (or a raw index), aligning the last row to the end where that is what it means. */
|
|
33
|
+
jumpTo: (index: number, behavior?: ScrollBehavior) => void;
|
|
34
|
+
first: () => void;
|
|
35
|
+
last: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one navigation seam (SPEC: useFeedNavigation): toolbar buttons, arrow keys, the outline rail
|
|
40
|
+
* and the minimap all call this, so they cannot disagree about what a step is.
|
|
41
|
+
*
|
|
42
|
+
* Stepping is from the row containing the scroll offset. The stop above it begins strictly higher
|
|
43
|
+
* and the stop below strictly lower, so every press moves the viewport — an index compared against
|
|
44
|
+
* a position cannot promise that while positions are still being measured. Steps glide (corrections
|
|
45
|
+
* move the window, not the scroll, so nothing cancels the animation); far jumps are instant, since
|
|
46
|
+
* a glide across unmounted rows is a journey over a blank screen.
|
|
47
|
+
*
|
|
48
|
+
* Distinct from react-ui-list's `useListNavigation`, which is focus traversal over fully-mounted
|
|
49
|
+
* rows — a different problem in the same naming family.
|
|
50
|
+
*/
|
|
51
|
+
export const useFeedNavigation = ({
|
|
52
|
+
controller,
|
|
53
|
+
stops,
|
|
54
|
+
current,
|
|
55
|
+
count,
|
|
56
|
+
scrollPastEnd,
|
|
57
|
+
onNavigate,
|
|
58
|
+
}: UseFeedNavigationOptions): FeedNavigation => {
|
|
59
|
+
const jumpTo = useCallback(
|
|
60
|
+
(index: number, behavior: ScrollBehavior = 'auto') => {
|
|
61
|
+
onNavigate?.(index);
|
|
62
|
+
const align = !scrollPastEnd && index >= count() - 1 ? 'end' : 'start';
|
|
63
|
+
controller.current?.scrollToIndex(index, align, behavior);
|
|
64
|
+
},
|
|
65
|
+
[controller, count, scrollPastEnd, onNavigate],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const step = useCallback(
|
|
69
|
+
(delta: number) => {
|
|
70
|
+
const all = stops();
|
|
71
|
+
if (!all.length) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const at = current();
|
|
76
|
+
const next =
|
|
77
|
+
delta > 0
|
|
78
|
+
? (all.find(({ index }) => index > at) ?? all[all.length - 1])
|
|
79
|
+
: ([...all].reverse().find(({ index }) => index < at) ?? all[0]);
|
|
80
|
+
|
|
81
|
+
jumpTo(next.index, 'smooth');
|
|
82
|
+
},
|
|
83
|
+
[stops, current, jumpTo],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const first = useCallback(() => jumpTo(0), [jumpTo]);
|
|
87
|
+
const last = useCallback(() => jumpTo(count() - 1), [jumpTo, count]);
|
|
88
|
+
|
|
89
|
+
return { step, jumpTo, first, last };
|
|
90
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Placement } from '../virtualizer/placement';
|
|
8
|
+
import { ScrollFollower } from './follow';
|
|
9
|
+
|
|
10
|
+
/** Distance from the end within which the reader counts as being at it. */
|
|
11
|
+
const STICKY_THRESHOLD = 32;
|
|
12
|
+
|
|
13
|
+
/** How long after a gesture a scroll is still attributable to the reader. */
|
|
14
|
+
const GESTURE_WINDOW = 300;
|
|
15
|
+
|
|
16
|
+
export type FollowHandle = {
|
|
17
|
+
/**
|
|
18
|
+
* A navigation is the reader answering "do you want the tail?" — and it must answer *before* the
|
|
19
|
+
* scroll moves. The intent is otherwise withdrawn by the scroll event the jump raises, which is
|
|
20
|
+
* asynchronous: the correction effect can run in between, still believe the reader is following,
|
|
21
|
+
* and snap them straight back to the tail they just left. Found by the outline rail — a click
|
|
22
|
+
* near the top of a sticky feed moved the list a few pixels and no further.
|
|
23
|
+
*/
|
|
24
|
+
onNavigate: (index: number) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UseFollowOptions = {
|
|
28
|
+
scrollerRef: RefObject<HTMLElement | null>;
|
|
29
|
+
/** The placement whose `endOffset` is the tail's resting place. */
|
|
30
|
+
placement: Placement;
|
|
31
|
+
/** Total content extent — the correction re-runs when it changes, which is how growth is noticed. */
|
|
32
|
+
extent: number;
|
|
33
|
+
count: number;
|
|
34
|
+
axis?: 'block' | 'inline';
|
|
35
|
+
/** Empty extent after the last row; the end the follow rests at is before it. */
|
|
36
|
+
reserve?: number;
|
|
37
|
+
/** The standing intent: off means this hook does nothing at all. */
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Travel to a moved tail instead of teleporting (DECIDED: default on).
|
|
41
|
+
*
|
|
42
|
+
* The glide only covers gaps up to a viewport: beyond that the reader could not have followed the
|
|
43
|
+
* motion anyway, and the travel would cross rows whose extents are still estimates.
|
|
44
|
+
*/
|
|
45
|
+
glide?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The sticky tail as an intent (SPEC: useFollow) — a policy over the window, not part of it.
|
|
50
|
+
*
|
|
51
|
+
* Withdrawn by one thing only: a scroll that moves *backwards*. That is the reader; a correction
|
|
52
|
+
* never moves the offset back, it moves the end away. Re-deriving the intent from proximity on
|
|
53
|
+
* every commit cannot survive its own corrections — measuring the tail grows the document under a
|
|
54
|
+
* reader who has not moved, the gap opens past any threshold worth having, and the follow reads
|
|
55
|
+
* that as the reader leaving and disengages for good (`bridge/Tail`, the second defect).
|
|
56
|
+
*
|
|
57
|
+
* Reusable for any streaming container — transcripts, logs — because nothing here knows about
|
|
58
|
+
* messages: it needs a scroller, a placement, and the two numbers that say the content grew.
|
|
59
|
+
*/
|
|
60
|
+
export const useFollow = ({
|
|
61
|
+
scrollerRef,
|
|
62
|
+
placement,
|
|
63
|
+
extent,
|
|
64
|
+
count,
|
|
65
|
+
axis = 'block',
|
|
66
|
+
reserve = 0,
|
|
67
|
+
enabled,
|
|
68
|
+
glide = true,
|
|
69
|
+
}: UseFollowOptions): FollowHandle => {
|
|
70
|
+
const following = useRef(!!enabled);
|
|
71
|
+
const wasEnabled = useRef(enabled);
|
|
72
|
+
if (wasEnabled.current !== enabled) {
|
|
73
|
+
wasEnabled.current = enabled;
|
|
74
|
+
following.current = !!enabled;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the reader has to be for the last row's rendered edge to rest on the viewport's.
|
|
79
|
+
*
|
|
80
|
+
* From the row's own rectangle when it is mounted, and from the arithmetic only when it is not:
|
|
81
|
+
* measured extents are rounded to integers and rendered rows are fractional, so over a mounted
|
|
82
|
+
* window the sum drifts from the layout by tens of pixels — an "arrived" that the reader can see
|
|
83
|
+
* is short. The rect already includes the window's transform, so the delta is exact.
|
|
84
|
+
*/
|
|
85
|
+
const endTarget = useCallback(() => {
|
|
86
|
+
const scroller = scrollerRef.current;
|
|
87
|
+
if (!scroller) {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const last = scroller.querySelector<HTMLElement>(`[data-index="${count - 1}"]`);
|
|
92
|
+
if (last) {
|
|
93
|
+
const current = axis === 'block' ? scroller.scrollTop : scroller.scrollLeft;
|
|
94
|
+
const delta =
|
|
95
|
+
axis === 'block'
|
|
96
|
+
? last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom
|
|
97
|
+
: last.getBoundingClientRect().right - scroller.getBoundingClientRect().right;
|
|
98
|
+
return Math.max(0, Math.round(current + delta));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return placement.endOffset();
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
}, [count, axis, placement, scrollerRef.current]);
|
|
104
|
+
|
|
105
|
+
// The glide, built once per scroller. Speeds are in rows, so it asks the placement for a live
|
|
106
|
+
// median of what is mounted rather than being handed a constant.
|
|
107
|
+
const follower = useMemo(() => {
|
|
108
|
+
const scroller = scrollerRef.current;
|
|
109
|
+
if (!scroller || axis !== 'block') {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new ScrollFollower(scroller, {
|
|
114
|
+
// Faster than the class's defaults, which were tuned for a single arrival: a streaming answer
|
|
115
|
+
// grows at hundreds of px/s, and a follow that cannot outrun the growth reads as falling
|
|
116
|
+
// behind for the whole turn. The ramp is what keeps this from feeling like a snap.
|
|
117
|
+
maxSpeed: 8,
|
|
118
|
+
acceleration: 32,
|
|
119
|
+
deceleration: 10,
|
|
120
|
+
// The follower's target is DOM-derived (scrollHeight - trailing - viewport); the authoritative
|
|
121
|
+
// end is the placement's (the last row's own edge, which the document disagrees with by the
|
|
122
|
+
// drift while estimates are being replaced). Expressing the placement's end *as* a trailing
|
|
123
|
+
// makes the two the same number.
|
|
124
|
+
trailing: () => scroller.scrollHeight - scroller.clientHeight - endTarget(),
|
|
125
|
+
rowHeight: () => {
|
|
126
|
+
const { first, last } = placement.layout();
|
|
127
|
+
const sizes = [];
|
|
128
|
+
for (let index = first; index <= last; index++) {
|
|
129
|
+
sizes.push(placement.extentOf(index));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
sizes.sort((a, b) => a - b);
|
|
133
|
+
const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 100;
|
|
134
|
+
return Math.min(median, scroller.clientHeight || median);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
// The scroller element is stable for the life of the binding; reserve is read through the closure.
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [scrollerRef.current, axis, placement, endTarget]);
|
|
140
|
+
useLayoutEffect(() => () => follower?.cancel(), [follower]);
|
|
141
|
+
|
|
142
|
+
// The intent, read from gestures. Own listener and own last-offset, deliberately: the window's
|
|
143
|
+
// scroll handler updates `placement.scroll` before this runs, so comparing against it would read
|
|
144
|
+
// every scroll as stationary.
|
|
145
|
+
// Why a backwards scroll alone cannot be the withdrawal: the machinery scrolls backwards too. A
|
|
146
|
+
// shrinking turn makes the browser clamp the offset by itself; a correction can move up; and none
|
|
147
|
+
// of it is the reader. Direction plus pre-announcement was tried and kept dying in races — this
|
|
148
|
+
// is the old engine's proven rule instead: the follow writes `scrollTop`, it does not turn wheels
|
|
149
|
+
// or press keys, so a scroll counts as the reader's only when a gesture preceded it. Returning to
|
|
150
|
+
// the tail, by any means, opts back in.
|
|
151
|
+
const lastOffset = useRef(0);
|
|
152
|
+
const gestureAt = useRef(0);
|
|
153
|
+
useLayoutEffect(() => {
|
|
154
|
+
const scroller = scrollerRef.current;
|
|
155
|
+
if (!scroller || !enabled) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onGesture = () => {
|
|
160
|
+
gestureAt.current = performance.now();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const onScroll = () => {
|
|
164
|
+
const current = axis === 'block' ? scroller.scrollTop : scroller.scrollLeft;
|
|
165
|
+
const viewport = axis === 'block' ? scroller.clientHeight : scroller.clientWidth;
|
|
166
|
+
const total = axis === 'block' ? scroller.scrollHeight : scroller.scrollWidth;
|
|
167
|
+
const back = current < lastOffset.current - 1;
|
|
168
|
+
lastOffset.current = current;
|
|
169
|
+
if (total - reserve - current - viewport <= STICKY_THRESHOLD) {
|
|
170
|
+
following.current = true;
|
|
171
|
+
} else if (back && performance.now() - gestureAt.current < GESTURE_WINDOW) {
|
|
172
|
+
following.current = false;
|
|
173
|
+
follower?.cancel();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
178
|
+
// Every way a reader can move a scroll container by hand: the wheel, a touch drag, the
|
|
179
|
+
// keyboard, and a pointer on the scrollbar itself.
|
|
180
|
+
for (const event of ['wheel', 'touchmove', 'keydown', 'pointerdown'] as const) {
|
|
181
|
+
scroller.addEventListener(event, onGesture, { passive: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
scroller.removeEventListener('scroll', onScroll);
|
|
186
|
+
for (const event of ['wheel', 'touchmove', 'keydown', 'pointerdown'] as const) {
|
|
187
|
+
scroller.removeEventListener(event, onGesture);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [scrollerRef.current, enabled, axis, reserve, follower]);
|
|
192
|
+
|
|
193
|
+
// The correction. Following is a navigation, not a correction in the §7 sense: the content the
|
|
194
|
+
// reader is pinned to has moved, so the scroll has to as well.
|
|
195
|
+
//
|
|
196
|
+
// Through `endOffset` rather than a jump: a jump re-bases the model on a sum of estimates, and
|
|
197
|
+
// this effect runs again every time the document's extent changes — so each answer mounted rows
|
|
198
|
+
// whose measurement changed the sum the next answer was drawn from. That recurs rather than
|
|
199
|
+
// converging, and React ends it by exceeding the update limit. Keyed on the extent as well as the
|
|
200
|
+
// count: measuring the rows a scroll reveals moves the end, so a tail pinned once drifts off it
|
|
201
|
+
// as the estimates are replaced.
|
|
202
|
+
// Whether the feed has arrived at its tail once. Opening a populated feed is not motion to
|
|
203
|
+
// follow — the reader was not watching the travel, so it arrives; the glide is for content
|
|
204
|
+
// arriving at a tail they are looking at.
|
|
205
|
+
const positioned = useRef(false);
|
|
206
|
+
|
|
207
|
+
useLayoutEffect(() => {
|
|
208
|
+
const scroller = scrollerRef.current;
|
|
209
|
+
if (!enabled || !following.current || !count || !scroller) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const target = endTarget();
|
|
214
|
+
const current = axis === 'block' ? scroller.scrollTop : scroller.scrollLeft;
|
|
215
|
+
const gap = target - current;
|
|
216
|
+
if (Math.abs(gap) <= 1) {
|
|
217
|
+
positioned.current = true;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const viewport = axis === 'block' ? scroller.clientHeight : scroller.clientWidth;
|
|
222
|
+
if (positioned.current && glide && follower && gap > 0 && gap <= viewport) {
|
|
223
|
+
// The follower recomputes its target every frame, so content arriving faster than the travel
|
|
224
|
+
// keeps the target ahead; when the arrivals stop, the travel lands by decelerating.
|
|
225
|
+
follower.start();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A far tail, a backwards correction, or no glide: arrive rather than travel. Written directly,
|
|
230
|
+
// never followed by an invalidate — the write raises a scroll event, which re-renders, which
|
|
231
|
+
// changes the extent this effect watches; invalidating here as well is a loop, and React says so.
|
|
232
|
+
follower?.cancel();
|
|
233
|
+
placement.scrollTo(target);
|
|
234
|
+
// Pre-announced, so the write's own scroll event cannot read as a gesture: a correction can
|
|
235
|
+
// move the offset *backwards* (an over-estimated tail shrinking), and the intent listener would
|
|
236
|
+
// otherwise take that for the reader leaving and withdraw the follow for good.
|
|
237
|
+
lastOffset.current = target;
|
|
238
|
+
if (axis === 'block') {
|
|
239
|
+
scroller.scrollTop = target;
|
|
240
|
+
} else {
|
|
241
|
+
scroller.scrollLeft = target;
|
|
242
|
+
}
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
}, [enabled, count, extent, placement, axis, glide, follower, endTarget, scrollerRef.current]);
|
|
245
|
+
|
|
246
|
+
const onNavigate = useCallback(
|
|
247
|
+
(index: number) => {
|
|
248
|
+
const wants = index >= count - 1;
|
|
249
|
+
following.current = !!enabled && wants;
|
|
250
|
+
if (!wants) {
|
|
251
|
+
follower?.cancel();
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
[enabled, count, follower],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return { onNavigate };
|
|
258
|
+
};
|