cursedbelt 5.22.0 → 5.23.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/react/components/SearchInput.d.ts +15 -1
- package/dist/react/components/SearchInput.d.ts.map +1 -1
- package/dist/react/components/SearchInput.js +36 -4
- package/dist/react/components/SearchInput.js.map +1 -1
- package/dist/react/components/searchInput.css +179 -0
- package/dist/react/hooks/useScrollMemory.d.ts +3 -1
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +66 -47
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +8 -2
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +2 -2
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +1 -1
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +16 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryReturn.d.ts +50 -0
- package/dist/react/media-gallery/galleryReturn.d.ts.map +1 -0
- package/dist/react/media-gallery/galleryReturn.js +221 -0
- package/dist/react/media-gallery/galleryReturn.js.map +1 -0
- package/package.json +8 -1
- package/src/demoFixture.spec.ts +4 -2
- package/src/react/components/SearchInput.spec.tsx +57 -0
- package/src/react/components/SearchInput.tsx +74 -20
- package/src/react/components/searchInput.css +179 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +38 -0
- package/src/react/hooks/useScrollMemory.ts +82 -47
- package/src/react/media/DenseGalleryShell.tsx +9 -1
- package/src/react/media-gallery/GalleryTable.tsx +2 -0
- package/src/react/media-gallery/MediaGallery.tsx +19 -1
- package/src/react/media-gallery/galleryReturn.ts +248 -0
|
@@ -224,4 +224,42 @@ describe('useScrollMemory', () => {
|
|
|
224
224
|
render(<Shell at='/' el={s.el} />);
|
|
225
225
|
expect(s.el.scrollTop).toBe(12_000);
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
test('a scroller that mounts AFTER the hook — a list behind a loading spinner — is still remembered', () => {
|
|
229
|
+
const s = fakeScroller(20_000);
|
|
230
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
231
|
+
s.scrollTo(8_000);
|
|
232
|
+
const list = { state: window.history.state, path: '/' };
|
|
233
|
+
push('/item/7');
|
|
234
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
235
|
+
|
|
236
|
+
back(list);
|
|
237
|
+
const Late = ({ ready }: { ready: boolean }) => {
|
|
238
|
+
useScrollMemory('/', { scroller: () => (ready ? s.el : null) });
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
241
|
+
cleanup();
|
|
242
|
+
const late = render(<Late ready={false} />);
|
|
243
|
+
late.rerender(<Late ready />);
|
|
244
|
+
expect(s.el.scrollTop).toBe(8_000);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a list screen that owns its scroller files on unmount and restores on remount', () => {
|
|
248
|
+
const s = fakeScroller(20_000);
|
|
249
|
+
const List = () => {
|
|
250
|
+
useScrollMemory('/people', { scroller: () => s.el });
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
window.history.replaceState(null, '', '/people');
|
|
254
|
+
const first = render(<List />);
|
|
255
|
+
s.scrollTo(5_500);
|
|
256
|
+
const list = { state: window.history.state, path: '/people' };
|
|
257
|
+
push('/person/1'); // the app navigates; the list unmounts
|
|
258
|
+
first.unmount();
|
|
259
|
+
s.el.scrollTop = 0; // a new screen's scroller, as far as this list knows
|
|
260
|
+
|
|
261
|
+
back(list);
|
|
262
|
+
render(<List />);
|
|
263
|
+
expect(s.el.scrollTop).toBe(5_500);
|
|
264
|
+
});
|
|
227
265
|
});
|
|
@@ -31,67 +31,102 @@ import { type RefObject, useLayoutEffect, useRef } from 'react';
|
|
|
31
31
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
32
32
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
33
33
|
*
|
|
34
|
-
* Mount it ONCE per scroller
|
|
34
|
+
* Mount it ONCE per scroller: in the app shell keyed on the route when the screens swap under one
|
|
35
|
+
* scroller (the window, a shell `ScrollArea`), or in the list screen itself when each screen owns
|
|
36
|
+
* its own `ScrollArea` — mounting is then the arrival and unmounting the leave.
|
|
35
37
|
*/
|
|
36
38
|
export function useScrollMemory(location: string, options: ScrollMemoryOptions = {}): void {
|
|
37
39
|
const { enabled = true, deadlineMs = RESTORE_DEADLINE_MS } = options;
|
|
38
|
-
|
|
39
|
-
// every render — which would file and restore on every keystroke.
|
|
40
|
-
const scrollerRef = useRef(options.scroller);
|
|
41
|
-
scrollerRef.current = options.scroller;
|
|
40
|
+
const spec = options.scroller ?? 'window';
|
|
42
41
|
const last = useRef<{ id: string; location: string } | null>(null);
|
|
42
|
+
const session = useRef<Session | null>(null);
|
|
43
43
|
|
|
44
|
+
// 🔴 Checked EVERY commit, not on a dependency list: the scroller can mount after the hook — a
|
|
45
|
+
// list behind a loading spinner has no ScrollArea on its first render — and an effect keyed on
|
|
46
|
+
// the location alone would find nothing, return, and never look again, so that whole visit
|
|
47
|
+
// would go unremembered. A session ends and the next begins only when the place, the switch or
|
|
48
|
+
// the scroller ELEMENT actually changes; every other commit is one comparison.
|
|
44
49
|
useLayoutEffect(() => {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
50
|
+
const scroller = enabled ? resolveScroller(spec) : null;
|
|
51
|
+
const current = session.current;
|
|
52
|
+
if (current && scroller && current.location === location && current.target === scroller.events) return;
|
|
53
|
+
if (!current && !scroller) return;
|
|
54
|
+
current?.end();
|
|
55
|
+
session.current = scroller ? begin(scroller, location, last, deadlineMs) : null;
|
|
56
|
+
});
|
|
57
|
+
useLayoutEffect(
|
|
58
|
+
() => () => {
|
|
59
|
+
session.current?.end();
|
|
60
|
+
session.current = null;
|
|
61
|
+
},
|
|
62
|
+
[],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Session {
|
|
67
|
+
location: string;
|
|
68
|
+
target: EventTarget;
|
|
69
|
+
/** Stop tracking and file the offset under this entry. */
|
|
70
|
+
end(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Arrive on the current history entry with this scroller: restore or start at the top, then track. */
|
|
74
|
+
function begin(
|
|
75
|
+
scroller: Scroller,
|
|
76
|
+
location: string,
|
|
77
|
+
last: { current: { id: string; location: string } | null },
|
|
78
|
+
deadlineMs: number,
|
|
79
|
+
): Session | null {
|
|
80
|
+
if (scroller.isWindow && 'scrollRestoration' in window.history) {
|
|
81
|
+
// Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
|
|
82
|
+
// React has swapped it — and the restore below would have to fight it.
|
|
83
|
+
window.history.scrollRestoration = 'manual';
|
|
84
|
+
}
|
|
85
|
+
const id = entryId();
|
|
86
|
+
if (!id) return null;
|
|
87
|
+
|
|
88
|
+
// Same entry, different location: the URL was replaced in place. It is the same place.
|
|
89
|
+
const replacedInPlace = last.current?.id === id && last.current.location !== location;
|
|
90
|
+
last.current = { id, location };
|
|
91
|
+
|
|
92
|
+
// What gets filed when this entry is left. While a restore is pending it is the TARGET, not
|
|
93
|
+
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
94
|
+
// not overwrite a deep position with a shallow one.
|
|
95
|
+
let offset = scroller.get();
|
|
96
|
+
let cancelRestore: (() => void) | null = null;
|
|
97
|
+
|
|
98
|
+
if (!replacedInPlace) {
|
|
99
|
+
const target = recalled(id);
|
|
100
|
+
if (target > 0) {
|
|
101
|
+
offset = target;
|
|
102
|
+
cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
103
|
+
cancelRestore = null;
|
|
104
|
+
if (outcome !== 'reached') offset = scroller.get();
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
scroller.set(0);
|
|
108
|
+
offset = 0;
|
|
78
109
|
}
|
|
110
|
+
}
|
|
79
111
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
const onScroll = () => {
|
|
113
|
+
if (!cancelRestore) offset = scroller.get();
|
|
114
|
+
};
|
|
115
|
+
const onHide = () => file(id, offset);
|
|
116
|
+
scroller.events.addEventListener('scroll', onScroll, { passive: true });
|
|
117
|
+
window.addEventListener('pagehide', onHide);
|
|
118
|
+
return {
|
|
119
|
+
location,
|
|
120
|
+
target: scroller.events,
|
|
121
|
+
end() {
|
|
87
122
|
const pending = cancelRestore;
|
|
88
123
|
cancelRestore = null;
|
|
89
124
|
pending?.();
|
|
90
125
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
91
126
|
window.removeEventListener('pagehide', onHide);
|
|
92
127
|
file(id, offset);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
/** What scrolls: the window, or an element — by ref or by a getter read when the screen arrives. */
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CSSProperties,
|
|
6
6
|
type MouseEvent as ReactMouseEvent,
|
|
7
7
|
type ReactNode,
|
|
8
|
+
type Ref,
|
|
8
9
|
useCallback,
|
|
9
10
|
useEffect,
|
|
10
11
|
useState,
|
|
@@ -231,6 +232,12 @@ export interface DenseGalleryShellProps<O extends string = string> {
|
|
|
231
232
|
* exactly one place the caption height is written down.
|
|
232
233
|
*/
|
|
233
234
|
rowExtraHeight?: number;
|
|
235
|
+
/**
|
|
236
|
+
* The shell's root element (`.cbgd-root`) — for a caller that has to find the browse view's
|
|
237
|
+
* scroller in the DOM. `MediaGallery` uses it to put the grid back where you were when a
|
|
238
|
+
* file closes (`media-gallery/galleryReturn.ts`), because closing a file mounts a new shell.
|
|
239
|
+
*/
|
|
240
|
+
rootRef?: Ref<HTMLDivElement>;
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
/** The browse view: header + optional filter strip + a virtualized thumbnail grid. */
|
|
@@ -254,9 +261,10 @@ export function DenseGalleryShell<O extends string = string>({
|
|
|
254
261
|
minColumnWidth = 160,
|
|
255
262
|
// 🔴 Deliberately NOT defaulted here — see the prop's doc comment.
|
|
256
263
|
rowExtraHeight,
|
|
264
|
+
rootRef,
|
|
257
265
|
}: DenseGalleryShellProps<O>) {
|
|
258
266
|
return (
|
|
259
|
-
<div className='cbgd-root'>
|
|
267
|
+
<div className='cbgd-root' ref={rootRef}>
|
|
260
268
|
{chrome === 'none' ? null : (
|
|
261
269
|
<header className='cbgd-header'>
|
|
262
270
|
<span className='cbgd-title'>{title}</span>
|
|
@@ -496,6 +496,8 @@ function GalleryTableRow({
|
|
|
496
496
|
// The same two hooks the grid tile carries: the attribute is what the gallery's reveal
|
|
497
497
|
// scrolls to, and `aria-current` is what says "this is the one" to a screen reader.
|
|
498
498
|
data-playing={stageMark ? 'true' : undefined}
|
|
499
|
+
// How a returning table finds the last file shown — see `galleryReturn.ts`.
|
|
500
|
+
data-item-id={item.id}
|
|
499
501
|
aria-current={stageMark ? 'true' : undefined}
|
|
500
502
|
{...(previewSrc
|
|
501
503
|
? {
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
} from '../hooks/useCheckboxSelection.js';
|
|
79
79
|
import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell.js';
|
|
80
80
|
import { CBGD_BARE_TILE_HEIGHT } from '../media/VirtualMediaGrid.js';
|
|
81
|
+
import { useGalleryReturn } from './galleryReturn.js';
|
|
81
82
|
import { useHlsSource } from '../media/hlsSource.js';
|
|
82
83
|
import {
|
|
83
84
|
isRecoverableFault,
|
|
@@ -552,12 +553,16 @@ interface TileDrag {
|
|
|
552
553
|
* `stopPropagation` in {@link SelectBox} buys.
|
|
553
554
|
*/
|
|
554
555
|
function TileShell({
|
|
556
|
+
id,
|
|
555
557
|
name,
|
|
556
558
|
selection,
|
|
557
559
|
drag,
|
|
558
560
|
stageMark,
|
|
559
561
|
children,
|
|
560
562
|
}: {
|
|
563
|
+
/** The item's id, carried as `data-item-id` — how a returning grid finds the last file shown
|
|
564
|
+
* (`galleryReturn.ts`). */
|
|
565
|
+
id: string;
|
|
561
566
|
name: string;
|
|
562
567
|
selection?: TileSelection;
|
|
563
568
|
drag?: TileDrag;
|
|
@@ -582,6 +587,7 @@ function TileShell({
|
|
|
582
587
|
// The hook the reveal below finds the tile by — see `MediaGallery`'s `revealPlaying`.
|
|
583
588
|
// An attribute rather than the class, so the query cannot be broken by a styling change.
|
|
584
589
|
data-playing={stageMark ? 'true' : undefined}
|
|
590
|
+
data-item-id={id}
|
|
585
591
|
aria-current={stageMark ? 'true' : undefined}
|
|
586
592
|
// 🔴 The DRAGGABLE element is this wrapper, not the tile inside it. Every tile branch
|
|
587
593
|
// is a <button>, and a draggable button is a browser-by-browser coin toss: some start
|
|
@@ -684,7 +690,7 @@ function GalleryTile({
|
|
|
684
690
|
* handlers, so this costs nothing and cannot change behavior anywhere else.
|
|
685
691
|
*/
|
|
686
692
|
return (
|
|
687
|
-
<TileShell name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
|
|
693
|
+
<TileShell id={item.id} name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
|
|
688
694
|
<RawGalleryTile
|
|
689
695
|
item={item}
|
|
690
696
|
cache={cache}
|
|
@@ -2021,6 +2027,17 @@ export function MediaGallery({
|
|
|
2021
2027
|
}, [checkedKey]);
|
|
2022
2028
|
|
|
2023
2029
|
const selected = selectedId ? (items.find((i) => i.id === selectedId) ?? null) : null;
|
|
2030
|
+
/** The browse view's root — how the return below finds the grid it puts back in place. */
|
|
2031
|
+
const browseRootRef = useRef<HTMLDivElement | null>(null);
|
|
2032
|
+
// Closing a file mounts a NEW grid; this puts it back where you were, then shows the last
|
|
2033
|
+
// file the stage showed. Task 2255 — see `galleryReturn.ts`.
|
|
2034
|
+
useGalleryReturn({
|
|
2035
|
+
rootRef: browseRootRef,
|
|
2036
|
+
browsing: selected === null,
|
|
2037
|
+
viewMode,
|
|
2038
|
+
shownId: selected?.id ?? null,
|
|
2039
|
+
displayIds,
|
|
2040
|
+
});
|
|
2024
2041
|
const select = (item: MediaGalleryItem | null) => {
|
|
2025
2042
|
// Only move our own state when we own it; a controlled consumer decides for itself what
|
|
2026
2043
|
// the notification means.
|
|
@@ -3164,6 +3181,7 @@ export function MediaGallery({
|
|
|
3164
3181
|
{capabilityDialogs}
|
|
3165
3182
|
<DenseGalleryShell<MediaGalleryOrder>
|
|
3166
3183
|
{...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
|
|
3184
|
+
rootRef={browseRootRef}
|
|
3167
3185
|
title={title}
|
|
3168
3186
|
order={{
|
|
3169
3187
|
value: order,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing a file puts the grid back where you were — task 2255.
|
|
3
|
+
*
|
|
4
|
+
* Owner, 2026-09-28, after flix's "Back keeps your place": *"is a common enough need … I want it
|
|
5
|
+
* to be in cb … find other apps we have that have scrolling and similar issues where we can't
|
|
6
|
+
* go back to the right place like flix had"*.
|
|
7
|
+
*
|
|
8
|
+
* `MediaGallery` draws `DenseGalleryDetail` while a file is open and `DenseGalleryShell`
|
|
9
|
+
* otherwise. They are different trees, so closing a file — by ✕, by a deep link's Back
|
|
10
|
+
* (`useGalleryDeepLink`'s `?i=`), by deleting it — MOUNTS A NEW GRID, and its scroller starts
|
|
11
|
+
* at zero. Measured in Chromium before this (`e2e/gallery-return.spec.ts`, 240 tiles, scrolled
|
|
12
|
+
* to 3,000px): every close landed at scrollTop 0, at phone and desktop size, two hundred tiles
|
|
13
|
+
* from where you had been.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 Why not `useScrollMemory`. That hook is per HISTORY ENTRY, and a gallery's open/close is
|
|
16
|
+
* a history entry only when its consumer deep-links it. An uncontrolled gallery closes with no
|
|
17
|
+
* navigation at all, so the memory has to live with the gallery — which is also what keeps
|
|
18
|
+
* every consumer from having to wire anything.
|
|
19
|
+
*
|
|
20
|
+
* Two rules, in order:
|
|
21
|
+
* 1. **The offset comes back** — the browse scroller's own `scrollTop` (the tile grid's
|
|
22
|
+
* `.cbgd-grid`, or the table's `.cbgt-root`), recorded while you browse and put back when
|
|
23
|
+
* the view returns, if it returns in the same grid/table mode. It retries each frame while
|
|
24
|
+
* the virtualized track is still growing into the offset, and a touch, wheel, key or
|
|
25
|
+
* pointer press cancels it — the viewer always wins.
|
|
26
|
+
* 2. **…then the last file SHOWN is on screen.** Stepping through twenty files moves you: the
|
|
27
|
+
* place you are now is the last file the stage showed, not the one you opened. When that
|
|
28
|
+
* file's tile is not visible after rule 1 it is brought to the middle of the scroller. A
|
|
29
|
+
* tile that is already even partly visible is left alone — a grid that jumps a few pixels
|
|
30
|
+
* under a tile you can see reads as a bug (the same limit the strip's reveal keeps).
|
|
31
|
+
* A virtualized tile that is not mounted is found by arithmetic on the tiles that are:
|
|
32
|
+
* their row pitch and column count place it, and the next frame centres it exactly.
|
|
33
|
+
*/
|
|
34
|
+
import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
|
|
35
|
+
|
|
36
|
+
/** The browse view's scroller in either view: the tile grid, or the table's frame. */
|
|
37
|
+
const SCROLLER = '.cbgd-grid, .cbgt-root';
|
|
38
|
+
/** Every tile and every table row carries its item's id under this attribute. */
|
|
39
|
+
export const ITEM_ID_ATTR = 'data-item-id';
|
|
40
|
+
/** How long a return may keep trying before it settles for what the scroller can hold. */
|
|
41
|
+
const DEADLINE_MS = 1500;
|
|
42
|
+
const CANCEL_EVENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
|
|
43
|
+
|
|
44
|
+
export interface GalleryReturnOptions {
|
|
45
|
+
/** `.cbgd-root` of the browse view — `DenseGalleryShell`'s `rootRef`. */
|
|
46
|
+
rootRef: RefObject<HTMLDivElement | null>;
|
|
47
|
+
/** True while the browse view (no file open) is the one on screen. */
|
|
48
|
+
browsing: boolean;
|
|
49
|
+
/** Grid or table: an offset recorded in one means nothing in the other. */
|
|
50
|
+
viewMode: string;
|
|
51
|
+
/** The file on the stage right now, or null. */
|
|
52
|
+
shownId: string | null;
|
|
53
|
+
/** The displayed order, which is what places a tile that is not mounted. */
|
|
54
|
+
displayIds: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useGalleryReturn({
|
|
58
|
+
rootRef,
|
|
59
|
+
browsing,
|
|
60
|
+
viewMode,
|
|
61
|
+
shownId,
|
|
62
|
+
displayIds,
|
|
63
|
+
}: GalleryReturnOptions): void {
|
|
64
|
+
const saved = useRef<{ mode: string; top: number } | null>(null);
|
|
65
|
+
const lastShown = useRef<string | null>(null);
|
|
66
|
+
const restoring = useRef(false);
|
|
67
|
+
const wasBrowsing = useRef(browsing);
|
|
68
|
+
// Read through refs at fire time: they are inputs the return needs CURRENT, not triggers.
|
|
69
|
+
const modeRef = useRef(viewMode);
|
|
70
|
+
const idsRef = useRef(displayIds);
|
|
71
|
+
modeRef.current = viewMode;
|
|
72
|
+
idsRef.current = displayIds;
|
|
73
|
+
if (shownId) lastShown.current = shownId;
|
|
74
|
+
|
|
75
|
+
// Record the browse scroller's offset while browsing. Capture phase, because `scroll` does
|
|
76
|
+
// not bubble and the scroller is whichever of the two views is mounted.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const root = rootRef.current;
|
|
79
|
+
if (!browsing || !root) return;
|
|
80
|
+
const onScroll = (event: Event): void => {
|
|
81
|
+
const target = event.target;
|
|
82
|
+
if (restoring.current || !(target instanceof HTMLElement) || !target.matches(SCROLLER)) return;
|
|
83
|
+
saved.current = { mode: modeRef.current, top: target.scrollTop };
|
|
84
|
+
};
|
|
85
|
+
root.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
86
|
+
return () => root.removeEventListener('scroll', onScroll, { capture: true });
|
|
87
|
+
}, [browsing, rootRef]);
|
|
88
|
+
|
|
89
|
+
// A layout effect, so the offset is back before the returning grid is ever painted at zero.
|
|
90
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `browsing` is the one trigger — a return is the moment it turns true. Everything else is read through refs above, on purpose: a re-sort or a new page of items must not replay a return.
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
const returning = browsing && !wasBrowsing.current;
|
|
93
|
+
wasBrowsing.current = browsing;
|
|
94
|
+
if (!returning) return;
|
|
95
|
+
const scroller = rootRef.current?.querySelector<HTMLElement>(SCROLLER);
|
|
96
|
+
if (!scroller) return;
|
|
97
|
+
const target = saved.current?.mode === modeRef.current ? saved.current.top : null;
|
|
98
|
+
const revealId = lastShown.current;
|
|
99
|
+
if (target === null && !revealId) return;
|
|
100
|
+
return returnTo(scroller, target, revealId, idsRef, () => {
|
|
101
|
+
restoring.current = false;
|
|
102
|
+
saved.current = { mode: modeRef.current, top: scroller.scrollTop };
|
|
103
|
+
}, restoring);
|
|
104
|
+
}, [browsing]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Put `scroller` back at `target`, then bring `revealId`'s tile into view. Returns the cancel.
|
|
109
|
+
*/
|
|
110
|
+
function returnTo(
|
|
111
|
+
scroller: HTMLElement,
|
|
112
|
+
target: number | null,
|
|
113
|
+
revealId: string | null,
|
|
114
|
+
idsRef: RefObject<readonly string[]>,
|
|
115
|
+
done: () => void,
|
|
116
|
+
restoring: { current: boolean },
|
|
117
|
+
): () => void {
|
|
118
|
+
// Each phase gets its own deadline, so a list that never grows back into the offset still
|
|
119
|
+
// leaves the reveal its full budget.
|
|
120
|
+
let started = performance.now();
|
|
121
|
+
let frame = 0;
|
|
122
|
+
let phase: 'offset' | 'reveal' | 'centre' = target === null ? 'reveal' : 'offset';
|
|
123
|
+
let finished = false;
|
|
124
|
+
restoring.current = true;
|
|
125
|
+
|
|
126
|
+
const finish = (): void => {
|
|
127
|
+
if (finished) return;
|
|
128
|
+
finished = true;
|
|
129
|
+
cancelAnimationFrame(frame);
|
|
130
|
+
for (const type of CANCEL_EVENTS) scroller.removeEventListener(type, finish);
|
|
131
|
+
done();
|
|
132
|
+
};
|
|
133
|
+
for (const type of CANCEL_EVENTS) scroller.addEventListener(type, finish, { passive: true });
|
|
134
|
+
|
|
135
|
+
const tick = (): void => {
|
|
136
|
+
if (finished) return;
|
|
137
|
+
const late = performance.now() - started > DEADLINE_MS;
|
|
138
|
+
if (phase === 'offset' && target !== null) {
|
|
139
|
+
scroller.scrollTop = target;
|
|
140
|
+
// Reached, or the list is simply shorter now (a filter, a delete): settle for its end.
|
|
141
|
+
if (Math.abs(scroller.scrollTop - target) < 1 || late) {
|
|
142
|
+
phase = 'reveal';
|
|
143
|
+
started = performance.now();
|
|
144
|
+
}
|
|
145
|
+
frame = requestAnimationFrame(tick);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const ids = idsRef.current ?? [];
|
|
149
|
+
const index = revealId ? ids.indexOf(revealId) : -1;
|
|
150
|
+
if (index < 0 || late) {
|
|
151
|
+
finish();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const tile = scroller.querySelector<HTMLElement>(`[${ITEM_ID_ATTR}="${cssEscape(revealId ?? '')}"]`);
|
|
155
|
+
const band = visibleBand(scroller);
|
|
156
|
+
if (tile) {
|
|
157
|
+
const box = tile.getBoundingClientRect();
|
|
158
|
+
const visible = box.bottom > band.top && box.top < band.bottom;
|
|
159
|
+
// Rule 2's limit: a tile you can already see is not moved — unless WE just jumped to an
|
|
160
|
+
// estimate of it, which is only ever close.
|
|
161
|
+
if (!visible || phase === 'centre') scroller.scrollTop += centreDelta(box, band);
|
|
162
|
+
finish();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// 🔴 Not mounted — but first make sure the VIRTUALIZER has caught up with the offset just
|
|
166
|
+
// set. The table re-windows on its own scroll state a render later, so for a frame or two
|
|
167
|
+
// after a jump it still holds the rows from the top; estimating off those would place a
|
|
168
|
+
// tile that is about to appear right where you are. Nothing mounted in the band = wait.
|
|
169
|
+
if (!mountedInBand(scroller, band)) {
|
|
170
|
+
frame = requestAnimationFrame(tick);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Truly windowed out. Place it by the tiles that ARE mounted.
|
|
174
|
+
const estimate = estimateTop(scroller, band, ids, index);
|
|
175
|
+
if (estimate === null) {
|
|
176
|
+
finish();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
scroller.scrollTop = estimate;
|
|
180
|
+
phase = 'centre';
|
|
181
|
+
frame = requestAnimationFrame(tick);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
tick();
|
|
185
|
+
return () => {
|
|
186
|
+
if (finished) return;
|
|
187
|
+
finished = true;
|
|
188
|
+
cancelAnimationFrame(frame);
|
|
189
|
+
for (const type of CANCEL_EVENTS) scroller.removeEventListener(type, finish);
|
|
190
|
+
restoring.current = false;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface Band {
|
|
195
|
+
top: number;
|
|
196
|
+
bottom: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The part of the scroller a person can see — below the table's sticky header, if any. */
|
|
200
|
+
function visibleBand(scroller: HTMLElement): Band {
|
|
201
|
+
const view = scroller.getBoundingClientRect();
|
|
202
|
+
const head = scroller.querySelector('thead');
|
|
203
|
+
const top = head ? Math.max(view.top, head.getBoundingClientRect().bottom) : view.top;
|
|
204
|
+
return { top, bottom: view.bottom };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Does any tile or row sit in the visible band — i.e. has the window caught up? */
|
|
208
|
+
function mountedInBand(scroller: HTMLElement, band: Band): boolean {
|
|
209
|
+
for (const el of scroller.querySelectorAll<HTMLElement>(`[${ITEM_ID_ATTR}]`)) {
|
|
210
|
+
const box = el.getBoundingClientRect();
|
|
211
|
+
if (box.bottom > band.top && box.top < band.bottom) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function centreDelta(box: DOMRect, band: Band): number {
|
|
217
|
+
return box.top + box.height / 2 - (band.top + band.bottom) / 2;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A `scrollTop` that centres the tile at `index`, derived from the mounted ones: the first
|
|
222
|
+
* mounted row gives the column count (only the LAST row of a grid can be short), and the next
|
|
223
|
+
* row down gives the pitch. A table is the same arithmetic with one column.
|
|
224
|
+
*/
|
|
225
|
+
function estimateTop(scroller: HTMLElement, band: Band, ids: readonly string[], index: number): number | null {
|
|
226
|
+
const placed: { index: number; top: number; height: number }[] = [];
|
|
227
|
+
for (const el of scroller.querySelectorAll<HTMLElement>(`[${ITEM_ID_ATTR}]`)) {
|
|
228
|
+
const at = ids.indexOf(el.getAttribute(ITEM_ID_ATTR) ?? '');
|
|
229
|
+
if (at < 0) continue;
|
|
230
|
+
const box = el.getBoundingClientRect();
|
|
231
|
+
placed.push({ index: at, top: box.top - band.top + scroller.scrollTop, height: box.height });
|
|
232
|
+
}
|
|
233
|
+
if (placed.length === 0) return null;
|
|
234
|
+
placed.sort((a, b) => a.index - b.index);
|
|
235
|
+
const anchor = placed[0] as (typeof placed)[number];
|
|
236
|
+
const cols = Math.max(1, placed.filter((p) => Math.abs(p.top - anchor.top) < 1).length);
|
|
237
|
+
const below = placed.find((p) => p.top - anchor.top >= 1);
|
|
238
|
+
const rowOf = (at: number): number => Math.floor(at / cols);
|
|
239
|
+
const pitch = below ? (below.top - anchor.top) / (rowOf(below.index) - rowOf(anchor.index) || 1) : anchor.height;
|
|
240
|
+
const rows = rowOf(index) - rowOf(anchor.index);
|
|
241
|
+
const top = anchor.top + rows * pitch;
|
|
242
|
+
return Math.max(0, top + anchor.height / 2 - (band.bottom - band.top) / 2);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `CSS.escape` where it exists (every browser); ids are app-chosen strings. */
|
|
246
|
+
function cssEscape(value: string): string {
|
|
247
|
+
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&');
|
|
248
|
+
}
|