@svgrid/grid 3.0.7 → 3.0.9
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/README.md +1 -1
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +490 -114
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +285 -164
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
- package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/validate-XuMe4_KR.js +80 -0
- package/dist/cell-render.js +25 -3
- package/dist/columns.js +16 -1
- package/dist/conditional-formatting.js +9 -2
- package/dist/core.d.ts +93 -0
- package/dist/core.js +313 -47
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/keyboard-handlers.js +5 -1
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +46 -12
- package/dist/server-block-cache.d.ts +7 -32
- package/dist/server-block-cache.js +21 -12
- package/dist/server-data-source.js +3 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/spreadsheet.js +7 -0
- package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
- package/dist/validate.js +13 -14
- package/dist/virtualization/column-virtualizer.d.ts +4 -0
- package/dist/virtualization/column-virtualizer.js +2 -0
- package/dist/virtualization/types.d.ts +20 -0
- package/dist/virtualization/virtualizer.js +90 -24
- package/dist/windowed-brand.d.ts +22 -0
- package/dist/windowed-brand.js +60 -0
- package/dist/windowed-data.d.ts +6 -0
- package/dist/windowed-data.js +34 -0
- package/dist/windowed-row-model.d.ts +1 -0
- package/dist/windowed-row-model.js +97 -0
- package/package.json +1 -1
- package/src/GridMenus.svelte +3 -2
- package/src/SvGrid.controller.svelte.ts +483 -122
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +285 -164
- package/src/SvGrid.types.ts +24 -7
- package/src/cell-render.ts +24 -2
- package/src/columns.ts +16 -1
- package/src/conditional-formatting.ts +6 -4
- package/src/core.sort.test.ts +61 -0
- package/src/core.ts +309 -49
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +89 -0
- package/src/headless.ts +1 -1
- package/src/index.ts +7 -1
- package/src/keyboard-handlers.ts +5 -1
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +51 -0
- package/src/selection.ts +47 -14
- package/src/server-block-cache.ts +27 -10
- package/src/server-data-source.ts +3 -1
- package/src/server.ts +6 -0
- package/src/spreadsheet.ts +6 -0
- package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
- package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
- package/src/svgrid.behavior.test.ts +58 -0
- package/src/svgrid.column-cell-recycling.test.ts +120 -0
- package/src/svgrid.interaction.test.ts +4 -1
- package/src/svgrid.new-features.wrapper.test.ts +5 -4
- package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
- package/src/validate.test.ts +20 -4
- package/src/validate.ts +13 -16
- package/src/virtualization/column-virtualizer.ts +6 -0
- package/src/virtualization/types.ts +20 -0
- package/src/virtualization/virtualizer.test.ts +152 -0
- package/src/virtualization/virtualizer.ts +93 -27
- package/src/windowed-brand.ts +67 -0
- package/src/windowed-data.test.ts +105 -0
- package/src/windowed-data.ts +36 -0
- package/src/windowed-row-model.ts +113 -0
- package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
- package/dist/cdn/validate-AZoD8BoR.js +0 -80
- /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
|
@@ -38,6 +38,9 @@ import {
|
|
|
38
38
|
resolveMaxDomHeight,
|
|
39
39
|
} from "./virtualization/scroll-scaling";
|
|
40
40
|
import { buildPreMeasureItems } from "./virtualization/virtualizer";
|
|
41
|
+
// Not on the public barrel: turns a lazy row list into a plain array before a full pass.
|
|
42
|
+
import { denseRows } from "./core";
|
|
43
|
+
import { isWindowedData } from "./windowed-brand";
|
|
41
44
|
import { DEV } from "esm-env";
|
|
42
45
|
import "./sv-grid-scrollbar";
|
|
43
46
|
import {
|
|
@@ -151,6 +154,14 @@ import { untrack } from "svelte";
|
|
|
151
154
|
*/
|
|
152
155
|
const MAX_DOM_SCROLL_HEIGHT_FALLBACK = 8_000_000;
|
|
153
156
|
|
|
157
|
+
/** Columns the column window moves by during a horizontal scroll. */
|
|
158
|
+
const COLUMN_WINDOW_STEP = 3;
|
|
159
|
+
/** The default `columnOverscan`. A margin of 2 measured 6% less scroll work,
|
|
160
|
+
* but the window at rest is `1 + columnOverscan` columns wide wherever the
|
|
161
|
+
* viewport measures 0 px - jsdom, where apps test their grids - and 2 left
|
|
162
|
+
* the fourth column out of tests that read it. */
|
|
163
|
+
const COLUMN_OVERSCAN = 3;
|
|
164
|
+
|
|
154
165
|
/**
|
|
155
166
|
* The browser's actual maximum *scrollable* element height in CSS px. Browsers
|
|
156
167
|
* clamp how tall a single element may be, and the cap is lower on mobile /
|
|
@@ -301,6 +312,12 @@ export function createSvGridController<
|
|
|
301
312
|
*/
|
|
302
313
|
const gridDomId = domIdBase ?? "svgrid";
|
|
303
314
|
|
|
315
|
+
// Detect the browser's element-height cap now, while this grid has no DOM.
|
|
316
|
+
// The probe reads offsetHeight, which lays out the whole document; run
|
|
317
|
+
// mid-render it laid out this grid's first frame too, and on a wide grid
|
|
318
|
+
// that one layout was seconds. Cached, so later calls are free.
|
|
319
|
+
if (typeof document !== "undefined") getMaxDomScrollHeight();
|
|
320
|
+
|
|
304
321
|
// Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
|
|
305
322
|
// Every controller read of a prop goes through the `props` proxy below (and the view
|
|
306
323
|
// reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
|
|
@@ -572,7 +589,11 @@ export function createSvGridController<
|
|
|
572
589
|
/** When an edit starts: true selects all text, false places the caret at the end. */
|
|
573
590
|
let editorSelectAll = true;
|
|
574
591
|
/** Per-column width overrides set by the resize handles. */
|
|
575
|
-
|
|
592
|
+
// Raw, like collapsedColumns and hiddenColumns below: every writer replaces
|
|
593
|
+
// the object, and a deep proxy allocated a reactive source for each column
|
|
594
|
+
// id anything asked about - every column, on every width rebuild, on a
|
|
595
|
+
// 10,000-column grid.
|
|
596
|
+
let columnWidths = $state.raw<Record<string, number>>({});
|
|
576
597
|
/**
|
|
577
598
|
* Columns folded to nothing, the way a sheet hides a column: the column
|
|
578
599
|
* keeps its index, its cells and its width for later, and takes no room.
|
|
@@ -580,7 +601,7 @@ export function createSvGridController<
|
|
|
580
601
|
* and shifts every index after it; a spreadsheet's formulas and formats
|
|
581
602
|
* are keyed by index and could not follow that.
|
|
582
603
|
*/
|
|
583
|
-
let collapsedColumns = $state<Record<string, boolean>>({});
|
|
604
|
+
let collapsedColumns = $state.raw<Record<string, boolean>>({});
|
|
584
605
|
function setColumnCollapsed(columnId: string, collapsed: boolean): void {
|
|
585
606
|
if (!!collapsedColumns[columnId] === collapsed) return;
|
|
586
607
|
if (collapsed) {
|
|
@@ -666,7 +687,10 @@ export function createSvGridController<
|
|
|
666
687
|
let contextMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
|
|
667
688
|
// Editable comments: internal overlay (rowId -> columnId -> note) merged on
|
|
668
689
|
// top of props.notes for immediate feedback, plus the open-editor state.
|
|
669
|
-
|
|
690
|
+
// Raw: writeNote replaces the object (and the row map) on every edit, and a
|
|
691
|
+
// deep proxy made every cell that asked about a note it does not have
|
|
692
|
+
// allocate a reactive source for that row id (0.3 ms of a far jump).
|
|
693
|
+
let noteOverrides = $state.raw<Record<string, Record<string, string>>>({});
|
|
670
694
|
let commentEditFor = $state<{ rowId: string; columnId: string; x: number; y: number } | null>(null);
|
|
671
695
|
let commentDraft = $state("");
|
|
672
696
|
let valueFilters = $state<Record<string, Set<string>>>({});
|
|
@@ -727,18 +751,20 @@ export function createSvGridController<
|
|
|
727
751
|
scrollHeight: 0,
|
|
728
752
|
scrollWidth: 0,
|
|
729
753
|
});
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
754
|
+
// The four reads below need a clean layout. This effect runs right after a
|
|
755
|
+
// scroll flush has written the new rows and columns, so reading here forced
|
|
756
|
+
// a synchronous layout on every scroll frame (~8 ms a frame on a 10,000-
|
|
757
|
+
// column grid). Instead the read waits for the start of the next frame,
|
|
758
|
+
// when the browser has already laid out and painted; triggers in between
|
|
759
|
+
// collapse into that one read.
|
|
760
|
+
function readScrollMetrics() {
|
|
735
761
|
const el = scrollContainer;
|
|
736
762
|
if (!el) return;
|
|
737
763
|
const scrollTop = el.scrollTop;
|
|
738
764
|
const scrollLeft = el.scrollLeft;
|
|
739
765
|
const scrollHeight = el.scrollHeight;
|
|
740
766
|
const scrollWidth = el.scrollWidth;
|
|
741
|
-
const prev =
|
|
767
|
+
const prev = scrollMetrics;
|
|
742
768
|
if (
|
|
743
769
|
prev.scrollTop === scrollTop &&
|
|
744
770
|
prev.scrollLeft === scrollLeft &&
|
|
@@ -748,6 +774,19 @@ export function createSvGridController<
|
|
|
748
774
|
return;
|
|
749
775
|
}
|
|
750
776
|
scrollMetrics = { scrollTop, scrollLeft, scrollHeight, scrollWidth };
|
|
777
|
+
}
|
|
778
|
+
$effect(function scrollMetrics_e() {
|
|
779
|
+
scrollVersion;
|
|
780
|
+
viewportVersion;
|
|
781
|
+
virtualizer.version;
|
|
782
|
+
columnVirtualizerVersion;
|
|
783
|
+
if (!scrollContainer) return;
|
|
784
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
785
|
+
untrack(readScrollMetrics);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const raf = requestAnimationFrame(readScrollMetrics);
|
|
789
|
+
return () => cancelAnimationFrame(raf);
|
|
751
790
|
});
|
|
752
791
|
/** Vertical overflow from the virtualizer's authoritative total size,
|
|
753
792
|
* NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
|
|
@@ -868,7 +907,7 @@ export function createSvGridController<
|
|
|
868
907
|
resolveCols(props.columns),
|
|
869
908
|
);
|
|
870
909
|
// svelte-ignore state_referenced_locally
|
|
871
|
-
let hiddenColumns = $state<Record<string, boolean>>(
|
|
910
|
+
let hiddenColumns = $state.raw<Record<string, boolean>>(
|
|
872
911
|
initialHiddenColumns(props.columns),
|
|
873
912
|
);
|
|
874
913
|
// The columns declared `visible: false` in the ColumnDefs. Kept separately so
|
|
@@ -918,7 +957,9 @@ export function createSvGridController<
|
|
|
918
957
|
// A fresh array object, because `getRowModel` memoises on identity. The
|
|
919
958
|
// elements are the same objects, so a row that did not move keeps its row
|
|
920
959
|
// object and a field write still reaches its cell through the proxy.
|
|
921
|
-
|
|
960
|
+
// Windowed data (a server row model's rows) is already a fresh array per
|
|
961
|
+
// change and copying it would read every one of its `length` entries.
|
|
962
|
+
internalData = length === 0 ? EMPTY_DATA : isWindowedData(next) ? next : next.slice();
|
|
922
963
|
// When the consumer replaces `data` (e.g. a "Reset" button), drop any
|
|
923
964
|
// accumulated cell-edit overrides - otherwise `getCellDisplayValue`
|
|
924
965
|
// would keep returning the old edited values from `editedCellValues`
|
|
@@ -1217,16 +1258,28 @@ export function createSvGridController<
|
|
|
1217
1258
|
}
|
|
1218
1259
|
|
|
1219
1260
|
const allColumns = $derived.by(function allColumns_d() {
|
|
1261
|
+
// Read the hidden set and the responsive flag once, not once per column.
|
|
1262
|
+
// `hiddenColumns` is a deep proxy: asking it about an id it does not
|
|
1263
|
+
// hold allocated a reactive source for that id, 10,000 of them on a
|
|
1264
|
+
// 10,000-column mount, and `props.responsive` is a reactive read too.
|
|
1265
|
+
// Iterating the proxy's own keys tracks additions with one dependency.
|
|
1266
|
+
const hiddenIds = new Set<string>();
|
|
1267
|
+
for (const id in hiddenColumns) if (hiddenColumns[id]) hiddenIds.add(id);
|
|
1268
|
+
const responsiveOn = !!props.responsive;
|
|
1269
|
+
// Derived values read once too: a read inside the filter is a tracked
|
|
1270
|
+
// get per column.
|
|
1271
|
+
const groupHidden = hiddenByGroupCollapse;
|
|
1272
|
+
const foldedSources = autoGroupSpec.hiddenSourceIds;
|
|
1220
1273
|
let raw = grid
|
|
1221
1274
|
.getAllColumns()
|
|
1222
1275
|
.filter(
|
|
1223
1276
|
(column) =>
|
|
1224
|
-
!
|
|
1225
|
-
!
|
|
1226
|
-
!isHiddenByResponsive(column) &&
|
|
1277
|
+
!hiddenIds.has(column.id) &&
|
|
1278
|
+
!groupHidden[column.id] &&
|
|
1279
|
+
!(responsiveOn && isHiddenByResponsive(column)) &&
|
|
1227
1280
|
// In a column display mode the grouped columns are folded into the
|
|
1228
1281
|
// auto column(s), so showing them too would just duplicate the value.
|
|
1229
|
-
!
|
|
1282
|
+
!foldedSources.has(column.id),
|
|
1230
1283
|
);
|
|
1231
1284
|
// Auto-group columns lead, like a row header.
|
|
1232
1285
|
if (autoGroupColumns.length) raw = [...autoGroupColumns, ...raw];
|
|
@@ -1319,6 +1372,10 @@ export function createSvGridController<
|
|
|
1319
1372
|
groupId?: string;
|
|
1320
1373
|
collapsible: boolean;
|
|
1321
1374
|
collapsed: boolean;
|
|
1375
|
+
/** Width of the unrendered columns before this cell when column
|
|
1376
|
+
* virtualization splits the window into pinned and centre runs; the
|
|
1377
|
+
* template draws a spacer of this width first. */
|
|
1378
|
+
gapBefore?: number;
|
|
1322
1379
|
};
|
|
1323
1380
|
const groupHeaderRows = $derived.by(function groupHeaderRows_d() {
|
|
1324
1381
|
const userCols: Array<ColumnDef<any, TData>> =
|
|
@@ -1509,6 +1566,10 @@ export function createSvGridController<
|
|
|
1509
1566
|
const map = new Map<string, ColumnStat>();
|
|
1510
1567
|
const formats = props.conditionalFormats;
|
|
1511
1568
|
if (!formats?.length || !formatsNeedingStats(formats)) return map;
|
|
1569
|
+
// Windowed data (a server row model) is mostly rows nobody has loaded;
|
|
1570
|
+
// its range is the server's to know. Scanning it would read every row of
|
|
1571
|
+
// the source. Give such formats `minValue` / `maxValue` instead.
|
|
1572
|
+
if (isWindowedData(internalData)) return map;
|
|
1512
1573
|
// `filtered` (default): every row that survives the filters, ignoring the
|
|
1513
1574
|
// page slice - so a value keeps the same colour as you page through (#61).
|
|
1514
1575
|
// `visible`: only the rows on screen, rescaling per page. `all`: the full
|
|
@@ -1653,16 +1714,32 @@ export function createSvGridController<
|
|
|
1653
1714
|
? compileCond(columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
|
|
1654
1715
|
: null,
|
|
1655
1716
|
}));
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1717
|
+
const only = compiledMenuFilters.length === 1 ? compiledMenuFilters[0]! : null;
|
|
1718
|
+
if (only && !only.b && only.a) {
|
|
1719
|
+
// One column, one condition - the common case. A plain loop over
|
|
1720
|
+
// `test(read(row))`, without the per-row every() closure and the
|
|
1721
|
+
// destructure: 1M rows spent most of their time in that wrapper.
|
|
1722
|
+
const read = only.readValue;
|
|
1723
|
+
const test = only.a;
|
|
1724
|
+
const input = denseRows(rows);
|
|
1725
|
+
const kept: Array<Row<TData>> = [];
|
|
1726
|
+
for (let i = 0; i < input.length; i++) {
|
|
1727
|
+
const row = input[i]!;
|
|
1728
|
+
if (test(read(row))) kept.push(row);
|
|
1729
|
+
}
|
|
1730
|
+
rows = kept;
|
|
1731
|
+
} else {
|
|
1732
|
+
rows = denseRows(rows).filter((row) =>
|
|
1733
|
+
compiledMenuFilters.every(({ readValue, join, a, b }) => {
|
|
1734
|
+
const cellValue = readValue(row);
|
|
1735
|
+
const ra = a ? a(cellValue) : null;
|
|
1736
|
+
const rb = b ? b(cellValue) : null;
|
|
1737
|
+
if (ra === null) return rb ?? true;
|
|
1738
|
+
if (rb === null) return ra;
|
|
1739
|
+
return join === "OR" ? ra || rb : ra && rb;
|
|
1740
|
+
}),
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1666
1743
|
}
|
|
1667
1744
|
|
|
1668
1745
|
const valueFilterEntries = Object.entries(valueFilters).filter(
|
|
@@ -1678,7 +1755,7 @@ export function createSvGridController<
|
|
|
1678
1755
|
readValue: makeColumnReader(columnId),
|
|
1679
1756
|
buckets: facetBucketsByColumn.get(columnId) ?? null,
|
|
1680
1757
|
}));
|
|
1681
|
-
rows = rows.filter((row) =>
|
|
1758
|
+
rows = denseRows(rows).filter((row) =>
|
|
1682
1759
|
bucketEntries.every(({ allowed, buckets, readValue }) => {
|
|
1683
1760
|
const raw = readValue(row);
|
|
1684
1761
|
if (buckets) {
|
|
@@ -1718,15 +1795,21 @@ export function createSvGridController<
|
|
|
1718
1795
|
|
|
1719
1796
|
let rows = rawRows;
|
|
1720
1797
|
if (globalFilter.trim()) {
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
);
|
|
1798
|
+
const locale = props.filterLocale ?? props.localization?.locale;
|
|
1799
|
+
const needle = normalizeForFilter(globalFilter, locale);
|
|
1800
|
+
// Read the values, not the cells. `getAllCells()` built a cell object
|
|
1801
|
+
// with two closures per column on EVERY row and kept them on the row:
|
|
1802
|
+
// ten million objects for one keystroke over a million rows, and a
|
|
1803
|
+
// search that froze the page for seconds. `cell.getValue()` and
|
|
1804
|
+
// `getCellValueByColumnId` read the same per-row values array, so the
|
|
1805
|
+
// answer is the same. The columns are the ones the rows were built with.
|
|
1806
|
+
const columnIds = grid.getAllColumns().map((c) => c.id);
|
|
1807
|
+
rows = denseRows(rows).filter((row) => {
|
|
1808
|
+
for (const id of columnIds) {
|
|
1809
|
+
if (normalizeForFilter(String(row.getCellValueByColumnId(id) ?? ""), locale).includes(needle)) return true;
|
|
1810
|
+
}
|
|
1811
|
+
return false;
|
|
1812
|
+
});
|
|
1730
1813
|
}
|
|
1731
1814
|
|
|
1732
1815
|
rows = applyColumnFilters(rows);
|
|
@@ -1992,11 +2075,17 @@ export function createSvGridController<
|
|
|
1992
2075
|
/** Whether the bar has anything to show: the model's count when it has one, else the loaded ticks. */
|
|
1993
2076
|
const selectionBarVisible = $derived(selectionBarOn && selectionBarCount > 0);
|
|
1994
2077
|
|
|
1995
|
-
// Forward selection changes to the consumer.
|
|
1996
|
-
//
|
|
1997
|
-
|
|
2078
|
+
// Forward selection changes to the consumer. The first run records the
|
|
2079
|
+
// starting selection instead of reporting it, so consumers don't get a
|
|
2080
|
+
// spurious callback on mount. (This was seeded with "", which never matched
|
|
2081
|
+
// "{}", so the mount call this comment always promised to skip was made.)
|
|
2082
|
+
let lastSelectionSerialized: string | null = null;
|
|
1998
2083
|
$effect(() => {
|
|
1999
2084
|
const serialized = JSON.stringify(rowSelectionState);
|
|
2085
|
+
if (lastSelectionSerialized === null) {
|
|
2086
|
+
lastSelectionSerialized = serialized;
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2000
2089
|
if (serialized === lastSelectionSerialized) return;
|
|
2001
2090
|
lastSelectionSerialized = serialized;
|
|
2002
2091
|
const callback = props.onRowSelectionChange;
|
|
@@ -2015,19 +2104,27 @@ export function createSvGridController<
|
|
|
2015
2104
|
// Forward cell-selection rectangle changes to the consumer. Same
|
|
2016
2105
|
// dedupe pattern - fires only when the serialized rectangle changes
|
|
2017
2106
|
// so consumers don't see spurious callbacks during re-renders.
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
// range. Depend on selectionRanges so a change to the committed set
|
|
2024
|
-
// re-runs this too.
|
|
2025
|
-
void selectionRanges;
|
|
2026
|
-
const ranges: Array<[number, number, number, number]> = getSelectionRects().map(
|
|
2107
|
+
// Every rectangle, the committed ones a Ctrl+click added and the active one
|
|
2108
|
+
// last, the order getSelectionRects gives: a consumer's status bar and
|
|
2109
|
+
// shading cover the whole selection, not only its last range.
|
|
2110
|
+
const currentCellRanges = (): Array<[number, number, number, number]> =>
|
|
2111
|
+
getSelectionRects().map(
|
|
2027
2112
|
(r: { minRow: number; minCol: number; maxRow: number; maxCol: number }) =>
|
|
2028
2113
|
[r.minRow, r.minCol, r.maxRow, r.maxCol] as [number, number, number, number],
|
|
2029
2114
|
);
|
|
2115
|
+
// `null` until the first run, which records the starting selection rather
|
|
2116
|
+
// than reporting it: mounting is not a change. (Not seeded eagerly -
|
|
2117
|
+
// getSelectionRects is bound further down this file.)
|
|
2118
|
+
let lastCellRangeSerialized: string | null = null;
|
|
2119
|
+
$effect(() => {
|
|
2120
|
+
// Depend on selectionRanges so a change to the committed set re-runs this too.
|
|
2121
|
+
void selectionRanges;
|
|
2122
|
+
const ranges = currentCellRanges();
|
|
2030
2123
|
const serialized = JSON.stringify(ranges);
|
|
2124
|
+
if (lastCellRangeSerialized === null) {
|
|
2125
|
+
lastCellRangeSerialized = serialized;
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2031
2128
|
if (serialized === lastCellRangeSerialized) return;
|
|
2032
2129
|
lastCellRangeSerialized = serialized;
|
|
2033
2130
|
props.onCellSelectionChange?.(ranges);
|
|
@@ -2871,27 +2968,40 @@ export function createSvGridController<
|
|
|
2871
2968
|
|
|
2872
2969
|
// Forward sort-clause changes to the consumer. Same dedupe pattern as the
|
|
2873
2970
|
// selection callback above - fires only when the serialized clauses change.
|
|
2874
|
-
|
|
2971
|
+
// Seeded from the starting sort, so mounting is not reported as a change:
|
|
2972
|
+
// it used to be, and a consumer that keeps the sort in the URL (reset the
|
|
2973
|
+
// page on a new sort) navigated away from ?page=2 on every load, while a
|
|
2974
|
+
// server data source refetched page 0 for nothing.
|
|
2975
|
+
const currentSorting = () =>
|
|
2976
|
+
(grid.getState().sorting ?? []) as Array<{ id: string; desc: boolean }>;
|
|
2977
|
+
let lastSortingSerialized = JSON.stringify(untrack(currentSorting));
|
|
2875
2978
|
$effect(() => {
|
|
2876
2979
|
gridStateVersion;
|
|
2877
|
-
const sorting = (
|
|
2878
|
-
id: string;
|
|
2879
|
-
desc: boolean;
|
|
2880
|
-
}>;
|
|
2980
|
+
const sorting = currentSorting();
|
|
2881
2981
|
const serialized = JSON.stringify(sorting);
|
|
2882
2982
|
if (serialized === lastSortingSerialized) return;
|
|
2883
2983
|
lastSortingSerialized = serialized;
|
|
2884
2984
|
props.onSortingChange?.(sorting);
|
|
2885
2985
|
});
|
|
2886
2986
|
|
|
2987
|
+
// A row model starts unsorted and takes no initial sort of its own. It
|
|
2988
|
+
// learned `initialSorting` from the mount report above; now that there is
|
|
2989
|
+
// none, hand the starting sort over directly - to each model the grid is
|
|
2990
|
+
// given, and only when there is a sort to hand over.
|
|
2991
|
+
$effect(() => {
|
|
2992
|
+
const model = rawProps.rowModel;
|
|
2993
|
+
untrack(() => {
|
|
2994
|
+
const sorting = currentSorting();
|
|
2995
|
+
if (model?.setSort && sorting.length) model.setSort(sorting);
|
|
2996
|
+
});
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2887
2999
|
// Forward filter-state changes to the consumer. Consolidates the three
|
|
2888
3000
|
// wrapper-managed filter stores (global text, per-column operator filters,
|
|
2889
3001
|
// facet checklists) into one shape so server-side consumers can build a
|
|
2890
3002
|
// single query. Skipped entirely when no callback is registered to avoid
|
|
2891
3003
|
// serializing on every keystroke.
|
|
2892
|
-
|
|
2893
|
-
$effect(() => {
|
|
2894
|
-
if (!props.onFiltersChange) return;
|
|
3004
|
+
function currentFilters() {
|
|
2895
3005
|
const menuEntries = Object.entries(filterMenuValues)
|
|
2896
3006
|
.filter(([, f]) => {
|
|
2897
3007
|
if (f.operator === "isBlank" || f.operator === "isNotBlank") return true;
|
|
@@ -2933,10 +3043,16 @@ export function createSvGridController<
|
|
|
2933
3043
|
: entry,
|
|
2934
3044
|
);
|
|
2935
3045
|
}
|
|
2936
|
-
|
|
3046
|
+
return {
|
|
2937
3047
|
global: globalFilter,
|
|
2938
3048
|
columns: Array.from(merged.values()),
|
|
2939
3049
|
};
|
|
3050
|
+
}
|
|
3051
|
+
// Seeded from the starting filters (empty), so mounting is not a change.
|
|
3052
|
+
let lastFiltersSerialized = JSON.stringify(untrack(currentFilters));
|
|
3053
|
+
$effect(() => {
|
|
3054
|
+
if (!props.onFiltersChange) return;
|
|
3055
|
+
const payload = currentFilters();
|
|
2940
3056
|
const serialized = JSON.stringify(payload);
|
|
2941
3057
|
if (serialized === lastFiltersSerialized) return;
|
|
2942
3058
|
lastFiltersSerialized = serialized;
|
|
@@ -3118,6 +3234,34 @@ export function createSvGridController<
|
|
|
3118
3234
|
const frozenRowList = $derived(frozenRowCount ? allRows.slice(0, frozenRowCount) : []);
|
|
3119
3235
|
/** The merged cells, indexed; null (the usual case) costs nothing. */
|
|
3120
3236
|
const mergeIndex = $derived(buildMergeIndex(props.mergedCells));
|
|
3237
|
+
/**
|
|
3238
|
+
* Rows laid out as flex lines rather than table rows (SvGrid.css,
|
|
3239
|
+
* `.sv-grid-table-flex`): a change to one row's cells no longer lays out
|
|
3240
|
+
* every other row. A merged cell spanning rows needs the table algorithm,
|
|
3241
|
+
* so any such merge, or `rowLayout: 'table'`, keeps table layout.
|
|
3242
|
+
*/
|
|
3243
|
+
const flexRows = $derived(
|
|
3244
|
+
props.rowLayout !== "table" &&
|
|
3245
|
+
!(props.mergedCells ?? []).some((m: { rowSpan?: number }) => (m.rowSpan ?? 1) > 1),
|
|
3246
|
+
);
|
|
3247
|
+
// The spreadsheetLayout action (a use: directive on a wrapper, outside this
|
|
3248
|
+
// component) switches the table back to table layout on the DOM, with
|
|
3249
|
+
// data-row-layout="table". Watch for it, so the shortcuts that only hold
|
|
3250
|
+
// under flex rows (no right spacer) switch off with it.
|
|
3251
|
+
let tableLayoutForced = $state(false);
|
|
3252
|
+
$effect(() => {
|
|
3253
|
+
const el = gridRootEl;
|
|
3254
|
+
if (!el || typeof MutationObserver === "undefined") return;
|
|
3255
|
+
const read = () => {
|
|
3256
|
+
tableLayoutForced = el.getAttribute("data-row-layout") === "table";
|
|
3257
|
+
};
|
|
3258
|
+
read();
|
|
3259
|
+
const observer = new MutationObserver(read);
|
|
3260
|
+
observer.observe(el, { attributes: true, attributeFilter: ["data-row-layout"] });
|
|
3261
|
+
return () => observer.disconnect();
|
|
3262
|
+
});
|
|
3263
|
+
/** Rows really are flex lines: flexRows, and no spreadsheetLayout forcing table layout. */
|
|
3264
|
+
const flexLayoutActive = $derived(flexRows && !tableLayoutForced);
|
|
3121
3265
|
const frozenBandHeight = $derived.by(() => {
|
|
3122
3266
|
if (!frozenRowCount) return 0;
|
|
3123
3267
|
rowResizeVersion;
|
|
@@ -3262,51 +3406,200 @@ export function createSvGridController<
|
|
|
3262
3406
|
});
|
|
3263
3407
|
};
|
|
3264
3408
|
|
|
3409
|
+
/**
|
|
3410
|
+
* The column window before any measurement: the columns that fit an
|
|
3411
|
+
* estimated viewport (the measured width once known, else 1,920 px) plus the
|
|
3412
|
+
* overscan, anchored at column 0. Deterministic, so a server render and the
|
|
3413
|
+
* first client render agree. It used to be every column, which put 10,000
|
|
3414
|
+
* columns' cells in the DOM for one frame and cost most of a wide mount.
|
|
3415
|
+
*/
|
|
3416
|
+
const preMeasureColumnItems = () => {
|
|
3417
|
+
const viewport = viewportWidth > 0 ? viewportWidth : 1920;
|
|
3418
|
+
let extra = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
|
|
3419
|
+
const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
|
|
3420
|
+
let start = 0;
|
|
3421
|
+
for (let i = 0; i < allColumns.length; i += 1) {
|
|
3422
|
+
if (start >= viewport && extra-- <= 0) break;
|
|
3423
|
+
const size = getColumnWidth(allColumns[i]!.id);
|
|
3424
|
+
items.push({ index: i, key: i, size, start, end: start + size });
|
|
3425
|
+
start += size;
|
|
3426
|
+
}
|
|
3427
|
+
return items;
|
|
3428
|
+
};
|
|
3429
|
+
|
|
3265
3430
|
const renderedColumnItems = $derived.by(function renderedColumnItems_d() {
|
|
3266
3431
|
if (!columnVirtualizationEnabled) return allColumnItems();
|
|
3267
3432
|
// Column virtualization is ON by default and its virtualizer, like the row
|
|
3268
3433
|
// one, only learns `count` from an effect - which never runs on a server.
|
|
3269
|
-
// Without this the server emitted rows containing no data cells.
|
|
3270
|
-
//
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3434
|
+
// Without this the server emitted rows containing no data cells. Before a
|
|
3435
|
+
// measurement the window is the columns that fit an estimated viewport.
|
|
3436
|
+
if (virtualColumns.length === 0 && allColumns.length > 0 && effectivePinning.left.length === 0 && effectivePinning.right.length === 0) {
|
|
3437
|
+
return preMeasureColumnItems();
|
|
3438
|
+
}
|
|
3274
3439
|
// Pinned columns are position:sticky, so they only stay pinned while their
|
|
3275
|
-
// cell is in the DOM.
|
|
3276
|
-
//
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
const
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
const lastIdx = window[window.length - 1]!.index;
|
|
3291
|
-
const startIndex = hasLeft ? 0 : firstIdx;
|
|
3292
|
-
const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
|
|
3293
|
-
if (startIndex === firstIdx && endIndex === lastIdx) return window;
|
|
3294
|
-
|
|
3295
|
-
const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
|
|
3440
|
+
// cell is in the DOM. allColumns is ordered [pinnedLeft, unpinned,
|
|
3441
|
+
// pinnedRight], so the rendered set is three runs: the pinned-left prefix,
|
|
3442
|
+
// the virtualizer's window, and the pinned-right suffix. Each run keeps its
|
|
3443
|
+
// real offsets, and the templates draw a spacer cell for the columns
|
|
3444
|
+
// between two runs (`gap` on `renderedColumns`), so the centre stays
|
|
3445
|
+
// virtualized however many columns sit between the pinned edges.
|
|
3446
|
+
const window = virtualColumns.length > 0 ? virtualColumns : preMeasureColumnItems();
|
|
3447
|
+
const leftCount = effectivePinning.left.length;
|
|
3448
|
+
const rightCount = effectivePinning.right.length;
|
|
3449
|
+
if ((leftCount === 0 && rightCount === 0) || window.length === 0) return window;
|
|
3450
|
+
|
|
3451
|
+
const total = allColumns.length;
|
|
3452
|
+
type PinnedItem = { index: number; key: number; size: number; start: number; end: number };
|
|
3453
|
+
const items: Array<(typeof window)[number] | PinnedItem> = [];
|
|
3454
|
+
// Pinned-left prefix: offsets from 0.
|
|
3296
3455
|
let offset = 0;
|
|
3297
|
-
for (let i = 0; i <
|
|
3298
|
-
for (let i = startIndex; i <= endIndex; i += 1) {
|
|
3456
|
+
for (let i = 0; i < Math.min(leftCount, total); i += 1) {
|
|
3299
3457
|
const size = getColumnWidth(allColumns[i]!.id);
|
|
3300
3458
|
items.push({ index: i, key: i, size, start: offset, end: offset + size });
|
|
3301
3459
|
offset += size;
|
|
3302
3460
|
}
|
|
3461
|
+
// The window, minus any pinned column it already covers.
|
|
3462
|
+
const firstRight = total - rightCount;
|
|
3463
|
+
for (const item of window) {
|
|
3464
|
+
if (item.index < leftCount || item.index >= firstRight) continue;
|
|
3465
|
+
items.push(item);
|
|
3466
|
+
}
|
|
3467
|
+
// Pinned-right suffix: offsets measured back from the virtualizer's total,
|
|
3468
|
+
// the same total the right spacer is computed against.
|
|
3469
|
+
const right: PinnedItem[] = [];
|
|
3470
|
+
let end = virtualColumnTotalSize;
|
|
3471
|
+
if (end <= 0) {
|
|
3472
|
+
// Not measured yet: the virtualizer has no total until its effect runs.
|
|
3473
|
+
for (const column of allColumns) end += getColumnWidth(column.id);
|
|
3474
|
+
}
|
|
3475
|
+
for (let i = total - 1; i >= Math.max(firstRight, leftCount); i -= 1) {
|
|
3476
|
+
const size = getColumnWidth(allColumns[i]!.id);
|
|
3477
|
+
right.push({ index: i, key: i, size, start: end - size, end });
|
|
3478
|
+
end -= size;
|
|
3479
|
+
}
|
|
3480
|
+
for (let i = right.length - 1; i >= 0; i -= 1) items.push(right[i]!);
|
|
3303
3481
|
return items;
|
|
3304
3482
|
});
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3483
|
+
/**
|
|
3484
|
+
* The rendered columns, each with `gap`: the width of the columns NOT
|
|
3485
|
+
* rendered between it and the previous rendered column. Non-zero only at the
|
|
3486
|
+
* seams of the pinned/centre runs above; every row template draws a spacer
|
|
3487
|
+
* cell of that width before the column, so all rows keep the same slots.
|
|
3488
|
+
*/
|
|
3489
|
+
// The entries handed out last time, by column id. A horizontal scroll moves
|
|
3490
|
+
// the window by a column or two per frame; an entry whose column, offset,
|
|
3491
|
+
// width and gap are unchanged is handed out again as the SAME object, so
|
|
3492
|
+
// the keyed `{#each}` in every row leaves those cells alone and only the
|
|
3493
|
+
// columns that entered the window are rendered. A fresh object per column
|
|
3494
|
+
// per frame re-ran every visible cell's effects on every scroll frame.
|
|
3495
|
+
let renderedColumnsPrev = new Map<string, { item: { index: number; start: number; size: number; end: number }; column: unknown; gap: number; key: string; pinned: "left" | "right" | null }>();
|
|
3496
|
+
// The body cells' {#each} key. A virtualized window column is keyed by a
|
|
3497
|
+
// slot, not by its id: a column keeps its slot (and so its <td>) while it
|
|
3498
|
+
// stays in the window, and a column entering the window takes a slot a
|
|
3499
|
+
// leaving column freed. A one-column scroll then hands one cell per row to
|
|
3500
|
+
// the new column, and a thumb drag that jumps to columns far away updates
|
|
3501
|
+
// every cell in place instead of destroying the window's cells and creating
|
|
3502
|
+
// new ones (~800 at a time on a wide grid). There are as many slots as the
|
|
3503
|
+
// widest window so far. Pinned columns, and every column without column
|
|
3504
|
+
// virtualization, keep their id.
|
|
3505
|
+
let columnSlots = new Map<string, number>();
|
|
3506
|
+
let columnSlotOrder: number[] = [];
|
|
3507
|
+
let columnSlotCount = 0;
|
|
3508
|
+
const renderedColumns = $derived.by(() => {
|
|
3509
|
+
const out: Array<{ item: (typeof renderedColumnItems)[number]; column: NonNullable<(typeof allColumns)[number]>; gap: number; key: string; pinned: "left" | "right" | null }> = [];
|
|
3510
|
+
const next = new Map<string, (typeof out)[number]>();
|
|
3511
|
+
const total = allColumns.length;
|
|
3512
|
+
const leftCount = effectivePinning.left.length;
|
|
3513
|
+
const firstRight = total - effectivePinning.right.length;
|
|
3514
|
+
const inWindow = (index: number) => columnVirtualizationEnabled && index >= leftCount && index < firstRight;
|
|
3515
|
+
// Window columns already holding a slot keep it; the others take the
|
|
3516
|
+
// slots nobody kept, in the order those cells sat in the row, then new
|
|
3517
|
+
// ones. In that order a jump to an unrelated window keeps the cells where
|
|
3518
|
+
// they are, and a step moves only the cells that changed column: Svelte
|
|
3519
|
+
// reorders keyed cells by key order.
|
|
3520
|
+
const slots = new Map<string, number>();
|
|
3521
|
+
const taken = new Set<number>();
|
|
3522
|
+
for (const item of renderedColumnItems) {
|
|
3523
|
+
const id = allColumns[item.index]?.id;
|
|
3524
|
+
if (id === undefined || !inWindow(item.index)) continue;
|
|
3525
|
+
const slot = columnSlots.get(id);
|
|
3526
|
+
if (slot !== undefined) {
|
|
3527
|
+
slots.set(id, slot);
|
|
3528
|
+
taken.add(slot);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
const free = columnSlotOrder.filter((slot) => !taken.has(slot));
|
|
3532
|
+
let nextFree = 0;
|
|
3533
|
+
const order: number[] = [];
|
|
3534
|
+
for (const item of renderedColumnItems) {
|
|
3535
|
+
const id = allColumns[item.index]?.id;
|
|
3536
|
+
if (id === undefined || !inWindow(item.index)) continue;
|
|
3537
|
+
let slot = slots.get(id);
|
|
3538
|
+
if (slot === undefined) {
|
|
3539
|
+
slot = nextFree < free.length ? free[nextFree++]! : columnSlotCount++;
|
|
3540
|
+
slots.set(id, slot);
|
|
3541
|
+
}
|
|
3542
|
+
order.push(slot);
|
|
3543
|
+
}
|
|
3544
|
+
// Slots nobody took this time stay at the end, for the next window.
|
|
3545
|
+
for (let i = nextFree; i < free.length; i += 1) order.push(free[i]!);
|
|
3546
|
+
columnSlots = slots;
|
|
3547
|
+
columnSlotOrder = order;
|
|
3548
|
+
// What isColumnPinned answers, once per column here instead of once per
|
|
3549
|
+
// cell in the template.
|
|
3550
|
+
const pinnedLeft = new Set(effectivePinning.left);
|
|
3551
|
+
const pinnedRight = new Set(effectivePinning.right);
|
|
3552
|
+
let prevEnd: number | null = null;
|
|
3553
|
+
for (const item of renderedColumnItems) {
|
|
3554
|
+
const column = allColumns[item.index];
|
|
3555
|
+
if (column === undefined) continue;
|
|
3556
|
+
let gap = 0;
|
|
3557
|
+
if (prevEnd !== null && item.start - prevEnd >= 0.5) gap = item.start - prevEnd;
|
|
3558
|
+
prevEnd = item.end;
|
|
3559
|
+
const slot = slots.get(column.id);
|
|
3560
|
+
const key = slot !== undefined ? `s:${slot}` : `c:${column.id}`;
|
|
3561
|
+
const pinned: "left" | "right" | null = pinnedLeft.has(column.id) ? "left" : pinnedRight.has(column.id) ? "right" : null;
|
|
3562
|
+
const prev = renderedColumnsPrev.get(column.id) as (typeof out)[number] | undefined;
|
|
3563
|
+
const entry =
|
|
3564
|
+
prev &&
|
|
3565
|
+
prev.column === column &&
|
|
3566
|
+
prev.gap === gap &&
|
|
3567
|
+
prev.key === key &&
|
|
3568
|
+
prev.pinned === pinned &&
|
|
3569
|
+
prev.item.index === item.index &&
|
|
3570
|
+
prev.item.start === item.start &&
|
|
3571
|
+
prev.item.size === item.size
|
|
3572
|
+
? prev
|
|
3573
|
+
: { item, column, gap, key, pinned };
|
|
3574
|
+
next.set(column.id, entry);
|
|
3575
|
+
out.push(entry);
|
|
3576
|
+
}
|
|
3577
|
+
renderedColumnsPrev = next;
|
|
3578
|
+
return out;
|
|
3579
|
+
});
|
|
3580
|
+
/**
|
|
3581
|
+
* The contiguous runs of rendered column indices, for whoever must not span
|
|
3582
|
+
* across a seam: a merged cell or a column-group header is clipped to the
|
|
3583
|
+
* run it starts in. One run unless pinned columns split the window.
|
|
3584
|
+
*/
|
|
3585
|
+
const renderedColumnRuns = $derived.by(function renderedColumnRuns_d() {
|
|
3586
|
+
const runs: Array<{ first: number; last: number; gap: number }> = [];
|
|
3587
|
+
for (const rendered of renderedColumns) {
|
|
3588
|
+
const i = rendered.item.index;
|
|
3589
|
+
const run = runs[runs.length - 1];
|
|
3590
|
+
if (run && i === run.last + 1 && rendered.gap === 0) run.last = i;
|
|
3591
|
+
else runs.push({ first: i, last: i, gap: runs.length === 0 ? 0 : rendered.gap });
|
|
3592
|
+
}
|
|
3593
|
+
return runs;
|
|
3594
|
+
});
|
|
3595
|
+
/** The run holding column `colIndex`, or the whole window if none does. */
|
|
3596
|
+
function columnRunOf(colIndex: number): { firstCol: number; lastCol: number } {
|
|
3597
|
+
const runs = renderedColumnRuns;
|
|
3598
|
+
for (const run of runs) {
|
|
3599
|
+
if (colIndex >= run.first && colIndex <= run.last) return { firstCol: run.first, lastCol: run.last };
|
|
3600
|
+
}
|
|
3601
|
+
return { firstCol: runs[0]?.first ?? 0, lastCol: runs[runs.length - 1]?.last ?? -1 };
|
|
3602
|
+
}
|
|
3310
3603
|
const totalColumnWidth = $derived.by(function totalColumnWidth_d() {
|
|
3311
3604
|
if (columnVirtualizationEnabled) return virtualColumnTotalSize;
|
|
3312
3605
|
let total = 0;
|
|
@@ -3355,22 +3648,34 @@ export function createSvGridController<
|
|
|
3355
3648
|
const groupHeaderRowsWindowed = $derived.by(function groupHeaderRowsWindowed_d() {
|
|
3356
3649
|
const base = groupHeaderRows;
|
|
3357
3650
|
if (!columnVirtualizationEnabled || base.length === 0) return base;
|
|
3358
|
-
const
|
|
3359
|
-
if (
|
|
3360
|
-
|
|
3361
|
-
|
|
3651
|
+
const runs = renderedColumnRuns;
|
|
3652
|
+
if (runs.length === 0) return base;
|
|
3653
|
+
// Clip each cell to every run it overlaps. With pinned columns the window
|
|
3654
|
+
// is several runs, so a group spanning a seam becomes one cell per run,
|
|
3655
|
+
// and the first cell of each later run carries the seam's gap.
|
|
3362
3656
|
return base.map((row) => {
|
|
3363
3657
|
const cells: GroupHeaderCell[] = [];
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
const
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3658
|
+
runs.forEach((run, runIndex) => {
|
|
3659
|
+
let first = true;
|
|
3660
|
+
for (const cell of row.cells) {
|
|
3661
|
+
const start = cell.firstLeafIndex;
|
|
3662
|
+
const end = start + cell.colSpan; // exclusive
|
|
3663
|
+
const from = Math.max(start, run.first);
|
|
3664
|
+
const to = Math.min(end, run.last + 1);
|
|
3665
|
+
if (to <= from) continue; // cell is entirely outside this run
|
|
3666
|
+
let widthPx = 0;
|
|
3667
|
+
for (let i = from; i < to; i += 1) widthPx += getColumnWidth(allColumns[i]!.id);
|
|
3668
|
+
cells.push({
|
|
3669
|
+
...cell,
|
|
3670
|
+
key: runIndex === 0 ? cell.key : `${cell.key}@${runIndex}`,
|
|
3671
|
+
colSpan: to - from,
|
|
3672
|
+
widthPx,
|
|
3673
|
+
firstLeafIndex: from,
|
|
3674
|
+
gapBefore: first && runIndex > 0 ? run.gap : 0,
|
|
3675
|
+
});
|
|
3676
|
+
first = false;
|
|
3677
|
+
}
|
|
3678
|
+
});
|
|
3374
3679
|
return { ...row, cells };
|
|
3375
3680
|
});
|
|
3376
3681
|
});
|
|
@@ -3756,10 +4061,14 @@ export function createSvGridController<
|
|
|
3756
4061
|
// instead of `scrollVersion` so this effect does NOT re-run on every
|
|
3757
4062
|
// scroll event - which would otherwise re-call setOptions hundreds of
|
|
3758
4063
|
// times during a drag.
|
|
3759
|
-
|
|
3760
|
-
|
|
4064
|
+
// The measured height comes from the `viewportHeight` derived, which
|
|
4065
|
+
// reads clientHeight only when viewportVersion moves. Reading it here
|
|
4066
|
+
// directly did so on every run of this effect - each filter, sort or
|
|
4067
|
+
// data change - in the middle of the DOM update, forcing a layout the
|
|
4068
|
+
// frame then repeated (6 ms of a 100k-row filter).
|
|
4069
|
+
const measuredHeight =
|
|
3761
4070
|
typeof props.containerHeight === "string"
|
|
3762
|
-
? (scrollContainer
|
|
4071
|
+
? (scrollContainer ? viewportHeight : 520)
|
|
3763
4072
|
: (props.containerHeight ?? 520);
|
|
3764
4073
|
const rh = props.rowHeight;
|
|
3765
4074
|
// Auto height: read measured sizes, falling back to the fixed rowHeight as
|
|
@@ -3788,7 +4097,7 @@ export function createSvGridController<
|
|
|
3788
4097
|
count: allRows.length,
|
|
3789
4098
|
estimateSize,
|
|
3790
4099
|
overscan: props.overscan ?? 8,
|
|
3791
|
-
viewportHeight,
|
|
4100
|
+
viewportHeight: measuredHeight,
|
|
3792
4101
|
});
|
|
3793
4102
|
});
|
|
3794
4103
|
|
|
@@ -3838,28 +4147,76 @@ export function createSvGridController<
|
|
|
3838
4147
|
|
|
3839
4148
|
$effect(() => {
|
|
3840
4149
|
// Reading columnWidths here registers it as a reactive dependency so
|
|
3841
|
-
// the effect re-runs when the user resizes a column.
|
|
3842
|
-
//
|
|
3843
|
-
//
|
|
3844
|
-
|
|
3845
|
-
|
|
4150
|
+
// the effect re-runs when the user resizes a column. The widths are read
|
|
4151
|
+
// once into an array and the size function reads that: passing a size
|
|
4152
|
+
// function makes the virtualizer rebuild every column's offset, and
|
|
4153
|
+
// resolving each width through getColumnWidth inside that rebuild cost
|
|
4154
|
+
// several reactive reads per column - 10,000 times on a wide grid.
|
|
4155
|
+
// getColumnWidth's rules, with the maps read once: collapsed is 0, a
|
|
4156
|
+
// fitted width wins unless the user resized the column, then the user's
|
|
4157
|
+
// width, and otherwise getColumnBaseWidth (the definition or the default,
|
|
4158
|
+
// exactly as the cells are sized).
|
|
4159
|
+
const resized = columnWidths;
|
|
4160
|
+
const collapsed = collapsedColumns;
|
|
4161
|
+
const fitted = fittedColumnWidths;
|
|
4162
|
+
const widths = new Float64Array(allColumns.length);
|
|
4163
|
+
for (let i = 0; i < widths.length; i++) {
|
|
4164
|
+
const id = allColumns[i]!.id;
|
|
4165
|
+
const own = resized[id];
|
|
4166
|
+
widths[i] = collapsed[id]
|
|
4167
|
+
? 0
|
|
4168
|
+
: own !== undefined
|
|
4169
|
+
? own
|
|
4170
|
+
: (fitted?.[id] ?? getColumnBaseWidth(id));
|
|
4171
|
+
}
|
|
4172
|
+
// `columnOverscan` columns stay rendered ahead of a scroll at all times.
|
|
4173
|
+
// The window does not move on every column boundary: it stays put while
|
|
4174
|
+
// that many columns are left ahead, then moves COLUMN_WINDOW_STEP
|
|
4175
|
+
// columns at once (overscanMin). A move costs about the same whatever
|
|
4176
|
+
// the number of columns entering, in every rendered row, so moving four
|
|
4177
|
+
// at a time did the same scroll for about half the work (1,000 columns,
|
|
4178
|
+
// 120 px per frame, interleaved A/B), and the frames in between change
|
|
4179
|
+
// nothing. Three, not four: a move is one longer frame, and three gave
|
|
4180
|
+
// the shortest longest frame of a wheel scroll (production build: 8.0 ms
|
|
4181
|
+
// against 10.2 at four and 9.4 at two) for about the same total work.
|
|
4182
|
+
const minAhead = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
|
|
3846
4183
|
columnVirtualizer.setOptions({
|
|
3847
|
-
count:
|
|
3848
|
-
estimateSize: (index: number) =>
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
4184
|
+
count: widths.length,
|
|
4185
|
+
estimateSize: (index: number) => widths[index] ?? (props.columnWidth ?? 140),
|
|
4186
|
+
overscan: minAhead + COLUMN_WINDOW_STEP - 1,
|
|
4187
|
+
// One column behind the scroll: the columns the scroll is leaving were
|
|
4188
|
+
// just passed, and a reversal needs one at most for the frame before
|
|
4189
|
+
// the window turns around.
|
|
4190
|
+
overscanBehind: Math.min(1, minAhead),
|
|
4191
|
+
overscanMin: minAhead,
|
|
3854
4192
|
});
|
|
3855
4193
|
});
|
|
4194
|
+
// The viewport on its own: a width change re-windows the columns without
|
|
4195
|
+
// rebuilding their offsets.
|
|
4196
|
+
$effect(() => {
|
|
4197
|
+
columnVirtualizer.setViewportWidth(viewportWidth);
|
|
4198
|
+
});
|
|
3856
4199
|
|
|
4200
|
+
// Re-sync the virtualizers to the scroll position. This effect re-runs on
|
|
4201
|
+
// anything domToLogicalRowOffset reads (the row count among them), and
|
|
4202
|
+
// reading scrollTop / scrollLeft in the middle of that update forced a
|
|
4203
|
+
// layout the frame then did again (5 ms of a 100k-row filter). The live
|
|
4204
|
+
// position is read once per container; after that the last position the
|
|
4205
|
+
// scroll handler or a programmatic scroll recorded (pendingScrollTop /
|
|
4206
|
+
// pendingScrollLeft) is the same number without the layout.
|
|
4207
|
+
let scrollSyncedContainer: HTMLDivElement | null = null;
|
|
3857
4208
|
$effect(() => {
|
|
3858
|
-
|
|
4209
|
+
const el = scrollContainer;
|
|
4210
|
+
if (!el) return;
|
|
4211
|
+
if (el !== scrollSyncedContainer) {
|
|
4212
|
+
scrollSyncedContainer = el;
|
|
4213
|
+
pendingScrollTop = el.scrollTop;
|
|
4214
|
+
pendingScrollLeft = el.scrollLeft;
|
|
4215
|
+
}
|
|
3859
4216
|
if (rowVirtualizationEnabled)
|
|
3860
|
-
virtualizer.setScrollOffset(domToLogicalRowOffset(
|
|
4217
|
+
virtualizer.setScrollOffset(domToLogicalRowOffset(pendingScrollTop));
|
|
3861
4218
|
if (columnVirtualizationEnabled)
|
|
3862
|
-
columnVirtualizer.setHorizontalOffset(
|
|
4219
|
+
columnVirtualizer.setHorizontalOffset(pendingScrollLeft);
|
|
3863
4220
|
});
|
|
3864
4221
|
|
|
3865
4222
|
// Re-arms once the user scrolls away from the bottom, so a long lazy-load
|
|
@@ -4509,6 +4866,8 @@ export function createSvGridController<
|
|
|
4509
4866
|
get columnWidths() { return columnWidths; },
|
|
4510
4867
|
get collapsedColumns() { return collapsedColumns; },
|
|
4511
4868
|
get mergeIndex() { return mergeIndex; },
|
|
4869
|
+
get flexRows() { return flexRows; },
|
|
4870
|
+
get flexLayoutActive() { return flexLayoutActive; },
|
|
4512
4871
|
get setColumnCollapsed() { return setColumnCollapsed; },
|
|
4513
4872
|
set columnWidths(v) { columnWidths = v as never; },
|
|
4514
4873
|
get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
|
|
@@ -4978,6 +5337,8 @@ export function createSvGridController<
|
|
|
4978
5337
|
get renderedColumnItems() { return renderedColumnItems; },
|
|
4979
5338
|
get hasRenderedColumn() { return hasRenderedColumn; },
|
|
4980
5339
|
get renderedColumns() { return renderedColumns; },
|
|
5340
|
+
get renderedColumnRuns() { return renderedColumnRuns; },
|
|
5341
|
+
get columnRunOf() { return columnRunOf; },
|
|
4981
5342
|
get totalColumnWidth() { return totalColumnWidth; },
|
|
4982
5343
|
get hasHorizontalOverflow() { return hasHorizontalOverflow; },
|
|
4983
5344
|
get columnWindowStart() { return columnWindowStart; },
|