@open-grid/virtual 0.1.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 +21 -0
- package/README.md +29 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +420 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Open Grid contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @open-grid/virtual
|
|
2
|
+
|
|
3
|
+
Framework-agnostic virtualization primitives for Open Grid.
|
|
4
|
+
|
|
5
|
+
Use this package for row and column virtualization calculations shared by React, Vue, Svelte, and custom renderers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @open-grid/virtual
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { getVirtualRange } from "@open-grid/virtual";
|
|
17
|
+
|
|
18
|
+
const range = getVirtualRange({
|
|
19
|
+
count: 10_000,
|
|
20
|
+
viewportSize: 480,
|
|
21
|
+
scrollOffset: 2_400,
|
|
22
|
+
estimateSize: 40,
|
|
23
|
+
overscan: 4,
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It also includes shared initial scroll frames, row virtual range helpers, sized column layout helpers, measured column layout cache helpers, measured layout signature/cache sync helpers, render item helpers, render item key helpers, focused-cell render-window checks, and spacer item type guards for virtualized rows, center-column windows, spacers, and grouped header clipping so framework renderers can keep header/body alignment consistent.
|
|
28
|
+
|
|
29
|
+
Most applications should use virtualization through a framework UI package. Use this package directly when building a custom renderer.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
interface VirtualItem {
|
|
2
|
+
index: number;
|
|
3
|
+
start: number;
|
|
4
|
+
size: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
interface VirtualRangeOptions {
|
|
8
|
+
count: number;
|
|
9
|
+
viewportSize: number;
|
|
10
|
+
scrollOffset: number;
|
|
11
|
+
estimateSize: number | ((index: number) => number);
|
|
12
|
+
overscan?: number;
|
|
13
|
+
}
|
|
14
|
+
interface VirtualRowRangeOptions {
|
|
15
|
+
count: number;
|
|
16
|
+
scrollFrame: ScrollFrame;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
estimateSize: number | ((index: number) => number);
|
|
19
|
+
overscan?: number;
|
|
20
|
+
}
|
|
21
|
+
interface VirtualRange {
|
|
22
|
+
totalSize: number;
|
|
23
|
+
startIndex: number;
|
|
24
|
+
endIndex: number;
|
|
25
|
+
items: VirtualItem[];
|
|
26
|
+
}
|
|
27
|
+
interface VirtualRowItem<TRow> {
|
|
28
|
+
row: TRow;
|
|
29
|
+
rowIndex: number;
|
|
30
|
+
virtualItem: VirtualItem | null;
|
|
31
|
+
}
|
|
32
|
+
interface ScrollFrameOptions {
|
|
33
|
+
scrollTop: number;
|
|
34
|
+
scrollLeft: number;
|
|
35
|
+
viewportHeight: number;
|
|
36
|
+
viewportWidth: number;
|
|
37
|
+
stickyTopOffset?: number;
|
|
38
|
+
}
|
|
39
|
+
interface ScrollFrame {
|
|
40
|
+
scrollTop: number;
|
|
41
|
+
scrollLeft: number;
|
|
42
|
+
viewportHeight: number;
|
|
43
|
+
viewportWidth: number;
|
|
44
|
+
}
|
|
45
|
+
interface ColumnVirtualLayoutItem {
|
|
46
|
+
id: string;
|
|
47
|
+
start: number;
|
|
48
|
+
end: number;
|
|
49
|
+
size: number;
|
|
50
|
+
pinned: false | "left" | "right";
|
|
51
|
+
pinnedStart?: number;
|
|
52
|
+
pinnedEdge?: "left" | "right";
|
|
53
|
+
}
|
|
54
|
+
type ColumnRenderItem<TLayout extends ColumnVirtualLayoutItem = ColumnVirtualLayoutItem> = {
|
|
55
|
+
type: "column";
|
|
56
|
+
layout: TLayout;
|
|
57
|
+
} | {
|
|
58
|
+
type: "spacer";
|
|
59
|
+
id: string;
|
|
60
|
+
size: number;
|
|
61
|
+
};
|
|
62
|
+
interface ColumnCellRenderItem<TLayout extends ColumnVirtualLayoutItem = ColumnVirtualLayoutItem> {
|
|
63
|
+
layout: TLayout;
|
|
64
|
+
beforeSpacerSize: number;
|
|
65
|
+
afterSpacerSize: number;
|
|
66
|
+
}
|
|
67
|
+
interface HeaderRenderHeaderLike {
|
|
68
|
+
id: string;
|
|
69
|
+
leafColumnIds: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
interface HeaderRenderGroupLike<THeader extends HeaderRenderHeaderLike> {
|
|
72
|
+
headers: readonly THeader[];
|
|
73
|
+
}
|
|
74
|
+
interface FocusedCellRenderWindowLike {
|
|
75
|
+
rowId: string;
|
|
76
|
+
columnId: string;
|
|
77
|
+
}
|
|
78
|
+
type HeaderRenderItem<THeader extends HeaderRenderHeaderLike = HeaderRenderHeaderLike> = {
|
|
79
|
+
type: "header";
|
|
80
|
+
header: THeader;
|
|
81
|
+
size: number;
|
|
82
|
+
leafColumnIds: string[];
|
|
83
|
+
} | {
|
|
84
|
+
type: "spacer";
|
|
85
|
+
id: string;
|
|
86
|
+
size: number;
|
|
87
|
+
};
|
|
88
|
+
declare function isColumnRenderSpacerItem<TItem extends ColumnRenderItem>(item: TItem): item is Extract<TItem, {
|
|
89
|
+
type: "spacer";
|
|
90
|
+
}>;
|
|
91
|
+
declare function isHeaderRenderSpacerItem<TItem extends HeaderRenderItem>(item: TItem): item is Extract<TItem, {
|
|
92
|
+
type: "spacer";
|
|
93
|
+
}>;
|
|
94
|
+
declare function getColumnRenderItemKey<TLayout extends ColumnVirtualLayoutItem>(item: ColumnRenderItem<TLayout>): string;
|
|
95
|
+
declare function getColumnCellRenderItems<TLayout extends ColumnVirtualLayoutItem>(items: readonly ColumnRenderItem<TLayout>[]): ColumnCellRenderItem<TLayout>[];
|
|
96
|
+
declare function getHeaderRenderItemKey<THeader extends HeaderRenderHeaderLike>(headerGroupId: string, item: HeaderRenderItem<THeader>): string;
|
|
97
|
+
interface ColumnRenderItemsOptions {
|
|
98
|
+
enabled: boolean;
|
|
99
|
+
overscan: number;
|
|
100
|
+
}
|
|
101
|
+
interface ColumnRenderItemsScrollFrame {
|
|
102
|
+
scrollLeft: number;
|
|
103
|
+
viewportWidth: number;
|
|
104
|
+
}
|
|
105
|
+
type MeasuredSizeKey = string | number;
|
|
106
|
+
interface MeasuredSizeCache {
|
|
107
|
+
readonly version: number;
|
|
108
|
+
readonly size: number;
|
|
109
|
+
get: (key: MeasuredSizeKey) => number | undefined;
|
|
110
|
+
set: (key: MeasuredSizeKey, size: number) => boolean;
|
|
111
|
+
delete: (key: MeasuredSizeKey) => boolean;
|
|
112
|
+
clear: () => boolean;
|
|
113
|
+
prune: (keys: Iterable<MeasuredSizeKey>) => boolean;
|
|
114
|
+
resolve: (key: MeasuredSizeKey, estimateSize: number | (() => number)) => number;
|
|
115
|
+
}
|
|
116
|
+
interface MeasuredColumnLayoutCacheOptions {
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
cache: Pick<MeasuredSizeCache, "get">;
|
|
119
|
+
tolerance?: number;
|
|
120
|
+
}
|
|
121
|
+
interface MeasuredColumnLayoutCacheSyncOptions {
|
|
122
|
+
enabled: boolean;
|
|
123
|
+
cache: Pick<MeasuredSizeCache, "clear">;
|
|
124
|
+
currentSignature: string;
|
|
125
|
+
nextSignature: string;
|
|
126
|
+
}
|
|
127
|
+
interface MeasuredColumnLayoutCacheSyncResult {
|
|
128
|
+
signature: string;
|
|
129
|
+
changed: boolean;
|
|
130
|
+
}
|
|
131
|
+
interface SizedColumnLayoutOptions {
|
|
132
|
+
getColumnSize: (columnId: string) => number | undefined;
|
|
133
|
+
getFallbackSize?: (columnId: string, fallbackSize: number) => number | undefined;
|
|
134
|
+
}
|
|
135
|
+
declare function getVirtualRange(options: VirtualRangeOptions): VirtualRange;
|
|
136
|
+
declare function getScrollFrame(options: ScrollFrameOptions): ScrollFrame;
|
|
137
|
+
declare function getInitialScrollFrame(): ScrollFrame;
|
|
138
|
+
declare function getVirtualRowRange(options: VirtualRowRangeOptions): VirtualRange | null;
|
|
139
|
+
declare function getVirtualRowItems<TRow>(rows: readonly TRow[], virtualRange: VirtualRange | null | undefined): VirtualRowItem<TRow>[];
|
|
140
|
+
declare function getIsFocusedCellInRenderWindow<TRow extends {
|
|
141
|
+
id: string;
|
|
142
|
+
}, TLayout extends ColumnVirtualLayoutItem>(focusedCell: FocusedCellRenderWindowLike | null | undefined, visibleRowItems: readonly VirtualRowItem<TRow>[], columnRenderItems: readonly ColumnRenderItem<TLayout>[]): boolean;
|
|
143
|
+
declare function getColumnRenderItems<TLayout extends ColumnVirtualLayoutItem>(layout: readonly TLayout[], scrollFrame: ColumnRenderItemsScrollFrame, options: ColumnRenderItemsOptions): ColumnRenderItem<TLayout>[];
|
|
144
|
+
declare function getHeaderRenderItems<THeader extends HeaderRenderHeaderLike, TLayout extends ColumnVirtualLayoutItem>(headerGroup: HeaderRenderGroupLike<THeader>, columnRenderItems: readonly ColumnRenderItem<TLayout>[], layoutById: ReadonlyMap<string, {
|
|
145
|
+
size: number;
|
|
146
|
+
}>): HeaderRenderItem<THeader>[];
|
|
147
|
+
declare function getMeasuredColumnLayout<TLayout extends ColumnVirtualLayoutItem>(layout: readonly TLayout[], resolveSize: (columnId: string, fallbackSize: number) => number): TLayout[];
|
|
148
|
+
declare function getSizedColumnLayout<TLayout extends ColumnVirtualLayoutItem>(layout: readonly TLayout[], options: SizedColumnLayoutOptions): TLayout[];
|
|
149
|
+
declare function getMeasuredColumnLayoutFromCache<TLayout extends ColumnVirtualLayoutItem>(layout: readonly TLayout[], options: MeasuredColumnLayoutCacheOptions): TLayout[];
|
|
150
|
+
declare function getColumnLayoutMeasurementSignature(layout: readonly ColumnVirtualLayoutItem[]): string;
|
|
151
|
+
declare function syncMeasuredColumnLayoutCache(options: MeasuredColumnLayoutCacheSyncOptions): MeasuredColumnLayoutCacheSyncResult;
|
|
152
|
+
declare function createMeasuredSizeCache(): MeasuredSizeCache;
|
|
153
|
+
declare function createMeasuredSizeResolver(cache: MeasuredSizeCache, getKey: (index: number) => MeasuredSizeKey, estimateSize: number | ((index: number) => number)): (index: number) => number;
|
|
154
|
+
|
|
155
|
+
export { type ColumnCellRenderItem, type ColumnRenderItem, type ColumnRenderItemsOptions, type ColumnRenderItemsScrollFrame, type ColumnVirtualLayoutItem, type FocusedCellRenderWindowLike, type HeaderRenderGroupLike, type HeaderRenderHeaderLike, type HeaderRenderItem, type MeasuredColumnLayoutCacheOptions, type MeasuredColumnLayoutCacheSyncOptions, type MeasuredColumnLayoutCacheSyncResult, type MeasuredSizeCache, type MeasuredSizeKey, type ScrollFrame, type ScrollFrameOptions, type SizedColumnLayoutOptions, type VirtualItem, type VirtualRange, type VirtualRangeOptions, type VirtualRowItem, type VirtualRowRangeOptions, createMeasuredSizeCache, createMeasuredSizeResolver, getColumnCellRenderItems, getColumnLayoutMeasurementSignature, getColumnRenderItemKey, getColumnRenderItems, getHeaderRenderItemKey, getHeaderRenderItems, getInitialScrollFrame, getIsFocusedCellInRenderWindow, getMeasuredColumnLayout, getMeasuredColumnLayoutFromCache, getScrollFrame, getSizedColumnLayout, getVirtualRange, getVirtualRowItems, getVirtualRowRange, isColumnRenderSpacerItem, isHeaderRenderSpacerItem, syncMeasuredColumnLayoutCache };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function isColumnRenderSpacerItem(item) {
|
|
3
|
+
return item.type === "spacer";
|
|
4
|
+
}
|
|
5
|
+
function isHeaderRenderSpacerItem(item) {
|
|
6
|
+
return item.type === "spacer";
|
|
7
|
+
}
|
|
8
|
+
function getColumnRenderItemKey(item) {
|
|
9
|
+
return isColumnRenderSpacerItem(item) ? item.id : item.layout.id;
|
|
10
|
+
}
|
|
11
|
+
function getColumnCellRenderItems(items) {
|
|
12
|
+
const columns = [];
|
|
13
|
+
let beforeSpacerSize = 0;
|
|
14
|
+
let afterSpacerSize = 0;
|
|
15
|
+
for (const item of items) {
|
|
16
|
+
if (!isColumnRenderSpacerItem(item)) {
|
|
17
|
+
columns.push(item.layout);
|
|
18
|
+
} else if (item.id === "center-before") {
|
|
19
|
+
beforeSpacerSize = item.size;
|
|
20
|
+
} else if (item.id === "center-after") {
|
|
21
|
+
afterSpacerSize = item.size;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const firstCenterIndex = columns.findIndex((layout) => layout.pinned === false);
|
|
25
|
+
let lastCenterIndex = -1;
|
|
26
|
+
for (let index = columns.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
if (columns[index]?.pinned === false) {
|
|
28
|
+
lastCenterIndex = index;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return columns.map((layout, index) => ({
|
|
33
|
+
layout,
|
|
34
|
+
beforeSpacerSize: index === firstCenterIndex ? beforeSpacerSize : 0,
|
|
35
|
+
afterSpacerSize: index === lastCenterIndex ? afterSpacerSize : 0
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
function getHeaderRenderItemKey(headerGroupId, item) {
|
|
39
|
+
return isHeaderRenderSpacerItem(item) ? item.id : `${headerGroupId}:${item.header.id}:${item.leafColumnIds.join(",")}`;
|
|
40
|
+
}
|
|
41
|
+
function getVirtualRange(options) {
|
|
42
|
+
const { count, viewportSize, scrollOffset, overscan = 2 } = options;
|
|
43
|
+
if (count <= 0) {
|
|
44
|
+
return {
|
|
45
|
+
totalSize: 0,
|
|
46
|
+
startIndex: 0,
|
|
47
|
+
endIndex: -1,
|
|
48
|
+
items: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (typeof options.estimateSize === "number" && Number.isFinite(options.estimateSize) && options.estimateSize > 0) {
|
|
52
|
+
return getFixedVirtualRange(count, viewportSize, scrollOffset, options.estimateSize, overscan);
|
|
53
|
+
}
|
|
54
|
+
const sizes = Array.from({ length: count }, (_, index) => resolveSize(options.estimateSize, index));
|
|
55
|
+
const offsets = createOffsets(sizes);
|
|
56
|
+
const totalSize = offsets[count] ?? 0;
|
|
57
|
+
const visibleStart = Math.max(0, findNearestIndex(offsets, scrollOffset) - overscan);
|
|
58
|
+
const viewportEnd = scrollOffset + viewportSize;
|
|
59
|
+
const lastVisibleIndex = viewportSize > 0 ? findLastIndexStartingBefore(offsets, viewportEnd) : findNearestIndex(offsets, scrollOffset);
|
|
60
|
+
const visibleEnd = Math.min(count - 1, Math.max(visibleStart, lastVisibleIndex) + overscan);
|
|
61
|
+
const items = [];
|
|
62
|
+
for (let index = visibleStart; index <= visibleEnd; index += 1) {
|
|
63
|
+
const start = offsets[index] ?? 0;
|
|
64
|
+
const size = sizes[index] ?? 0;
|
|
65
|
+
items.push({
|
|
66
|
+
index,
|
|
67
|
+
start,
|
|
68
|
+
size,
|
|
69
|
+
end: start + size
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
totalSize,
|
|
74
|
+
startIndex: visibleStart,
|
|
75
|
+
endIndex: visibleEnd,
|
|
76
|
+
items
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function getFixedVirtualRange(count, viewportSize, scrollOffset, size, overscan) {
|
|
80
|
+
const totalSize = count * size;
|
|
81
|
+
const resolvedScrollOffset = Math.max(0, scrollOffset);
|
|
82
|
+
const resolvedViewportEnd = Math.max(0, resolvedScrollOffset + viewportSize);
|
|
83
|
+
const visibleStart = Math.max(0, Math.min(count - 1, Math.floor(resolvedScrollOffset / size)) - overscan);
|
|
84
|
+
const lastVisibleIndex = viewportSize > 0 ? Math.max(0, Math.ceil(resolvedViewportEnd / size) - 1) : Math.floor(resolvedScrollOffset / size);
|
|
85
|
+
const visibleEnd = Math.min(count - 1, Math.max(visibleStart, lastVisibleIndex) + overscan);
|
|
86
|
+
const items = [];
|
|
87
|
+
for (let index = visibleStart; index <= visibleEnd; index += 1) {
|
|
88
|
+
const start = index * size;
|
|
89
|
+
items.push({ index, start, size, end: start + size });
|
|
90
|
+
}
|
|
91
|
+
return { totalSize, startIndex: visibleStart, endIndex: visibleEnd, items };
|
|
92
|
+
}
|
|
93
|
+
function getScrollFrame(options) {
|
|
94
|
+
const stickyTopOffset = options.stickyTopOffset ?? 0;
|
|
95
|
+
return {
|
|
96
|
+
scrollTop: Math.max(0, options.scrollTop - stickyTopOffset),
|
|
97
|
+
scrollLeft: Math.max(0, options.scrollLeft),
|
|
98
|
+
viewportHeight: Math.max(0, options.viewportHeight - stickyTopOffset),
|
|
99
|
+
viewportWidth: Math.max(0, options.viewportWidth)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function getInitialScrollFrame() {
|
|
103
|
+
return {
|
|
104
|
+
scrollTop: 0,
|
|
105
|
+
scrollLeft: 0,
|
|
106
|
+
viewportHeight: 0,
|
|
107
|
+
viewportWidth: 0
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function getVirtualRowRange(options) {
|
|
111
|
+
if (!options.enabled) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const rangeOptions = {
|
|
115
|
+
count: options.count,
|
|
116
|
+
viewportSize: options.scrollFrame.viewportHeight,
|
|
117
|
+
scrollOffset: options.scrollFrame.scrollTop,
|
|
118
|
+
estimateSize: options.estimateSize
|
|
119
|
+
};
|
|
120
|
+
if (options.overscan !== void 0) {
|
|
121
|
+
rangeOptions.overscan = options.overscan;
|
|
122
|
+
}
|
|
123
|
+
return getVirtualRange(rangeOptions);
|
|
124
|
+
}
|
|
125
|
+
function getVirtualRowItems(rows, virtualRange) {
|
|
126
|
+
if (!virtualRange) {
|
|
127
|
+
return rows.map((row, rowIndex) => ({ row, rowIndex, virtualItem: null }));
|
|
128
|
+
}
|
|
129
|
+
return virtualRange.items.flatMap((item) => {
|
|
130
|
+
const row = rows[item.index];
|
|
131
|
+
return row ? [{ row, rowIndex: item.index, virtualItem: item }] : [];
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function getIsFocusedCellInRenderWindow(focusedCell, visibleRowItems, columnRenderItems) {
|
|
135
|
+
if (!focusedCell) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return visibleRowItems.some((item) => item.row.id === focusedCell.rowId) && columnRenderItems.some((item) => !isColumnRenderSpacerItem(item) && item.layout.id === focusedCell.columnId);
|
|
139
|
+
}
|
|
140
|
+
function getColumnRenderItems(layout, scrollFrame, options) {
|
|
141
|
+
if (!options.enabled) {
|
|
142
|
+
return layout.map((item) => ({ type: "column", layout: item }));
|
|
143
|
+
}
|
|
144
|
+
const leftPinned = layout.filter((item) => item.pinned === "left");
|
|
145
|
+
const rightPinned = layout.filter((item) => item.pinned === "right");
|
|
146
|
+
const center = layout.filter((item) => item.pinned === false);
|
|
147
|
+
const leftPinnedSize = leftPinned.reduce((total, item) => total + item.size, 0);
|
|
148
|
+
const rightPinnedSize = rightPinned.reduce((total, item) => total + item.size, 0);
|
|
149
|
+
const centerStart = center[0]?.start ?? leftPinnedSize;
|
|
150
|
+
const centerRange = getVirtualRange({
|
|
151
|
+
count: center.length,
|
|
152
|
+
viewportSize: Math.max(0, scrollFrame.viewportWidth - leftPinnedSize - rightPinnedSize),
|
|
153
|
+
scrollOffset: Math.max(0, scrollFrame.scrollLeft - centerStart),
|
|
154
|
+
estimateSize: (index) => center[index]?.size ?? 0,
|
|
155
|
+
overscan: options.overscan
|
|
156
|
+
});
|
|
157
|
+
const virtualCenter = centerRange.items.flatMap((item) => {
|
|
158
|
+
const column = center[item.index];
|
|
159
|
+
return column ? [column] : [];
|
|
160
|
+
});
|
|
161
|
+
const centerEnd = center.at(-1)?.end ?? centerStart;
|
|
162
|
+
const firstVirtualCenter = virtualCenter[0];
|
|
163
|
+
const lastVirtualCenter = virtualCenter.at(-1);
|
|
164
|
+
const beforeCenterSize = Math.max(0, (firstVirtualCenter?.start ?? centerEnd) - centerStart);
|
|
165
|
+
const afterCenterSize = Math.max(0, centerEnd - (lastVirtualCenter?.end ?? centerStart));
|
|
166
|
+
const items = [...leftPinned.map((item) => ({ type: "column", layout: item }))];
|
|
167
|
+
if (beforeCenterSize > 0) {
|
|
168
|
+
items.push({ type: "spacer", id: "center-before", size: beforeCenterSize });
|
|
169
|
+
}
|
|
170
|
+
items.push(...virtualCenter.map((item) => ({ type: "column", layout: item })));
|
|
171
|
+
if (afterCenterSize > 0) {
|
|
172
|
+
items.push({ type: "spacer", id: "center-after", size: afterCenterSize });
|
|
173
|
+
}
|
|
174
|
+
items.push(...rightPinned.map((item) => ({ type: "column", layout: item })));
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
177
|
+
function getHeaderRenderItems(headerGroup, columnRenderItems, layoutById) {
|
|
178
|
+
const headersByLeafId = /* @__PURE__ */ new Map();
|
|
179
|
+
for (const header of headerGroup.headers) {
|
|
180
|
+
for (const leafColumnId of header.leafColumnIds) {
|
|
181
|
+
headersByLeafId.set(leafColumnId, header);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const items = [];
|
|
185
|
+
let activeHeader = null;
|
|
186
|
+
let activeSize = 0;
|
|
187
|
+
let activeLeafColumnIds = [];
|
|
188
|
+
const flushActiveHeader = () => {
|
|
189
|
+
if (!activeHeader) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
items.push({
|
|
193
|
+
type: "header",
|
|
194
|
+
header: activeHeader,
|
|
195
|
+
size: activeSize,
|
|
196
|
+
leafColumnIds: activeLeafColumnIds
|
|
197
|
+
});
|
|
198
|
+
activeHeader = null;
|
|
199
|
+
activeSize = 0;
|
|
200
|
+
activeLeafColumnIds = [];
|
|
201
|
+
};
|
|
202
|
+
for (const item of columnRenderItems) {
|
|
203
|
+
if (isColumnRenderSpacerItem(item)) {
|
|
204
|
+
flushActiveHeader();
|
|
205
|
+
items.push(item);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const header = headersByLeafId.get(item.layout.id);
|
|
209
|
+
if (!header) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const size = layoutById.get(item.layout.id)?.size ?? item.layout.size;
|
|
213
|
+
if (activeHeader?.id !== header.id) {
|
|
214
|
+
flushActiveHeader();
|
|
215
|
+
activeHeader = header;
|
|
216
|
+
}
|
|
217
|
+
activeSize += size;
|
|
218
|
+
activeLeafColumnIds.push(item.layout.id);
|
|
219
|
+
}
|
|
220
|
+
flushActiveHeader();
|
|
221
|
+
return items;
|
|
222
|
+
}
|
|
223
|
+
function getMeasuredColumnLayout(layout, resolveSize2) {
|
|
224
|
+
const leftPinned = layout.filter((item) => item.pinned === "left");
|
|
225
|
+
const rightPinned = layout.filter((item) => item.pinned === "right");
|
|
226
|
+
const lastLeftPinnedId = leftPinned.at(-1)?.id;
|
|
227
|
+
const firstRightPinnedId = rightPinned[0]?.id;
|
|
228
|
+
const measuredSizes = new Map(layout.map((item) => [item.id, resolveSize2(item.id, item.size)]));
|
|
229
|
+
const rightOffsets = /* @__PURE__ */ new Map();
|
|
230
|
+
let rightOffset = 0;
|
|
231
|
+
for (const item of [...rightPinned].reverse()) {
|
|
232
|
+
rightOffsets.set(item.id, rightOffset);
|
|
233
|
+
rightOffset += measuredSizes.get(item.id) ?? item.size;
|
|
234
|
+
}
|
|
235
|
+
let start = 0;
|
|
236
|
+
let leftOffset = 0;
|
|
237
|
+
return layout.map((item) => {
|
|
238
|
+
const size = measuredSizes.get(item.id) ?? item.size;
|
|
239
|
+
const measured = {
|
|
240
|
+
id: item.id,
|
|
241
|
+
size,
|
|
242
|
+
start,
|
|
243
|
+
end: start + size,
|
|
244
|
+
pinned: item.pinned
|
|
245
|
+
};
|
|
246
|
+
if (item.pinned === "left") {
|
|
247
|
+
measured.pinnedStart = leftOffset;
|
|
248
|
+
leftOffset += size;
|
|
249
|
+
if (item.id === lastLeftPinnedId) {
|
|
250
|
+
measured.pinnedEdge = "left";
|
|
251
|
+
}
|
|
252
|
+
} else if (item.pinned === "right") {
|
|
253
|
+
measured.pinnedStart = rightOffsets.get(item.id) ?? 0;
|
|
254
|
+
if (item.id === firstRightPinnedId) {
|
|
255
|
+
measured.pinnedEdge = "right";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
start += size;
|
|
259
|
+
return measured;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function getSizedColumnLayout(layout, options) {
|
|
263
|
+
return getMeasuredColumnLayout(
|
|
264
|
+
layout,
|
|
265
|
+
(columnId, fallbackSize) => options.getColumnSize(columnId) ?? options.getFallbackSize?.(columnId, fallbackSize) ?? fallbackSize
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
function getMeasuredColumnLayoutFromCache(layout, options) {
|
|
269
|
+
const tolerance = options.tolerance ?? 1;
|
|
270
|
+
return getMeasuredColumnLayout(layout, (columnId, fallbackSize) => {
|
|
271
|
+
const measuredSize = options.enabled ? options.cache.get(columnId) : void 0;
|
|
272
|
+
return measuredSize !== void 0 && Math.abs(measuredSize - fallbackSize) <= tolerance ? measuredSize : fallbackSize;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function getColumnLayoutMeasurementSignature(layout) {
|
|
276
|
+
return layout.map((item) => `${item.id}:${item.size}:${item.pinned}:${item.pinnedStart ?? ""}:${item.pinnedEdge ?? ""}`).join("|");
|
|
277
|
+
}
|
|
278
|
+
function syncMeasuredColumnLayoutCache(options) {
|
|
279
|
+
if (!options.enabled) {
|
|
280
|
+
return {
|
|
281
|
+
signature: options.nextSignature,
|
|
282
|
+
changed: false
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (options.currentSignature === options.nextSignature) {
|
|
286
|
+
return {
|
|
287
|
+
signature: options.currentSignature,
|
|
288
|
+
changed: false
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
signature: options.nextSignature,
|
|
293
|
+
changed: options.cache.clear()
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function createMeasuredSizeCache() {
|
|
297
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
298
|
+
let version = 0;
|
|
299
|
+
const bump = () => {
|
|
300
|
+
version += 1;
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
get version() {
|
|
304
|
+
return version;
|
|
305
|
+
},
|
|
306
|
+
get size() {
|
|
307
|
+
return sizes.size;
|
|
308
|
+
},
|
|
309
|
+
get: (key) => sizes.get(key),
|
|
310
|
+
set: (key, size) => {
|
|
311
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (sizes.get(key) === size) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
sizes.set(key, size);
|
|
318
|
+
bump();
|
|
319
|
+
return true;
|
|
320
|
+
},
|
|
321
|
+
delete: (key) => {
|
|
322
|
+
const deleted = sizes.delete(key);
|
|
323
|
+
if (deleted) {
|
|
324
|
+
bump();
|
|
325
|
+
}
|
|
326
|
+
return deleted;
|
|
327
|
+
},
|
|
328
|
+
clear: () => {
|
|
329
|
+
if (sizes.size === 0) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
sizes.clear();
|
|
333
|
+
bump();
|
|
334
|
+
return true;
|
|
335
|
+
},
|
|
336
|
+
prune: (keys) => {
|
|
337
|
+
const available = new Set(keys);
|
|
338
|
+
let changed = false;
|
|
339
|
+
for (const key of sizes.keys()) {
|
|
340
|
+
if (!available.has(key)) {
|
|
341
|
+
sizes.delete(key);
|
|
342
|
+
changed = true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (changed) {
|
|
346
|
+
bump();
|
|
347
|
+
}
|
|
348
|
+
return changed;
|
|
349
|
+
},
|
|
350
|
+
resolve: (key, estimateSize) => sizes.get(key) ?? (typeof estimateSize === "function" ? estimateSize() : estimateSize)
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function createMeasuredSizeResolver(cache, getKey, estimateSize) {
|
|
354
|
+
return (index) => cache.resolve(getKey(index), typeof estimateSize === "function" ? () => estimateSize(index) : estimateSize);
|
|
355
|
+
}
|
|
356
|
+
function resolveSize(estimateSize, index) {
|
|
357
|
+
return typeof estimateSize === "function" ? estimateSize(index) : estimateSize;
|
|
358
|
+
}
|
|
359
|
+
function createOffsets(sizes) {
|
|
360
|
+
const offsets = [0];
|
|
361
|
+
for (const size of sizes) {
|
|
362
|
+
offsets.push((offsets[offsets.length - 1] ?? 0) + size);
|
|
363
|
+
}
|
|
364
|
+
return offsets;
|
|
365
|
+
}
|
|
366
|
+
function findNearestIndex(offsets, value) {
|
|
367
|
+
let low = 0;
|
|
368
|
+
let high = Math.max(0, offsets.length - 2);
|
|
369
|
+
while (low <= high) {
|
|
370
|
+
const mid = Math.floor((low + high) / 2);
|
|
371
|
+
const start = offsets[mid] ?? 0;
|
|
372
|
+
const end = offsets[mid + 1] ?? start;
|
|
373
|
+
if (value >= start && value < end) {
|
|
374
|
+
return mid;
|
|
375
|
+
}
|
|
376
|
+
if (value < start) {
|
|
377
|
+
high = mid - 1;
|
|
378
|
+
} else {
|
|
379
|
+
low = mid + 1;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return Math.max(0, Math.min(offsets.length - 2, low));
|
|
383
|
+
}
|
|
384
|
+
function findLastIndexStartingBefore(offsets, value) {
|
|
385
|
+
let low = 0;
|
|
386
|
+
let high = Math.max(0, offsets.length - 2);
|
|
387
|
+
let result = 0;
|
|
388
|
+
while (low <= high) {
|
|
389
|
+
const mid = Math.floor((low + high) / 2);
|
|
390
|
+
if ((offsets[mid] ?? 0) < value) {
|
|
391
|
+
result = mid;
|
|
392
|
+
low = mid + 1;
|
|
393
|
+
} else {
|
|
394
|
+
high = mid - 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
export {
|
|
400
|
+
createMeasuredSizeCache,
|
|
401
|
+
createMeasuredSizeResolver,
|
|
402
|
+
getColumnCellRenderItems,
|
|
403
|
+
getColumnLayoutMeasurementSignature,
|
|
404
|
+
getColumnRenderItemKey,
|
|
405
|
+
getColumnRenderItems,
|
|
406
|
+
getHeaderRenderItemKey,
|
|
407
|
+
getHeaderRenderItems,
|
|
408
|
+
getInitialScrollFrame,
|
|
409
|
+
getIsFocusedCellInRenderWindow,
|
|
410
|
+
getMeasuredColumnLayout,
|
|
411
|
+
getMeasuredColumnLayoutFromCache,
|
|
412
|
+
getScrollFrame,
|
|
413
|
+
getSizedColumnLayout,
|
|
414
|
+
getVirtualRange,
|
|
415
|
+
getVirtualRowItems,
|
|
416
|
+
getVirtualRowRange,
|
|
417
|
+
isColumnRenderSpacerItem,
|
|
418
|
+
isHeaderRenderSpacerItem,
|
|
419
|
+
syncMeasuredColumnLayoutCache
|
|
420
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-grid/virtual",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic virtualization primitives for Open Grid.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"data-grid",
|
|
7
|
+
"grid",
|
|
8
|
+
"table",
|
|
9
|
+
"virtualization",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://goatshave.github.io/open-grid/packages#virtual",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Goatshave/open-grid/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
34
|
+
"check": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run --passWithNoTests",
|
|
36
|
+
"lint": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"tsup": "^8.3.5",
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"vitest": "^2.1.8"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Goatshave/open-grid.git",
|
|
46
|
+
"directory": "packages/virtual"
|
|
47
|
+
}
|
|
48
|
+
}
|