@zakkster/lite-virtual 1.0.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/LICENSE.txt +21 -0
- package/README.md +620 -0
- package/Virtual.d.ts +218 -0
- package/Virtual.js +447 -0
- package/llms.txt +244 -0
- package/package.json +86 -0
package/Virtual.d.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-virtual · type declarations
|
|
3
|
+
*
|
|
4
|
+
* Thrash-free list/grid windowing on @zakkster/lite-signal. Reactive integer
|
|
5
|
+
* indices gate downstream work: scrolling within a single row is allocation-
|
|
6
|
+
* and DOM-write-free. Two layers:
|
|
7
|
+
*
|
|
8
|
+
* • Pure math axes (`virtualAxis`, `virtualGrid`, `variableAxis`) — headless
|
|
9
|
+
* reactive state. Pair with any renderer.
|
|
10
|
+
* • lite-element renderers (`mountList`, `mountKeyedList`, `mountGrid`,
|
|
11
|
+
* `mountVariableList`) — opinionated recycling renderers built on the math.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Read-only reactive value: call to read+track, `.peek()` for untracked. */
|
|
15
|
+
export interface ReadSignal<T> {
|
|
16
|
+
(): T;
|
|
17
|
+
peek(): T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Writable reactive value (lite-signal's signal handle). */
|
|
21
|
+
export interface WritableSignal<T> extends ReadSignal<T> {
|
|
22
|
+
set(value: T): void;
|
|
23
|
+
update(fn: (prev: T) => T): void;
|
|
24
|
+
subscribe(fn: (value: T) => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The mounting scope (lite-element's `scope` API). The renderers need three
|
|
29
|
+
* primitives: `effect` for reactive bindings, `on` for auto-removed listeners,
|
|
30
|
+
* `onCleanup` for arbitrary teardown.
|
|
31
|
+
*/
|
|
32
|
+
export interface MountScope {
|
|
33
|
+
effect(fn: () => void): () => void;
|
|
34
|
+
on(el: EventTarget, type: string, handler: (ev: any) => void, opts?: AddEventListenerOptions | boolean): () => void;
|
|
35
|
+
onCleanup(fn: () => void): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Fixed-size 1-D axis ─────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface VirtualAxisOptions {
|
|
41
|
+
/** Number of items in the list. */
|
|
42
|
+
count: number;
|
|
43
|
+
/** Pixel size of every item along the scroll axis (height for vertical, width for horizontal). */
|
|
44
|
+
itemSize: number;
|
|
45
|
+
/** Viewport size along the scroll axis (clientHeight / clientWidth). */
|
|
46
|
+
viewport: number;
|
|
47
|
+
/** Extra items to keep mounted on each side of the visible window. Default 3. */
|
|
48
|
+
overscan?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface VirtualAxis {
|
|
52
|
+
/** Current scroll position in px (writable; integer-truncated on set). */
|
|
53
|
+
readonly scrollPos: WritableSignal<number>;
|
|
54
|
+
/** First visible index (inclusive). Object.is-gated — only changes on boundary crossing. */
|
|
55
|
+
readonly start: ReadSignal<number>;
|
|
56
|
+
/** Last visible index (exclusive). */
|
|
57
|
+
readonly end: ReadSignal<number>;
|
|
58
|
+
/** Pixel offset of `start` (= start * itemSize). */
|
|
59
|
+
readonly offsetStart: ReadSignal<number>;
|
|
60
|
+
/** Total scroll length in px (= count * itemSize). */
|
|
61
|
+
readonly totalSize: ReadSignal<number>;
|
|
62
|
+
setScroll(px: number): void;
|
|
63
|
+
setViewport(px: number): void;
|
|
64
|
+
setCount(n: number): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function virtualAxis(options: VirtualAxisOptions): VirtualAxis;
|
|
68
|
+
|
|
69
|
+
// ─── Fixed-size 2-D grid ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface VirtualGridOptions {
|
|
72
|
+
rowCount: number;
|
|
73
|
+
colCount: number;
|
|
74
|
+
rowHeight: number;
|
|
75
|
+
colWidth: number;
|
|
76
|
+
viewportHeight: number;
|
|
77
|
+
viewportWidth: number;
|
|
78
|
+
/** Extra rows AND columns to keep mounted on each side of the visible window. Default 2. */
|
|
79
|
+
overscan?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface VirtualGrid {
|
|
83
|
+
readonly rowStart: ReadSignal<number>;
|
|
84
|
+
readonly rowEnd: ReadSignal<number>;
|
|
85
|
+
readonly rowOffset: ReadSignal<number>;
|
|
86
|
+
readonly totalHeight: ReadSignal<number>;
|
|
87
|
+
readonly colStart: ReadSignal<number>;
|
|
88
|
+
readonly colEnd: ReadSignal<number>;
|
|
89
|
+
readonly colOffset: ReadSignal<number>;
|
|
90
|
+
readonly totalWidth: ReadSignal<number>;
|
|
91
|
+
setScroll(left: number, top: number): void;
|
|
92
|
+
setViewport(width: number, height: number): void;
|
|
93
|
+
setCounts(rowCount: number, colCount: number): void;
|
|
94
|
+
/** Underlying axes for advanced use (debugging, scroll-to-index). Treat as internal. */
|
|
95
|
+
readonly _rows: VirtualAxis;
|
|
96
|
+
readonly _cols: VirtualAxis;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function virtualGrid(options: VirtualGridOptions): VirtualGrid;
|
|
100
|
+
|
|
101
|
+
// ─── Variable-size 1-D axis ──────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface VariableAxisOptions {
|
|
104
|
+
count: number;
|
|
105
|
+
/** Returns the pixel size of item `i`. Called once per item at build time and on `remeasure()`. */
|
|
106
|
+
sizeAt: (index: number) => number;
|
|
107
|
+
viewport: number;
|
|
108
|
+
overscan?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface VariableAxis {
|
|
112
|
+
readonly scrollPos: WritableSignal<number>;
|
|
113
|
+
readonly start: ReadSignal<number>;
|
|
114
|
+
readonly end: ReadSignal<number>;
|
|
115
|
+
readonly offsetStart: ReadSignal<number>;
|
|
116
|
+
readonly totalSize: ReadSignal<number>;
|
|
117
|
+
/** Pixel offset of item `i` from the top of the list. O(1). */
|
|
118
|
+
positionAt(index: number): number;
|
|
119
|
+
/** The smallest row size seen at build time. Used by renderers to size their pool. */
|
|
120
|
+
minSize(): number;
|
|
121
|
+
setScroll(px: number): void;
|
|
122
|
+
setViewport(px: number): void;
|
|
123
|
+
setCount(n: number): void;
|
|
124
|
+
/** Re-walk `sizeAt` and rebuild the prefix-sum offsets. O(n). */
|
|
125
|
+
remeasure(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function variableAxis(options: VariableAxisOptions): VariableAxis;
|
|
129
|
+
|
|
130
|
+
// ─── Recycling renderers ─────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export interface MountListOptions {
|
|
133
|
+
count: number;
|
|
134
|
+
itemHeight: number;
|
|
135
|
+
/** Initial viewport height; auto-updated via ResizeObserver if available. */
|
|
136
|
+
viewport: number;
|
|
137
|
+
overscan?: number;
|
|
138
|
+
/** Render callback. Called once per index when that index enters the window or its node is recycled. */
|
|
139
|
+
render: (rowEl: HTMLElement, index: number) => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Index-based recycling list. O(1) per boundary crossing during smooth scroll;
|
|
144
|
+
* zero work between crossings. Stateless rows only (a node is reused across
|
|
145
|
+
* logical indices). For stateful rows use `mountKeyedList`.
|
|
146
|
+
*
|
|
147
|
+
* Returns the underlying axis so callers can read `start`/`end`/scroll, or set
|
|
148
|
+
* counts externally (e.g. tail-following a growing log).
|
|
149
|
+
*/
|
|
150
|
+
export function mountList(
|
|
151
|
+
host: HTMLElement,
|
|
152
|
+
scope: MountScope,
|
|
153
|
+
options: MountListOptions,
|
|
154
|
+
): VirtualAxis;
|
|
155
|
+
|
|
156
|
+
export interface MountKeyedListOptions<T> {
|
|
157
|
+
/** Reactive array accessor — must read from a signal/store and return the current array. */
|
|
158
|
+
items: () => readonly T[];
|
|
159
|
+
itemHeight: number;
|
|
160
|
+
viewport: number;
|
|
161
|
+
overscan?: number;
|
|
162
|
+
/** Stable identity per item. The node bound to a key is that key's home for its lifetime. */
|
|
163
|
+
key: (item: T, index: number) => string | number;
|
|
164
|
+
/** Render callback — called ONCE when a key enters the window. Bind reactively inside. */
|
|
165
|
+
render: (rowEl: HTMLElement, item: T, index: number) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Keyed renderer for STATEFUL rows (inputs, media, expanded state). A node is
|
|
170
|
+
* created when its key enters the window and removed when the key leaves; the
|
|
171
|
+
* node's identity stays with the data row, so per-node DOM state survives
|
|
172
|
+
* reordering.
|
|
173
|
+
*/
|
|
174
|
+
export function mountKeyedList<T>(
|
|
175
|
+
host: HTMLElement,
|
|
176
|
+
scope: MountScope,
|
|
177
|
+
options: MountKeyedListOptions<T>,
|
|
178
|
+
): VirtualAxis;
|
|
179
|
+
|
|
180
|
+
export interface MountGridOptions {
|
|
181
|
+
rowCount: number;
|
|
182
|
+
colCount: number;
|
|
183
|
+
rowHeight: number;
|
|
184
|
+
colWidth: number;
|
|
185
|
+
viewportWidth: number;
|
|
186
|
+
viewportHeight: number;
|
|
187
|
+
overscan?: number;
|
|
188
|
+
render: (cellEl: HTMLElement, row: number, col: number) => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 2-D recycling grid renderer. Crossing a row boundary re-renders one new row
|
|
193
|
+
* of cells; crossing a column boundary re-renders one new column. Stateless
|
|
194
|
+
* cells only.
|
|
195
|
+
*/
|
|
196
|
+
export function mountGrid(
|
|
197
|
+
host: HTMLElement,
|
|
198
|
+
scope: MountScope,
|
|
199
|
+
options: MountGridOptions,
|
|
200
|
+
): VirtualGrid;
|
|
201
|
+
|
|
202
|
+
export interface MountVariableListOptions {
|
|
203
|
+
count: number;
|
|
204
|
+
sizeAt: (index: number) => number;
|
|
205
|
+
viewport: number;
|
|
206
|
+
overscan?: number;
|
|
207
|
+
render: (rowEl: HTMLElement, index: number) => void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Variable-height list renderer. Pool is pre-sized from the smallest row so
|
|
212
|
+
* the recycling map never reshuffles mid-scroll. Stateless rows only.
|
|
213
|
+
*/
|
|
214
|
+
export function mountVariableList(
|
|
215
|
+
host: HTMLElement,
|
|
216
|
+
scope: MountScope,
|
|
217
|
+
options: MountVariableListOptions,
|
|
218
|
+
): VariableAxis;
|
package/Virtual.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-virtual (DRAFT) — thrash-free list/grid windowing on lite-signal.
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* The visible range derives from floor(scrollTop / itemSize), so it changes ONLY when
|
|
5
|
+
* you cross a row/column boundary — not on every scroll pixel. lite-signal's Object.is
|
|
6
|
+
* cutoff then halts propagation between boundaries, so a fast scroll inside one row
|
|
7
|
+
* does zero reactive work and writes nothing to the DOM. Fixed-size in the core;
|
|
8
|
+
* variable-size is a position-cache extension (sketched at the bottom).
|
|
9
|
+
*
|
|
10
|
+
* Headless: the windowing math is pure reactive state. Pair it with any renderer; a
|
|
11
|
+
* lite-element recycling renderer is included.
|
|
12
|
+
*
|
|
13
|
+
* MIT © Zahary Shinikchiev
|
|
14
|
+
*/
|
|
15
|
+
import { signal, computed } from "@zakkster/lite-signal";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fixed-height (or fixed-width) 1-D windowing.
|
|
19
|
+
* @returns reactive handles: start, end (exclusive), offsetStart (px), totalSize (px),
|
|
20
|
+
* plus scrollTop and setters. start/end are integers, so they only fire on a
|
|
21
|
+
* boundary crossing.
|
|
22
|
+
*/
|
|
23
|
+
export function virtualAxis({ count, itemSize, viewport, overscan = 3 }) {
|
|
24
|
+
const scrollPos = signal(0);
|
|
25
|
+
const viewportSize = signal(viewport);
|
|
26
|
+
const itemCount = signal(count);
|
|
27
|
+
const sz = itemSize;
|
|
28
|
+
|
|
29
|
+
// The single source of windowing truth: which item is first under the viewport edge.
|
|
30
|
+
// Integer → Object.is means sub-item scrolling does NOT propagate.
|
|
31
|
+
const firstItem = computed(() => {
|
|
32
|
+
const f = Math.floor(scrollPos() / sz);
|
|
33
|
+
if (f < 0) return 0; // iOS/macOS rubber-band: negative scrollTop → pin at top
|
|
34
|
+
const c = itemCount();
|
|
35
|
+
return f > c ? c : f; // bottom overscroll past the end → pin at the tail
|
|
36
|
+
});
|
|
37
|
+
const perView = computed(() => Math.ceil(viewportSize() / sz) + 1); // +1 for the partial edge row
|
|
38
|
+
|
|
39
|
+
const start = computed(() => Math.max(0, firstItem() - overscan));
|
|
40
|
+
const end = computed(() => Math.min(itemCount(), firstItem() + perView() + overscan));
|
|
41
|
+
const offsetStart = computed(() => start() * sz);
|
|
42
|
+
const totalSize = computed(() => itemCount() * sz);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
scrollPos, start, end, offsetStart, totalSize,
|
|
46
|
+
setScroll(px) { scrollPos.set(px | 0); },
|
|
47
|
+
setViewport(px) { viewportSize.set(px); },
|
|
48
|
+
setCount(n) { itemCount.set(n); },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 2-D windowing: independent row and column axes sharing the same math. */
|
|
53
|
+
export function virtualGrid({ rowCount, colCount, rowHeight, colWidth, viewportHeight, viewportWidth, overscan = 2 }) {
|
|
54
|
+
const rows = virtualAxis({ count: rowCount, itemSize: rowHeight, viewport: viewportHeight, overscan });
|
|
55
|
+
const cols = virtualAxis({ count: colCount, itemSize: colWidth, viewport: viewportWidth, overscan });
|
|
56
|
+
return {
|
|
57
|
+
rowStart: rows.start, rowEnd: rows.end, rowOffset: rows.offsetStart, totalHeight: rows.totalSize,
|
|
58
|
+
colStart: cols.start, colEnd: cols.end, colOffset: cols.offsetStart, totalWidth: cols.totalSize,
|
|
59
|
+
setScroll(left, top) { cols.setScroll(left); rows.setScroll(top); },
|
|
60
|
+
setViewport(w, h) { cols.setViewport(w); rows.setViewport(h); },
|
|
61
|
+
setCounts(rc, cc) { rows.setCount(rc); cols.setCount(cc); },
|
|
62
|
+
_rows: rows, _cols: cols,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* lite-element recycling renderer. O(1) per boundary crossing during smooth scroll.
|
|
68
|
+
*
|
|
69
|
+
* Each row is absolutely positioned at translateY(index * itemHeight) — there is NO
|
|
70
|
+
* translated wrapper. A logical index maps to a physical node by `index % poolSize`,
|
|
71
|
+
* and each node remembers the index it currently shows. On a one-row scroll the only
|
|
72
|
+
* node whose mapped index changes is the one that wrapped from top to bottom, so the
|
|
73
|
+
* `idx !== i` guard re-renders exactly that node and skips the rest. A jump re-renders
|
|
74
|
+
* only the indices that newly entered the window. Warmed scrolling allocates nothing.
|
|
75
|
+
* (Circular-buffer + absolute-rows technique — same idea as a RecyclerView.)
|
|
76
|
+
*
|
|
77
|
+
* CONSTRAINT: recycling is INDEX-based, so rows must be stateless/presentation (text,
|
|
78
|
+
* static markup). A node is reused across logical indices, so per-node DOM state —
|
|
79
|
+
* input focus/value, media playback, accordion-expanded — would follow the node, not
|
|
80
|
+
* the row. For stateful rows use {@link mountKeyedList}, which binds a node to a stable
|
|
81
|
+
* data key for its lifetime.
|
|
82
|
+
*
|
|
83
|
+
* mountList(host, scope, {
|
|
84
|
+
* count: 100000, itemHeight: 32, viewport: host.clientHeight,
|
|
85
|
+
* render: (rowEl, index) => { rowEl.textContent = `row ${index}`; },
|
|
86
|
+
* });
|
|
87
|
+
*/
|
|
88
|
+
export function mountList(host, scope, { count, itemHeight, viewport, overscan = 3, render }) {
|
|
89
|
+
const axis = virtualAxis({ count, itemSize: itemHeight, viewport, overscan });
|
|
90
|
+
host.style.overflow = "auto";
|
|
91
|
+
host.style.position = "relative";
|
|
92
|
+
if (host.tabIndex < 0) host.tabIndex = 0; // arrow/page keys need focusability
|
|
93
|
+
// Bulletproof spacer: absolute + 1px wide + hidden. Survives any parent
|
|
94
|
+
// formatting context (flex, grid, block) without collapsing or rounding.
|
|
95
|
+
const spacer = host.ownerDocument.createElement("div");
|
|
96
|
+
spacer.style.position = "absolute";
|
|
97
|
+
spacer.style.top = "0";
|
|
98
|
+
spacer.style.left = "0";
|
|
99
|
+
spacer.style.width = "1px";
|
|
100
|
+
spacer.style.visibility = "hidden";
|
|
101
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
102
|
+
host.appendChild(spacer);
|
|
103
|
+
|
|
104
|
+
const pool = []; // [{ el, idx }] — idx = index currently shown
|
|
105
|
+
scope.on(host, "scroll", () => axis.setScroll(host.scrollTop));
|
|
106
|
+
scope.effect(() => { spacer.style.height = axis.totalSize() + "px"; });
|
|
107
|
+
scope.effect(() => {
|
|
108
|
+
const s = axis.start(), e = axis.end(), n = e - s;
|
|
109
|
+
while (pool.length < n) { // grow pool only on (re)size
|
|
110
|
+
const row = host.ownerDocument.createElement("div");
|
|
111
|
+
row.style.position = "absolute";
|
|
112
|
+
row.style.left = "0";
|
|
113
|
+
row.style.right = "0";
|
|
114
|
+
row.style.height = itemHeight + "px";
|
|
115
|
+
host.appendChild(row);
|
|
116
|
+
pool.push({ el: row, idx: -1 });
|
|
117
|
+
}
|
|
118
|
+
const size = pool.length;
|
|
119
|
+
for (let i = s; i < e; i++) { // touch only nodes whose index changed
|
|
120
|
+
const p = pool[i % size];
|
|
121
|
+
if (p.idx !== i) {
|
|
122
|
+
p.idx = i;
|
|
123
|
+
p.el.style.transform = `translateY(${i * itemHeight}px)`;
|
|
124
|
+
p.el.style.display = "";
|
|
125
|
+
render(p.el, i);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (let i = 0; i < size; i++) { // park nodes that fell out of the window
|
|
129
|
+
const p = pool[i];
|
|
130
|
+
if (p.idx < s || p.idx >= e) { p.el.style.display = "none"; p.idx = -1; }
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Keep perView correct across rotation / window resize. contentRect avoids a
|
|
135
|
+
// synchronous layout flush; onCleanup disconnects so there is zero leak.
|
|
136
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
137
|
+
const ro = new ResizeObserver((entries) => { axis.setViewport(entries[0].contentRect.height); });
|
|
138
|
+
ro.observe(host);
|
|
139
|
+
scope.onCleanup(() => ro.disconnect());
|
|
140
|
+
}
|
|
141
|
+
return axis;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Keyed recycling renderer (sketch) — for STATEFUL rows (inputs, media, expansion).
|
|
146
|
+
* A node is bound to a stable data key for its lifetime and repositioned as its index
|
|
147
|
+
* moves; nodes are created when their key enters the window and removed when it leaves.
|
|
148
|
+
* State stays with the right row because identity is the key, not a pool slot. More
|
|
149
|
+
* allocation than mountList (create/remove on churn), but correct for stateful content.
|
|
150
|
+
*
|
|
151
|
+
* mountKeyedList(host, scope, {
|
|
152
|
+
* items: () => rows(), // reactive array accessor (signal/store)
|
|
153
|
+
* itemHeight: 48, viewport: host.clientHeight,
|
|
154
|
+
* key: (item) => item.id, // stable identity
|
|
155
|
+
* render: (rowEl, item, index) => { ... bind once; node is this key's home ... },
|
|
156
|
+
* });
|
|
157
|
+
*/
|
|
158
|
+
export function mountKeyedList(host, scope, { items, itemHeight, viewport, overscan = 3, key, render }) {
|
|
159
|
+
const axis = virtualAxis({ count: items().length, itemSize: itemHeight, viewport, overscan });
|
|
160
|
+
host.style.overflow = "auto";
|
|
161
|
+
host.style.position = "relative";
|
|
162
|
+
if (host.tabIndex < 0) host.tabIndex = 0;
|
|
163
|
+
const spacer = host.ownerDocument.createElement("div");
|
|
164
|
+
spacer.style.position = "absolute";
|
|
165
|
+
spacer.style.top = "0";
|
|
166
|
+
spacer.style.left = "0";
|
|
167
|
+
spacer.style.width = "1px";
|
|
168
|
+
spacer.style.visibility = "hidden";
|
|
169
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
170
|
+
host.appendChild(spacer);
|
|
171
|
+
|
|
172
|
+
const byKey = new Map(); // key -> { el }
|
|
173
|
+
scope.on(host, "scroll", () => axis.setScroll(host.scrollTop));
|
|
174
|
+
scope.effect(() => axis.setCount(items().length)); // keep total size reactive to the data
|
|
175
|
+
scope.effect(() => { spacer.style.height = axis.totalSize() + "px"; });
|
|
176
|
+
scope.effect(() => {
|
|
177
|
+
const arr = items();
|
|
178
|
+
const s = axis.start(), e = Math.min(axis.end(), arr.length);
|
|
179
|
+
const live = new Set();
|
|
180
|
+
for (let i = s; i < e; i++) {
|
|
181
|
+
const item = arr[i];
|
|
182
|
+
const k = key(item, i);
|
|
183
|
+
live.add(k);
|
|
184
|
+
let node = byKey.get(k);
|
|
185
|
+
if (node === undefined) { // key entered the window → create + bind once
|
|
186
|
+
const el = host.ownerDocument.createElement("div");
|
|
187
|
+
el.style.position = "absolute";
|
|
188
|
+
el.style.left = "0";
|
|
189
|
+
el.style.right = "0";
|
|
190
|
+
el.style.height = itemHeight + "px";
|
|
191
|
+
host.appendChild(el);
|
|
192
|
+
node = { el, idx: -1 };
|
|
193
|
+
byKey.set(k, node);
|
|
194
|
+
render(el, item, i); // bind once; node is this key's home for its lifetime
|
|
195
|
+
}
|
|
196
|
+
if (node.idx !== i) { // reposition ONLY on index change (scroll keeps it fixed; reorder moves it)
|
|
197
|
+
node.idx = i;
|
|
198
|
+
node.el.style.transform = `translateY(${i * itemHeight}px)`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const [k, node] of byKey) { // key left the window → remove (1 per boundary)
|
|
202
|
+
if (!live.has(k)) { node.el.remove(); byKey.delete(k); }
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
207
|
+
const ro = new ResizeObserver((entries) => { axis.setViewport(entries[0].contentRect.height); });
|
|
208
|
+
ro.observe(host);
|
|
209
|
+
scope.onCleanup(() => ro.disconnect());
|
|
210
|
+
}
|
|
211
|
+
return axis;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 2-D recycling renderer for a windowed grid. Both axes derive from the same integer-
|
|
216
|
+
* gated math, so scrolling within a cell does nothing; crossing a row boundary
|
|
217
|
+
* re-renders only the one new ROW of cells, crossing a column boundary only the one new
|
|
218
|
+
* COLUMN. Cells are absolutely positioned at translate(c*colWidth, r*rowHeight) and
|
|
219
|
+
* recycled by a 2-D circular buffer (r % poolRows, c % poolCols); each cell remembers
|
|
220
|
+
* its (r,c) and re-renders only when that pair changes. Stateless cells (same constraint
|
|
221
|
+
* as mountList).
|
|
222
|
+
*
|
|
223
|
+
* mountGrid(host, scope, {
|
|
224
|
+
* rowCount: 50000, colCount: 500, rowHeight: 28, colWidth: 120,
|
|
225
|
+
* viewportWidth: host.clientWidth, viewportHeight: host.clientHeight,
|
|
226
|
+
* render: (cellEl, row, col) => { cellEl.textContent = `${row},${col}`; },
|
|
227
|
+
* });
|
|
228
|
+
*/
|
|
229
|
+
export function mountGrid(host, scope, { rowCount, colCount, rowHeight, colWidth, viewportWidth, viewportHeight, overscan = 2, render }) {
|
|
230
|
+
const grid = virtualGrid({ rowCount, colCount, rowHeight, colWidth, viewportHeight, viewportWidth, overscan });
|
|
231
|
+
host.style.overflow = "auto";
|
|
232
|
+
host.style.position = "relative";
|
|
233
|
+
if (host.tabIndex < 0) host.tabIndex = 0;
|
|
234
|
+
// Bulletproof spacer — absolute + 1px in BOTH dimensions; height/width are
|
|
235
|
+
// assigned below by the totalHeight/totalWidth effects.
|
|
236
|
+
const spacer = host.ownerDocument.createElement("div");
|
|
237
|
+
spacer.style.position = "absolute";
|
|
238
|
+
spacer.style.top = "0";
|
|
239
|
+
spacer.style.left = "0";
|
|
240
|
+
spacer.style.visibility = "hidden";
|
|
241
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
242
|
+
host.appendChild(spacer);
|
|
243
|
+
|
|
244
|
+
// Pool is pre-sized to the MAX window (perView + 2*overscan on each axis) so scrolling
|
|
245
|
+
// never resizes it — the 2-D flat index (r%PR)*PC + c%PC restrides if PR/PC change, so a
|
|
246
|
+
// grow-mid-scroll would re-render everything. Only a viewport RESIZE rebuilds (rare).
|
|
247
|
+
const cells = [];
|
|
248
|
+
let PR = 0, PC = 0, vw = viewportWidth, vh = viewportHeight;
|
|
249
|
+
function ensurePool() {
|
|
250
|
+
const needR = Math.min(Math.ceil(vh / rowHeight) + 1 + 2 * overscan, rowCount);
|
|
251
|
+
const needC = Math.min(Math.ceil(vw / colWidth) + 1 + 2 * overscan, colCount);
|
|
252
|
+
if (needR <= PR && needC <= PC) return;
|
|
253
|
+
for (const cell of cells) cell.el.remove();
|
|
254
|
+
cells.length = 0;
|
|
255
|
+
PR = Math.max(PR, needR); PC = Math.max(PC, needC);
|
|
256
|
+
for (let i = 0; i < PR * PC; i++) {
|
|
257
|
+
const el = host.ownerDocument.createElement("div");
|
|
258
|
+
el.style.position = "absolute";
|
|
259
|
+
el.style.width = colWidth + "px";
|
|
260
|
+
el.style.height = rowHeight + "px";
|
|
261
|
+
el.style.display = "none";
|
|
262
|
+
host.appendChild(el);
|
|
263
|
+
cells.push({ el, r: -1, c: -1 });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
ensurePool();
|
|
267
|
+
|
|
268
|
+
scope.on(host, "scroll", () => grid.setScroll(host.scrollLeft, host.scrollTop));
|
|
269
|
+
scope.effect(() => { spacer.style.height = grid.totalHeight() + "px"; });
|
|
270
|
+
scope.effect(() => { spacer.style.width = grid.totalWidth() + "px"; });
|
|
271
|
+
scope.effect(() => {
|
|
272
|
+
const rs = grid.rowStart(), re = grid.rowEnd(), cs = grid.colStart(), ce = grid.colEnd();
|
|
273
|
+
for (let r = rs; r < re; r++) {
|
|
274
|
+
for (let c = cs; c < ce; c++) {
|
|
275
|
+
const cell = cells[(r % PR) * PC + (c % PC)];
|
|
276
|
+
if (cell.r !== r || cell.c !== c) { // only the freshly-entered row/col of cells changes
|
|
277
|
+
cell.r = r; cell.c = c;
|
|
278
|
+
cell.el.style.transform = `translate(${c * colWidth}px, ${r * rowHeight}px)`;
|
|
279
|
+
cell.el.style.display = "";
|
|
280
|
+
render(cell.el, r, c);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (let i = 0; i < cells.length; i++) { // park cells that fell outside the window
|
|
285
|
+
const cell = cells[i];
|
|
286
|
+
if (cell.r !== -1 && (cell.r < rs || cell.r >= re || cell.c < cs || cell.c >= ce)) {
|
|
287
|
+
cell.el.style.display = "none"; cell.r = -1; cell.c = -1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
293
|
+
const ro = new ResizeObserver((entries) => {
|
|
294
|
+
const b = entries[0].contentRect;
|
|
295
|
+
vw = b.width; vh = b.height;
|
|
296
|
+
grid.setViewport(vw, vh);
|
|
297
|
+
ensurePool(); // grow pool only if the viewport grew
|
|
298
|
+
});
|
|
299
|
+
ro.observe(host);
|
|
300
|
+
scope.onCleanup(() => ro.disconnect());
|
|
301
|
+
}
|
|
302
|
+
return grid;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Variable-size axis. Item heights are not uniform: a prefix sum (offsets[i] = top of item
|
|
307
|
+
* i, offsets[count] = total) is built once, and the visible range is found by binary search
|
|
308
|
+
* instead of division. firstItem is still an integer index, so Object.is halts propagation
|
|
309
|
+
* while scrolling WITHIN an item — the no-thrash property carries over unchanged. Heights
|
|
310
|
+
* are known up front via sizeAt(index); call setCount(n) or remeasure() if they change.
|
|
311
|
+
*
|
|
312
|
+
* Returns the same surface as virtualAxis plus positionAt(i) (= offsets[i]) and minSize()
|
|
313
|
+
* (the smallest row — a renderer uses it to pre-size its pool). Scrolling within a known
|
|
314
|
+
* height range costs one binary search and zero downstream work.
|
|
315
|
+
*
|
|
316
|
+
* NOT handled here: MEASURED / auto-height rows whose size is unknown until rendered. That
|
|
317
|
+
* needs an estimate-then-correct loop — render with an estimated height, measure via
|
|
318
|
+
* ResizeObserver, patch the affected prefix sums, bump the version, and anchor the scroll
|
|
319
|
+
* position to the item under the viewport top so the correction doesn't visibly jump. The
|
|
320
|
+
* windowing/recycling below is reused verbatim; only the offset source becomes incremental.
|
|
321
|
+
*/
|
|
322
|
+
export function variableAxis({ count, sizeAt, viewport, overscan = 3 }) {
|
|
323
|
+
const itemCount = signal(count);
|
|
324
|
+
const viewportSize = signal(viewport);
|
|
325
|
+
const scrollPos = signal(0);
|
|
326
|
+
const version = signal(0); // bumped when offsets are (re)built
|
|
327
|
+
|
|
328
|
+
let offsets = new Float64Array(count + 1);
|
|
329
|
+
let smallest = 0;
|
|
330
|
+
const build = () => {
|
|
331
|
+
const c = itemCount.peek();
|
|
332
|
+
if (offsets.length !== c + 1) offsets = new Float64Array(c + 1);
|
|
333
|
+
let acc = 0, min = Infinity;
|
|
334
|
+
for (let i = 0; i < c; i++) {
|
|
335
|
+
offsets[i] = acc;
|
|
336
|
+
const sz = sizeAt(i);
|
|
337
|
+
if (sz < min) min = sz;
|
|
338
|
+
acc += sz;
|
|
339
|
+
}
|
|
340
|
+
offsets[c] = acc;
|
|
341
|
+
smallest = min === Infinity ? 0 : min;
|
|
342
|
+
version.set(version.peek() + 1);
|
|
343
|
+
};
|
|
344
|
+
build();
|
|
345
|
+
|
|
346
|
+
const findItem = (pos) => { // largest i in [0,count] with offsets[i] <= pos
|
|
347
|
+
const c = itemCount.peek();
|
|
348
|
+
if (pos <= 0) return 0;
|
|
349
|
+
if (pos >= offsets[c]) return c;
|
|
350
|
+
let lo = 0, hi = c;
|
|
351
|
+
while (lo < hi) {
|
|
352
|
+
const mid = (lo + hi + 1) >> 1;
|
|
353
|
+
if (offsets[mid] <= pos) lo = mid; else hi = mid - 1;
|
|
354
|
+
}
|
|
355
|
+
return lo;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const firstItem = computed(() => { version(); return findItem(scrollPos()); });
|
|
359
|
+
const lastItem = computed(() => { version(); return findItem(scrollPos() + viewportSize()); });
|
|
360
|
+
const start = computed(() => { const f = firstItem() - overscan; return f < 0 ? 0 : f; });
|
|
361
|
+
const end = computed(() => {
|
|
362
|
+
const c = itemCount();
|
|
363
|
+
const e = lastItem() + 1 + overscan;
|
|
364
|
+
return e > c ? c : e;
|
|
365
|
+
});
|
|
366
|
+
const offsetStart = computed(() => { version(); return offsets[start()]; });
|
|
367
|
+
const totalSize = computed(() => { version(); return offsets[itemCount()]; });
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
start, end, offsetStart, totalSize, scrollPos,
|
|
371
|
+
positionAt: (i) => offsets[i],
|
|
372
|
+
minSize: () => smallest,
|
|
373
|
+
setScroll: (p) => scrollPos.set(p),
|
|
374
|
+
setViewport: (v) => viewportSize.set(v),
|
|
375
|
+
setCount: (n) => { itemCount.set(n); build(); },
|
|
376
|
+
remeasure: () => build(),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Variable-height list renderer. Each row is positioned at its absolute offset and given its
|
|
382
|
+
* own height; recycling is the same circular buffer as mountList. The pool is pre-sized from
|
|
383
|
+
* the SMALLEST row (ceil(viewport/minSize) + 1 + 2*overscan) so the visible count never
|
|
384
|
+
* exceeds it and the modulo mapping never reshuffles. Stateless rows (use a keyed variant for
|
|
385
|
+
* stateful content, same as mountList → mountKeyedList).
|
|
386
|
+
*/
|
|
387
|
+
export function mountVariableList(host, scope, { count, sizeAt, viewport, overscan = 3, render }) {
|
|
388
|
+
const axis = variableAxis({ count, sizeAt, viewport, overscan });
|
|
389
|
+
host.style.overflow = "auto";
|
|
390
|
+
host.style.position = "relative";
|
|
391
|
+
if (host.tabIndex < 0) host.tabIndex = 0;
|
|
392
|
+
const spacer = host.ownerDocument.createElement("div");
|
|
393
|
+
spacer.style.position = "absolute";
|
|
394
|
+
spacer.style.top = "0";
|
|
395
|
+
spacer.style.left = "0";
|
|
396
|
+
spacer.style.width = "1px";
|
|
397
|
+
spacer.style.visibility = "hidden";
|
|
398
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
399
|
+
host.appendChild(spacer);
|
|
400
|
+
scope.effect(() => { spacer.style.height = axis.totalSize() + "px"; });
|
|
401
|
+
scope.on(host, "scroll", () => axis.setScroll(host.scrollTop));
|
|
402
|
+
|
|
403
|
+
const pool = [];
|
|
404
|
+
let PS = 0, vh = viewport;
|
|
405
|
+
const ensurePool = () => {
|
|
406
|
+
const min = axis.minSize() || 1;
|
|
407
|
+
const need = Math.min(count, Math.ceil(vh / min) + 1 + 2 * overscan);
|
|
408
|
+
if (need <= PS) return;
|
|
409
|
+
for (const p of pool) p.el.remove();
|
|
410
|
+
pool.length = 0; PS = need;
|
|
411
|
+
for (let i = 0; i < PS; i++) {
|
|
412
|
+
const el = host.ownerDocument.createElement("div");
|
|
413
|
+
el.style.position = "absolute";
|
|
414
|
+
el.style.left = "0";
|
|
415
|
+
el.style.right = "0";
|
|
416
|
+
el.style.display = "none";
|
|
417
|
+
host.appendChild(el);
|
|
418
|
+
pool.push({ el, idx: -1 });
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
ensurePool();
|
|
422
|
+
|
|
423
|
+
scope.effect(() => {
|
|
424
|
+
const s = axis.start(), e = axis.end();
|
|
425
|
+
for (let i = s; i < e; i++) {
|
|
426
|
+
const cell = pool[i % PS];
|
|
427
|
+
if (cell.idx !== i) {
|
|
428
|
+
cell.idx = i;
|
|
429
|
+
cell.el.style.transform = `translateY(${axis.positionAt(i)}px)`;
|
|
430
|
+
cell.el.style.height = sizeAt(i) + "px";
|
|
431
|
+
cell.el.style.display = "";
|
|
432
|
+
render(cell.el, i);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (let i = 0; i < PS; i++) {
|
|
436
|
+
const p = pool[i];
|
|
437
|
+
if (p.idx !== -1 && (p.idx < s || p.idx >= e)) { p.el.style.display = "none"; p.idx = -1; }
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
442
|
+
const ro = new ResizeObserver((entries) => { vh = entries[0].contentRect.height; axis.setViewport(vh); ensurePool(); });
|
|
443
|
+
ro.observe(host);
|
|
444
|
+
scope.onCleanup(() => ro.disconnect());
|
|
445
|
+
}
|
|
446
|
+
return axis;
|
|
447
|
+
}
|