@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.
Files changed (230) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/chunk-src.mjs +2228 -0
  3. package/dist/lib/chunk-src.mjs.map +1 -0
  4. package/dist/lib/index.mjs +2 -0
  5. package/dist/lib/testing.mjs +1923 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
  8. package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
  9. package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
  10. package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
  11. package/dist/types/src/components/Block/block.stories.d.ts +21 -0
  12. package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Block/construction.d.ts +36 -0
  14. package/dist/types/src/components/Block/construction.d.ts.map +1 -0
  15. package/dist/types/src/components/Block/extensions.d.ts +23 -0
  16. package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
  17. package/dist/types/src/components/Block/highlight.d.ts +14 -0
  18. package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
  19. package/dist/types/src/components/Block/index.d.ts +6 -0
  20. package/dist/types/src/components/Block/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Block/selection-group.d.ts +21 -0
  22. package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
  23. package/dist/types/src/components/Block/widget-state.d.ts +33 -0
  24. package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
  25. package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
  26. package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
  27. package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
  28. package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
  29. package/dist/types/src/components/MessageList/index.d.ts +3 -0
  30. package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
  31. package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
  32. package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
  33. package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
  34. package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
  35. package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
  36. package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Minimap/index.d.ts +2 -0
  38. package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Outline/Outline.d.ts +46 -0
  40. package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
  41. package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
  42. package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/Outline/index.d.ts +2 -0
  44. package/dist/types/src/components/Outline/index.d.ts.map +1 -0
  45. package/dist/types/src/components/index.d.ts +5 -0
  46. package/dist/types/src/components/index.d.ts.map +1 -0
  47. package/dist/types/src/debug/Debug.d.ts +17 -0
  48. package/dist/types/src/debug/Debug.d.ts.map +1 -0
  49. package/dist/types/src/debug/Stats.d.ts +30 -0
  50. package/dist/types/src/debug/Stats.d.ts.map +1 -0
  51. package/dist/types/src/debug/debug-model.d.ts +32 -0
  52. package/dist/types/src/debug/debug-model.d.ts.map +1 -0
  53. package/dist/types/src/debug/debug-model.test.d.ts +2 -0
  54. package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
  55. package/dist/types/src/debug/frame-meter.d.ts +48 -0
  56. package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
  57. package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
  58. package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
  59. package/dist/types/src/debug/index.d.ts +6 -0
  60. package/dist/types/src/debug/index.d.ts.map +1 -0
  61. package/dist/types/src/debug/use-debug.d.ts +19 -0
  62. package/dist/types/src/debug/use-debug.d.ts.map +1 -0
  63. package/dist/types/src/hooks/follow.d.ts +89 -0
  64. package/dist/types/src/hooks/follow.d.ts.map +1 -0
  65. package/dist/types/src/hooks/follow.stories.d.ts +25 -0
  66. package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
  67. package/dist/types/src/hooks/follow.test.d.ts +2 -0
  68. package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
  69. package/dist/types/src/hooks/index.d.ts +6 -0
  70. package/dist/types/src/hooks/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useDecorations.d.ts +31 -0
  72. package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
  74. package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useFollow.d.ts +46 -0
  76. package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
  78. package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/model/feed-model.d.ts +67 -0
  82. package/dist/types/src/model/feed-model.d.ts.map +1 -0
  83. package/dist/types/src/model/feed-model.test.d.ts +2 -0
  84. package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
  85. package/dist/types/src/model/feed.d.ts +66 -0
  86. package/dist/types/src/model/feed.d.ts.map +1 -0
  87. package/dist/types/src/model/feed.test.d.ts +2 -0
  88. package/dist/types/src/model/feed.test.d.ts.map +1 -0
  89. package/dist/types/src/model/index.d.ts +5 -0
  90. package/dist/types/src/model/index.d.ts.map +1 -0
  91. package/dist/types/src/model/list-model.d.ts +61 -0
  92. package/dist/types/src/model/list-model.d.ts.map +1 -0
  93. package/dist/types/src/model/list-model.test.d.ts +2 -0
  94. package/dist/types/src/model/list-model.test.d.ts.map +1 -0
  95. package/dist/types/src/model/useListModel.d.ts +22 -0
  96. package/dist/types/src/model/useListModel.d.ts.map +1 -0
  97. package/dist/types/src/stories/assistant.stories.d.ts +38 -0
  98. package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
  99. package/dist/types/src/stories/baseline.stories.d.ts +32 -0
  100. package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
  101. package/dist/types/src/stories/bridge.stories.d.ts +72 -0
  102. package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
  103. package/dist/types/src/stories/construction.stories.d.ts +17 -0
  104. package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
  105. package/dist/types/src/stories/fill.stories.d.ts +51 -0
  106. package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
  107. package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
  108. package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
  109. package/dist/types/src/stories/mount.stories.d.ts +19 -0
  110. package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
  111. package/dist/types/src/stories/navigation.stories.d.ts +33 -0
  112. package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
  113. package/dist/types/src/stories/streaming.stories.d.ts +17 -0
  114. package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
  115. package/dist/types/src/stories/tail.stories.d.ts +47 -0
  116. package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
  117. package/dist/types/src/stories/widget.stories.d.ts +16 -0
  118. package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
  119. package/dist/types/src/testing/FeedStory.d.ts +30 -0
  120. package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
  121. package/dist/types/src/testing/MessageWindow.d.ts +48 -0
  122. package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
  123. package/dist/types/src/testing/controls.d.ts +46 -0
  124. package/dist/types/src/testing/controls.d.ts.map +1 -0
  125. package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
  126. package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
  127. package/dist/types/src/testing/debug/index.d.ts +4 -0
  128. package/dist/types/src/testing/debug/index.d.ts.map +1 -0
  129. package/dist/types/src/testing/debug/sweep.d.ts +18 -0
  130. package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
  131. package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
  132. package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
  133. package/dist/types/src/testing/generator.d.ts +16 -0
  134. package/dist/types/src/testing/generator.d.ts.map +1 -0
  135. package/dist/types/src/testing/index.d.ts +10 -0
  136. package/dist/types/src/testing/index.d.ts.map +1 -0
  137. package/dist/types/src/testing/scenarios.d.ts +58 -0
  138. package/dist/types/src/testing/scenarios.d.ts.map +1 -0
  139. package/dist/types/src/testing/stream.d.ts +22 -0
  140. package/dist/types/src/testing/stream.d.ts.map +1 -0
  141. package/dist/types/src/testing/turn.d.ts +21 -0
  142. package/dist/types/src/testing/turn.d.ts.map +1 -0
  143. package/dist/types/src/testing/widgets.d.ts +3 -0
  144. package/dist/types/src/testing/widgets.d.ts.map +1 -0
  145. package/dist/types/src/virtualizer/Window.d.ts +137 -0
  146. package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
  147. package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
  148. package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
  149. package/dist/types/src/virtualizer/index.d.ts +3 -0
  150. package/dist/types/src/virtualizer/index.d.ts.map +1 -0
  151. package/dist/types/src/virtualizer/placement.d.ts +188 -0
  152. package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
  153. package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
  154. package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
  155. package/dist/types/tsconfig.tsbuildinfo +1 -0
  156. package/package.json +72 -0
  157. package/src/components/Block/HtmlBlock.tsx +26 -0
  158. package/src/components/Block/MarkdownBlock.tsx +157 -0
  159. package/src/components/Block/block.stories.tsx +140 -0
  160. package/src/components/Block/construction.ts +77 -0
  161. package/src/components/Block/extensions.ts +95 -0
  162. package/src/components/Block/highlight.ts +53 -0
  163. package/src/components/Block/index.ts +10 -0
  164. package/src/components/Block/selection-group.ts +53 -0
  165. package/src/components/Block/widget-state.tsx +75 -0
  166. package/src/components/MessageList/MessageList.stories.tsx +111 -0
  167. package/src/components/MessageList/MessageList.tsx +647 -0
  168. package/src/components/MessageList/index.ts +6 -0
  169. package/src/components/MessageList/position-log.ts +167 -0
  170. package/src/components/Minimap/Minimap.stories.tsx +129 -0
  171. package/src/components/Minimap/Minimap.tsx +84 -0
  172. package/src/components/Minimap/index.ts +5 -0
  173. package/src/components/Outline/Outline.stories.tsx +196 -0
  174. package/src/components/Outline/Outline.tsx +383 -0
  175. package/src/components/Outline/index.ts +5 -0
  176. package/src/components/index.ts +8 -0
  177. package/src/debug/Debug.tsx +90 -0
  178. package/src/debug/Stats.tsx +57 -0
  179. package/src/debug/debug-model.test.ts +36 -0
  180. package/src/debug/debug-model.ts +66 -0
  181. package/src/debug/frame-meter.test.ts +49 -0
  182. package/src/debug/frame-meter.ts +188 -0
  183. package/src/debug/index.ts +9 -0
  184. package/src/debug/use-debug.tsx +41 -0
  185. package/src/hooks/follow.stories.tsx +200 -0
  186. package/src/hooks/follow.test.ts +105 -0
  187. package/src/hooks/follow.ts +241 -0
  188. package/src/hooks/index.ts +9 -0
  189. package/src/hooks/useDecorations.tsx +54 -0
  190. package/src/hooks/useFeedNavigation.ts +90 -0
  191. package/src/hooks/useFollow.ts +258 -0
  192. package/src/hooks/useItemSelection.tsx +53 -0
  193. package/src/index.ts +7 -0
  194. package/src/model/feed-model.test.ts +84 -0
  195. package/src/model/feed-model.ts +214 -0
  196. package/src/model/feed.test.ts +65 -0
  197. package/src/model/feed.ts +157 -0
  198. package/src/model/index.ts +8 -0
  199. package/src/model/list-model.test.ts +71 -0
  200. package/src/model/list-model.ts +152 -0
  201. package/src/model/useListModel.ts +53 -0
  202. package/src/stories/assistant.stories.tsx +425 -0
  203. package/src/stories/baseline.stories.tsx +78 -0
  204. package/src/stories/bridge.stories.tsx +376 -0
  205. package/src/stories/construction.stories.tsx +147 -0
  206. package/src/stories/fill.stories.tsx +271 -0
  207. package/src/stories/horizontal.stories.tsx +129 -0
  208. package/src/stories/mount.stories.tsx +233 -0
  209. package/src/stories/navigation.stories.tsx +145 -0
  210. package/src/stories/streaming.stories.tsx +132 -0
  211. package/src/stories/tail.stories.tsx +149 -0
  212. package/src/stories/widget.stories.tsx +107 -0
  213. package/src/testing/FeedStory.tsx +452 -0
  214. package/src/testing/MessageWindow.tsx +139 -0
  215. package/src/testing/controls.tsx +96 -0
  216. package/src/testing/debug/FeedStats.tsx +182 -0
  217. package/src/testing/debug/index.ts +16 -0
  218. package/src/testing/debug/sweep.ts +85 -0
  219. package/src/testing/debug/use-feed-debug.ts +91 -0
  220. package/src/testing/generator.ts +90 -0
  221. package/src/testing/index.ts +13 -0
  222. package/src/testing/scenarios.tsx +552 -0
  223. package/src/testing/stream.ts +70 -0
  224. package/src/testing/turn.ts +91 -0
  225. package/src/testing/widgets.tsx +183 -0
  226. package/src/virtualizer/Window.stories.tsx +683 -0
  227. package/src/virtualizer/Window.tsx +435 -0
  228. package/src/virtualizer/index.ts +6 -0
  229. package/src/virtualizer/placement.test.ts +312 -0
  230. package/src/virtualizer/placement.ts +403 -0
