vastgrid 0.1.1 → 0.2.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/README.md +175 -13
- package/dist/canvas/geometry.d.ts +92 -0
- package/dist/canvas/geometry.d.ts.map +1 -0
- package/dist/canvas/geometry.js +197 -0
- package/dist/canvas/geometry.js.map +1 -0
- package/dist/canvas/grid-canvas.d.ts +161 -0
- package/dist/canvas/grid-canvas.d.ts.map +1 -0
- package/dist/canvas/grid-canvas.js +1146 -0
- package/dist/canvas/grid-canvas.js.map +1 -0
- package/dist/canvas/hooks.d.ts +61 -0
- package/dist/canvas/hooks.d.ts.map +1 -0
- package/dist/canvas/hooks.js +2 -0
- package/dist/canvas/hooks.js.map +1 -0
- package/dist/canvas/keys.d.ts +26 -0
- package/dist/canvas/keys.d.ts.map +1 -0
- package/dist/canvas/keys.js +133 -0
- package/dist/canvas/keys.js.map +1 -0
- package/dist/canvas/layout.d.ts +26 -0
- package/dist/canvas/layout.d.ts.map +1 -0
- package/dist/canvas/layout.js +73 -0
- package/dist/canvas/layout.js.map +1 -0
- package/dist/canvas/scroll.d.ts +31 -0
- package/dist/canvas/scroll.d.ts.map +1 -0
- package/dist/canvas/scroll.js +111 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/text.d.ts +23 -0
- package/dist/canvas/text.d.ts.map +1 -0
- package/dist/canvas/text.js +142 -0
- package/dist/canvas/text.js.map +1 -0
- package/dist/{theme.d.ts → canvas/theme.d.ts} +8 -0
- package/dist/canvas/theme.d.ts.map +1 -0
- package/dist/canvas/theme.js +62 -0
- package/dist/canvas/theme.js.map +1 -0
- package/dist/core/cache.d.ts +48 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +222 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/checked.d.ts +20 -0
- package/dist/core/checked.d.ts.map +1 -0
- package/dist/core/checked.js +61 -0
- package/dist/core/checked.js.map +1 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +103 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/conditions.d.ts +48 -0
- package/dist/core/conditions.d.ts.map +1 -0
- package/dist/core/conditions.js +105 -0
- package/dist/core/conditions.js.map +1 -0
- package/dist/core/evaluate.d.ts +20 -0
- package/dist/core/evaluate.d.ts.map +1 -0
- package/dist/core/evaluate.js +210 -0
- package/dist/core/evaluate.js.map +1 -0
- package/dist/core/filters.d.ts +51 -0
- package/dist/core/filters.d.ts.map +1 -0
- package/dist/core/filters.js +303 -0
- package/dist/core/filters.js.map +1 -0
- package/dist/core/format.d.ts +14 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +303 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/measures.d.ts +26 -0
- package/dist/core/measures.d.ts.map +1 -0
- package/dist/core/measures.js +105 -0
- package/dist/core/measures.js.map +1 -0
- package/dist/core/request-ids.d.ts +20 -0
- package/dist/core/request-ids.d.ts.map +1 -0
- package/dist/core/request-ids.js +55 -0
- package/dist/core/request-ids.js.map +1 -0
- package/dist/core/rows.d.ts +79 -0
- package/dist/core/rows.d.ts.map +1 -0
- package/dist/core/rows.js +173 -0
- package/dist/core/rows.js.map +1 -0
- package/dist/core/store.d.ts +233 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/store.js +1162 -0
- package/dist/core/store.js.map +1 -0
- package/dist/core/totals.d.ts +24 -0
- package/dist/core/totals.d.ts.map +1 -0
- package/dist/core/totals.js +49 -0
- package/dist/core/totals.js.map +1 -0
- package/dist/core/trace.d.ts +7 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +26 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/transports.d.ts +50 -0
- package/dist/core/transports.d.ts.map +1 -0
- package/dist/core/transports.js +160 -0
- package/dist/core/transports.js.map +1 -0
- package/dist/core/types.d.ts +150 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +45 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +11 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/dist/react/accessible-rows.d.ts +13 -0
- package/dist/react/accessible-rows.d.ts.map +1 -0
- package/dist/react/accessible-rows.js +320 -0
- package/dist/react/accessible-rows.js.map +1 -0
- package/dist/react/cell-editor.d.ts +22 -0
- package/dist/react/cell-editor.d.ts.map +1 -0
- package/dist/react/cell-editor.js +431 -0
- package/dist/react/cell-editor.js.map +1 -0
- package/dist/react/cell-menu.d.ts +18 -0
- package/dist/react/cell-menu.d.ts.map +1 -0
- package/dist/react/cell-menu.js +299 -0
- package/dist/react/cell-menu.js.map +1 -0
- package/dist/react/column-menu.d.ts +16 -0
- package/dist/react/column-menu.d.ts.map +1 -0
- package/dist/react/column-menu.js +1311 -0
- package/dist/react/column-menu.js.map +1 -0
- package/dist/react/columns-menu.d.ts +7 -0
- package/dist/react/columns-menu.d.ts.map +1 -0
- package/dist/react/columns-menu.js +249 -0
- package/dist/react/columns-menu.js.map +1 -0
- package/dist/react/conditions-menu.d.ts +10 -0
- package/dist/react/conditions-menu.d.ts.map +1 -0
- package/dist/react/conditions-menu.js +795 -0
- package/dist/react/conditions-menu.js.map +1 -0
- package/dist/react/context.d.ts +29 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +18 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/data-grid.d.ts +79 -0
- package/dist/react/data-grid.d.ts.map +1 -0
- package/dist/react/data-grid.js +653 -0
- package/dist/react/data-grid.js.map +1 -0
- package/dist/react/detail-panel.d.ts +22 -0
- package/dist/react/detail-panel.d.ts.map +1 -0
- package/dist/react/detail-panel.js +242 -0
- package/dist/react/detail-panel.js.map +1 -0
- package/dist/react/export-menu.d.ts +11 -0
- package/dist/react/export-menu.d.ts.map +1 -0
- package/dist/react/export-menu.js +304 -0
- package/dist/react/export-menu.js.map +1 -0
- package/dist/react/find-bar.d.ts +11 -0
- package/dist/react/find-bar.d.ts.map +1 -0
- package/dist/react/find-bar.js +303 -0
- package/dist/react/find-bar.js.map +1 -0
- package/dist/react/format-form.d.ts +12 -0
- package/dist/react/format-form.d.ts.map +1 -0
- package/dist/react/format-form.js +863 -0
- package/dist/react/format-form.js.map +1 -0
- package/dist/react/formula-menu.d.ts +6 -0
- package/dist/react/formula-menu.d.ts.map +1 -0
- package/dist/react/formula-menu.js +343 -0
- package/dist/react/formula-menu.js.map +1 -0
- package/dist/react/grid-body.d.ts +33 -0
- package/dist/react/grid-body.d.ts.map +1 -0
- package/dist/react/grid-body.js +1389 -0
- package/dist/react/grid-body.js.map +1 -0
- package/dist/react/group-menu.d.ts +7 -0
- package/dist/react/group-menu.d.ts.map +1 -0
- package/dist/react/group-menu.js +508 -0
- package/dist/react/group-menu.js.map +1 -0
- package/dist/react/hooks.d.ts +33 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +161 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/messages.d.ts +180 -0
- package/dist/react/messages.d.ts.map +1 -0
- package/dist/react/messages.js +414 -0
- package/dist/react/messages.js.map +1 -0
- package/dist/react/save.d.ts +9 -0
- package/dist/react/save.d.ts.map +1 -0
- package/dist/react/save.js +102 -0
- package/dist/react/save.js.map +1 -0
- package/dist/react/static-rows.d.ts +9 -0
- package/dist/react/static-rows.d.ts.map +1 -0
- package/dist/react/static-rows.js +176 -0
- package/dist/react/static-rows.js.map +1 -0
- package/dist/react/status-bar.d.ts +12 -0
- package/dist/react/status-bar.d.ts.map +1 -0
- package/dist/react/status-bar.js +413 -0
- package/dist/react/status-bar.js.map +1 -0
- package/dist/react/toolbar-popover.d.ts +14 -0
- package/dist/react/toolbar-popover.d.ts.map +1 -0
- package/dist/react/toolbar-popover.js +143 -0
- package/dist/react/toolbar-popover.js.map +1 -0
- package/dist/react/toolbar.d.ts +13 -0
- package/dist/react/toolbar.d.ts.map +1 -0
- package/dist/react/toolbar.js +344 -0
- package/dist/react/toolbar.js.map +1 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +45 -0
- package/dist/server.js.map +1 -0
- package/dist/styles.css +496 -316
- package/package.json +42 -17
- package/src/canvas/geometry.ts +197 -0
- package/src/canvas/grid-canvas.ts +1222 -0
- package/src/canvas/hooks.ts +61 -0
- package/src/canvas/keys.ts +96 -0
- package/src/canvas/layout.ts +77 -0
- package/src/canvas/scroll.ts +120 -0
- package/src/canvas/text.ts +155 -0
- package/src/{theme.ts → canvas/theme.ts} +14 -1
- package/src/core/cache.ts +247 -0
- package/src/core/checked.ts +64 -0
- package/src/core/client.ts +103 -0
- package/src/core/conditions.ts +89 -0
- package/src/core/evaluate.ts +221 -0
- package/src/core/filters.ts +268 -0
- package/src/core/format.ts +309 -0
- package/src/core/measures.ts +99 -0
- package/src/core/request-ids.ts +54 -0
- package/src/core/rows.ts +213 -0
- package/src/core/store.ts +1122 -0
- package/src/core/totals.ts +59 -0
- package/src/core/trace.ts +44 -0
- package/src/core/transports.ts +177 -0
- package/src/core/types.ts +168 -0
- package/src/core.ts +39 -0
- package/src/index.ts +24 -12
- package/src/react/accessible-rows.tsx +242 -0
- package/src/react/cell-editor.tsx +216 -0
- package/src/react/cell-menu.tsx +166 -0
- package/src/react/column-menu.tsx +619 -0
- package/src/react/columns-menu.tsx +116 -0
- package/src/react/conditions-menu.tsx +289 -0
- package/src/react/context.ts +33 -0
- package/src/react/data-grid.tsx +286 -0
- package/src/react/detail-panel.tsx +92 -0
- package/src/react/export-menu.tsx +112 -0
- package/src/react/find-bar.tsx +112 -0
- package/src/react/format-form.tsx +421 -0
- package/src/react/formula-menu.tsx +163 -0
- package/src/react/grid-body.tsx +744 -0
- package/src/react/group-menu.tsx +214 -0
- package/src/react/hooks.ts +104 -0
- package/src/react/messages.ts +556 -0
- package/src/react/save.ts +42 -0
- package/src/react/static-rows.tsx +59 -0
- package/src/react/status-bar.tsx +175 -0
- package/src/react/toolbar-popover.tsx +69 -0
- package/src/react/toolbar.tsx +152 -0
- package/src/server.ts +58 -0
- package/src/styles.css +496 -316
- package/dist/components/bars.d.ts +0 -14
- package/dist/components/bars.d.ts.map +0 -1
- package/dist/components/bars.js +0 -68
- package/dist/components/bars.js.map +0 -1
- package/dist/components/cell-inspector.d.ts +0 -11
- package/dist/components/cell-inspector.d.ts.map +0 -1
- package/dist/components/cell-inspector.js +0 -37
- package/dist/components/cell-inspector.js.map +0 -1
- package/dist/components/columns-panel.d.ts +0 -12
- package/dist/components/columns-panel.d.ts.map +0 -1
- package/dist/components/columns-panel.js +0 -11
- package/dist/components/columns-panel.js.map +0 -1
- package/dist/components/filter-panel.d.ts +0 -22
- package/dist/components/filter-panel.d.ts.map +0 -1
- package/dist/components/filter-panel.js +0 -169
- package/dist/components/filter-panel.js.map +0 -1
- package/dist/components/find-bar.d.ts +0 -9
- package/dist/components/find-bar.d.ts.map +0 -1
- package/dist/components/find-bar.js +0 -46
- package/dist/components/find-bar.js.map +0 -1
- package/dist/components/popover.d.ts +0 -38
- package/dist/components/popover.d.ts.map +0 -1
- package/dist/components/popover.js +0 -104
- package/dist/components/popover.js.map +0 -1
- package/dist/components/stats-panel.d.ts +0 -12
- package/dist/components/stats-panel.d.ts.map +0 -1
- package/dist/components/stats-panel.js +0 -40
- package/dist/components/stats-panel.js.map +0 -1
- package/dist/controller.d.ts +0 -227
- package/dist/controller.d.ts.map +0 -1
- package/dist/controller.js +0 -845
- package/dist/controller.js.map +0 -1
- package/dist/data-grid.d.ts +0 -51
- package/dist/data-grid.d.ts.map +0 -1
- package/dist/data-grid.js +0 -704
- package/dist/data-grid.js.map +0 -1
- package/dist/labels.d.ts +0 -9
- package/dist/labels.d.ts.map +0 -1
- package/dist/labels.js +0 -38
- package/dist/labels.js.map +0 -1
- package/dist/messages.d.ts +0 -99
- package/dist/messages.d.ts.map +0 -1
- package/dist/messages.js +0 -276
- package/dist/messages.js.map +0 -1
- package/dist/renderer.d.ts +0 -52
- package/dist/renderer.d.ts.map +0 -1
- package/dist/renderer.js +0 -522
- package/dist/renderer.js.map +0 -1
- package/dist/text.d.ts +0 -14
- package/dist/text.d.ts.map +0 -1
- package/dist/text.js +0 -63
- package/dist/text.js.map +0 -1
- package/dist/theme.d.ts.map +0 -1
- package/dist/theme.js +0 -54
- package/dist/theme.js.map +0 -1
- package/src/components/bars.tsx +0 -180
- package/src/components/cell-inspector.tsx +0 -70
- package/src/components/columns-panel.tsx +0 -59
- package/src/components/filter-panel.tsx +0 -293
- package/src/components/find-bar.tsx +0 -104
- package/src/components/popover.tsx +0 -194
- package/src/components/stats-panel.tsx +0 -153
- package/src/controller.ts +0 -945
- package/src/data-grid.tsx +0 -1035
- package/src/labels.ts +0 -46
- package/src/messages.ts +0 -390
- package/src/renderer.ts +0 -535
- package/src/text.ts +0 -55
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import { type Column, columnsOf, type DisplayedColumn, type JsonValue, partsOf, textOf } from "vastgrid-spec";
|
|
2
|
+
import { allState, checkState } from "../core/checked.js";
|
|
3
|
+
import { compileRule, type Rule } from "../core/evaluate.js";
|
|
4
|
+
import { formatterFor } from "../core/format.js";
|
|
5
|
+
import type { GridStore } from "../core/store.js";
|
|
6
|
+
import { now, track } from "../core/trace.js";
|
|
7
|
+
import type { GridSnapshot } from "../core/types.js";
|
|
8
|
+
import { CHECK_WIDTH, Geometry, MENU_WIDTH } from "./geometry.js";
|
|
9
|
+
import type { CellAction, CellHooks, CellStyle, ColumnGroup, GridCell } from "./hooks.js";
|
|
10
|
+
import { ColumnLayout, fitFrozen } from "./layout.js";
|
|
11
|
+
import { TextFitter } from "./text.js";
|
|
12
|
+
import { cssLength, type GridTheme, resolveTheme } from "./theme.js";
|
|
13
|
+
|
|
14
|
+
const PADDING = 8;
|
|
15
|
+
const MIN_WIDTH = 40;
|
|
16
|
+
const MAX_AUTO_WIDTH = 360;
|
|
17
|
+
/** Lines of a row while a displayed column wraps its text (§5.1 of the specification). */
|
|
18
|
+
const DEFAULT_WRAP_LINES = 2;
|
|
19
|
+
/** How long scrolling must pause before the accessibility rows and the session follow (§6.1 of react.md). */
|
|
20
|
+
const SETTLE_MS = 150;
|
|
21
|
+
|
|
22
|
+
/** The rows on screen once scrolling pauses, for the accessibility rows. */
|
|
23
|
+
export interface Settled {
|
|
24
|
+
top: number;
|
|
25
|
+
rows: number;
|
|
26
|
+
/** The first and last display positions on screen. */
|
|
27
|
+
left: number;
|
|
28
|
+
right: number;
|
|
29
|
+
version: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CanvasElements {
|
|
33
|
+
root: HTMLElement;
|
|
34
|
+
canvas: HTMLCanvasElement;
|
|
35
|
+
scroller: HTMLElement;
|
|
36
|
+
sizer: HTMLElement;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CanvasOptions {
|
|
40
|
+
rowHeight?: number;
|
|
41
|
+
headerHeight?: number;
|
|
42
|
+
groupHeaderHeight?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isNumeric(column: Column) {
|
|
46
|
+
return column.type === "integer" || column.type === "bigint" || column.type === "decimal" || column.type === "float";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The width a column starts with before its values are measured. */
|
|
50
|
+
function defaultWidth(column: Column) {
|
|
51
|
+
switch (column.type) {
|
|
52
|
+
case "boolean":
|
|
53
|
+
return 72;
|
|
54
|
+
case "integer":
|
|
55
|
+
case "bigint":
|
|
56
|
+
case "float":
|
|
57
|
+
case "decimal":
|
|
58
|
+
return 104;
|
|
59
|
+
case "date":
|
|
60
|
+
case "time":
|
|
61
|
+
return 112;
|
|
62
|
+
case "timestamp":
|
|
63
|
+
case "timestamptz":
|
|
64
|
+
return 176;
|
|
65
|
+
default:
|
|
66
|
+
return 160;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The rows of groups that a list of groups needs: the depth of its nesting. */
|
|
71
|
+
function depthOf(groups: readonly (string | ColumnGroup)[]): number {
|
|
72
|
+
let depth = 0;
|
|
73
|
+
for (const item of groups) if (typeof item !== "string") depth = Math.max(depth, 1 + depthOf(item.children));
|
|
74
|
+
return depth;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The group of each displayed column in each of `rows` rows of groups, the innermost group in the row just above the
|
|
79
|
+
* names. A column named in several groups takes the first.
|
|
80
|
+
*/
|
|
81
|
+
function groupPaths(
|
|
82
|
+
groups: readonly ColumnGroup[],
|
|
83
|
+
columns: readonly DisplayedColumn[],
|
|
84
|
+
rows: number,
|
|
85
|
+
): ({ id: number; name: string } | null)[][] {
|
|
86
|
+
const paths = new Map<string, { id: number; name: string }[]>();
|
|
87
|
+
let id = 0;
|
|
88
|
+
const walk = (items: readonly (string | ColumnGroup)[], path: { id: number; name: string }[]) => {
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
if (typeof item === "string") {
|
|
91
|
+
if (!paths.has(item)) paths.set(item, path);
|
|
92
|
+
} else {
|
|
93
|
+
walk(item.children, [...path, { id: id++, name: item.name }]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(groups, []);
|
|
98
|
+
return columns.map((item) => {
|
|
99
|
+
const path = paths.get(item.column.name) ?? [];
|
|
100
|
+
return Array.from({ length: rows }, (_, level) => path[level - (rows - path.length)] ?? null);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Draws the view on screen of a store onto a canvas and scrolls it, without React (§1 and §6 of react.md):
|
|
106
|
+
* cells, headers, row numbers, the selection and the columns pinned at each side, at most once per frame and only
|
|
107
|
+
* what is on screen. It tells the store which rows it shows, so that the store reads them.
|
|
108
|
+
*/
|
|
109
|
+
export class GridCanvas {
|
|
110
|
+
readonly geometry = new Geometry();
|
|
111
|
+
/** Where dragged columns would go, while a header is dragged. */
|
|
112
|
+
private dropX: number | null = null;
|
|
113
|
+
private wrapLines = DEFAULT_WRAP_LINES;
|
|
114
|
+
private hooks: CellHooks = {};
|
|
115
|
+
private groups: readonly ColumnGroup[] = [];
|
|
116
|
+
/** The row height of the theme or the props; rows are taller while a column wraps. */
|
|
117
|
+
private baseRowHeight = 24;
|
|
118
|
+
private locale: string | undefined = undefined;
|
|
119
|
+
/** By column name: the text of its values by its format, and its style rules (§5.1). */
|
|
120
|
+
private formatters = new Map<string, (value: JsonValue) => string | null>();
|
|
121
|
+
private rules = new Map<string, { rule: Rule; color?: string; background?: string; description?: string }[]>();
|
|
122
|
+
/** Columns that the style rules read besides those on screen. */
|
|
123
|
+
private ruleColumns: string[] = [];
|
|
124
|
+
private store: GridStore | null = null;
|
|
125
|
+
private elements: CanvasElements | null = null;
|
|
126
|
+
private context: CanvasRenderingContext2D | null = null;
|
|
127
|
+
private theme: GridTheme | null = null;
|
|
128
|
+
private cellText: TextFitter | null = null;
|
|
129
|
+
private headerText: TextFitter | null = null;
|
|
130
|
+
private ratio = 1;
|
|
131
|
+
private frame = 0;
|
|
132
|
+
private snapshot: GridSnapshot | null = null;
|
|
133
|
+
private columns: readonly DisplayedColumn[] = [];
|
|
134
|
+
private index = new Map<string, number>();
|
|
135
|
+
private readonly measured = new Map<string, number>();
|
|
136
|
+
private resizing: { column: number; width: number } | null = null;
|
|
137
|
+
private texts = new Map<string, { value: JsonValue; text: string }>();
|
|
138
|
+
private textsView: unknown = null;
|
|
139
|
+
private settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
140
|
+
private settled: Settled = { top: 0, rows: 0, left: 0, right: -1, version: 0 };
|
|
141
|
+
private readonly settledListeners = new Set<() => void>();
|
|
142
|
+
private scrolling = false;
|
|
143
|
+
private lastActive: unknown = null;
|
|
144
|
+
private lastScroll: unknown = null;
|
|
145
|
+
private lastView: unknown = null;
|
|
146
|
+
private durations: number[] = [];
|
|
147
|
+
private readonly frameListeners = new Set<() => void>();
|
|
148
|
+
|
|
149
|
+
constructor(private options: CanvasOptions = {}) {}
|
|
150
|
+
|
|
151
|
+
/** Shows where dragged columns would go, a line at `x`, or nothing. */
|
|
152
|
+
previewDrop(x: number | null) {
|
|
153
|
+
if (this.dropX === x) return;
|
|
154
|
+
this.dropX = x;
|
|
155
|
+
this.schedule();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Shows a column at a width while it is dragged, before the command that sets it. */
|
|
159
|
+
previewWidth(column: number, width: number | null) {
|
|
160
|
+
this.resizing = width === null ? null : { column, width: Math.max(MIN_WIDTH, Math.round(width)) };
|
|
161
|
+
this.relayout();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Text, colors, drawing and actions of cells from the host (§5.1: they win over the document). */
|
|
165
|
+
setHooks(hooks: CellHooks) {
|
|
166
|
+
this.hooks = hooks;
|
|
167
|
+
this.texts.clear();
|
|
168
|
+
this.schedule();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Groups of columns in header rows above the names, each `groupHeight` pixels high (or the CSS's). */
|
|
172
|
+
setColumnGroups(groups: readonly ColumnGroup[] | undefined, groupHeight: number | undefined) {
|
|
173
|
+
this.groups = groups ?? [];
|
|
174
|
+
this.options = { ...this.options, groupHeaderHeight: groupHeight };
|
|
175
|
+
this.refreshTheme();
|
|
176
|
+
this.relayout();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How many lines rows have while a displayed column wraps its text. */
|
|
180
|
+
setWrapLines(lines: number | undefined) {
|
|
181
|
+
const next = Math.max(1, Math.floor(lines ?? DEFAULT_WRAP_LINES));
|
|
182
|
+
if (next === this.wrapLines) return;
|
|
183
|
+
this.wrapLines = next;
|
|
184
|
+
this.relayout();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Shows a check box for each row in the gutter, and one for every row in the corner. */
|
|
188
|
+
setCheckboxes(on: boolean) {
|
|
189
|
+
if (this.geometry.checkboxes === on) return;
|
|
190
|
+
this.geometry.checkboxes = on;
|
|
191
|
+
this.relayout();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The language that formats show numbers and dates in (§5.1). */
|
|
195
|
+
setLocale(locale: string | undefined) {
|
|
196
|
+
if (this.locale === locale) return;
|
|
197
|
+
this.locale = locale;
|
|
198
|
+
this.relayout();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The row height for displayed columns: room for `WRAP_LINES` lines when one of them wraps. */
|
|
202
|
+
private wrappedHeight(columns: readonly DisplayedColumn[]) {
|
|
203
|
+
const wraps = columns.some((item) => item.setting.format?.wrap === true);
|
|
204
|
+
const lineHeight = this.theme?.lineHeight ?? 16;
|
|
205
|
+
return wraps ? Math.max(this.baseRowHeight, Math.ceil(lineHeight * this.wrapLines + 8)) : this.baseRowHeight;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Row and header heights given as props, instead of the CSS custom properties. */
|
|
209
|
+
setSizes(rowHeight: number | undefined, headerHeight: number | undefined) {
|
|
210
|
+
if (this.options.rowHeight === rowHeight && this.options.headerHeight === headerHeight) return;
|
|
211
|
+
this.options = { ...this.options, rowHeight, headerHeight };
|
|
212
|
+
this.refreshTheme();
|
|
213
|
+
this.relayout();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ----- attaching -----
|
|
217
|
+
|
|
218
|
+
/** Draws `store` into the elements and follows both; gives the function that stops it. */
|
|
219
|
+
attach(elements: CanvasElements, store: GridStore): () => void {
|
|
220
|
+
this.elements = elements;
|
|
221
|
+
this.store = store;
|
|
222
|
+
const context = elements.canvas.getContext("2d", { alpha: false });
|
|
223
|
+
this.context = context;
|
|
224
|
+
this.refreshTheme();
|
|
225
|
+
this.snapshot = store.getSnapshot();
|
|
226
|
+
this.lastActive = this.snapshot.session.active;
|
|
227
|
+
this.lastScroll = this.snapshot.session.scroll;
|
|
228
|
+
this.relayout();
|
|
229
|
+
const { scroller } = elements;
|
|
230
|
+
const resize = () => {
|
|
231
|
+
this.geometry.width = scroller.clientWidth;
|
|
232
|
+
this.geometry.height = scroller.clientHeight;
|
|
233
|
+
this.relayout();
|
|
234
|
+
};
|
|
235
|
+
resize();
|
|
236
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
|
237
|
+
observer?.observe(scroller);
|
|
238
|
+
const onScroll = () => this.onScroll();
|
|
239
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
240
|
+
const stopState = store.subscribe(() => this.onState());
|
|
241
|
+
const stopRows = store.subscribeRows(() => this.onRows());
|
|
242
|
+
const media = typeof matchMedia === "undefined" ? null : matchMedia("(prefers-color-scheme: dark)");
|
|
243
|
+
const refresh = () => {
|
|
244
|
+
this.refreshTheme();
|
|
245
|
+
this.relayout();
|
|
246
|
+
};
|
|
247
|
+
media?.addEventListener("change", refresh);
|
|
248
|
+
const themeObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(refresh);
|
|
249
|
+
// Pages mark their theme on the root element, and hosts on the body too: VS Code's webviews by a class
|
|
250
|
+
// (`vscode-dark`), JupyterLab by `data-jp-theme-light`.
|
|
251
|
+
const marks = { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-jp-theme-light"] };
|
|
252
|
+
themeObserver?.observe(document.documentElement, marks);
|
|
253
|
+
if (document.body) themeObserver?.observe(document.body, marks);
|
|
254
|
+
// And the grid's own class and style, as `className` and `style` of DataGrid set them.
|
|
255
|
+
const grid = elements.root.closest(".vg-root") ?? elements.root;
|
|
256
|
+
themeObserver?.observe(grid, { attributes: true, attributeFilter: ["class", "style"] });
|
|
257
|
+
void document.fonts?.ready.then(refresh);
|
|
258
|
+
// The session's place (§5.2) when the grid starts.
|
|
259
|
+
const scroll = this.snapshot.session.scroll;
|
|
260
|
+
if (scroll) this.scrollToRow(scroll.row, "top");
|
|
261
|
+
this.schedule();
|
|
262
|
+
return () => {
|
|
263
|
+
observer?.disconnect();
|
|
264
|
+
scroller.removeEventListener("scroll", onScroll);
|
|
265
|
+
stopState();
|
|
266
|
+
stopRows();
|
|
267
|
+
media?.removeEventListener("change", refresh);
|
|
268
|
+
themeObserver?.disconnect();
|
|
269
|
+
clearTimeout(this.settleTimer);
|
|
270
|
+
if (this.frame) cancelAnimationFrame(this.frame);
|
|
271
|
+
this.frame = 0;
|
|
272
|
+
this.elements = null;
|
|
273
|
+
this.store = null;
|
|
274
|
+
this.context = null;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Reads the theme again, after the grid's class or style changed. */
|
|
279
|
+
refreshTheme() {
|
|
280
|
+
const elements = this.elements;
|
|
281
|
+
if (!elements || !this.context) return;
|
|
282
|
+
const theme = resolveTheme(elements.root);
|
|
283
|
+
this.theme = theme;
|
|
284
|
+
this.cellText = new TextFitter(this.context, theme.font);
|
|
285
|
+
this.headerText = new TextFitter(this.context, theme.headerFont);
|
|
286
|
+
this.baseRowHeight = this.options.rowHeight ?? cssLength(elements.root, "--vg-row-height", 24);
|
|
287
|
+
this.geometry.rowHeight = this.wrappedHeight(this.columns);
|
|
288
|
+
this.geometry.nameHeight = this.options.headerHeight ?? cssLength(elements.root, "--vg-header-height", 32);
|
|
289
|
+
this.geometry.groupHeight =
|
|
290
|
+
this.options.groupHeaderHeight ?? cssLength(elements.root, "--vg-group-header-height", 24);
|
|
291
|
+
this.geometry.groupRows = depthOf(this.groups);
|
|
292
|
+
this.geometry.headerHeight = this.geometry.groupTop + this.geometry.nameHeight;
|
|
293
|
+
this.texts.clear();
|
|
294
|
+
this.schedule();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ----- following the store -----
|
|
298
|
+
|
|
299
|
+
private onState() {
|
|
300
|
+
const store = this.store;
|
|
301
|
+
if (!store) return;
|
|
302
|
+
const previous = this.snapshot;
|
|
303
|
+
const snapshot = store.getSnapshot();
|
|
304
|
+
this.snapshot = snapshot;
|
|
305
|
+
if (snapshot.displayed !== previous?.displayed || snapshot.view?.rowCount !== previous?.view?.rowCount) {
|
|
306
|
+
this.relayout();
|
|
307
|
+
}
|
|
308
|
+
// Totals that come widen their columns as rows do.
|
|
309
|
+
if (snapshot.totals !== previous?.totals) this.measure();
|
|
310
|
+
if (snapshot.view !== this.lastView) {
|
|
311
|
+
this.lastView = snapshot.view;
|
|
312
|
+
this.texts.clear();
|
|
313
|
+
}
|
|
314
|
+
const session = snapshot.session;
|
|
315
|
+
if (session.scroll !== this.lastScroll) {
|
|
316
|
+
this.lastScroll = session.scroll;
|
|
317
|
+
// A place set by a command or by view.update (§5.4), not the one this canvas reported.
|
|
318
|
+
if (session.scroll && session.scroll.row !== this.geometry.topRow()) this.scrollToRow(session.scroll.row, "top");
|
|
319
|
+
}
|
|
320
|
+
if (session.active !== this.lastActive) {
|
|
321
|
+
this.lastActive = session.active;
|
|
322
|
+
if (session.active) {
|
|
323
|
+
const column = this.index.get(session.active.column);
|
|
324
|
+
if (column !== undefined) this.reveal(session.active.row, column);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
this.schedule();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private onRows() {
|
|
331
|
+
this.measure();
|
|
332
|
+
if (!this.scrolling) this.bumpSettled();
|
|
333
|
+
this.schedule();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The columns and their widths for the displayed columns of the snapshot. */
|
|
337
|
+
relayout() {
|
|
338
|
+
const snapshot = this.snapshot;
|
|
339
|
+
const columns = snapshot?.displayed ?? [];
|
|
340
|
+
this.columns = columns;
|
|
341
|
+
this.index = new Map(columns.map((item, position) => [item.column.name, position]));
|
|
342
|
+
const widths = columns.map((item, position) =>
|
|
343
|
+
this.resizing?.column === position
|
|
344
|
+
? this.resizing.width
|
|
345
|
+
: (item.setting.width ?? this.measured.get(item.column.name) ?? defaultWidth(item.column)),
|
|
346
|
+
);
|
|
347
|
+
this.geometry.layout = new ColumnLayout(widths);
|
|
348
|
+
this.geometry.rowHeight = this.wrappedHeight(columns);
|
|
349
|
+
this.formatters = new Map();
|
|
350
|
+
this.rules = new Map();
|
|
351
|
+
const read = new Set<string>();
|
|
352
|
+
const view = snapshot?.view?.columns ?? [];
|
|
353
|
+
for (const item of columns) {
|
|
354
|
+
const format = item.setting.format;
|
|
355
|
+
const formatter = formatterFor(item.column, format, this.locale);
|
|
356
|
+
if (formatter) this.formatters.set(item.column.name, formatter);
|
|
357
|
+
const rules = (format?.styles ?? []).flatMap((style) => {
|
|
358
|
+
// A rule whose `when` the grid does not evaluate over the view is skipped (§5.1).
|
|
359
|
+
const compiled = compileRule(style.when, view);
|
|
360
|
+
if (!compiled) return [];
|
|
361
|
+
for (const name of compiled.columns) read.add(name);
|
|
362
|
+
return [
|
|
363
|
+
{ rule: compiled.rule, color: style.color, background: style.background, description: style.description },
|
|
364
|
+
];
|
|
365
|
+
});
|
|
366
|
+
if (rules.length > 0) this.rules.set(item.column.name, rules);
|
|
367
|
+
}
|
|
368
|
+
this.ruleColumns = [...read];
|
|
369
|
+
this.geometry.groupPaths = groupPaths(this.groups, columns, this.geometry.groupRows);
|
|
370
|
+
this.texts.clear();
|
|
371
|
+
const left = columns.filter((item) => item.setting.pin === "left").length;
|
|
372
|
+
const right = columns.filter((item) => item.setting.pin === "right").length;
|
|
373
|
+
const rowCount = snapshot?.view?.rowCount ?? 0;
|
|
374
|
+
this.geometry.rowCount = rowCount;
|
|
375
|
+
const totals = !snapshot?.document.plan?.aggregate && columns.some((item) => item.setting.total !== undefined);
|
|
376
|
+
this.geometry.footer = totals ? this.geometry.rowHeight : 0;
|
|
377
|
+
const digits = String(Math.max(1, rowCount)).length;
|
|
378
|
+
this.geometry.gutterWidth =
|
|
379
|
+
Math.max(40, Math.ceil(digits * 8 + PADDING * 2)) + (this.geometry.checkboxes ? CHECK_WIDTH : 0);
|
|
380
|
+
const fitted = fitFrozen(this.geometry.layout, left, right, this.geometry.width - this.geometry.gutterWidth);
|
|
381
|
+
this.geometry.frozenLeft = fitted.left;
|
|
382
|
+
this.geometry.frozenRight = fitted.right;
|
|
383
|
+
const elements = this.elements;
|
|
384
|
+
if (elements) {
|
|
385
|
+
const height = this.geometry.scrollY.resize(
|
|
386
|
+
this.geometry.headerHeight + rowCount * this.geometry.rowHeight + this.geometry.footer,
|
|
387
|
+
this.geometry.height,
|
|
388
|
+
);
|
|
389
|
+
const width = this.geometry.scrollX.resize(
|
|
390
|
+
this.geometry.gutterWidth + this.geometry.layout.total,
|
|
391
|
+
this.geometry.width,
|
|
392
|
+
);
|
|
393
|
+
elements.sizer.style.width = `${width}px`;
|
|
394
|
+
elements.sizer.style.height = `${height}px`;
|
|
395
|
+
}
|
|
396
|
+
this.updateViewport();
|
|
397
|
+
// Columns that came or went (a derived column, a hidden one) change the columns on screen.
|
|
398
|
+
const [first, last] = this.geometry.visibleScrollColumns();
|
|
399
|
+
if (!this.scrolling && (first !== this.settled.left || last !== this.settled.right)) this.bumpSettled();
|
|
400
|
+
this.schedule();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Fits the columns without a width to the values on screen, growing them only. */
|
|
404
|
+
private measure() {
|
|
405
|
+
const store = this.store;
|
|
406
|
+
const fitter = this.cellText;
|
|
407
|
+
const header = this.headerText;
|
|
408
|
+
const context = this.context;
|
|
409
|
+
if (!store || !fitter || !header || !context) return;
|
|
410
|
+
const [first, last] = this.geometry.visibleRows();
|
|
411
|
+
let changed = false;
|
|
412
|
+
this.columns.forEach((item) => {
|
|
413
|
+
const name = item.column.name;
|
|
414
|
+
if (item.setting.width !== undefined) return;
|
|
415
|
+
const before = this.measured.get(name);
|
|
416
|
+
if (before !== undefined && before >= MAX_AUTO_WIDTH) return;
|
|
417
|
+
let loaded = false;
|
|
418
|
+
context.font = header.font;
|
|
419
|
+
let widest = Math.max(
|
|
420
|
+
before ?? 0,
|
|
421
|
+
header.width(item.setting.format?.label ?? name) + PADDING * 2 + 18 + MENU_WIDTH,
|
|
422
|
+
);
|
|
423
|
+
context.font = fitter.font;
|
|
424
|
+
for (let row = first; row < Math.min(last, first + 200); row++) {
|
|
425
|
+
const value = store.cell(row, name);
|
|
426
|
+
if (value === undefined) continue;
|
|
427
|
+
loaded = true;
|
|
428
|
+
const text = this.text(row, item.column, value);
|
|
429
|
+
if (text) widest = Math.max(widest, fitter.width(text.length > 120 ? text.slice(0, 120) : text) + PADDING * 2);
|
|
430
|
+
}
|
|
431
|
+
const total = this.totalText(item.column.name);
|
|
432
|
+
if (total) widest = Math.max(widest, fitter.width(total) + PADDING * 2);
|
|
433
|
+
if (!loaded) return;
|
|
434
|
+
// Columns grow to fit the rows that come on screen, and never shrink while the view stays.
|
|
435
|
+
const width = Math.min(MAX_AUTO_WIDTH, Math.max(MIN_WIDTH, Math.ceil(widest)));
|
|
436
|
+
if (width === before) return;
|
|
437
|
+
this.measured.set(name, width);
|
|
438
|
+
changed = true;
|
|
439
|
+
});
|
|
440
|
+
if (changed) this.relayout();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ----- scrolling -----
|
|
444
|
+
|
|
445
|
+
private onScroll() {
|
|
446
|
+
const scroller = this.elements?.scroller;
|
|
447
|
+
if (!scroller) return;
|
|
448
|
+
this.geometry.scrollX.onNativeScroll(scroller.scrollLeft);
|
|
449
|
+
this.geometry.scrollY.onNativeScroll(scroller.scrollTop);
|
|
450
|
+
this.scrolling = true;
|
|
451
|
+
this.updateViewport();
|
|
452
|
+
this.schedule();
|
|
453
|
+
clearTimeout(this.settleTimer);
|
|
454
|
+
this.settleTimer = setTimeout(() => {
|
|
455
|
+
this.scrolling = false;
|
|
456
|
+
this.bumpSettled();
|
|
457
|
+
this.reportScroll();
|
|
458
|
+
}, SETTLE_MS);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private updateViewport() {
|
|
462
|
+
const store = this.store;
|
|
463
|
+
if (!store) return;
|
|
464
|
+
const [first, last] = this.geometry.visibleRows();
|
|
465
|
+
const [left, right] = this.geometry.visibleScrollColumns();
|
|
466
|
+
const names: string[] = [];
|
|
467
|
+
for (let column = 0; column < this.geometry.frozenLeft; column++) names.push(this.columns[column].column.name);
|
|
468
|
+
for (let column = left; column <= right; column++) names.push(this.columns[column].column.name);
|
|
469
|
+
for (let column = this.geometry.rightStart; column < this.columns.length; column++) {
|
|
470
|
+
names.push(this.columns[column].column.name);
|
|
471
|
+
}
|
|
472
|
+
for (const name of this.ruleColumns) if (!names.includes(name)) names.push(name);
|
|
473
|
+
store.setViewport({ top: first, rows: Math.max(1, last - first), columns: names });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Scrolls so that a row is at the top, or just visible. */
|
|
477
|
+
scrollToRow(row: number, place: "top" | "nearest") {
|
|
478
|
+
const scroller = this.elements?.scroller;
|
|
479
|
+
if (!scroller) return;
|
|
480
|
+
const body = Math.max(this.geometry.rowHeight, this.geometry.bottom - this.geometry.headerHeight);
|
|
481
|
+
const y = row * this.geometry.rowHeight;
|
|
482
|
+
let target = this.geometry.scrollY.position;
|
|
483
|
+
if (place === "top") target = y;
|
|
484
|
+
else if (y < target) target = y;
|
|
485
|
+
else if (y + this.geometry.rowHeight > target + body) target = y + this.geometry.rowHeight - body;
|
|
486
|
+
if (target === this.geometry.scrollY.position) return;
|
|
487
|
+
scroller.scrollTop = this.geometry.scrollY.scrollTo(target);
|
|
488
|
+
this.updateViewport();
|
|
489
|
+
this.schedule();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Scrolls so that a cell is on screen. */
|
|
493
|
+
reveal(row: number, column: number) {
|
|
494
|
+
this.scrollToRow(row, "nearest");
|
|
495
|
+
const scroller = this.elements?.scroller;
|
|
496
|
+
if (!scroller || column < this.geometry.frozenLeft || column >= this.geometry.rightStart) return;
|
|
497
|
+
const left = this.geometry.layout.left(column);
|
|
498
|
+
const right = this.geometry.layout.right(column);
|
|
499
|
+
const start = this.geometry.frozenWidth + this.geometry.scrollX.position;
|
|
500
|
+
const room = this.geometry.width - this.geometry.gutterWidth - this.geometry.frozenRightWidth;
|
|
501
|
+
let target = this.geometry.scrollX.position;
|
|
502
|
+
if (left < start) target = left - this.geometry.frozenWidth;
|
|
503
|
+
else if (right > this.geometry.scrollX.position + room) target = right - room;
|
|
504
|
+
if (target !== this.geometry.scrollX.position) {
|
|
505
|
+
scroller.scrollLeft = this.geometry.scrollX.scrollTo(target);
|
|
506
|
+
this.updateViewport();
|
|
507
|
+
this.schedule();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Puts the place on screen in the session (§5.2) once scrolling pauses. */
|
|
512
|
+
private reportScroll() {
|
|
513
|
+
const store = this.store;
|
|
514
|
+
const snapshot = this.snapshot;
|
|
515
|
+
if (!store || !snapshot?.view || this.columns.length === 0) return;
|
|
516
|
+
const row = this.geometry.topRow();
|
|
517
|
+
const [left] = this.geometry.visibleScrollColumns();
|
|
518
|
+
const column = this.columns[Math.max(left, 0)]?.column.name;
|
|
519
|
+
const current = snapshot.session.scroll;
|
|
520
|
+
if (current?.row === row && current.column === column) return;
|
|
521
|
+
void store.dispatch(column === undefined ? { command: "scroll.to", row } : { command: "scroll.to", row, column });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ----- the accessibility rows -----
|
|
525
|
+
|
|
526
|
+
getSettled = (): Settled => this.settled;
|
|
527
|
+
|
|
528
|
+
subscribeSettled = (listener: () => void): (() => void) => {
|
|
529
|
+
this.settledListeners.add(listener);
|
|
530
|
+
return () => {
|
|
531
|
+
this.settledListeners.delete(listener);
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
private bumpSettled() {
|
|
536
|
+
const [top, last] = this.geometry.visibleRows();
|
|
537
|
+
const [left, right] = this.geometry.visibleScrollColumns();
|
|
538
|
+
this.settled = { top, rows: last - top, left, right, version: this.settled.version + 1 };
|
|
539
|
+
for (const listener of this.settledListeners) listener();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** The text of a cell as the grid shows it: the value's text form (§2.4). */
|
|
543
|
+
text(row: number, column: Column, value: JsonValue): string {
|
|
544
|
+
if (value === null) return "";
|
|
545
|
+
if (this.textsView !== this.snapshot?.view) {
|
|
546
|
+
this.textsView = this.snapshot?.view;
|
|
547
|
+
this.texts.clear();
|
|
548
|
+
}
|
|
549
|
+
const key = `${row}\u0000${column.name}`;
|
|
550
|
+
// Kept with its value: a cell shows another value while a write of it is being answered.
|
|
551
|
+
const kept = this.texts.get(key);
|
|
552
|
+
if (kept !== undefined && kept.value === value) return kept.text;
|
|
553
|
+
const shown = this.formatters.get(column.name)?.(value) ?? textOf(value, column) ?? "";
|
|
554
|
+
const text = this.hooks.cellText?.({ row, column, value, text: shown }) ?? shown;
|
|
555
|
+
if (this.texts.size > 50_000) this.texts.clear();
|
|
556
|
+
this.texts.set(key, { value, text });
|
|
557
|
+
return text;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Follows the frames the canvas draws, for what is placed over it (the cell editor). */
|
|
561
|
+
subscribeFrames = (listener: () => void): (() => void) => {
|
|
562
|
+
this.frameListeners.add(listener);
|
|
563
|
+
return () => {
|
|
564
|
+
this.frameListeners.delete(listener);
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// ----- drawing -----
|
|
569
|
+
|
|
570
|
+
schedule() {
|
|
571
|
+
if (this.frame || !this.elements) return;
|
|
572
|
+
this.frame = requestAnimationFrame(() => {
|
|
573
|
+
this.frame = 0;
|
|
574
|
+
this.draw();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Draws now, as layout effects do before the browser paints. */
|
|
579
|
+
draw() {
|
|
580
|
+
if (!this.context || !this.theme || !this.elements) return;
|
|
581
|
+
const started = now();
|
|
582
|
+
this.paint(this.context, this.theme);
|
|
583
|
+
const duration = now() - started;
|
|
584
|
+
this.durations.push(duration);
|
|
585
|
+
if (this.durations.length > 240) this.durations.shift();
|
|
586
|
+
track("frame", started, "canvas", "secondary");
|
|
587
|
+
for (const listener of this.frameListeners) listener();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Durations of the recent frames in milliseconds, for the bench. */
|
|
591
|
+
frameTimes(): readonly number[] {
|
|
592
|
+
return this.durations;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private resizeCanvas(context: CanvasRenderingContext2D) {
|
|
596
|
+
const canvas = context.canvas;
|
|
597
|
+
const ratio = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
|
|
598
|
+
const pixelWidth = Math.max(1, Math.round(this.geometry.width * ratio));
|
|
599
|
+
const pixelHeight = Math.max(1, Math.round(this.geometry.height * ratio));
|
|
600
|
+
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight || this.ratio !== ratio) {
|
|
601
|
+
canvas.width = pixelWidth;
|
|
602
|
+
canvas.height = pixelHeight;
|
|
603
|
+
canvas.style.width = `${this.geometry.width}px`;
|
|
604
|
+
canvas.style.height = `${this.geometry.height}px`;
|
|
605
|
+
this.ratio = ratio;
|
|
606
|
+
}
|
|
607
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** A position for a line one device pixel wide. */
|
|
611
|
+
private snap(value: number) {
|
|
612
|
+
return (Math.round(value * this.ratio) + 0.5) / this.ratio;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private paint(ctx: CanvasRenderingContext2D, theme: GridTheme) {
|
|
616
|
+
this.resizeCanvas(ctx);
|
|
617
|
+
ctx.fillStyle = theme.background;
|
|
618
|
+
ctx.fillRect(0, 0, this.geometry.width, this.geometry.height);
|
|
619
|
+
const snapshot = this.snapshot;
|
|
620
|
+
const store = this.store;
|
|
621
|
+
if (!snapshot?.view || !store || this.columns.length === 0) return;
|
|
622
|
+
const [first, last] = this.geometry.visibleRows();
|
|
623
|
+
const [left, right] = this.geometry.visibleScrollColumns();
|
|
624
|
+
const top = this.geometry.headerHeight;
|
|
625
|
+
const scrollLeft = this.geometry.gutterWidth + this.geometry.frozenWidth;
|
|
626
|
+
const scrollRight = this.geometry.width - this.geometry.frozenRightWidth;
|
|
627
|
+
const regions: [number, number, number, number][] = [
|
|
628
|
+
[scrollLeft, scrollRight, left, right],
|
|
629
|
+
[this.geometry.gutterWidth, scrollLeft, 0, this.geometry.frozenLeft - 1],
|
|
630
|
+
[scrollRight, this.geometry.width, this.geometry.rightStart, this.columns.length - 1],
|
|
631
|
+
];
|
|
632
|
+
for (const [x0, x1, from, to] of regions) {
|
|
633
|
+
if (x1 <= x0 || to < from) continue;
|
|
634
|
+
this.clipped(ctx, x0, top, x1 - x0, this.geometry.bottom - top, () => {
|
|
635
|
+
for (let column = from; column <= to; column++) this.drawCells(ctx, theme, store, column, first, last);
|
|
636
|
+
this.drawLines(ctx, theme, from, to, first, last);
|
|
637
|
+
this.drawSelection(ctx, theme, snapshot, from, to);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
this.drawHeaders(ctx, theme, snapshot, left, right);
|
|
641
|
+
this.drawGutter(ctx, theme, snapshot, first, last);
|
|
642
|
+
if (this.geometry.footer > 0) this.drawTotals(ctx, theme, snapshot, left, right);
|
|
643
|
+
ctx.fillStyle = theme.headerBackground;
|
|
644
|
+
ctx.fillRect(0, 0, this.geometry.gutterWidth, top);
|
|
645
|
+
if (this.geometry.checkboxes) {
|
|
646
|
+
const all = allState(snapshot.session.checked, snapshot.document.plan, snapshot.view.rowCount);
|
|
647
|
+
this.drawCheck(
|
|
648
|
+
ctx,
|
|
649
|
+
theme,
|
|
650
|
+
this.geometry.groupTop + this.geometry.nameHeight / 2,
|
|
651
|
+
all === "all" ? "checked" : all === "none" ? "unchecked" : "unknown",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
ctx.strokeStyle = theme.lineStrong;
|
|
655
|
+
ctx.lineWidth = 1;
|
|
656
|
+
ctx.beginPath();
|
|
657
|
+
const headerLine = this.snap(top) - 0.5 / this.ratio;
|
|
658
|
+
ctx.moveTo(0, headerLine);
|
|
659
|
+
ctx.lineTo(this.geometry.width, headerLine);
|
|
660
|
+
const gutterLine = this.snap(this.geometry.gutterWidth) - 0.5 / this.ratio;
|
|
661
|
+
ctx.moveTo(gutterLine, 0);
|
|
662
|
+
ctx.lineTo(gutterLine, this.geometry.height);
|
|
663
|
+
for (const edge of [
|
|
664
|
+
this.geometry.frozenLeft > 0 ? scrollLeft : -1,
|
|
665
|
+
this.geometry.frozenRight > 0 ? scrollRight : -1,
|
|
666
|
+
]) {
|
|
667
|
+
if (edge < 0) continue;
|
|
668
|
+
const x = this.snap(edge) - 0.5 / this.ratio;
|
|
669
|
+
ctx.moveTo(x, 0);
|
|
670
|
+
ctx.lineTo(x, this.geometry.height);
|
|
671
|
+
}
|
|
672
|
+
ctx.stroke();
|
|
673
|
+
if (snapshot.pending) {
|
|
674
|
+
// The view on screen stays until the new one's first rows arrive (§4.2 of react.md).
|
|
675
|
+
ctx.fillStyle = theme.background;
|
|
676
|
+
ctx.globalAlpha = 0.45;
|
|
677
|
+
ctx.fillRect(
|
|
678
|
+
this.geometry.gutterWidth,
|
|
679
|
+
top,
|
|
680
|
+
this.geometry.width - this.geometry.gutterWidth,
|
|
681
|
+
this.geometry.bottom - top,
|
|
682
|
+
);
|
|
683
|
+
ctx.globalAlpha = 1;
|
|
684
|
+
}
|
|
685
|
+
if (this.resizing) {
|
|
686
|
+
const x = this.geometry.columnX(this.resizing.column) + this.geometry.layout.width(this.resizing.column);
|
|
687
|
+
ctx.fillStyle = theme.accent;
|
|
688
|
+
ctx.fillRect(x - 1, 0, 2, this.geometry.height);
|
|
689
|
+
}
|
|
690
|
+
if (this.dropX !== null) {
|
|
691
|
+
ctx.fillStyle = theme.accent;
|
|
692
|
+
ctx.fillRect(this.dropX - 1.5, 0, 3, this.geometry.height);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private clipped(
|
|
697
|
+
ctx: CanvasRenderingContext2D,
|
|
698
|
+
x: number,
|
|
699
|
+
y: number,
|
|
700
|
+
width: number,
|
|
701
|
+
height: number,
|
|
702
|
+
draw: () => void,
|
|
703
|
+
) {
|
|
704
|
+
if (width <= 0 || height <= 0) return;
|
|
705
|
+
ctx.save();
|
|
706
|
+
ctx.beginPath();
|
|
707
|
+
ctx.rect(x, y, width, height);
|
|
708
|
+
ctx.clip();
|
|
709
|
+
draw();
|
|
710
|
+
ctx.restore();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private drawCells(
|
|
714
|
+
ctx: CanvasRenderingContext2D,
|
|
715
|
+
theme: GridTheme,
|
|
716
|
+
store: GridStore,
|
|
717
|
+
column: number,
|
|
718
|
+
first: number,
|
|
719
|
+
last: number,
|
|
720
|
+
) {
|
|
721
|
+
const item = this.columns[column];
|
|
722
|
+
const fitter = this.cellText;
|
|
723
|
+
if (!item || !fitter) return;
|
|
724
|
+
const x = this.geometry.columnX(column);
|
|
725
|
+
const width = this.geometry.layout.width(column);
|
|
726
|
+
const numeric = isNumeric(item.column);
|
|
727
|
+
const align =
|
|
728
|
+
item.setting.format?.align ?? (numeric ? "right" : item.column.type === "boolean" ? "center" : "left");
|
|
729
|
+
const wrap = item.setting.format?.wrap === true;
|
|
730
|
+
ctx.font = fitter.font;
|
|
731
|
+
ctx.textBaseline = "middle";
|
|
732
|
+
ctx.textAlign = align;
|
|
733
|
+
const textX = align === "right" ? x + width - PADDING : align === "center" ? x + width / 2 : x + PADDING;
|
|
734
|
+
for (let row = first; row < last; row++) {
|
|
735
|
+
const y = this.geometry.rowY(row);
|
|
736
|
+
const value = store.cell(row, item.column.name);
|
|
737
|
+
if (value === undefined) {
|
|
738
|
+
ctx.fillStyle = theme.placeholder;
|
|
739
|
+
ctx.fillRect(
|
|
740
|
+
x + PADDING,
|
|
741
|
+
y + this.geometry.rowHeight / 2 - 4,
|
|
742
|
+
Math.max(0, Math.min(width - PADDING * 2, 64)),
|
|
743
|
+
8,
|
|
744
|
+
);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const style = this.cellStyleOf(row, item.column, value);
|
|
748
|
+
const text = value === null ? "" : this.text(row, item.column, value);
|
|
749
|
+
const action = value === null ? null : this.actionOf(row, item.column, value, text);
|
|
750
|
+
const drawDefault = () => {
|
|
751
|
+
if (style?.background) {
|
|
752
|
+
ctx.fillStyle = style.background;
|
|
753
|
+
ctx.fillRect(x, y, width, this.geometry.rowHeight);
|
|
754
|
+
}
|
|
755
|
+
if (value === null) return;
|
|
756
|
+
const shown = store.isCut(row, item.column.name) ? `${text}…` : text;
|
|
757
|
+
ctx.fillStyle = style?.color ?? (action?.kind === "link" ? theme.link : theme.text);
|
|
758
|
+
const lines = wrap
|
|
759
|
+
? fitter.lines(
|
|
760
|
+
shown,
|
|
761
|
+
width - PADDING * 2,
|
|
762
|
+
Math.max(1, Math.floor((this.geometry.rowHeight - 4) / theme.lineHeight)),
|
|
763
|
+
)
|
|
764
|
+
: [fitter.fit(shown, width - PADDING * 2)];
|
|
765
|
+
const lineHeight = wrap ? theme.lineHeight : this.geometry.rowHeight;
|
|
766
|
+
const top = y + (this.geometry.rowHeight - lines.length * lineHeight) / 2 + lineHeight / 2;
|
|
767
|
+
lines.forEach((line, index) => {
|
|
768
|
+
if (!line) return;
|
|
769
|
+
const middle = top + index * lineHeight;
|
|
770
|
+
ctx.fillText(line, textX, middle);
|
|
771
|
+
// Links are underlined.
|
|
772
|
+
if (action?.kind !== "link") return;
|
|
773
|
+
const lineWidth = fitter.width(line);
|
|
774
|
+
const start = align === "right" ? textX - lineWidth : align === "center" ? textX - lineWidth / 2 : textX;
|
|
775
|
+
ctx.fillRect(start, middle + theme.fontSize / 2, lineWidth, 1);
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
const draw = this.hooks.drawCell;
|
|
779
|
+
if (!draw || value === null) {
|
|
780
|
+
drawDefault();
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
ctx.save();
|
|
784
|
+
draw({
|
|
785
|
+
row,
|
|
786
|
+
column: item.column,
|
|
787
|
+
value,
|
|
788
|
+
text,
|
|
789
|
+
ctx,
|
|
790
|
+
rect: { x, y, width, height: this.geometry.rowHeight },
|
|
791
|
+
style: style ?? null,
|
|
792
|
+
theme,
|
|
793
|
+
drawDefault,
|
|
794
|
+
});
|
|
795
|
+
ctx.restore();
|
|
796
|
+
ctx.font = fitter.font;
|
|
797
|
+
ctx.textBaseline = "middle";
|
|
798
|
+
ctx.textAlign = align;
|
|
799
|
+
}
|
|
800
|
+
ctx.textAlign = "left";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private drawLines(
|
|
804
|
+
ctx: CanvasRenderingContext2D,
|
|
805
|
+
theme: GridTheme,
|
|
806
|
+
from: number,
|
|
807
|
+
to: number,
|
|
808
|
+
first: number,
|
|
809
|
+
last: number,
|
|
810
|
+
) {
|
|
811
|
+
ctx.strokeStyle = theme.line;
|
|
812
|
+
ctx.lineWidth = 1;
|
|
813
|
+
ctx.beginPath();
|
|
814
|
+
for (let column = from; column <= to; column++) {
|
|
815
|
+
const x = this.snap(this.geometry.columnX(column) + this.geometry.layout.width(column)) - 0.5 / this.ratio;
|
|
816
|
+
ctx.moveTo(x, this.geometry.headerHeight);
|
|
817
|
+
ctx.lineTo(x, Math.min(this.geometry.height, this.geometry.rowY(last)));
|
|
818
|
+
}
|
|
819
|
+
for (let row = first; row <= last; row++) {
|
|
820
|
+
const y = this.snap(this.geometry.rowY(row)) - 0.5 / this.ratio;
|
|
821
|
+
if (y < this.geometry.headerHeight) continue;
|
|
822
|
+
ctx.moveTo(this.geometry.gutterWidth, y);
|
|
823
|
+
ctx.lineTo(this.geometry.width, y);
|
|
824
|
+
}
|
|
825
|
+
ctx.stroke();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** The selected ranges (§5.2) and the active cell, within the display positions `[from, to]`. */
|
|
829
|
+
private drawSelection(
|
|
830
|
+
ctx: CanvasRenderingContext2D,
|
|
831
|
+
theme: GridTheme,
|
|
832
|
+
snapshot: GridSnapshot,
|
|
833
|
+
from: number,
|
|
834
|
+
to: number,
|
|
835
|
+
) {
|
|
836
|
+
const session = snapshot.session;
|
|
837
|
+
const rowCount = snapshot.view?.rowCount ?? 0;
|
|
838
|
+
if (rowCount === 0) return;
|
|
839
|
+
const ranges = session.selection ?? [];
|
|
840
|
+
const rect = (rows: [number, number], columns: number[]) => {
|
|
841
|
+
const visible = columns.filter((column) => column >= from && column <= to);
|
|
842
|
+
if (visible.length === 0) return null;
|
|
843
|
+
const x0 = Math.min(...visible.map((column) => this.geometry.columnX(column)));
|
|
844
|
+
const x1 = Math.max(
|
|
845
|
+
...visible.map((column) => this.geometry.columnX(column) + this.geometry.layout.width(column)),
|
|
846
|
+
);
|
|
847
|
+
const y0 = this.geometry.rowY(rows[0]);
|
|
848
|
+
const y1 = this.geometry.rowY(rows[1]);
|
|
849
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
850
|
+
};
|
|
851
|
+
ranges.forEach((range, position) => {
|
|
852
|
+
const rows: [number, number] =
|
|
853
|
+
range.rows === "all" ? [0, rowCount] : [range.rows[0], Math.min(range.rows[1], rowCount)];
|
|
854
|
+
const columns =
|
|
855
|
+
range.columns === "all"
|
|
856
|
+
? this.columns.map((_, index) => index)
|
|
857
|
+
: range.columns.flatMap((name) => this.index.get(name) ?? []);
|
|
858
|
+
const area = rect(rows, columns);
|
|
859
|
+
if (!area) return;
|
|
860
|
+
const single = rows[1] - rows[0] === 1 && columns.length === 1 && ranges.length === 1;
|
|
861
|
+
if (!single) {
|
|
862
|
+
ctx.fillStyle = theme.selection;
|
|
863
|
+
ctx.fillRect(area.x, area.y, area.width, area.height);
|
|
864
|
+
}
|
|
865
|
+
if (position === ranges.length - 1 && !single) {
|
|
866
|
+
ctx.strokeStyle = theme.selectionBorder;
|
|
867
|
+
ctx.lineWidth = 1;
|
|
868
|
+
ctx.strokeRect(this.snap(area.x), this.snap(area.y), area.width - 1, area.height - 1);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
const active = session.active ?? (this.columns.length > 0 ? { row: 0, column: this.columns[0].column.name } : null);
|
|
872
|
+
if (!active) return;
|
|
873
|
+
const column = this.index.get(active.column);
|
|
874
|
+
if (column === undefined || column < from || column > to || active.row >= rowCount) return;
|
|
875
|
+
const x = this.geometry.columnX(column);
|
|
876
|
+
const y = this.geometry.rowY(active.row);
|
|
877
|
+
ctx.strokeStyle = theme.activeBorder;
|
|
878
|
+
ctx.lineWidth = 2;
|
|
879
|
+
ctx.strokeRect(x + 1, y + 1, this.geometry.layout.width(column) - 2, this.geometry.rowHeight - 2);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private drawHeaders(
|
|
883
|
+
ctx: CanvasRenderingContext2D,
|
|
884
|
+
theme: GridTheme,
|
|
885
|
+
snapshot: GridSnapshot,
|
|
886
|
+
left: number,
|
|
887
|
+
right: number,
|
|
888
|
+
) {
|
|
889
|
+
const fitter = this.headerText;
|
|
890
|
+
if (!fitter) return;
|
|
891
|
+
const sort = snapshot.document.plan?.sort ?? [];
|
|
892
|
+
// Columns with conditions of their own (§6.2.1) show a funnel instead of the menu's arrow.
|
|
893
|
+
const filtered = new Set<string>();
|
|
894
|
+
for (const part of partsOf(snapshot.document.plan?.where)) {
|
|
895
|
+
if ("search" in part) continue;
|
|
896
|
+
const names = columnsOf(part);
|
|
897
|
+
if (names.size === 1) for (const name of names) filtered.add(name);
|
|
898
|
+
}
|
|
899
|
+
const draw = (column: number) => {
|
|
900
|
+
const item = this.columns[column];
|
|
901
|
+
const x = this.geometry.columnX(column);
|
|
902
|
+
const width = this.geometry.layout.width(column);
|
|
903
|
+
ctx.fillStyle = theme.headerBackground;
|
|
904
|
+
ctx.fillRect(x, 0, width, this.geometry.headerHeight);
|
|
905
|
+
const nameMiddle = this.geometry.groupTop + this.geometry.nameHeight / 2;
|
|
906
|
+
const at = sort.findIndex((key) => key.column === item.column.name);
|
|
907
|
+
const mark = at < 0 ? "" : `${sort[at].direction === "asc" ? "▲" : "▼"}${sort.length > 1 ? at + 1 : ""}`;
|
|
908
|
+
ctx.font = fitter.font;
|
|
909
|
+
ctx.textBaseline = "middle";
|
|
910
|
+
ctx.fillStyle = theme.headerText;
|
|
911
|
+
ctx.textAlign = "left";
|
|
912
|
+
const space = width - PADDING * 2 - MENU_WIDTH - (mark ? 18 : 0);
|
|
913
|
+
ctx.fillText(fitter.fit(item.setting.format?.label ?? item.column.name, space), x + PADDING, nameMiddle);
|
|
914
|
+
if (mark) {
|
|
915
|
+
ctx.fillStyle = theme.accent;
|
|
916
|
+
ctx.textAlign = "right";
|
|
917
|
+
ctx.fillText(mark, x + width - PADDING - MENU_WIDTH, nameMiddle);
|
|
918
|
+
ctx.textAlign = "left";
|
|
919
|
+
}
|
|
920
|
+
const middle = nameMiddle;
|
|
921
|
+
const center = x + width - PADDING / 2 - MENU_WIDTH / 2;
|
|
922
|
+
ctx.beginPath();
|
|
923
|
+
if (filtered.has(item.column.name)) {
|
|
924
|
+
ctx.fillStyle = theme.accent;
|
|
925
|
+
ctx.moveTo(center - 5, middle - 4);
|
|
926
|
+
ctx.lineTo(center + 5, middle - 4);
|
|
927
|
+
ctx.lineTo(center + 1, middle + 1);
|
|
928
|
+
ctx.lineTo(center + 1, middle + 5);
|
|
929
|
+
ctx.lineTo(center - 1, middle + 5);
|
|
930
|
+
ctx.lineTo(center - 1, middle + 1);
|
|
931
|
+
} else {
|
|
932
|
+
ctx.fillStyle = theme.muted;
|
|
933
|
+
ctx.moveTo(center - 4, middle - 2);
|
|
934
|
+
ctx.lineTo(center + 4, middle - 2);
|
|
935
|
+
ctx.lineTo(center, middle + 3);
|
|
936
|
+
}
|
|
937
|
+
ctx.closePath();
|
|
938
|
+
ctx.fill();
|
|
939
|
+
ctx.strokeStyle = theme.line;
|
|
940
|
+
ctx.lineWidth = 1;
|
|
941
|
+
ctx.beginPath();
|
|
942
|
+
const edge = this.snap(x + width) - 0.5 / this.ratio;
|
|
943
|
+
ctx.moveTo(edge, this.geometry.groupTop + 6);
|
|
944
|
+
ctx.lineTo(edge, this.geometry.headerHeight - 6);
|
|
945
|
+
ctx.stroke();
|
|
946
|
+
};
|
|
947
|
+
const groups = (from: number, to: number) => {
|
|
948
|
+
if (this.geometry.groupRows > 0 && to >= from) this.drawGroups(ctx, theme, from, to);
|
|
949
|
+
};
|
|
950
|
+
const scrollLeft = this.geometry.gutterWidth + this.geometry.frozenWidth;
|
|
951
|
+
const scrollRight = this.geometry.width - this.geometry.frozenRightWidth;
|
|
952
|
+
this.clipped(ctx, scrollLeft, 0, scrollRight - scrollLeft, this.geometry.headerHeight, () => {
|
|
953
|
+
for (let column = left; column <= right; column++) draw(column);
|
|
954
|
+
groups(left, right);
|
|
955
|
+
});
|
|
956
|
+
this.clipped(ctx, this.geometry.gutterWidth, 0, this.geometry.frozenWidth, this.geometry.headerHeight, () => {
|
|
957
|
+
for (let column = 0; column < this.geometry.frozenLeft; column++) draw(column);
|
|
958
|
+
groups(0, this.geometry.frozenLeft - 1);
|
|
959
|
+
});
|
|
960
|
+
this.clipped(ctx, scrollRight, 0, this.geometry.frozenRightWidth, this.geometry.headerHeight, () => {
|
|
961
|
+
for (let column = this.geometry.rightStart; column < this.columns.length; column++) draw(column);
|
|
962
|
+
groups(this.geometry.rightStart, this.columns.length - 1);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** The columns of a group header: the run of display positions next to `column` in the same group. */
|
|
967
|
+
groupSpan(column: number, level: number): [number, number] | null {
|
|
968
|
+
const id = this.geometry.groupPaths[column]?.[level]?.id;
|
|
969
|
+
if (id === undefined) return null;
|
|
970
|
+
const region = (at: number) => (at < this.geometry.frozenLeft ? 0 : at >= this.geometry.rightStart ? 2 : 1);
|
|
971
|
+
let first = column;
|
|
972
|
+
let last = column;
|
|
973
|
+
while (first > 0 && this.geometry.groupPaths[first - 1]?.[level]?.id === id && region(first - 1) === region(column))
|
|
974
|
+
first--;
|
|
975
|
+
while (
|
|
976
|
+
last < this.columns.length - 1 &&
|
|
977
|
+
this.geometry.groupPaths[last + 1]?.[level]?.id === id &&
|
|
978
|
+
region(last + 1) === region(column)
|
|
979
|
+
) {
|
|
980
|
+
last++;
|
|
981
|
+
}
|
|
982
|
+
return [first, last];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The names of a column's groups, from the outermost, for screen readers. */
|
|
986
|
+
groupNames(column: number): string[] {
|
|
987
|
+
return (this.geometry.groupPaths[column] ?? []).flatMap((group) => (group ? [group.name] : []));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The rows of groups over display positions `[from, to]` of one region: each group over the run of its columns
|
|
992
|
+
* that are next to each other there, so a group whose columns were moved apart, hidden or pinned is split, and
|
|
993
|
+
* each part shows its name.
|
|
994
|
+
*/
|
|
995
|
+
private drawGroups(ctx: CanvasRenderingContext2D, theme: GridTheme, from: number, to: number) {
|
|
996
|
+
const fitter = this.headerText;
|
|
997
|
+
if (!fitter) return;
|
|
998
|
+
ctx.font = fitter.font;
|
|
999
|
+
ctx.textBaseline = "middle";
|
|
1000
|
+
ctx.textAlign = "left";
|
|
1001
|
+
ctx.lineWidth = 1;
|
|
1002
|
+
for (let level = 0; level < this.geometry.groupRows; level++) {
|
|
1003
|
+
const top = level * this.geometry.groupHeight;
|
|
1004
|
+
let column = from;
|
|
1005
|
+
while (column <= to) {
|
|
1006
|
+
const group = this.geometry.groupPaths[column]?.[level];
|
|
1007
|
+
let last = column;
|
|
1008
|
+
while (last < to && group && this.geometry.groupPaths[last + 1]?.[level]?.id === group.id) last++;
|
|
1009
|
+
if (group) {
|
|
1010
|
+
const x = this.geometry.columnX(column);
|
|
1011
|
+
const width = this.geometry.columnX(last) + this.geometry.layout.width(last) - x;
|
|
1012
|
+
ctx.fillStyle = theme.headerText;
|
|
1013
|
+
ctx.fillText(fitter.fit(group.name, width - PADDING * 2), x + PADDING, top + this.geometry.groupHeight / 2);
|
|
1014
|
+
ctx.strokeStyle = theme.line;
|
|
1015
|
+
ctx.beginPath();
|
|
1016
|
+
const bottom = this.snap(top + this.geometry.groupHeight) - 0.5 / this.ratio;
|
|
1017
|
+
ctx.moveTo(x + 4, bottom);
|
|
1018
|
+
ctx.lineTo(x + width - 4, bottom);
|
|
1019
|
+
const edge = this.snap(x + width) - 0.5 / this.ratio;
|
|
1020
|
+
ctx.moveTo(edge, top + 4);
|
|
1021
|
+
ctx.lineTo(edge, top + this.geometry.groupHeight);
|
|
1022
|
+
ctx.stroke();
|
|
1023
|
+
}
|
|
1024
|
+
column = last + 1;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private drawGutter(
|
|
1030
|
+
ctx: CanvasRenderingContext2D,
|
|
1031
|
+
theme: GridTheme,
|
|
1032
|
+
snapshot: GridSnapshot,
|
|
1033
|
+
first: number,
|
|
1034
|
+
last: number,
|
|
1035
|
+
) {
|
|
1036
|
+
const fitter = this.cellText;
|
|
1037
|
+
if (!fitter) return;
|
|
1038
|
+
const active = snapshot.session.active?.row ?? 0;
|
|
1039
|
+
this.clipped(
|
|
1040
|
+
ctx,
|
|
1041
|
+
0,
|
|
1042
|
+
this.geometry.headerHeight,
|
|
1043
|
+
this.geometry.gutterWidth,
|
|
1044
|
+
this.geometry.bottom - this.geometry.headerHeight,
|
|
1045
|
+
() => {
|
|
1046
|
+
ctx.fillStyle = theme.gutterBackground;
|
|
1047
|
+
ctx.fillRect(
|
|
1048
|
+
0,
|
|
1049
|
+
this.geometry.headerHeight,
|
|
1050
|
+
this.geometry.gutterWidth,
|
|
1051
|
+
this.geometry.bottom - this.geometry.headerHeight,
|
|
1052
|
+
);
|
|
1053
|
+
ctx.font = fitter.font;
|
|
1054
|
+
ctx.textBaseline = "middle";
|
|
1055
|
+
ctx.textAlign = "right";
|
|
1056
|
+
for (let row = first; row < last; row++) {
|
|
1057
|
+
const y = this.geometry.rowY(row);
|
|
1058
|
+
if (row === active) {
|
|
1059
|
+
ctx.fillStyle = theme.headerActive;
|
|
1060
|
+
ctx.fillRect(0, y, this.geometry.gutterWidth, this.geometry.rowHeight);
|
|
1061
|
+
}
|
|
1062
|
+
ctx.fillStyle = row === active ? theme.headerText : theme.gutterText;
|
|
1063
|
+
ctx.fillText(String(row + 1), this.geometry.gutterWidth - PADDING, y + this.geometry.rowHeight / 2);
|
|
1064
|
+
const key = this.geometry.checkboxes ? this.store?.rowKey(row) : undefined;
|
|
1065
|
+
if (key) {
|
|
1066
|
+
this.drawCheck(
|
|
1067
|
+
ctx,
|
|
1068
|
+
theme,
|
|
1069
|
+
y + this.geometry.rowHeight / 2,
|
|
1070
|
+
checkState(snapshot.session.checked, snapshot.document.plan, key),
|
|
1071
|
+
);
|
|
1072
|
+
ctx.textAlign = "right";
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
ctx.textAlign = "left";
|
|
1076
|
+
},
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The colors of a cell: the host's `cellStyle`, or else the column's style rules. */
|
|
1081
|
+
private cellStyleOf(row: number, column: Column, value: JsonValue): CellStyle | undefined {
|
|
1082
|
+
const hook = this.hooks.cellStyle;
|
|
1083
|
+
if (hook) {
|
|
1084
|
+
const style = hook({ row, column, value, text: value === null ? "" : this.text(row, column, value) });
|
|
1085
|
+
if (style) return style;
|
|
1086
|
+
}
|
|
1087
|
+
return this.styleOf(row, column.name);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** What a cell does, by the host's `cellAction`. */
|
|
1091
|
+
private actionOf(row: number, column: Column, value: JsonValue, text: string): CellAction | null {
|
|
1092
|
+
return this.hooks.cellAction?.({ row, column, value, text }) ?? null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** The action of a cell at a display position, while its value is read; null for plain cells. */
|
|
1096
|
+
actionAt(row: number, columnIndex: number): { action: CellAction; cell: GridCell } | null {
|
|
1097
|
+
const column = this.columns[columnIndex]?.column;
|
|
1098
|
+
const store = this.store;
|
|
1099
|
+
if (!column || !store || !this.hooks.cellAction) return null;
|
|
1100
|
+
const value = store.cell(row, column.name);
|
|
1101
|
+
if (value === undefined || value === null) return null;
|
|
1102
|
+
const text = this.text(row, column, value);
|
|
1103
|
+
const action = this.actionOf(row, column, value, text);
|
|
1104
|
+
return action ? { action, cell: { row, column, value, text } } : null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** The style rule that colors a cell: the first whose `when` is true for its row (§5.1); undefined for none. */
|
|
1108
|
+
styleOf(row: number, name: string): CellStyle | undefined {
|
|
1109
|
+
const rules = this.rules.get(name);
|
|
1110
|
+
const store = this.store;
|
|
1111
|
+
if (!rules || !store) return undefined;
|
|
1112
|
+
const read = (column: string) => store.cell(row, column);
|
|
1113
|
+
return rules.find((item) => item.rule(read) === true);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** A check box in the gutter, centered at `middle`: checked, not checked, or unknown (a dash). */
|
|
1117
|
+
private drawCheck(
|
|
1118
|
+
ctx: CanvasRenderingContext2D,
|
|
1119
|
+
theme: GridTheme,
|
|
1120
|
+
middle: number,
|
|
1121
|
+
state: "checked" | "unchecked" | "unknown",
|
|
1122
|
+
) {
|
|
1123
|
+
const size = 12;
|
|
1124
|
+
const x = Math.round((CHECK_WIDTH - size) / 2) + 2;
|
|
1125
|
+
const y = Math.round(middle - size / 2);
|
|
1126
|
+
ctx.lineWidth = 1;
|
|
1127
|
+
if (state === "unchecked") {
|
|
1128
|
+
ctx.strokeStyle = theme.gutterText;
|
|
1129
|
+
ctx.strokeRect(x + 0.5, y + 0.5, size - 1, size - 1);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
ctx.fillStyle = theme.accent;
|
|
1133
|
+
ctx.fillRect(x, y, size, size);
|
|
1134
|
+
ctx.strokeStyle = theme.accentText;
|
|
1135
|
+
ctx.lineWidth = 1.75;
|
|
1136
|
+
ctx.beginPath();
|
|
1137
|
+
if (state === "checked") {
|
|
1138
|
+
ctx.moveTo(x + 2.5, y + 6);
|
|
1139
|
+
ctx.lineTo(x + 5, y + 8.5);
|
|
1140
|
+
ctx.lineTo(x + 9.5, y + 3.5);
|
|
1141
|
+
} else {
|
|
1142
|
+
ctx.moveTo(x + 3, y + 6);
|
|
1143
|
+
ctx.lineTo(x + 9, y + 6);
|
|
1144
|
+
}
|
|
1145
|
+
ctx.stroke();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** The text of a column's total; undefined while it is read. */
|
|
1149
|
+
private totalText(name: string): string | undefined {
|
|
1150
|
+
const totals = this.snapshot?.totals;
|
|
1151
|
+
const value = totals?.values[name];
|
|
1152
|
+
const type = totals?.columns[name];
|
|
1153
|
+
if (value === undefined || !type) return undefined;
|
|
1154
|
+
return textOf(value, type) ?? "";
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The totals row, at the bottom: each displayed column's total (§5.1), in the column's format and alignment, a
|
|
1159
|
+
* placeholder while it is read, and Σ in the gutter.
|
|
1160
|
+
*/
|
|
1161
|
+
private drawTotals(
|
|
1162
|
+
ctx: CanvasRenderingContext2D,
|
|
1163
|
+
theme: GridTheme,
|
|
1164
|
+
snapshot: GridSnapshot,
|
|
1165
|
+
left: number,
|
|
1166
|
+
right: number,
|
|
1167
|
+
) {
|
|
1168
|
+
const fitter = this.cellText;
|
|
1169
|
+
if (!fitter) return;
|
|
1170
|
+
const top = this.geometry.bottom;
|
|
1171
|
+
const middle = top + this.geometry.footer / 2;
|
|
1172
|
+
ctx.fillStyle = theme.headerBackground;
|
|
1173
|
+
ctx.fillRect(0, top, this.geometry.width, this.geometry.footer);
|
|
1174
|
+
ctx.font = fitter.font;
|
|
1175
|
+
ctx.textBaseline = "middle";
|
|
1176
|
+
const totals = snapshot.totals;
|
|
1177
|
+
const draw = (column: number) => {
|
|
1178
|
+
const item = this.columns[column];
|
|
1179
|
+
if (!item?.setting.total) return;
|
|
1180
|
+
const x = this.geometry.columnX(column);
|
|
1181
|
+
const width = this.geometry.layout.width(column);
|
|
1182
|
+
const text = this.totalText(item.column.name);
|
|
1183
|
+
if (text === undefined) {
|
|
1184
|
+
ctx.fillStyle = theme.placeholder;
|
|
1185
|
+
ctx.fillRect(x + PADDING, middle - 4, Math.max(0, Math.min(width - PADDING * 2, 64)), 8);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const type = totals?.columns[item.column.name] ?? item.column;
|
|
1189
|
+
const fitted = fitter.fit(text, width - PADDING * 2);
|
|
1190
|
+
ctx.fillStyle = theme.headerText;
|
|
1191
|
+
const align = item.setting.format?.align ?? (isNumeric(type) ? "right" : "left");
|
|
1192
|
+
ctx.textAlign = align;
|
|
1193
|
+
ctx.fillText(
|
|
1194
|
+
fitted,
|
|
1195
|
+
align === "right" ? x + width - PADDING : align === "center" ? x + width / 2 : x + PADDING,
|
|
1196
|
+
middle,
|
|
1197
|
+
);
|
|
1198
|
+
};
|
|
1199
|
+
const scrollLeft = this.geometry.gutterWidth + this.geometry.frozenWidth;
|
|
1200
|
+
const scrollRight = this.geometry.width - this.geometry.frozenRightWidth;
|
|
1201
|
+
this.clipped(ctx, scrollLeft, top, scrollRight - scrollLeft, this.geometry.footer, () => {
|
|
1202
|
+
for (let column = left; column <= right; column++) draw(column);
|
|
1203
|
+
});
|
|
1204
|
+
this.clipped(ctx, this.geometry.gutterWidth, top, this.geometry.frozenWidth, this.geometry.footer, () => {
|
|
1205
|
+
for (let column = 0; column < this.geometry.frozenLeft; column++) draw(column);
|
|
1206
|
+
});
|
|
1207
|
+
this.clipped(ctx, scrollRight, top, this.geometry.frozenRightWidth, this.geometry.footer, () => {
|
|
1208
|
+
for (let column = this.geometry.rightStart; column < this.columns.length; column++) draw(column);
|
|
1209
|
+
});
|
|
1210
|
+
ctx.fillStyle = theme.gutterText;
|
|
1211
|
+
ctx.textAlign = "right";
|
|
1212
|
+
ctx.fillText("Σ", this.geometry.gutterWidth - PADDING, middle);
|
|
1213
|
+
ctx.textAlign = "left";
|
|
1214
|
+
ctx.strokeStyle = theme.lineStrong;
|
|
1215
|
+
ctx.lineWidth = 1;
|
|
1216
|
+
ctx.beginPath();
|
|
1217
|
+
const line = this.snap(top) - 0.5 / this.ratio;
|
|
1218
|
+
ctx.moveTo(0, line);
|
|
1219
|
+
ctx.lineTo(this.geometry.width, line);
|
|
1220
|
+
ctx.stroke();
|
|
1221
|
+
}
|
|
1222
|
+
}
|