@react-x11/components 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/internal/heights.d.ts +33 -1
- package/dist/internal/heights.d.ts.map +1 -1
- package/dist/internal/heights.js +72 -11
- package/dist/internal/heights.js.map +1 -1
- package/dist/internal/scroll.d.ts +73 -0
- package/dist/internal/scroll.d.ts.map +1 -0
- package/dist/internal/scroll.js +264 -0
- package/dist/internal/scroll.js.map +1 -0
- package/dist/internal/timers.d.ts +8 -0
- package/dist/internal/timers.d.ts.map +1 -1
- package/dist/internal/timers.js +10 -0
- package/dist/internal/timers.js.map +1 -1
- package/dist/internal/window.d.ts +161 -0
- package/dist/internal/window.d.ts.map +1 -0
- package/dist/internal/window.js +417 -0
- package/dist/internal/window.js.map +1 -0
- package/dist/table/index.d.ts +73 -2
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +468 -178
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts +65 -5
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +494 -202
- package/dist/tree/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/heights.ts +73 -11
- package/src/internal/scroll.ts +344 -0
- package/src/internal/timers.ts +13 -0
- package/src/internal/window.ts +570 -0
- package/src/table/index.ts +666 -194
- package/src/tree/index.ts +730 -263
package/dist/tree/index.js
CHANGED
|
@@ -36,7 +36,9 @@ import { hx } from './hx.js';
|
|
|
36
36
|
// Shared with <Table> — internal, deliberately not a shared *module*; the
|
|
37
37
|
// header of src/internal/heights.ts says why.
|
|
38
38
|
import { RowHeights } from '../internal/heights.js';
|
|
39
|
-
import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
|
|
39
|
+
import { afterLayout, cancelAfterLayout, cancelLater, later, } from '../internal/timers.js';
|
|
40
|
+
import { useReveal } from '../internal/scroll.js';
|
|
41
|
+
import { BURST_BUDGET, DEFAULT_OVERSCAN, DEFAULT_PREFETCH, SCROLL_HINT_DELAY_MS, SKELETON_THRESHOLD, SETTLE_BUDGET, useVirtualWindow, } from '../internal/window.js';
|
|
40
42
|
import { typeAheadChar, useTypeAhead } from './internal.js';
|
|
41
43
|
import { branchEdges, findItem, groupRows, isGroup, resolveAccessors, visibleRows, } from './rows.js';
|
|
42
44
|
export { branchEdges, findItem, resolveAccessors, visibleRows, } from './rows.js';
|
|
@@ -49,16 +51,6 @@ const TWISTY = 12;
|
|
|
49
51
|
* is half of that, so `size` for one reads as its width. */
|
|
50
52
|
const TWISTY_GLYPH = 10;
|
|
51
53
|
const ROW_HEIGHT = 22;
|
|
52
|
-
/** Rows kept either side of the viewport, so a fast scroll does not show a
|
|
53
|
-
* gap before the next frame catches up. */
|
|
54
|
-
const OVERSCAN = 6;
|
|
55
|
-
/**
|
|
56
|
-
* What to build before the viewport has been measured. `onViewport` cannot
|
|
57
|
-
* arrive until layout has run, which is a frame after the first commit, so
|
|
58
|
-
* there is always one render that has to guess — and guessing "all of them"
|
|
59
|
-
* puts a hundred thousand rows in the tree for a frame.
|
|
60
|
-
*/
|
|
61
|
-
const ASSUMED_ROWS = 40;
|
|
62
54
|
/**
|
|
63
55
|
* Where `virtual="auto"` starts virtualizing.
|
|
64
56
|
*
|
|
@@ -109,6 +101,39 @@ const s = createStyles({
|
|
|
109
101
|
},
|
|
110
102
|
subtree: { flexShrink: 0 },
|
|
111
103
|
spacer: { flexShrink: 0 },
|
|
104
|
+
/** The bar inside a skeleton row — a line of "text" with no text, so a
|
|
105
|
+
* band of placeholders reads as rows arriving rather than a void. */
|
|
106
|
+
skeletonBar: {
|
|
107
|
+
height: 8,
|
|
108
|
+
borderRadius: 4,
|
|
109
|
+
alignSelf: 'center',
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
},
|
|
112
|
+
/** The box the scroll pane and the fast-scroll pill share — it exists so
|
|
113
|
+
* the pill can float *outside* the pane, where a scroll cannot move it. */
|
|
114
|
+
outer: { flexGrow: 1, minHeight: 0 },
|
|
115
|
+
/** The lane the fast-scroll pill floats in: absolute against the outer
|
|
116
|
+
* box so the pane scrolls under it, full-width so the pill centres
|
|
117
|
+
* itself, and transparent to the pointer so the rows beneath stay
|
|
118
|
+
* clickable. */
|
|
119
|
+
scrollHintLane: {
|
|
120
|
+
position: 'absolute',
|
|
121
|
+
left: 0,
|
|
122
|
+
right: 0,
|
|
123
|
+
bottom: 12,
|
|
124
|
+
flexDirection: 'row',
|
|
125
|
+
justifyContent: 'center',
|
|
126
|
+
pointerEvents: 'none',
|
|
127
|
+
},
|
|
128
|
+
scrollHint: {
|
|
129
|
+
paddingStart: 10,
|
|
130
|
+
paddingEnd: 10,
|
|
131
|
+
paddingTop: 5,
|
|
132
|
+
paddingBottom: 5,
|
|
133
|
+
borderRadius: 12,
|
|
134
|
+
flexDirection: 'row',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
},
|
|
112
137
|
});
|
|
113
138
|
/** A string or a number becomes a `<text>`; anything else is already a
|
|
114
139
|
* node. */
|
|
@@ -117,6 +142,144 @@ function labelNode(label, style) {
|
|
|
117
142
|
? hx('text', { key: 'label', style }, String(label))
|
|
118
143
|
: label;
|
|
119
144
|
}
|
|
145
|
+
function TreeRowView(props) {
|
|
146
|
+
const { row, isSelected, indent, rowHeight, rtl, theme, renderToggle, renderGuide, renderLabel, renderContent, rowStyle, guideStyle, toggleStyle, labelStyle, getLabel, onToggle, onGo, onOpen, register, } = props;
|
|
147
|
+
const color = row.disabled
|
|
148
|
+
? theme.textMuted
|
|
149
|
+
: isSelected
|
|
150
|
+
? theme.hoverText
|
|
151
|
+
: theme.text;
|
|
152
|
+
const state = {
|
|
153
|
+
...row,
|
|
154
|
+
selected: isSelected,
|
|
155
|
+
color,
|
|
156
|
+
toggle: (open) => onToggle(row.id, row.item, open),
|
|
157
|
+
select: () => onGo(row),
|
|
158
|
+
};
|
|
159
|
+
const content = [];
|
|
160
|
+
// The indent. With no guide seam it is one padding value rather than
|
|
161
|
+
// `depth` empty boxes — a tree ten deep would otherwise build ten nodes
|
|
162
|
+
// per row to draw nothing.
|
|
163
|
+
if (renderGuide && row.depth > 0) {
|
|
164
|
+
const edges = branchEdges(row);
|
|
165
|
+
for (let level = 0; level < row.depth; level++) {
|
|
166
|
+
const guide = {
|
|
167
|
+
row: state,
|
|
168
|
+
level,
|
|
169
|
+
continues: edges[level],
|
|
170
|
+
own: level === row.depth - 1,
|
|
171
|
+
width: indent,
|
|
172
|
+
height: rowHeight,
|
|
173
|
+
};
|
|
174
|
+
content.push(hx('box', {
|
|
175
|
+
key: `guide${level}`,
|
|
176
|
+
style: [
|
|
177
|
+
s.guide,
|
|
178
|
+
{ width: indent },
|
|
179
|
+
typeof guideStyle === 'function' ? guideStyle(guide) : guideStyle,
|
|
180
|
+
],
|
|
181
|
+
}, renderGuide(guide)));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const toggleState = { ...state, size: TWISTY_GLYPH };
|
|
185
|
+
content.push(hx('box', {
|
|
186
|
+
key: 'toggle',
|
|
187
|
+
style: [s.twisty, toggleStyle],
|
|
188
|
+
// The twisty is its own hit target: clicking it opens the branch
|
|
189
|
+
// without moving the selection, the way a file browser lets you
|
|
190
|
+
// peek inside a folder you have not chosen.
|
|
191
|
+
onClick: row.branch
|
|
192
|
+
? (ev) => {
|
|
193
|
+
ev.stopPropagation();
|
|
194
|
+
onToggle(row.id, row.item);
|
|
195
|
+
}
|
|
196
|
+
: undefined,
|
|
197
|
+
}, renderToggle
|
|
198
|
+
? renderToggle(toggleState)
|
|
199
|
+
: row.branch
|
|
200
|
+
? React.createElement(Icon, {
|
|
201
|
+
name: row.open
|
|
202
|
+
? 'chevronDown'
|
|
203
|
+
: rtl
|
|
204
|
+
? 'chevronLeft'
|
|
205
|
+
: 'chevronRight',
|
|
206
|
+
size: TWISTY_GLYPH,
|
|
207
|
+
// dimmer than the label on a resting row, and the row's own
|
|
208
|
+
// ink once it is selected
|
|
209
|
+
style: isSelected ? undefined : { color: theme.textMuted },
|
|
210
|
+
})
|
|
211
|
+
: null));
|
|
212
|
+
content.push(renderLabel
|
|
213
|
+
? // Keyed here rather than by the app, for the reason `renderSubtree`
|
|
214
|
+
// is: the label sits in an array beside the guides and the twisty,
|
|
215
|
+
// and "add a key to the box you return" is not something a render
|
|
216
|
+
// prop should have to know.
|
|
217
|
+
React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
|
|
218
|
+
: labelNode(getLabel(row.item), [s.label, labelStyle]));
|
|
219
|
+
return hx('box', {
|
|
220
|
+
role: 'treeitem',
|
|
221
|
+
'aria-level': row.depth + 1,
|
|
222
|
+
'aria-selected': isSelected,
|
|
223
|
+
'aria-expanded': row.branch ? row.open : undefined,
|
|
224
|
+
'aria-posinset': row.posInSet,
|
|
225
|
+
'aria-setsize': row.setSize,
|
|
226
|
+
// `disabled` rather than `aria-disabled`: on a react-x11 node it is
|
|
227
|
+
// the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
|
|
228
|
+
// selects the `:disabled` style block — and there is no aria spelling
|
|
229
|
+
// of it to write instead.
|
|
230
|
+
disabled: row.disabled || undefined,
|
|
231
|
+
// The index the row was drawn at travels with the node, so measuring
|
|
232
|
+
// does not have to search a hundred thousand rows for where it is.
|
|
233
|
+
// It can go stale — the rows may move before the tick that measures —
|
|
234
|
+
// and both this and the height index check it rather than trust it.
|
|
235
|
+
ref: (node) => {
|
|
236
|
+
register(row.id, row.index, node);
|
|
237
|
+
},
|
|
238
|
+
onClick: (ev) => {
|
|
239
|
+
if (row.disabled)
|
|
240
|
+
return;
|
|
241
|
+
onGo(row);
|
|
242
|
+
// Select on the first click, open on the second — the gesture every
|
|
243
|
+
// file list has. `detail` is the click count the renderer already
|
|
244
|
+
// counts for text selection.
|
|
245
|
+
if (ev.detail === 2)
|
|
246
|
+
onOpen(row);
|
|
247
|
+
},
|
|
248
|
+
style: [
|
|
249
|
+
s.row,
|
|
250
|
+
// A floor, not a height. The row grows to whatever its content
|
|
251
|
+
// needs — a wrapped label, two lines, a thumbnail — and the height
|
|
252
|
+
// index reads back what it actually became.
|
|
253
|
+
{ minHeight: rowHeight },
|
|
254
|
+
// The indent is what says "inside", so it is measured from the edge
|
|
255
|
+
// the row's label begins at.
|
|
256
|
+
{ paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
|
|
257
|
+
{
|
|
258
|
+
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
259
|
+
// The row's ink, said once: `color` inherits, so the label takes
|
|
260
|
+
// it without being handed it.
|
|
261
|
+
color,
|
|
262
|
+
},
|
|
263
|
+
!row.disabled && {
|
|
264
|
+
':hover': {
|
|
265
|
+
backgroundColor: isSelected
|
|
266
|
+
? theme.hoverBackground
|
|
267
|
+
: theme.surfaceHover,
|
|
268
|
+
},
|
|
269
|
+
// The selection only moves on the release, and `:active` marks
|
|
270
|
+
// the whole press chain, so a press on the label or the twisty
|
|
271
|
+
// still darkens the row it is in.
|
|
272
|
+
':active': {
|
|
273
|
+
backgroundColor: isSelected
|
|
274
|
+
? theme.accentActive
|
|
275
|
+
: theme.surfaceActive,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
|
|
279
|
+
],
|
|
280
|
+
}, renderContent ? renderContent(state, content) : content);
|
|
281
|
+
}
|
|
282
|
+
const MemoTreeRow = React.memo(TreeRowView);
|
|
120
283
|
/**
|
|
121
284
|
* `<Tree items />` — a disclosure tree.
|
|
122
285
|
*
|
|
@@ -150,17 +313,17 @@ function labelNode(label, style) {
|
|
|
150
313
|
* such policy is expressible on top of what is here: hold the set yourself,
|
|
151
314
|
* pass `selected` for the cursor, and paint the rest from `styles.row`.
|
|
152
315
|
*/
|
|
153
|
-
export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan =
|
|
316
|
+
export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan = DEFAULT_OVERSCAN, prefetch = DEFAULT_PREFETCH, layout = 'flat', renderToggle, renderGuide, renderLabel, renderContent, renderSubtree, renderScrollHint, scrollHintDelay = SCROLL_HINT_DELAY_MS, catchup, styles, style, ref,
|
|
154
317
|
// the accessors, pulled out so the rest can be spread onto the box
|
|
155
318
|
getId, getLabel, getText, getChildren, isBranch, isDisabled,
|
|
156
319
|
// ours to chain rather than to hand over: virtualization is measured
|
|
157
320
|
// through both of these
|
|
158
321
|
onScroll, onViewport, ...boxProps }) {
|
|
322
|
+
globalThis.__renders = (globalThis.__renders ?? 0) + 1;
|
|
159
323
|
const theme = useTheme();
|
|
160
324
|
const rtl = useDirection() === 'rtl';
|
|
161
325
|
const [ownExpanded, setOwnExpanded] = useState(() => new Set(defaultExpanded));
|
|
162
326
|
const [ownSelected, setOwnSelected] = useState(defaultSelected ?? null);
|
|
163
|
-
const [view, setView] = useState({ top: 0, height: 0 });
|
|
164
327
|
// Bumped by a measurement pass that found a row taller or shorter than the
|
|
165
328
|
// index believed. It is the only reason the component re-renders for a
|
|
166
329
|
// measurement, and a pass that finds nothing new does not bump it, which is
|
|
@@ -199,8 +362,6 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
199
362
|
rowsRef.current = rows;
|
|
200
363
|
const itemsRef = useRef(items);
|
|
201
364
|
itemsRef.current = items;
|
|
202
|
-
const viewRef = useRef(view);
|
|
203
|
-
viewRef.current = view;
|
|
204
365
|
const virtualizing = layout === 'flat' &&
|
|
205
366
|
(virtual === true ||
|
|
206
367
|
(virtual === 'auto' && rows.length > VIRTUAL_THRESHOLD));
|
|
@@ -209,28 +370,27 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
209
370
|
// on every render that did not change the tree.
|
|
210
371
|
const index = heights;
|
|
211
372
|
index.sync(rows, estimate);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const above
|
|
233
|
-
const below = virtualizing ? index.total() - index.offsetAt(last) : 0;
|
|
373
|
+
/** The viewport, and the slice worth building from it — the machinery
|
|
374
|
+
* shared with `<Table>` (`../internal/window.ts`). */
|
|
375
|
+
const win = useVirtualWindow({
|
|
376
|
+
box: scroller,
|
|
377
|
+
heights,
|
|
378
|
+
rows,
|
|
379
|
+
// tree rows are always measured, so the idle band above the viewport
|
|
380
|
+
// only re-builds territory already visited — see `exact` on the inputs
|
|
381
|
+
exact: false,
|
|
382
|
+
virtualizing,
|
|
383
|
+
overscan,
|
|
384
|
+
prefetch,
|
|
385
|
+
threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
|
|
386
|
+
burstBudget: catchup?.burst ?? BURST_BUDGET,
|
|
387
|
+
settleBudget: catchup?.settle ?? SETTLE_BUDGET,
|
|
388
|
+
});
|
|
389
|
+
const { view, viewRef } = win;
|
|
390
|
+
/** Whether the fast-scroll pill is up — kept across renders so it does not
|
|
391
|
+
* flicker through a catch-up, only appearing and disappearing once. */
|
|
392
|
+
const hintShown = useRef(false);
|
|
393
|
+
const { first, last, above, below } = win.slice;
|
|
234
394
|
const setExpandedSet = useCallback((next, change) => {
|
|
235
395
|
openRef.current = next;
|
|
236
396
|
if (expanded === undefined)
|
|
@@ -247,34 +407,30 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
247
407
|
setExpandedSet(next, { id, item, open: shouldOpen });
|
|
248
408
|
}, [setExpandedSet]);
|
|
249
409
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* normal case, so the height index answers instead: where the row starts,
|
|
257
|
-
* and how tall it is or is estimated to be.
|
|
410
|
+
* The scroll the tree owes a row, and the pane's real offset read back
|
|
411
|
+
* after every layout — the two halves of `../internal/scroll.ts`, which
|
|
412
|
+
* says why a reveal cannot be a one-shot and why `onScroll` is not the
|
|
413
|
+
* whole story. A tree grows and shrinks under its own hands: opening a
|
|
414
|
+
* branch is a content that got taller between the ask and the layout, in
|
|
415
|
+
* exactly the way an arriving row is.
|
|
258
416
|
*/
|
|
259
|
-
const reveal =
|
|
260
|
-
|
|
417
|
+
const reveal = useReveal({
|
|
418
|
+
box: scroller,
|
|
419
|
+
rows: rowsRef,
|
|
420
|
+
nodes: rowNodes,
|
|
421
|
+
heights,
|
|
422
|
+
});
|
|
423
|
+
/** Put a row in view, by the index its call site already has. */
|
|
424
|
+
const revealAt = useCallback((at) => {
|
|
261
425
|
const row = rowsRef.current[at];
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const rowH = heights.heightAt(at);
|
|
271
|
-
const height = viewRef.current.height;
|
|
272
|
-
if (top < box.scrollY)
|
|
273
|
-
box.scrollTo({ y: top });
|
|
274
|
-
else if (height > 0 && top + rowH > box.scrollY + height) {
|
|
275
|
-
box.scrollTo({ y: top + rowH - height });
|
|
276
|
-
}
|
|
277
|
-
}, []);
|
|
426
|
+
if (row)
|
|
427
|
+
reveal.to(row.id);
|
|
428
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
|
|
429
|
+
// stable handle
|
|
430
|
+
}, [reveal]);
|
|
431
|
+
/** Re-read the offset the pane is *actually* at — the window's `sync`; see
|
|
432
|
+
* `../internal/window.ts` for why the pane moves silently. */
|
|
433
|
+
const syncScroll = win.sync;
|
|
278
434
|
/**
|
|
279
435
|
* Read back what the rows on screen actually laid out at.
|
|
280
436
|
*
|
|
@@ -290,7 +446,7 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
290
446
|
*/
|
|
291
447
|
const measureRows = useCallback(() => {
|
|
292
448
|
if (!virtualizing)
|
|
293
|
-
return;
|
|
449
|
+
return false;
|
|
294
450
|
const box = scroller.current;
|
|
295
451
|
const rows = rowsRef.current;
|
|
296
452
|
const idx = heights;
|
|
@@ -313,17 +469,84 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
313
469
|
shift += height - was;
|
|
314
470
|
}
|
|
315
471
|
if (!changed)
|
|
316
|
-
return;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
472
|
+
return false;
|
|
473
|
+
// A debt, not a one-shot: the pane clamps against the last layout's
|
|
474
|
+
// content height, so a shift from rows measured above the viewport can
|
|
475
|
+
// land short until the layout that admits the growth has run.
|
|
476
|
+
reveal.nudge(shift);
|
|
477
|
+
setMeasured((n) => n + 1);
|
|
478
|
+
return true;
|
|
479
|
+
}, [virtualizing]);
|
|
480
|
+
/**
|
|
481
|
+
* Let the estimate learn from the rows that have been measured — the
|
|
482
|
+
* scrollbar of a measured tree starts as a guess times the row count, and
|
|
483
|
+
* the measured mean is a far better guess for the rows not yet seen. Idle
|
|
484
|
+
* only: every unmeasured offset moves when it applies, and the anchor
|
|
485
|
+
* arithmetic keeping the screen still is `measureRows`'s.
|
|
486
|
+
*/
|
|
487
|
+
const adaptEstimate = useCallback(() => {
|
|
488
|
+
if (!virtualizing)
|
|
489
|
+
return false;
|
|
490
|
+
const box = scroller.current;
|
|
491
|
+
if (!box)
|
|
492
|
+
return false;
|
|
493
|
+
const anchor = heights.indexAt(box.scrollY);
|
|
494
|
+
const before = heights.offsetAt(anchor);
|
|
495
|
+
if (!heights.adapt())
|
|
496
|
+
return false;
|
|
497
|
+
reveal.nudge(heights.offsetAt(anchor) - before);
|
|
320
498
|
setMeasured((n) => n + 1);
|
|
499
|
+
return true;
|
|
500
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
|
|
501
|
+
// `reveal` are stable instances
|
|
321
502
|
}, [virtualizing]);
|
|
503
|
+
/**
|
|
504
|
+
* The one tick after layout, and everything that can only be known there:
|
|
505
|
+
* what the rows measured, whether an owed scroll can go further now that
|
|
506
|
+
* the rows it was waiting for are laid out, and where the pane actually
|
|
507
|
+
* ended up. In that order — each step can move the offset the next one
|
|
508
|
+
* reads.
|
|
509
|
+
*/
|
|
510
|
+
/** Whether some drawn row has no size yet — a commit can land between
|
|
511
|
+
* frame flushes, and a measure pass over it reads zeros. */
|
|
512
|
+
const rowsPendingLayout = useCallback(() => {
|
|
513
|
+
const rows = rowsRef.current;
|
|
514
|
+
for (const [id, { node, at }] of rowNodes.current) {
|
|
515
|
+
if (rows[at]?.id === id && !(node.abs.height > 0))
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}, []);
|
|
322
520
|
useEffect(() => {
|
|
323
521
|
if (!virtualizing)
|
|
324
522
|
return undefined;
|
|
325
|
-
|
|
326
|
-
|
|
523
|
+
let look = null;
|
|
524
|
+
let tries = 0;
|
|
525
|
+
const pass = () => {
|
|
526
|
+
globalThis.__ticks = (globalThis.__ticks ?? 0) + 1;
|
|
527
|
+
// `measureRows` first, and its answer handed on: a pass that moved the
|
|
528
|
+
// heights has not settled anything, and an owed scroll judged against
|
|
529
|
+
// the layout it is about to invalidate is not owed any less. During a
|
|
530
|
+
// flick nothing is measured at all — every correction at that speed is
|
|
531
|
+
// invalidated by the next event — and the settle tick that follows any
|
|
532
|
+
// burst is where the deferred passes catch up.
|
|
533
|
+
const moved = win.fast() ? false : measureRows();
|
|
534
|
+
const adapted = !win.scrolling() && adaptEstimate();
|
|
535
|
+
reveal.retry(moved || adapted);
|
|
536
|
+
syncScroll();
|
|
537
|
+
// A commit can land between frame flushes: its rows report zero size
|
|
538
|
+
// until the flush, this tick has already run, and nothing else would
|
|
539
|
+
// come back for them — a window that just finished growing renders
|
|
540
|
+
// nothing further, and the missed measurements would stand for good.
|
|
541
|
+
// Look again, briefly, while any drawn row is still unsized.
|
|
542
|
+
if (rowsPendingLayout() && tries++ < 8)
|
|
543
|
+
look = later(pass, 16);
|
|
544
|
+
};
|
|
545
|
+
const id = afterLayout(pass);
|
|
546
|
+
return () => {
|
|
547
|
+
cancelAfterLayout(id);
|
|
548
|
+
cancelLater(look);
|
|
549
|
+
};
|
|
327
550
|
});
|
|
328
551
|
const goTo = useCallback((row) => {
|
|
329
552
|
if (!row)
|
|
@@ -332,8 +555,8 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
332
555
|
if (selected === undefined)
|
|
333
556
|
setOwnSelected(row.id);
|
|
334
557
|
onSelect?.(row.id, row.item);
|
|
335
|
-
|
|
336
|
-
}, [selected, onSelect,
|
|
558
|
+
revealAt(row.index);
|
|
559
|
+
}, [selected, onSelect, revealAt]);
|
|
337
560
|
const activate = useCallback((row) => {
|
|
338
561
|
if (row.branch)
|
|
339
562
|
toggleId(row.id, row.item);
|
|
@@ -476,16 +699,100 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
476
699
|
const row = rowsRef.current.find((r) => r.id === id);
|
|
477
700
|
if (!row)
|
|
478
701
|
return false;
|
|
479
|
-
|
|
702
|
+
revealAt(row.index);
|
|
480
703
|
return true;
|
|
481
704
|
},
|
|
482
705
|
handleKey,
|
|
483
706
|
rows: () => rowsRef.current,
|
|
484
|
-
}), [goTo, toggleId,
|
|
707
|
+
}), [goTo, toggleId, revealAt, handleKey, selected, accessors]);
|
|
485
708
|
// --- rendering -----------------------------------------------------------
|
|
486
709
|
const rowStyleProp = styles?.row;
|
|
487
710
|
const guideStyleProp = styles?.guide;
|
|
711
|
+
// The row component's stable half — `MemoTreeRow` bails out of a
|
|
712
|
+
// re-render only if every prop kept its identity, and this one would
|
|
713
|
+
// otherwise be rebuilt per row per render.
|
|
714
|
+
const registerRow = useCallback((id, at, node) => {
|
|
715
|
+
if (node)
|
|
716
|
+
rowNodes.current.set(id, { node, at });
|
|
717
|
+
else
|
|
718
|
+
rowNodes.current.delete(id);
|
|
719
|
+
}, []);
|
|
720
|
+
/**
|
|
721
|
+
* The row *elements*, reused by identity while nothing they depend on has
|
|
722
|
+
* changed. The memo already skips re-rendering an unchanged row, but the
|
|
723
|
+
* skip still costs a `createElement` and a props compare per row per
|
|
724
|
+
* notch — the burst profile put bare `createElement` at a tenth of a
|
|
725
|
+
* flick's CPU. Handing React the identical element object instead takes
|
|
726
|
+
* the cheapest path it has: the fiber is reused with no compare at all.
|
|
727
|
+
*/
|
|
728
|
+
const rowElems = useRef(new Map());
|
|
729
|
+
const rowElemDeps = useRef([]);
|
|
730
|
+
{
|
|
731
|
+
const deps = [
|
|
732
|
+
indent,
|
|
733
|
+
rowHeight,
|
|
734
|
+
rtl,
|
|
735
|
+
theme,
|
|
736
|
+
renderToggle,
|
|
737
|
+
renderGuide,
|
|
738
|
+
renderLabel,
|
|
739
|
+
renderContent,
|
|
740
|
+
rowStyleProp,
|
|
741
|
+
guideStyleProp,
|
|
742
|
+
styles?.toggle,
|
|
743
|
+
styles?.label,
|
|
744
|
+
accessors,
|
|
745
|
+
toggleId,
|
|
746
|
+
goTo,
|
|
747
|
+
activate,
|
|
748
|
+
registerRow,
|
|
749
|
+
];
|
|
750
|
+
const prev = rowElemDeps.current;
|
|
751
|
+
if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
|
|
752
|
+
rowElems.current.clear();
|
|
753
|
+
rowElemDeps.current = deps;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
488
756
|
const renderOneRow = (row) => {
|
|
757
|
+
const isSelected = row.id === current;
|
|
758
|
+
const cached = rowElems.current.get(row.id);
|
|
759
|
+
if (cached && cached.row === row && cached.selected === isSelected) {
|
|
760
|
+
return cached.el;
|
|
761
|
+
}
|
|
762
|
+
const el = React.createElement(MemoTreeRow, {
|
|
763
|
+
key: String(row.id),
|
|
764
|
+
row,
|
|
765
|
+
isSelected,
|
|
766
|
+
indent,
|
|
767
|
+
rowHeight,
|
|
768
|
+
rtl,
|
|
769
|
+
theme,
|
|
770
|
+
renderToggle,
|
|
771
|
+
renderGuide,
|
|
772
|
+
renderLabel,
|
|
773
|
+
renderContent,
|
|
774
|
+
rowStyle: rowStyleProp,
|
|
775
|
+
guideStyle: guideStyleProp,
|
|
776
|
+
toggleStyle: styles?.toggle,
|
|
777
|
+
labelStyle: styles?.label,
|
|
778
|
+
getLabel: accessors.getLabel,
|
|
779
|
+
onToggle: toggleId,
|
|
780
|
+
onGo: goTo,
|
|
781
|
+
onOpen: activate,
|
|
782
|
+
register: registerRow,
|
|
783
|
+
});
|
|
784
|
+
rowElems.current.set(row.id, { row, selected: isSelected, el });
|
|
785
|
+
return el;
|
|
786
|
+
};
|
|
787
|
+
/**
|
|
788
|
+
* A row the window said not to build in full yet: the box at its indexed
|
|
789
|
+
* height and none of its content — no guides, no twisty, no label — so
|
|
790
|
+
* the commit answering a flood lands frames before the full rows could.
|
|
791
|
+
* `styles.row` still applies, so row backgrounds hold. Not registered in
|
|
792
|
+
* `rowNodes`: a skeleton must not be measured into the height index, and
|
|
793
|
+
* cannot satisfy a reveal.
|
|
794
|
+
*/
|
|
795
|
+
const renderSkeletonRow = (row) => {
|
|
489
796
|
const isSelected = row.id === current;
|
|
490
797
|
const color = row.disabled
|
|
491
798
|
? theme.textMuted
|
|
@@ -499,136 +806,35 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
499
806
|
toggle: (open) => toggleId(row.id, row.item, open),
|
|
500
807
|
select: () => goTo(row),
|
|
501
808
|
};
|
|
502
|
-
const content = [];
|
|
503
|
-
// The indent. With no guide seam it is one padding value rather than
|
|
504
|
-
// `depth` empty boxes — a tree ten deep would otherwise build ten nodes
|
|
505
|
-
// per row to draw nothing.
|
|
506
|
-
if (renderGuide && row.depth > 0) {
|
|
507
|
-
const edges = branchEdges(row);
|
|
508
|
-
for (let level = 0; level < row.depth; level++) {
|
|
509
|
-
const guide = {
|
|
510
|
-
row: state,
|
|
511
|
-
level,
|
|
512
|
-
continues: edges[level],
|
|
513
|
-
own: level === row.depth - 1,
|
|
514
|
-
width: indent,
|
|
515
|
-
height: rowHeight,
|
|
516
|
-
};
|
|
517
|
-
content.push(hx('box', {
|
|
518
|
-
key: `guide${level}`,
|
|
519
|
-
style: [
|
|
520
|
-
s.guide,
|
|
521
|
-
{ width: indent },
|
|
522
|
-
typeof guideStyleProp === 'function'
|
|
523
|
-
? guideStyleProp(guide)
|
|
524
|
-
: guideStyleProp,
|
|
525
|
-
],
|
|
526
|
-
}, renderGuide(guide)));
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
const toggleState = { ...state, size: TWISTY_GLYPH };
|
|
530
|
-
content.push(hx('box', {
|
|
531
|
-
key: 'toggle',
|
|
532
|
-
style: [s.twisty, styles?.toggle],
|
|
533
|
-
// The twisty is its own hit target: clicking it opens the branch
|
|
534
|
-
// without moving the selection, the way a file browser lets you
|
|
535
|
-
// peek inside a folder you have not chosen.
|
|
536
|
-
onClick: row.branch
|
|
537
|
-
? (ev) => {
|
|
538
|
-
ev.stopPropagation();
|
|
539
|
-
toggleId(row.id, row.item);
|
|
540
|
-
}
|
|
541
|
-
: undefined,
|
|
542
|
-
}, renderToggle
|
|
543
|
-
? renderToggle(toggleState)
|
|
544
|
-
: row.branch
|
|
545
|
-
? React.createElement(Icon, {
|
|
546
|
-
name: row.open
|
|
547
|
-
? 'chevronDown'
|
|
548
|
-
: rtl
|
|
549
|
-
? 'chevronLeft'
|
|
550
|
-
: 'chevronRight',
|
|
551
|
-
size: TWISTY_GLYPH,
|
|
552
|
-
// dimmer than the label on a resting row, and the row's own
|
|
553
|
-
// ink once it is selected
|
|
554
|
-
style: isSelected ? undefined : { color: theme.textMuted },
|
|
555
|
-
})
|
|
556
|
-
: null));
|
|
557
|
-
content.push(renderLabel
|
|
558
|
-
? // Keyed here rather than by the app, for the reason `renderSubtree`
|
|
559
|
-
// is: the label sits in an array beside the guides and the twisty,
|
|
560
|
-
// and "add a key to the box you return" is not something a render
|
|
561
|
-
// prop should have to know.
|
|
562
|
-
React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
|
|
563
|
-
: labelNode(accessors.getLabel(row.item), [s.label, styles?.label]));
|
|
564
809
|
return hx('box', {
|
|
565
810
|
key: String(row.id),
|
|
566
|
-
|
|
567
|
-
'aria-level': row.depth + 1,
|
|
568
|
-
'aria-selected': isSelected,
|
|
569
|
-
'aria-expanded': row.branch ? row.open : undefined,
|
|
570
|
-
'aria-posinset': row.posInSet,
|
|
571
|
-
'aria-setsize': row.setSize,
|
|
572
|
-
// `disabled` rather than `aria-disabled`: on a react-x11 node it is
|
|
573
|
-
// the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
|
|
574
|
-
// selects the `:disabled` style block — and there is no aria spelling
|
|
575
|
-
// of it to write instead.
|
|
576
|
-
disabled: row.disabled || undefined,
|
|
577
|
-
// The index the row was drawn at travels with the node, so measuring
|
|
578
|
-
// does not have to search a hundred thousand rows for where it is.
|
|
579
|
-
// It can go stale — the rows may move before the tick that measures —
|
|
580
|
-
// and both this and the height index check it rather than trust it.
|
|
581
|
-
ref: (node) => {
|
|
582
|
-
if (node)
|
|
583
|
-
rowNodes.current.set(row.id, { node, at: row.index });
|
|
584
|
-
else
|
|
585
|
-
rowNodes.current.delete(row.id);
|
|
586
|
-
},
|
|
587
|
-
onClick: (ev) => {
|
|
588
|
-
if (row.disabled)
|
|
589
|
-
return;
|
|
590
|
-
goTo(row);
|
|
591
|
-
// Select on the first click, open on the second — the gesture every
|
|
592
|
-
// file list has. `detail` is the click count the renderer already
|
|
593
|
-
// counts for text selection.
|
|
594
|
-
if (ev.detail === 2)
|
|
595
|
-
activate(row);
|
|
596
|
-
},
|
|
811
|
+
'aria-hidden': true,
|
|
597
812
|
style: [
|
|
598
813
|
s.row,
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
{ minHeight: rowHeight },
|
|
603
|
-
// The indent is what says "inside", so it is measured from the edge
|
|
604
|
-
// the row's label begins at.
|
|
605
|
-
{ paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
|
|
814
|
+
// Exactly what the index believes, so the spacers and the
|
|
815
|
+
// scrollbar agree with the rows on where everything is.
|
|
816
|
+
{ height: index.heightAt(row.index) },
|
|
606
817
|
{
|
|
607
818
|
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
608
|
-
// The row's ink, said once: `color` inherits, so the label takes
|
|
609
|
-
// it without being handed it.
|
|
610
|
-
color,
|
|
611
|
-
},
|
|
612
|
-
!row.disabled && {
|
|
613
|
-
':hover': {
|
|
614
|
-
backgroundColor: isSelected
|
|
615
|
-
? theme.hoverBackground
|
|
616
|
-
: theme.surfaceHover,
|
|
617
|
-
},
|
|
618
|
-
// The selection only moves on the release, and `:active` marks
|
|
619
|
-
// the whole press chain, so a press on the label or the twisty
|
|
620
|
-
// still darkens the row it is in.
|
|
621
|
-
':active': {
|
|
622
|
-
backgroundColor: isSelected
|
|
623
|
-
? theme.accentActive
|
|
624
|
-
: theme.surfaceActive,
|
|
625
|
-
},
|
|
626
819
|
},
|
|
627
820
|
typeof rowStyleProp === 'function'
|
|
628
821
|
? rowStyleProp(state)
|
|
629
822
|
: rowStyleProp,
|
|
630
823
|
],
|
|
631
|
-
},
|
|
824
|
+
},
|
|
825
|
+
// A line of "text" with no text, at the row's own indent, so a band
|
|
826
|
+
// of placeholders reads as the tree arriving rather than a void.
|
|
827
|
+
hx('box', {
|
|
828
|
+
key: 'bar',
|
|
829
|
+
style: [
|
|
830
|
+
s.skeletonBar,
|
|
831
|
+
{
|
|
832
|
+
width: 72 + ((row.index * 37) % 89),
|
|
833
|
+
marginStart: 4 + row.depth * indent + TWISTY + 4,
|
|
834
|
+
backgroundColor: theme.track,
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
}));
|
|
632
838
|
};
|
|
633
839
|
/** `layout="nested"`: the same rows, wrapped group by group. */
|
|
634
840
|
const renderGroup = (group, key) => {
|
|
@@ -663,23 +869,100 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
663
869
|
style: [s.spacer, { height: above }],
|
|
664
870
|
}));
|
|
665
871
|
}
|
|
666
|
-
for (let i = first; i < last; i++)
|
|
667
|
-
body.push(
|
|
872
|
+
for (let i = first; i < last; i++) {
|
|
873
|
+
body.push(win.skeletons.has(rows[i].id)
|
|
874
|
+
? renderSkeletonRow(rows[i])
|
|
875
|
+
: renderOneRow(rows[i]));
|
|
876
|
+
}
|
|
668
877
|
if (virtualizing && last < rows.length) {
|
|
669
878
|
body.push(hx('box', {
|
|
670
879
|
key: 'spacer:after',
|
|
671
880
|
style: [s.spacer, { height: below }],
|
|
672
881
|
}));
|
|
673
882
|
}
|
|
883
|
+
// Rows that left the window leave the cache too, once it has grown
|
|
884
|
+
// well past the window — a scrub across a long list would otherwise
|
|
885
|
+
// hold an element for every row it passed.
|
|
886
|
+
if (rowElems.current.size > (last - first) * 3 + 64) {
|
|
887
|
+
rowElems.current.clear();
|
|
888
|
+
}
|
|
674
889
|
}
|
|
675
|
-
|
|
890
|
+
/**
|
|
891
|
+
* The fast-scroll overlay — shown only while placeholders cover enough of
|
|
892
|
+
* the viewport that the user would otherwise be looking at blank rows.
|
|
893
|
+
* The half-viewport threshold keeps a near-miss quiet: a scroll the next
|
|
894
|
+
* frame will absorb is not worth announcing. Once up it stays until the
|
|
895
|
+
* view is whole again, so it does not flicker through the catch-up.
|
|
896
|
+
*
|
|
897
|
+
* A sibling of the scroll pane, never a child: everything inside the pane
|
|
898
|
+
* — absolute children included — is shifted by the scroll, so a pill in
|
|
899
|
+
* there rides away with the very flick it is meant to narrate. Outside,
|
|
900
|
+
* it is painted after the pane on every repaint frame, which is what a
|
|
901
|
+
* scrub produces (a jump past the viewport cannot take the blit fast
|
|
902
|
+
* path), so it stays put while the content flies.
|
|
903
|
+
*/
|
|
904
|
+
let scrollHint = null;
|
|
905
|
+
if (virtualizing && rows.length > 0 && view.height > 0) {
|
|
906
|
+
const vFirst = index.indexAt(view.top);
|
|
907
|
+
const vLast = Math.min(rows.length - 1, index.indexAt(view.top + view.height));
|
|
908
|
+
// Two ways in: placeholders covering enough of the viewport that it
|
|
909
|
+
// would otherwise read as blank, or a scrub — the window teleporting
|
|
910
|
+
// while the burst is still in flight, where every commit chases a
|
|
911
|
+
// viewport that has already left and nothing useful can be on screen.
|
|
912
|
+
// Either way only once the catch-up has already *lasted*: a jump the
|
|
913
|
+
// next few frames absorb is not worth announcing, so the pill waits
|
|
914
|
+
// out the show-delay against the catch-up clock. Latched once
|
|
915
|
+
// triggered: `pending` bounces to zero between catch-up commits, and a
|
|
916
|
+
// pill that blinked with it would read as a glitch. It goes when the
|
|
917
|
+
// burst does.
|
|
918
|
+
const engaged = (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
|
|
919
|
+
(win.jumped && win.scrolling());
|
|
920
|
+
const lasted = win.catchupSince !== null &&
|
|
921
|
+
Date.now() - win.catchupSince >= scrollHintDelay;
|
|
922
|
+
const show = (engaged && lasted) ||
|
|
923
|
+
(hintShown.current && (win.pending > 0 || win.scrolling()));
|
|
924
|
+
hintShown.current = show;
|
|
925
|
+
if (show) {
|
|
926
|
+
const hintState = {
|
|
927
|
+
row: rows[vFirst],
|
|
928
|
+
from: vFirst + 1,
|
|
929
|
+
to: vLast + 1,
|
|
930
|
+
count: rows.length,
|
|
931
|
+
pending: win.pending,
|
|
932
|
+
since: win.catchupSince ?? Date.now(),
|
|
933
|
+
};
|
|
934
|
+
const content = renderScrollHint
|
|
935
|
+
? renderScrollHint(hintState)
|
|
936
|
+
: hx('text', { style: { fontSize: 11, color: theme.hoverText } }, `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`);
|
|
937
|
+
if (content !== null && content !== undefined && content !== false) {
|
|
938
|
+
scrollHint = hx('box', {
|
|
939
|
+
key: 'scroll-hint',
|
|
940
|
+
// The pill duplicates what the scrollbar already tells an
|
|
941
|
+
// assistive technology, and it comes and goes with the
|
|
942
|
+
// catch-up — chatter, not content.
|
|
943
|
+
'aria-hidden': true,
|
|
944
|
+
style: s.scrollHintLane,
|
|
945
|
+
}, hx('box', {
|
|
946
|
+
style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
|
|
947
|
+
}, content));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
hintShown.current = false;
|
|
953
|
+
}
|
|
954
|
+
// The wrapper exists for the overlay: the scroll pane keeps the role, the
|
|
955
|
+
// focus, the refs and the events — everything a `<Tree>` has always put
|
|
956
|
+
// on its root — and the caller's `style` lands out here, where the
|
|
957
|
+
// tree's place in the layout is decided.
|
|
958
|
+
return hx('box', { style: [s.outer, style] }, hx('box', {
|
|
676
959
|
theme,
|
|
677
960
|
role: 'tree',
|
|
678
961
|
// The tree takes the focus, not the row — see the doc comment.
|
|
679
962
|
focusable: true,
|
|
680
963
|
...boxProps,
|
|
681
964
|
ref: scroller,
|
|
682
|
-
style:
|
|
965
|
+
style: s.root,
|
|
683
966
|
/**
|
|
684
967
|
* `preventDefault` is the load-bearing half.
|
|
685
968
|
*
|
|
@@ -702,15 +985,24 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
702
985
|
// worth building — and it is also where a page key gets its distance,
|
|
703
986
|
// so this is measured whether or not the tree virtualizes.
|
|
704
987
|
onViewport: (ev) => {
|
|
705
|
-
|
|
988
|
+
win.sized(ev.width, ev.height);
|
|
989
|
+
// The content just changed size, which is both the moment an owed
|
|
990
|
+
// scroll can reach further than the clamp let it and the moment the
|
|
991
|
+
// pane may have re-clamped its offset without saying so. It is not a
|
|
992
|
+
// moment anything can be *settled* in: this runs from layout, a tick
|
|
993
|
+
// before the pass that reads the rows it just drew back.
|
|
994
|
+
reveal.retry(virtualizing);
|
|
995
|
+
syncScroll();
|
|
706
996
|
onViewport?.(ev);
|
|
707
997
|
},
|
|
708
998
|
onScroll: (ev) => {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
999
|
+
// A scroll this component did not ask for is the user taking over,
|
|
1000
|
+
// and an owed reveal must not yank the tree back out from under them
|
|
1001
|
+
// on the next layout.
|
|
1002
|
+
reveal.heard(ev.scrollY);
|
|
1003
|
+
win.scrolled(ev.scrollY);
|
|
712
1004
|
onScroll?.(ev);
|
|
713
1005
|
},
|
|
714
|
-
}, body);
|
|
1006
|
+
}, body), scrollHint);
|
|
715
1007
|
}
|
|
716
1008
|
//# sourceMappingURL=index.js.map
|