@@ -0,0 +1,403 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Where the rows go, in one number each.
7
+ *
8
+ * Scalars only — `start`, `extent`, `viewport` — never `top` or `height`, so the axis is the DOM
9
+ * binding's business and never gets baked in here (§9). No DOM, no React, no CodeMirror: everything
10
+ * this decides can be decided from arithmetic, and everything it cannot decide is a measurement
11
+ * somebody hands it.
12
+ *
13
+ * The model is one authoritative fact and derivations from it: an **anchor**, identified by message
14
+ * id, whose absolute position in content space is known. Every other row's position is that position
15
+ * plus or minus the extents in between. Prepending rows does not move the anchor, so it does not move
16
+ * anything at or after it — which is the property §7 needs and the reason this is not a prefix sum
17
+ * from index 0.
18
+ */
19
+ export type Extents = {
20
+ /**
21
+ * Extent along the scroll axis of the row at `index`, when nothing has been measured for it.
22
+ */
23
+ of: (index: number) => number;
24
+ /**
25
+ * Never revised by measurement.
26
+ *
27
+ * The layout constrains the extent — a horizontal feed of declared-width items, a list of
28
+ * fixed-height rows — so offsets are an exact prefix sum and there is nothing to correct. It does
29
+ * **not** mean unchecked: the binding still measures in dev and reports a mismatch (§8).
30
+ */
31
+ exact?: boolean;
32
+ };
33
+
34
+ export type PlacementOptions = {
35
+ count: number;
36
+ /** Identity of the row at `index`. Extents are stored against this, so a prepend cannot shift them. */
37
+ getId: (index: number) => string;
38
+ extents: Extents;
39
+ /** Extent of the visible region along the scroll axis. */
40
+ viewport: number;
41
+ /** Rows to mount beyond the visible region, each side. */
42
+ overscan?: number;
43
+ /**
44
+ * Empty extent after the last row, so the reader can scroll it up to the start of the viewport.
45
+ *
46
+ * A number the host supplies, not a mode the list has. On the old design this was a flag that
47
+ * special-cased the tail everywhere it was consulted, and it could not be made stable; here it is
48
+ * added to the sizer and nothing else knows about it (§7).
49
+ */
50
+ reserve?: number;
51
+ };
52
+
53
+ /** The mounted range and where its parent goes. */
54
+ export type Layout = {
55
+ /** First and last mounted row, inclusive — the visible rows plus overscan. */
56
+ first: number;
57
+ last: number;
58
+ /**
59
+ * First and last row the reader can actually see.
60
+ *
61
+ * Reported separately because it is what a readout means by "where am I": the mounted range
62
+ * includes rows deliberately kept off screen, and naming one of those would be describing the
63
+ * overscan rather than the reader.
64
+ */
65
+ visible: { first: number; last: number };
66
+ /** Absolute position of the first mounted row: what the parent is translated by (§7). */
67
+ offset: number;
68
+ /** Total extent of the scrollable content, which is what the thumb is computed from. */
69
+ sizerExtent: number;
70
+ };
71
+
72
+ /** What reaching an edge revealed about the estimates, when it revealed anything. */
73
+ export type EdgeDrift = {
74
+ edge: 'start' | 'end';
75
+ /** How far out the estimate was. Positive means the region was shorter than assumed. */
76
+ delta: number;
77
+ };
78
+
79
+ const DEFAULT_OVERSCAN = 8;
80
+
81
+ /**
82
+ *
83
+ */
84
+ export class Placement {
85
+ #count: number;
86
+ #getId: (index: number) => string;
87
+ #extents: Extents;
88
+ #viewport: number;
89
+ #overscan: number;
90
+ #reserve: number;
91
+
92
+ /** Measured extents by message id, so that reordering the model cannot invalidate them. */
93
+ readonly #measured = new Map<string, number>();
94
+
95
+ /** The one authoritative position: this row starts exactly here. */
96
+ #anchor: { id: string; index: number; start: number };
97
+
98
+ #scroll = 0;
99
+
100
+ constructor({ count, getId, extents, viewport, overscan = DEFAULT_OVERSCAN, reserve = 0 }: PlacementOptions) {
101
+ this.#count = count;
102
+ this.#getId = getId;
103
+ this.#extents = extents;
104
+ this.#viewport = viewport;
105
+ this.#overscan = overscan;
106
+ this.#reserve = reserve;
107
+ this.#anchor = { id: count ? getId(0) : '', index: 0, start: 0 };
108
+ }
109
+
110
+ get scroll(): number {
111
+ return this.#scroll;
112
+ }
113
+
114
+ get count(): number {
115
+ return this.#count;
116
+ }
117
+
118
+ /** The viewport is the binding's to know: it is the one thing here that comes from the DOM. */
119
+ setViewport(viewport: number): void {
120
+ this.#viewport = viewport;
121
+ }
122
+
123
+ /**
124
+ * The host's answer about extents can change — a row is edited, a panel opens, a story flips a
125
+ * fixture — and it is a *function*, so keeping the one handed over at construction quietly pins
126
+ * the layout to whatever was true then. Measured extents survive: they are keyed by id and this
127
+ * only replaces what the host would say about a row nobody has measured.
128
+ */
129
+ setExtents(extents: Extents): void {
130
+ this.#extents = extents;
131
+ }
132
+
133
+ setReserve(reserve: number): void {
134
+ this.#reserve = reserve;
135
+ }
136
+
137
+ /**
138
+ * The host's identity function, which is a closure over the host's own list.
139
+ *
140
+ * Appending a message gives a *new* `getId`, and a placement holding the one it was constructed
141
+ * with resolves the new row's index to an id nobody will ever measure. The binding then stores
142
+ * that row's measurement under the id the DOM carries and reads it back under the stale one, so
143
+ * the row is measured, found to disagree, measured again — every commit, for ever. It presents as
144
+ * a render loop rather than as a wrong size, which is what made `bridge/Tail` look like a tail
145
+ * defect (§8: measurement is only a correction if it can be read back).
146
+ */
147
+ setGetId(getId: (index: number) => string): void {
148
+ this.#getId = getId;
149
+ }
150
+
151
+ get anchor(): Readonly<{ id: string; index: number; start: number }> {
152
+ return this.#anchor;
153
+ }
154
+
155
+ /** Extent of one row: what was measured, or what the host says. */
156
+ extentOf(index: number): number {
157
+ const measured = this.#measured.get(this.#getId(index));
158
+ return measured ?? this.#extents.of(index);
159
+ }
160
+
161
+ /**
162
+ * Absolute position of a row, derived from the anchor rather than summed from the start.
163
+ *
164
+ * Rows between here and the anchor are the only ones whose extents matter (§5): everything before
165
+ * the window is described by the anchor's position, not by adding up guesses about it.
166
+ */
167
+ positionOf(index: number): number {
168
+ let position = this.#anchor.start;
169
+ if (index >= this.#anchor.index) {
170
+ for (let row = this.#anchor.index; row < index; row++) {
171
+ position += this.extentOf(row);
172
+ }
173
+ } else {
174
+ for (let row = index; row < this.#anchor.index; row++) {
175
+ position -= this.extentOf(row);
176
+ }
177
+ }
178
+
179
+ return position;
180
+ }
181
+
182
+ /** The reader moved. Nothing is corrected here — only the mounted range changes. */
183
+ scrollTo(offset: number): void {
184
+ this.#scroll = offset;
185
+ this.#reanchor();
186
+ }
187
+
188
+ /**
189
+ * A row turned out to be a different size than assumed.
190
+ *
191
+ * Rows at or after the anchor push the ones after them and leave the anchor alone. A row *before*
192
+ * the anchor moves the rows between it and the anchor, which is correct and unavoidable: the
193
+ * anchor is the thing being held still, and it is still being held.
194
+ */
195
+ measure(id: string, extent: number): void {
196
+ if (this.#extents.exact) {
197
+ return;
198
+ }
199
+
200
+ this.#measured.set(id, extent);
201
+ }
202
+
203
+ /** The model changed length. `prepended` rows arrived before the anchor and shift its index. */
204
+ setCount(count: number, { prepended = 0 }: { prepended?: number } = {}): void {
205
+ this.#count = count;
206
+ if (prepended) {
207
+ this.#anchor = { ...this.#anchor, index: this.#anchor.index + prepended };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Move to a row that may be nowhere near the window.
213
+ *
214
+ * The only operation that positions from estimates rather than from measurement, because there is
215
+ * no measured path to somewhere the reader has never been. A jump is a discontinuity the reader
216
+ * asked for, so being approximately right is what it can be.
217
+ */
218
+ jumpTo(index: number, align: 'start' | 'end' = 'start'): void {
219
+ const clamped = Math.max(0, Math.min(index, this.#count - 1));
220
+ let start = 0;
221
+ for (let row = 0; row < clamped; row++) {
222
+ start += this.extentOf(row);
223
+ }
224
+
225
+ this.#anchor = { id: this.#getId(clamped), index: clamped, start };
226
+ // The last row's end *is* the content's end, whatever the estimates in between say. Summing to
227
+ // it instead lands wherever the guesses add up to, which for a chat's "go to the bottom" is a
228
+ // tail that is nearly at the bottom — the one place approximately right is wrong.
229
+ const end =
230
+ clamped === this.#count - 1 ? this.layout().sizerExtent - this.#reserve : start + this.extentOf(clamped);
231
+ this.#scroll = align === 'end' ? end - this.#viewport : start;
232
+ this.#scroll = Math.max(0, this.#scroll);
233
+ this.#reanchor();
234
+ }
235
+
236
+ /**
237
+ * Where the reader has to be for `index` to sit at the start (or end) of the viewport.
238
+ *
239
+ * Reports; does not move. `jumpTo` re-anchors as well, which is right for a discontinuity and
240
+ * wrong for a glide: the anchor would arrive at the destination while the element was still
241
+ * travelling, so the reader would watch the rows they were scrolling *through* unmount and the
242
+ * journey happen over nothing.
243
+ */
244
+ offsetOf(index: number, align: 'start' | 'end' = 'start'): number {
245
+ const clamped = Math.max(0, Math.min(index, this.#count - 1));
246
+ const start = this.positionOf(clamped);
247
+ return Math.max(0, align === 'end' ? start + this.extentOf(clamped) - this.#viewport : start);
248
+ }
249
+
250
+ /**
251
+ * Where the reader has to be for the last row to rest against the end of the viewport.
252
+ *
253
+ * Derived from the anchor, like every other position, and deliberately **not** from a sum over the
254
+ * model: a follow asks this again on every commit, and each answer mounts rows whose measurement
255
+ * replaces the estimates that sum is over — so a sum-based end moves every time it is consulted,
256
+ * and consulting it is what moves it. That is not slow convergence, it is a feedback loop, and in
257
+ * React it terminates by exceeding the update limit rather than by settling (`bridge/Tail`).
258
+ *
259
+ * Anchor-relative, the answer only changes as the rows *between the anchor and the last row* are
260
+ * measured — each of them once — so a follow settles in a couple of commits and rows the reader
261
+ * has already passed cannot disturb it.
262
+ */
263
+ endOffset(): number {
264
+ if (!this.#count) {
265
+ return 0;
266
+ }
267
+
268
+ const last = this.#count - 1;
269
+ return Math.max(0, this.positionOf(last) + this.extentOf(last) - this.#viewport);
270
+ }
271
+
272
+ /** The mounted range, the parent's offset, and the extent the thumb is computed from. */
273
+ layout(): Layout {
274
+ if (!this.#count) {
275
+ return { first: 0, last: -1, visible: { first: 0, last: -1 }, offset: 0, sizerExtent: 0 };
276
+ }
277
+
278
+ const { first, last, visible } = this.#range();
279
+ const offset = this.positionOf(first);
280
+ let windowExtent = 0;
281
+ for (let row = first; row <= last; row++) {
282
+ windowExtent += this.extentOf(row);
283
+ }
284
+
285
+ let after = 0;
286
+ for (let row = last + 1; row < this.#count; row++) {
287
+ after += this.extentOf(row);
288
+ }
289
+
290
+ return { first, last, visible, offset, sizerExtent: offset + windowExtent + after + this.#reserve };
291
+ }
292
+
293
+ /**
294
+ * Repay the start edge's drift: shift every position so row 0 starts at exactly zero, and move
295
+ * the scroll by the same amount so nothing the reader sees moves.
296
+ *
297
+ * The first row starting at zero is the one absolute fact of content space, and estimates being
298
+ * replaced above the anchor break it: a feed opened at its tail whose rows measure taller than
299
+ * assumed pushes row 0 *negative*, where no scroll can reach it — the reader arrives at the top
300
+ * and the first messages simply are not there. This is the single correction that must touch the
301
+ * scroll (§7's rule is about corrections and readers sharing a channel; here both the content and
302
+ * the offset move by the same delta in the same commit, so the reader sees nothing at all).
303
+ * Returns the shift for the binding to apply to the element.
304
+ */
305
+ rebaseStart(): number {
306
+ if (!this.#count) {
307
+ return 0;
308
+ }
309
+
310
+ const shift = -this.positionOf(0);
311
+ if (shift === 0) {
312
+ return 0;
313
+ }
314
+
315
+ this.#anchor = { ...this.#anchor, start: this.#anchor.start + shift };
316
+ this.#scroll = Math.max(0, this.#scroll + shift);
317
+ return shift;
318
+ }
319
+
320
+ /**
321
+ * What an edge revealed, if the window has reached one.
322
+ *
323
+ * The edges are where estimate meets ground truth: the first row starts at zero and the last one
324
+ * ends at the content's end, by definition, so a computed position that says otherwise is the
325
+ * measure of how wrong the estimates were. Reported rather than silently applied — absorbing it
326
+ * into the anchor would move what the reader is looking at, and at an edge they are looking at
327
+ * exactly the thing that would move.
328
+ */
329
+ drift(): EdgeDrift | undefined {
330
+ if (!this.#count) {
331
+ return undefined;
332
+ }
333
+
334
+ const { first, last } = this.#range();
335
+ if (first === 0 && this.positionOf(0) !== 0) {
336
+ return { edge: 'start', delta: this.positionOf(0) };
337
+ }
338
+
339
+ if (last === this.#count - 1) {
340
+ const end = this.positionOf(last) + this.extentOf(last);
341
+ // Against the content's end, not the document's: reserved space is somewhere the reader may go
342
+ // and nowhere any row is, so counting it would report a drift the size of the reserve for ever.
343
+ const { sizerExtent } = this.layout();
344
+ const content = sizerExtent - this.#reserve;
345
+ if (end !== content) {
346
+ return { edge: 'end', delta: content - end };
347
+ }
348
+ }
349
+
350
+ return undefined;
351
+ }
352
+
353
+ /** Visible rows, plus overscan, clamped to the model. */
354
+ #range(): { first: number; last: number; visible: { first: number; last: number } } {
355
+ let first = this.#anchor.index;
356
+ // Walk out from the anchor rather than searching from zero: the anchor is the only position
357
+ // known exactly, so it is the only sound place to start.
358
+ while (first > 0 && this.positionOf(first) > this.#scroll) {
359
+ first--;
360
+ }
361
+ while (first < this.#count - 1 && this.positionOf(first) + this.extentOf(first) <= this.#scroll) {
362
+ first++;
363
+ }
364
+
365
+ let last = first;
366
+ let filled = this.positionOf(first) + this.extentOf(first);
367
+ while (last < this.#count - 1 && filled < this.#scroll + this.#viewport) {
368
+ last++;
369
+ filled += this.extentOf(last);
370
+ }
371
+
372
+ return {
373
+ first: Math.max(0, first - this.#overscan),
374
+ last: Math.min(this.#count - 1, last + this.#overscan),
375
+ visible: { first, last },
376
+ };
377
+ }
378
+
379
+ /**
380
+ * The anchor is the first row the reader can see — always, not merely when the old one leaves
381
+ * the window.
382
+ *
383
+ * The anchor is the row held still while others are measured, so it must be the reader's
384
+ * reference point. Clamping the old anchor into the mounted range let it drift to the window's
385
+ * *far* edge after an upward scroll — below everything on screen — and a widget toggled open
386
+ * above it then pushed the whole viewport up by its own growth: the engine held a row the reader
387
+ * could not see still, at the expense of every row they could. Re-anchoring preserves the new
388
+ * anchor's computed position exactly, so the move itself shifts nothing.
389
+ */
390
+ #reanchor(): void {
391
+ if (!this.#count) {
392
+ return;
393
+ }
394
+
395
+ const { visible } = this.#range();
396
+ if (this.#anchor.index === visible.first) {
397
+ return;
398
+ }
399
+
400
+ const index = visible.first;
401
+ this.#anchor = { id: this.#getId(index), index, start: this.positionOf(index) };
402
+ }
403
+ }