vastgrid 0.1.1 → 0.2.1

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