@rs-x/cli 2.0.0-next.15 → 2.0.0-next.16
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/bin/rsx.cjs +113 -1
- package/package.json +1 -1
- package/{rs-x-vscode-extension-2.0.0-next.15.vsix → rs-x-vscode-extension-2.0.0-next.16.vsix} +0 -0
- package/templates/next-demo/README.md +26 -0
- package/templates/next-demo/app/globals.css +431 -0
- package/templates/next-demo/app/layout.tsx +22 -0
- package/templates/next-demo/app/page.tsx +5 -0
- package/templates/next-demo/components/demo-app.tsx +110 -0
- package/templates/next-demo/components/virtual-table-row.tsx +40 -0
- package/templates/next-demo/components/virtual-table-shell.tsx +86 -0
- package/templates/next-demo/hooks/use-virtual-table-controller.ts +26 -0
- package/templates/next-demo/hooks/use-virtual-table-viewport.ts +41 -0
- package/templates/next-demo/lib/row-data.ts +35 -0
- package/templates/next-demo/lib/row-model.ts +45 -0
- package/templates/next-demo/lib/rsx-bootstrap.ts +46 -0
- package/templates/next-demo/lib/virtual-table-controller.ts +247 -0
- package/templates/next-demo/lib/virtual-table-data.service.ts +126 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type FC, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { initRsx } from '@/lib/rsx-bootstrap';
|
|
6
|
+
|
|
7
|
+
import { VirtualTableShell } from './virtual-table-shell';
|
|
8
|
+
|
|
9
|
+
type ThemeMode = 'light' | 'dark';
|
|
10
|
+
|
|
11
|
+
function getInitialTheme(): ThemeMode {
|
|
12
|
+
const storedTheme = window.localStorage.getItem('rsx-theme');
|
|
13
|
+
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
14
|
+
return storedTheme;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return 'dark';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DemoApp: FC = () => {
|
|
21
|
+
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
|
22
|
+
const [ready, setReady] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let active = true;
|
|
26
|
+
|
|
27
|
+
void initRsx().then(() => {
|
|
28
|
+
if (active) {
|
|
29
|
+
setReady(true);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
active = false;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
40
|
+
document.body.setAttribute('data-theme', theme);
|
|
41
|
+
window.localStorage.setItem('rsx-theme', theme);
|
|
42
|
+
}, [theme]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<main className="app-shell">
|
|
46
|
+
<section className="hero">
|
|
47
|
+
<div className="container">
|
|
48
|
+
<div className="heroGrid">
|
|
49
|
+
<div className="heroLeft">
|
|
50
|
+
<p className="app-eyebrow">RS-X React Demo</p>
|
|
51
|
+
<h1 className="hTitle">Virtual Table</h1>
|
|
52
|
+
<p className="hSubhead">
|
|
53
|
+
Million-row scrolling with a fixed RS-X expression pool.
|
|
54
|
+
</p>
|
|
55
|
+
<p className="hSub">
|
|
56
|
+
This demo keeps rendering bounded while streaming pages on demand,
|
|
57
|
+
so scrolling stays smooth without growing expression memory with the
|
|
58
|
+
dataset.
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<div className="heroActions">
|
|
62
|
+
<a
|
|
63
|
+
className="btn btnGhost"
|
|
64
|
+
href="https://www.rsxjs.com/"
|
|
65
|
+
target="_blank"
|
|
66
|
+
rel="noreferrer"
|
|
67
|
+
>
|
|
68
|
+
rs-x
|
|
69
|
+
</a>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className="btn btnGhost theme-toggle"
|
|
73
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
74
|
+
onClick={() => {
|
|
75
|
+
setTheme((currentTheme) =>
|
|
76
|
+
currentTheme === 'dark' ? 'light' : 'dark',
|
|
77
|
+
);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<aside className="card heroNote">
|
|
86
|
+
<h2 className="cardTitle">What This Shows</h2>
|
|
87
|
+
<p className="cardText">
|
|
88
|
+
Only a small row-model pool stays alive while pages stream in
|
|
89
|
+
around the viewport. That means one million logical rows without
|
|
90
|
+
one million live bindings.
|
|
91
|
+
</p>
|
|
92
|
+
</aside>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<section className="section">
|
|
98
|
+
<div className="container">
|
|
99
|
+
<section className="app-panel card">
|
|
100
|
+
{ready ? (
|
|
101
|
+
<VirtualTableShell />
|
|
102
|
+
) : (
|
|
103
|
+
<div className="table-loading">Initializing RS-X…</div>
|
|
104
|
+
)}
|
|
105
|
+
</section>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
</main>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type FC, memo } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useRsxExpression } from '@rs-x/react';
|
|
6
|
+
|
|
7
|
+
import type { RowView } from '@/lib/virtual-table-controller';
|
|
8
|
+
|
|
9
|
+
type VirtualTableRowProps = {
|
|
10
|
+
item: RowView;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const VirtualTableRowComponent: FC<VirtualTableRowProps> = ({ item }) => {
|
|
14
|
+
const id = useRsxExpression(item.row.idExpr);
|
|
15
|
+
const name = useRsxExpression(item.row.nameExpr);
|
|
16
|
+
const category = useRsxExpression(item.row.categoryExpr);
|
|
17
|
+
const price = useRsxExpression(item.row.priceExpr);
|
|
18
|
+
const quantity = useRsxExpression(item.row.quantityExpr);
|
|
19
|
+
const total = useRsxExpression(item.row.totalExpr);
|
|
20
|
+
const updatedAt = useRsxExpression(item.row.updatedAtExpr);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="table-row"
|
|
25
|
+
style={{ transform: `translateY(${item.top}px)` }}
|
|
26
|
+
>
|
|
27
|
+
<span data-label="ID">#{id ?? 0}</span>
|
|
28
|
+
<span data-label="Name">{name ?? ''}</span>
|
|
29
|
+
<span data-label="Category">{category ?? ''}</span>
|
|
30
|
+
<span data-label="Price">€{price ?? 0}</span>
|
|
31
|
+
<span data-label="Qty">{quantity ?? 0}</span>
|
|
32
|
+
<span data-label="Total" className="total">
|
|
33
|
+
€{total ?? 0}
|
|
34
|
+
</span>
|
|
35
|
+
<span data-label="Updated">{updatedAt ?? '--'}</span>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const VirtualTableRow = memo(VirtualTableRowComponent);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type FC } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useVirtualTableController } from '@/hooks/use-virtual-table-controller';
|
|
6
|
+
import { useVirtualTableViewport } from '@/hooks/use-virtual-table-viewport';
|
|
7
|
+
|
|
8
|
+
import { VirtualTableRow } from './virtual-table-row';
|
|
9
|
+
|
|
10
|
+
export const VirtualTableShell: FC = () => {
|
|
11
|
+
const { controller, snapshot } = useVirtualTableController();
|
|
12
|
+
const viewportRef = useVirtualTableViewport(controller);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<section className="table-toolbar">
|
|
17
|
+
<div className="toolbar-left">
|
|
18
|
+
<h2>Inventory Snapshot</h2>
|
|
19
|
+
<p>
|
|
20
|
+
{snapshot.totalRows} rows • {snapshot.poolSize} pre-wired models
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="toolbar-right">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={() => {
|
|
27
|
+
controller.toggleSort('price');
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
Sort by price
|
|
31
|
+
</button>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => {
|
|
35
|
+
controller.toggleSort('quantity');
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
Sort by stock
|
|
39
|
+
</button>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={() => {
|
|
43
|
+
controller.toggleSort('name');
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
Sort by name
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<div className="table-header">
|
|
52
|
+
<span>ID</span>
|
|
53
|
+
<span>Name</span>
|
|
54
|
+
<span>Category</span>
|
|
55
|
+
<span>Price</span>
|
|
56
|
+
<span>Qty</span>
|
|
57
|
+
<span>Total</span>
|
|
58
|
+
<span>Updated</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
ref={viewportRef}
|
|
63
|
+
className="table-viewport"
|
|
64
|
+
onScroll={(event) => {
|
|
65
|
+
controller.setScrollTop(event.currentTarget.scrollTop);
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
className="table-spacer"
|
|
70
|
+
style={{ height: `${snapshot.spacerHeight}px` }}
|
|
71
|
+
/>
|
|
72
|
+
{snapshot.visibleRows.map((item) => (
|
|
73
|
+
<VirtualTableRow key={item.index} item={item} />
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="table-footer">
|
|
78
|
+
<div>
|
|
79
|
+
Rows in view: {snapshot.rowsInView} • Loaded pages:{' '}
|
|
80
|
+
{snapshot.loadedPageCount}
|
|
81
|
+
</div>
|
|
82
|
+
<div>Scroll to stream pages from a 1,000,000-row virtual dataset.</div>
|
|
83
|
+
</div>
|
|
84
|
+
</>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
VirtualTableController,
|
|
7
|
+
type VirtualTableSnapshot,
|
|
8
|
+
} from '@/lib/virtual-table-controller';
|
|
9
|
+
|
|
10
|
+
export function useVirtualTableController(): {
|
|
11
|
+
controller: VirtualTableController;
|
|
12
|
+
snapshot: VirtualTableSnapshot;
|
|
13
|
+
} {
|
|
14
|
+
const controllerRef = useRef<VirtualTableController | null>(null);
|
|
15
|
+
if (!controllerRef.current) {
|
|
16
|
+
controllerRef.current = new VirtualTableController();
|
|
17
|
+
}
|
|
18
|
+
const controller = controllerRef.current;
|
|
19
|
+
const snapshot = useSyncExternalStore(
|
|
20
|
+
controller.subscribe,
|
|
21
|
+
controller.getSnapshot,
|
|
22
|
+
controller.getSnapshot,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return { controller, snapshot };
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type RefObject, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { VirtualTableController } from '@/lib/virtual-table-controller';
|
|
6
|
+
|
|
7
|
+
const COMPACT_BREAKPOINT_PX = 720;
|
|
8
|
+
const DEFAULT_ROW_HEIGHT = 36;
|
|
9
|
+
const COMPACT_ROW_HEIGHT = 168;
|
|
10
|
+
|
|
11
|
+
export function useVirtualTableViewport(
|
|
12
|
+
controller: VirtualTableController,
|
|
13
|
+
): RefObject<HTMLDivElement | null> {
|
|
14
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const viewport = viewportRef.current;
|
|
18
|
+
if (!viewport) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const syncMetrics = (): void => {
|
|
23
|
+
controller.setViewportHeight(viewport.clientHeight);
|
|
24
|
+
controller.setRowHeight(
|
|
25
|
+
viewport.clientWidth <= COMPACT_BREAKPOINT_PX
|
|
26
|
+
? COMPACT_ROW_HEIGHT
|
|
27
|
+
: DEFAULT_ROW_HEIGHT,
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
syncMetrics();
|
|
32
|
+
const observer = new ResizeObserver(syncMetrics);
|
|
33
|
+
observer.observe(viewport);
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
observer.disconnect();
|
|
37
|
+
};
|
|
38
|
+
}, [controller]);
|
|
39
|
+
|
|
40
|
+
return viewportRef;
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type SortKey = 'id' | 'name' | 'price' | 'quantity' | 'category';
|
|
2
|
+
export type SortDirection = 'asc' | 'desc';
|
|
3
|
+
|
|
4
|
+
export type RowData = {
|
|
5
|
+
id: number;
|
|
6
|
+
name: string;
|
|
7
|
+
price: number;
|
|
8
|
+
quantity: number;
|
|
9
|
+
category: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const categories = ['Hardware', 'Software', 'Design', 'Ops'];
|
|
14
|
+
|
|
15
|
+
function pad(value: number): string {
|
|
16
|
+
return value.toString().padStart(2, '0');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createRowData(id: number): RowData {
|
|
20
|
+
const zeroBasedId = id - 1;
|
|
21
|
+
const price = 25 + (zeroBasedId % 1000) / 10;
|
|
22
|
+
const quantity = 1 + (Math.floor(zeroBasedId / 1000) % 100);
|
|
23
|
+
const category = categories[zeroBasedId % categories.length] ?? 'General';
|
|
24
|
+
const month = pad(((zeroBasedId * 7) % 12) + 1);
|
|
25
|
+
const day = pad(((zeroBasedId * 11) % 28) + 1);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
name: `Product ${id.toString().padStart(7, '0')}`,
|
|
30
|
+
price,
|
|
31
|
+
quantity,
|
|
32
|
+
category,
|
|
33
|
+
updatedAt: `2026-${month}-${day}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { rsx, type IExpression } from '@rs-x/expression-parser';
|
|
2
|
+
|
|
3
|
+
import type { RowData } from './row-data';
|
|
4
|
+
|
|
5
|
+
export type RowModel = {
|
|
6
|
+
model: RowData;
|
|
7
|
+
idExpr: IExpression<number>;
|
|
8
|
+
nameExpr: IExpression<string>;
|
|
9
|
+
categoryExpr: IExpression<string>;
|
|
10
|
+
priceExpr: IExpression<number>;
|
|
11
|
+
quantityExpr: IExpression<number>;
|
|
12
|
+
updatedAtExpr: IExpression<string>;
|
|
13
|
+
totalExpr: IExpression<number>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createRowModel(): RowModel {
|
|
17
|
+
const model: RowData = {
|
|
18
|
+
id: 0,
|
|
19
|
+
name: '',
|
|
20
|
+
price: 0,
|
|
21
|
+
quantity: 0,
|
|
22
|
+
category: 'General',
|
|
23
|
+
updatedAt: '2026-01-01',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
model,
|
|
28
|
+
idExpr: rsx<number>('id')(model),
|
|
29
|
+
nameExpr: rsx<string>('name')(model),
|
|
30
|
+
categoryExpr: rsx<string>('category')(model),
|
|
31
|
+
priceExpr: rsx<number>('price')(model),
|
|
32
|
+
quantityExpr: rsx<number>('quantity')(model),
|
|
33
|
+
updatedAtExpr: rsx<string>('updatedAt')(model),
|
|
34
|
+
totalExpr: rsx<number>('price * quantity')(model),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function updateRowModel(target: RowModel, data: RowData): void {
|
|
39
|
+
target.model.id = data.id;
|
|
40
|
+
target.model.name = data.name;
|
|
41
|
+
target.model.price = data.price;
|
|
42
|
+
target.model.quantity = data.quantity;
|
|
43
|
+
target.model.category = data.category;
|
|
44
|
+
target.model.updatedAt = data.updatedAt;
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { InjectionContainer } from '@rs-x/core';
|
|
2
|
+
import { RsXExpressionParserModule } from '@rs-x/expression-parser';
|
|
3
|
+
|
|
4
|
+
let initialized = false;
|
|
5
|
+
|
|
6
|
+
type RsxCompiledModule = {
|
|
7
|
+
registerRsxAotCompiledExpressions?: () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RsxPreparsedModule = {
|
|
11
|
+
registerRsxAotParsedExpressionCache?: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async function loadCompiledModule(): Promise<RsxCompiledModule> {
|
|
15
|
+
try {
|
|
16
|
+
return (await import(
|
|
17
|
+
'../app/rsx-generated/' + 'rsx-aot-compiled.generated'
|
|
18
|
+
)) as RsxCompiledModule;
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadPreparsedModule(): Promise<RsxPreparsedModule> {
|
|
25
|
+
try {
|
|
26
|
+
return (await import(
|
|
27
|
+
'../app/rsx-generated/' + 'rsx-aot-preparsed.generated'
|
|
28
|
+
)) as RsxPreparsedModule;
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function initRsx(): Promise<void> {
|
|
35
|
+
if (initialized) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const preparsedModule = await loadPreparsedModule();
|
|
40
|
+
const compiledModule = await loadCompiledModule();
|
|
41
|
+
|
|
42
|
+
preparsedModule.registerRsxAotParsedExpressionCache?.();
|
|
43
|
+
compiledModule.registerRsxAotCompiledExpressions?.();
|
|
44
|
+
await InjectionContainer.load(RsXExpressionParserModule);
|
|
45
|
+
initialized = true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { type RowData, type SortDirection, type SortKey } from '@/lib/row-data';
|
|
2
|
+
import { createRowModel, updateRowModel, type RowModel } from '@/lib/row-model';
|
|
3
|
+
import { VirtualTableDataService } from '@/lib/virtual-table-data.service';
|
|
4
|
+
|
|
5
|
+
export type RowView = {
|
|
6
|
+
index: number;
|
|
7
|
+
top: number;
|
|
8
|
+
row: RowModel;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type VirtualTableSnapshot = {
|
|
12
|
+
visibleRows: RowView[];
|
|
13
|
+
totalRows: number;
|
|
14
|
+
poolSize: number;
|
|
15
|
+
rowsInView: number;
|
|
16
|
+
loadedPageCount: number;
|
|
17
|
+
spacerHeight: number;
|
|
18
|
+
sortKey: SortKey;
|
|
19
|
+
sortDirection: SortDirection;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ROW_HEIGHT = 36;
|
|
23
|
+
const PAGE_SIZE = 50;
|
|
24
|
+
const POOL_PAGES = 4;
|
|
25
|
+
const CACHE_PADDING_PAGES = 2;
|
|
26
|
+
const RETAIN_PADDING_PAGES = 4;
|
|
27
|
+
|
|
28
|
+
export class VirtualTableController {
|
|
29
|
+
public rowHeight = ROW_HEIGHT;
|
|
30
|
+
public readonly pageSize = PAGE_SIZE;
|
|
31
|
+
public readonly poolSize = PAGE_SIZE * POOL_PAGES;
|
|
32
|
+
public readonly totalRows: number;
|
|
33
|
+
|
|
34
|
+
private scrollTop = 0;
|
|
35
|
+
private viewportHeight = 520;
|
|
36
|
+
private sortKey: SortKey = 'id';
|
|
37
|
+
private sortDirection: SortDirection = 'asc';
|
|
38
|
+
private spacerHeight: number;
|
|
39
|
+
private rowsInView = Math.max(1, Math.ceil(this.viewportHeight / this.rowHeight));
|
|
40
|
+
private visibleRows: RowView[] = [];
|
|
41
|
+
private snapshot: VirtualTableSnapshot;
|
|
42
|
+
|
|
43
|
+
private readonly listeners = new Set<() => void>();
|
|
44
|
+
private readonly pool = Array.from({ length: PAGE_SIZE * POOL_PAGES }, () =>
|
|
45
|
+
createRowModel(),
|
|
46
|
+
);
|
|
47
|
+
private readonly dataByIndex = new Map<number, RowData>();
|
|
48
|
+
private readonly loadedPages = new Set<number>();
|
|
49
|
+
private readonly pageLoading = new Map<number, Promise<void>>();
|
|
50
|
+
private readonly dataService = new VirtualTableDataService();
|
|
51
|
+
|
|
52
|
+
public constructor() {
|
|
53
|
+
this.totalRows = this.dataService.totalRows;
|
|
54
|
+
this.spacerHeight = this.totalRows * this.rowHeight;
|
|
55
|
+
this.snapshot = {
|
|
56
|
+
visibleRows: this.visibleRows,
|
|
57
|
+
totalRows: this.totalRows,
|
|
58
|
+
poolSize: this.poolSize,
|
|
59
|
+
rowsInView: this.rowsInView,
|
|
60
|
+
loadedPageCount: this.loadedPages.size,
|
|
61
|
+
spacerHeight: this.spacerHeight,
|
|
62
|
+
sortKey: this.sortKey,
|
|
63
|
+
sortDirection: this.sortDirection,
|
|
64
|
+
};
|
|
65
|
+
this.refresh();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public subscribe = (listener: () => void): (() => void) => {
|
|
69
|
+
this.listeners.add(listener);
|
|
70
|
+
return () => {
|
|
71
|
+
this.listeners.delete(listener);
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
public getSnapshot = (): VirtualTableSnapshot => {
|
|
76
|
+
return this.snapshot;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
public setViewportHeight(height: number): void {
|
|
80
|
+
this.viewportHeight = height;
|
|
81
|
+
this.rowsInView = Math.max(1, Math.ceil(this.viewportHeight / this.rowHeight));
|
|
82
|
+
this.refresh();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public setRowHeight(height: number): void {
|
|
86
|
+
if (this.rowHeight === height) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.rowHeight = height;
|
|
91
|
+
this.spacerHeight = this.totalRows * this.rowHeight;
|
|
92
|
+
this.rowsInView = Math.max(1, Math.ceil(this.viewportHeight / this.rowHeight));
|
|
93
|
+
this.refresh();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public setScrollTop(value: number): void {
|
|
97
|
+
this.scrollTop = value;
|
|
98
|
+
this.refresh();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public toggleSort(nextKey: SortKey): void {
|
|
102
|
+
if (this.sortKey === nextKey) {
|
|
103
|
+
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
104
|
+
} else {
|
|
105
|
+
this.sortKey = nextKey;
|
|
106
|
+
this.sortDirection = 'asc';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.resetLoadedData();
|
|
110
|
+
this.refresh();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private refresh(): void {
|
|
114
|
+
const scrollIndex = Math.floor(this.scrollTop / this.rowHeight);
|
|
115
|
+
const bufferTop = Math.max(0, Math.floor((this.poolSize - this.rowsInView) / 2));
|
|
116
|
+
const maxStart = Math.max(0, this.totalRows - this.poolSize);
|
|
117
|
+
const startIndex = Math.min(Math.max(scrollIndex - bufferTop, 0), maxStart);
|
|
118
|
+
const endIndex = Math.min(startIndex + this.poolSize, this.totalRows);
|
|
119
|
+
const startPage = Math.max(
|
|
120
|
+
0,
|
|
121
|
+
Math.floor(startIndex / this.pageSize) - CACHE_PADDING_PAGES,
|
|
122
|
+
);
|
|
123
|
+
const endPage = Math.min(
|
|
124
|
+
Math.floor((endIndex - 1) / this.pageSize) + CACHE_PADDING_PAGES,
|
|
125
|
+
Math.floor((this.totalRows - 1) / this.pageSize),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
this.ensurePages(startPage, endPage);
|
|
129
|
+
this.pruneCachedPages(startPage, endPage);
|
|
130
|
+
|
|
131
|
+
const nextRows: RowView[] = [];
|
|
132
|
+
const length = endIndex - startIndex;
|
|
133
|
+
|
|
134
|
+
for (let offset = 0; offset < length; offset += 1) {
|
|
135
|
+
const index = startIndex + offset;
|
|
136
|
+
const target = this.pool[offset];
|
|
137
|
+
updateRowModel(target, this.getRowData(index));
|
|
138
|
+
|
|
139
|
+
nextRows.push({
|
|
140
|
+
index,
|
|
141
|
+
top: index * this.rowHeight,
|
|
142
|
+
row: target,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.visibleRows = nextRows;
|
|
147
|
+
this.snapshot = {
|
|
148
|
+
visibleRows: this.visibleRows,
|
|
149
|
+
totalRows: this.totalRows,
|
|
150
|
+
poolSize: this.poolSize,
|
|
151
|
+
rowsInView: this.rowsInView,
|
|
152
|
+
loadedPageCount: this.loadedPages.size,
|
|
153
|
+
spacerHeight: this.spacerHeight,
|
|
154
|
+
sortKey: this.sortKey,
|
|
155
|
+
sortDirection: this.sortDirection,
|
|
156
|
+
};
|
|
157
|
+
this.emit();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private emit(): void {
|
|
161
|
+
for (const listener of this.listeners) {
|
|
162
|
+
listener();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private ensurePages(startPage: number, endPage: number): void {
|
|
167
|
+
for (let pageIndex = startPage; pageIndex <= endPage; pageIndex += 1) {
|
|
168
|
+
this.ensurePageLoaded(pageIndex);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private ensurePageLoaded(pageIndex: number): void {
|
|
173
|
+
if (this.loadedPages.has(pageIndex) || this.pageLoading.has(pageIndex)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const task = this.loadPageAsync(pageIndex).finally(() => {
|
|
178
|
+
this.pageLoading.delete(pageIndex);
|
|
179
|
+
this.loadedPages.add(pageIndex);
|
|
180
|
+
this.refresh();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.pageLoading.set(pageIndex, task);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async loadPageAsync(pageIndex: number): Promise<void> {
|
|
187
|
+
const page = await this.dataService.fetchPage(
|
|
188
|
+
pageIndex,
|
|
189
|
+
this.pageSize,
|
|
190
|
+
this.sortKey,
|
|
191
|
+
this.sortDirection,
|
|
192
|
+
);
|
|
193
|
+
const startIndex = pageIndex * this.pageSize;
|
|
194
|
+
|
|
195
|
+
for (let offset = 0; offset < page.items.length; offset += 1) {
|
|
196
|
+
const item = page.items[offset];
|
|
197
|
+
if (!item) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.dataByIndex.set(startIndex + offset, item);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private getRowData(index: number): RowData {
|
|
206
|
+
const cached = this.dataByIndex.get(index);
|
|
207
|
+
if (cached) {
|
|
208
|
+
return cached;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
id: index + 1,
|
|
213
|
+
name: 'Loading...',
|
|
214
|
+
price: 0,
|
|
215
|
+
quantity: 0,
|
|
216
|
+
category: 'Pending',
|
|
217
|
+
updatedAt: '--',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private resetLoadedData(): void {
|
|
222
|
+
this.dataByIndex.clear();
|
|
223
|
+
this.loadedPages.clear();
|
|
224
|
+
this.pageLoading.clear();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private pruneCachedPages(startPage: number, endPage: number): void {
|
|
228
|
+
const minPage = Math.max(0, startPage - RETAIN_PADDING_PAGES);
|
|
229
|
+
const maxPage = Math.min(
|
|
230
|
+
Math.floor((this.totalRows - 1) / this.pageSize),
|
|
231
|
+
endPage + RETAIN_PADDING_PAGES,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
for (const pageIndex of Array.from(this.loadedPages)) {
|
|
235
|
+
if (pageIndex >= minPage && pageIndex <= maxPage) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.loadedPages.delete(pageIndex);
|
|
240
|
+
const pageStart = pageIndex * this.pageSize;
|
|
241
|
+
const pageEnd = Math.min(pageStart + this.pageSize, this.totalRows);
|
|
242
|
+
for (let index = pageStart; index < pageEnd; index += 1) {
|
|
243
|
+
this.dataByIndex.delete(index);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|