@bendyline/squisq-grid-react 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +16 -0
- package/README.md +23 -0
- package/THIRD_PARTY_LICENSES.txt +8 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +1778 -0
- package/dist/styles/index.css +341 -0
- package/dist/styles/index.d.ts +1 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1778 @@
|
|
|
1
|
+
// src/store/columns.ts
|
|
2
|
+
import { isNumericCellText } from "@bendyline/squisq/table";
|
|
3
|
+
function inferKind(cells, col) {
|
|
4
|
+
let sawNumber = false;
|
|
5
|
+
let sawBoolean = false;
|
|
6
|
+
let sawValue = false;
|
|
7
|
+
for (const row of cells) {
|
|
8
|
+
const cell = row[col];
|
|
9
|
+
if (cell === null || cell === void 0 || cell === "") continue;
|
|
10
|
+
sawValue = true;
|
|
11
|
+
if (typeof cell === "number") {
|
|
12
|
+
sawNumber = true;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (typeof cell === "boolean") {
|
|
16
|
+
sawBoolean = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const text = String(cell);
|
|
20
|
+
if (isNumericCellText(text)) {
|
|
21
|
+
sawNumber = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (/^(?:true|false)$/i.test(text.trim())) {
|
|
25
|
+
sawBoolean = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
return "string";
|
|
29
|
+
}
|
|
30
|
+
if (!sawValue) return "string";
|
|
31
|
+
if (sawNumber && !sawBoolean) return "number";
|
|
32
|
+
if (sawBoolean && !sawNumber) return "boolean";
|
|
33
|
+
return "string";
|
|
34
|
+
}
|
|
35
|
+
function isBlank(cell) {
|
|
36
|
+
return cell === null || cell === void 0 || typeof cell === "string" && cell.trim() === "";
|
|
37
|
+
}
|
|
38
|
+
function buildColumnarTable(table) {
|
|
39
|
+
const rowCount = table.cells.length;
|
|
40
|
+
const columns = table.headers.map((name, col) => {
|
|
41
|
+
const kind = table.hints?.[col]?.kind ?? inferKind(table.cells, col);
|
|
42
|
+
if (kind === "number") {
|
|
43
|
+
const data = new Float64Array(rowCount);
|
|
44
|
+
const valid = new Uint8Array(rowCount);
|
|
45
|
+
for (let row = 0; row < rowCount; row++) {
|
|
46
|
+
const cell = table.cells[row]?.[col];
|
|
47
|
+
if (isBlank(cell)) continue;
|
|
48
|
+
const value = typeof cell === "number" ? cell : Number(String(cell).trim());
|
|
49
|
+
if (!Number.isFinite(value)) continue;
|
|
50
|
+
data[row] = value;
|
|
51
|
+
valid[row] = 1;
|
|
52
|
+
}
|
|
53
|
+
return { kind, name, data, valid };
|
|
54
|
+
}
|
|
55
|
+
if (kind === "boolean") {
|
|
56
|
+
const data = new Uint8Array(rowCount);
|
|
57
|
+
const valid = new Uint8Array(rowCount);
|
|
58
|
+
for (let row = 0; row < rowCount; row++) {
|
|
59
|
+
const cell = table.cells[row]?.[col];
|
|
60
|
+
if (isBlank(cell)) continue;
|
|
61
|
+
const truthy = typeof cell === "boolean" ? cell : /^true$/i.test(String(cell).trim());
|
|
62
|
+
data[row] = truthy ? 1 : 0;
|
|
63
|
+
valid[row] = 1;
|
|
64
|
+
}
|
|
65
|
+
return { kind, name, data, valid };
|
|
66
|
+
}
|
|
67
|
+
const codes = new Int32Array(rowCount);
|
|
68
|
+
const dict = [];
|
|
69
|
+
const codeByValue = /* @__PURE__ */ new Map();
|
|
70
|
+
for (let row = 0; row < rowCount; row++) {
|
|
71
|
+
const cell = table.cells[row]?.[col];
|
|
72
|
+
if (isBlank(cell)) {
|
|
73
|
+
codes[row] = -1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const text = typeof cell === "string" ? cell : String(cell);
|
|
77
|
+
let code = codeByValue.get(text);
|
|
78
|
+
if (code === void 0) {
|
|
79
|
+
code = dict.length;
|
|
80
|
+
dict.push(text);
|
|
81
|
+
codeByValue.set(text, code);
|
|
82
|
+
}
|
|
83
|
+
codes[row] = code;
|
|
84
|
+
}
|
|
85
|
+
return { kind, name, codes, dict };
|
|
86
|
+
});
|
|
87
|
+
return { columns, rowCount };
|
|
88
|
+
}
|
|
89
|
+
function columnCellValue(column, row) {
|
|
90
|
+
if (column.kind === "number") {
|
|
91
|
+
return column.valid[row] ? column.data[row] : null;
|
|
92
|
+
}
|
|
93
|
+
if (column.kind === "boolean") {
|
|
94
|
+
return column.valid[row] ? column.data[row] === 1 : null;
|
|
95
|
+
}
|
|
96
|
+
const code = column.codes[row];
|
|
97
|
+
return code < 0 ? null : column.dict[code];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/store/kernel.ts
|
|
101
|
+
function tableKernel(scope) {
|
|
102
|
+
let columns = [];
|
|
103
|
+
let rowCount = 0;
|
|
104
|
+
let perm = [];
|
|
105
|
+
let activeSort = [];
|
|
106
|
+
let activeFilter = [];
|
|
107
|
+
const collator = new Intl.Collator(void 0, { numeric: true });
|
|
108
|
+
function isNumericText(value) {
|
|
109
|
+
const trimmed = value.trim();
|
|
110
|
+
if (trimmed === "") return false;
|
|
111
|
+
if (/^0\d/.test(trimmed)) return false;
|
|
112
|
+
return Number.isFinite(Number(trimmed));
|
|
113
|
+
}
|
|
114
|
+
function ensureRank(column) {
|
|
115
|
+
if (!column.rank || column.rank.length !== (column.dict?.length ?? 0)) {
|
|
116
|
+
const dict = column.dict ?? [];
|
|
117
|
+
const order = dict.map((_, index) => index);
|
|
118
|
+
order.sort((a, b) => collator.compare(dict[a], dict[b]));
|
|
119
|
+
const rank = new Int32Array(dict.length);
|
|
120
|
+
for (let position = 0; position < order.length; position++) {
|
|
121
|
+
rank[order[position]] = position;
|
|
122
|
+
}
|
|
123
|
+
column.rank = rank;
|
|
124
|
+
}
|
|
125
|
+
return column.rank;
|
|
126
|
+
}
|
|
127
|
+
function ensureLower(column) {
|
|
128
|
+
if (!column.lower || column.lower.length !== (column.dict?.length ?? 0)) {
|
|
129
|
+
column.lower = (column.dict ?? []).map((value) => value.toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
return column.lower;
|
|
132
|
+
}
|
|
133
|
+
function isBlank2(column, row) {
|
|
134
|
+
if (column.kind === "number" || column.kind === "boolean") {
|
|
135
|
+
return !column.valid || column.valid[row] === 0;
|
|
136
|
+
}
|
|
137
|
+
return column.data[row] < 0;
|
|
138
|
+
}
|
|
139
|
+
function cellText(column, row) {
|
|
140
|
+
if (isBlank2(column, row)) return "";
|
|
141
|
+
if (column.kind === "number") return String(column.data[row]);
|
|
142
|
+
if (column.kind === "boolean") return column.data[row] === 1 ? "true" : "false";
|
|
143
|
+
return column.dict[column.data[row]];
|
|
144
|
+
}
|
|
145
|
+
function cellValue(column, row) {
|
|
146
|
+
if (isBlank2(column, row)) return null;
|
|
147
|
+
if (column.kind === "number") return column.data[row];
|
|
148
|
+
if (column.kind === "boolean") return column.data[row] === 1;
|
|
149
|
+
return column.dict[column.data[row]];
|
|
150
|
+
}
|
|
151
|
+
function matches(row, clause) {
|
|
152
|
+
const column = columns[clause.col];
|
|
153
|
+
if (!column) return true;
|
|
154
|
+
const valueTrim = clause.value.trim();
|
|
155
|
+
const blank = isBlank2(column, row);
|
|
156
|
+
const fold = (text) => clause.caseSensitive ? text : text.toLowerCase();
|
|
157
|
+
const textFor = () => {
|
|
158
|
+
if (!clause.caseSensitive && (column.kind === "string" || column.kind === "date") && !blank) {
|
|
159
|
+
return ensureLower(column)[column.data[row]].trim();
|
|
160
|
+
}
|
|
161
|
+
return fold(cellText(column, row).trim());
|
|
162
|
+
};
|
|
163
|
+
switch (clause.op) {
|
|
164
|
+
case "=": {
|
|
165
|
+
if (valueTrim === "") return blank;
|
|
166
|
+
if (blank) return false;
|
|
167
|
+
if (column.kind === "number" && isNumericText(valueTrim)) {
|
|
168
|
+
return column.data[row] === Number(valueTrim);
|
|
169
|
+
}
|
|
170
|
+
return textFor() === fold(valueTrim);
|
|
171
|
+
}
|
|
172
|
+
case "!=":
|
|
173
|
+
return !matches(row, { ...clause, op: "=" });
|
|
174
|
+
case "~":
|
|
175
|
+
if (blank) return false;
|
|
176
|
+
return textFor().includes(fold(valueTrim));
|
|
177
|
+
case "!~":
|
|
178
|
+
return !matches(row, { ...clause, op: "~" });
|
|
179
|
+
case "^~":
|
|
180
|
+
if (blank) return false;
|
|
181
|
+
return textFor().startsWith(fold(valueTrim));
|
|
182
|
+
case "$~":
|
|
183
|
+
if (blank) return false;
|
|
184
|
+
return textFor().endsWith(fold(valueTrim));
|
|
185
|
+
default: {
|
|
186
|
+
if (blank) return false;
|
|
187
|
+
let comparison;
|
|
188
|
+
if (column.kind === "number" && isNumericText(valueTrim)) {
|
|
189
|
+
comparison = column.data[row] - Number(valueTrim);
|
|
190
|
+
} else {
|
|
191
|
+
comparison = collator.compare(cellText(column, row).trim(), valueTrim);
|
|
192
|
+
}
|
|
193
|
+
if (clause.op === ">") return comparison > 0;
|
|
194
|
+
if (clause.op === "<") return comparison < 0;
|
|
195
|
+
if (clause.op === ">=") return comparison >= 0;
|
|
196
|
+
return comparison <= 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function recomputeView() {
|
|
201
|
+
const ids = [];
|
|
202
|
+
for (let row = 0; row < rowCount; row++) {
|
|
203
|
+
let pass = true;
|
|
204
|
+
for (const clause of activeFilter) {
|
|
205
|
+
if (!matches(row, clause)) {
|
|
206
|
+
pass = false;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (pass) ids.push(row);
|
|
211
|
+
}
|
|
212
|
+
if (activeSort.length > 0) {
|
|
213
|
+
for (const term of activeSort) {
|
|
214
|
+
const column = columns[term.col];
|
|
215
|
+
if (column && (column.kind === "string" || column.kind === "date")) ensureRank(column);
|
|
216
|
+
}
|
|
217
|
+
ids.sort((a, b) => {
|
|
218
|
+
for (const term of activeSort) {
|
|
219
|
+
const column = columns[term.col];
|
|
220
|
+
if (!column) continue;
|
|
221
|
+
const aBlank = isBlank2(column, a);
|
|
222
|
+
const bBlank = isBlank2(column, b);
|
|
223
|
+
if (aBlank || bBlank) {
|
|
224
|
+
if (aBlank && bBlank) continue;
|
|
225
|
+
return aBlank ? 1 : -1;
|
|
226
|
+
}
|
|
227
|
+
let comparison;
|
|
228
|
+
if (column.kind === "number") {
|
|
229
|
+
comparison = column.data[a] - column.data[b];
|
|
230
|
+
} else if (column.kind === "boolean") {
|
|
231
|
+
comparison = column.data[a] - column.data[b];
|
|
232
|
+
} else {
|
|
233
|
+
const rank = column.rank;
|
|
234
|
+
comparison = rank[column.data[a]] - rank[column.data[b]];
|
|
235
|
+
}
|
|
236
|
+
if (comparison !== 0) return term.dir === "desc" ? -comparison : comparison;
|
|
237
|
+
}
|
|
238
|
+
return a - b;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
perm = ids;
|
|
242
|
+
}
|
|
243
|
+
function applyEdit(edit) {
|
|
244
|
+
const column = columns[edit.col];
|
|
245
|
+
if (!column || edit.rowId < 0 || edit.rowId >= rowCount) return;
|
|
246
|
+
if (column.kind === "number") {
|
|
247
|
+
const blank = edit.value === null || edit.value === "";
|
|
248
|
+
column.valid[edit.rowId] = blank ? 0 : 1;
|
|
249
|
+
column.data[edit.rowId] = blank ? 0 : Number(edit.value);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (column.kind === "boolean") {
|
|
253
|
+
const blank = edit.value === null || edit.value === "";
|
|
254
|
+
column.valid[edit.rowId] = blank ? 0 : 1;
|
|
255
|
+
column.data[edit.rowId] = edit.value === true ? 1 : 0;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (edit.value === null || edit.value === "") {
|
|
259
|
+
column.data[edit.rowId] = -1;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const text = String(edit.value);
|
|
263
|
+
if (!column.codeByValue) {
|
|
264
|
+
column.codeByValue = /* @__PURE__ */ new Map();
|
|
265
|
+
(column.dict ?? []).forEach((value, code2) => column.codeByValue.set(value, code2));
|
|
266
|
+
}
|
|
267
|
+
let code = column.codeByValue.get(text);
|
|
268
|
+
if (code === void 0) {
|
|
269
|
+
code = column.dict.length;
|
|
270
|
+
column.dict.push(text);
|
|
271
|
+
column.codeByValue.set(text, code);
|
|
272
|
+
column.rank = void 0;
|
|
273
|
+
column.lower = void 0;
|
|
274
|
+
}
|
|
275
|
+
column.data[edit.rowId] = code;
|
|
276
|
+
}
|
|
277
|
+
scope.onmessage = (event) => {
|
|
278
|
+
const message = event.data;
|
|
279
|
+
try {
|
|
280
|
+
if (message.type === "init") {
|
|
281
|
+
columns = message.columns.map((payload) => ({ ...payload }));
|
|
282
|
+
rowCount = message.rowCount;
|
|
283
|
+
activeSort = [];
|
|
284
|
+
activeFilter = [];
|
|
285
|
+
recomputeView();
|
|
286
|
+
scope.postMessage({ type: "ready", seq: message.seq });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (message.type === "setView") {
|
|
290
|
+
activeSort = message.sort;
|
|
291
|
+
activeFilter = message.filter;
|
|
292
|
+
recomputeView();
|
|
293
|
+
scope.postMessage({
|
|
294
|
+
type: "viewResult",
|
|
295
|
+
seq: message.seq,
|
|
296
|
+
viewRowCount: perm.length
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (message.type === "rows") {
|
|
301
|
+
const start = Math.max(0, message.start);
|
|
302
|
+
const end = Math.min(perm.length, start + message.count);
|
|
303
|
+
const rowIds = [];
|
|
304
|
+
const cells = [];
|
|
305
|
+
for (let index = start; index < end; index++) {
|
|
306
|
+
const rowId = perm[index];
|
|
307
|
+
rowIds.push(rowId);
|
|
308
|
+
cells.push(columns.map((column) => cellValue(column, rowId)));
|
|
309
|
+
}
|
|
310
|
+
scope.postMessage({ type: "rowsResult", seq: message.seq, start, rowIds, cells });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (message.type === "applyEdits") {
|
|
314
|
+
const touched = /* @__PURE__ */ new Set();
|
|
315
|
+
for (const edit of message.edits) {
|
|
316
|
+
applyEdit(edit);
|
|
317
|
+
touched.add(edit.col);
|
|
318
|
+
}
|
|
319
|
+
const staleView = activeSort.some((term) => touched.has(term.col)) || activeFilter.some((clause) => touched.has(clause.col));
|
|
320
|
+
scope.postMessage({ type: "editResult", seq: message.seq, staleView });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (message.type === "distinct") {
|
|
324
|
+
const column = columns[message.col];
|
|
325
|
+
const limit = Math.max(1, message.limit);
|
|
326
|
+
let hasBlank = false;
|
|
327
|
+
let values = [];
|
|
328
|
+
let totalDistinct = 0;
|
|
329
|
+
if (column && (column.kind === "string" || column.kind === "date")) {
|
|
330
|
+
const dict = column.dict ?? [];
|
|
331
|
+
const used = new Uint8Array(dict.length);
|
|
332
|
+
const codes = column.data;
|
|
333
|
+
for (let row = 0; row < rowCount; row++) {
|
|
334
|
+
const code = codes[row];
|
|
335
|
+
if (code < 0) hasBlank = true;
|
|
336
|
+
else used[code] = 1;
|
|
337
|
+
}
|
|
338
|
+
const rank = ensureRank(column);
|
|
339
|
+
const present = [];
|
|
340
|
+
for (let code = 0; code < used.length; code++) {
|
|
341
|
+
if (used[code] === 1) present.push(code);
|
|
342
|
+
}
|
|
343
|
+
present.sort((a, b) => rank[a] - rank[b]);
|
|
344
|
+
totalDistinct = present.length;
|
|
345
|
+
values = present.slice(0, limit).map((code) => dict[code]);
|
|
346
|
+
} else if (column && column.kind === "number") {
|
|
347
|
+
const data = column.data;
|
|
348
|
+
const seen = /* @__PURE__ */ new Set();
|
|
349
|
+
for (let row = 0; row < rowCount; row++) {
|
|
350
|
+
if (!column.valid || column.valid[row] === 0) hasBlank = true;
|
|
351
|
+
else seen.add(data[row]);
|
|
352
|
+
}
|
|
353
|
+
const sorted = [...seen].sort((a, b) => a - b);
|
|
354
|
+
totalDistinct = sorted.length;
|
|
355
|
+
values = sorted.slice(0, limit).map((value) => String(value));
|
|
356
|
+
} else if (column) {
|
|
357
|
+
let sawTrue = false;
|
|
358
|
+
let sawFalse = false;
|
|
359
|
+
const data = column.data;
|
|
360
|
+
for (let row = 0; row < rowCount; row++) {
|
|
361
|
+
if (!column.valid || column.valid[row] === 0) hasBlank = true;
|
|
362
|
+
else if (data[row] === 1) sawTrue = true;
|
|
363
|
+
else sawFalse = true;
|
|
364
|
+
}
|
|
365
|
+
if (sawFalse) values.push("false");
|
|
366
|
+
if (sawTrue) values.push("true");
|
|
367
|
+
totalDistinct = values.length;
|
|
368
|
+
values = values.slice(0, limit);
|
|
369
|
+
}
|
|
370
|
+
scope.postMessage({
|
|
371
|
+
type: "distinctResult",
|
|
372
|
+
seq: message.seq,
|
|
373
|
+
values,
|
|
374
|
+
totalDistinct,
|
|
375
|
+
hasBlank
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (message.type === "dispose") {
|
|
380
|
+
columns = [];
|
|
381
|
+
perm = [];
|
|
382
|
+
rowCount = 0;
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const seq = "seq" in message ? message.seq : -1;
|
|
386
|
+
scope.postMessage({
|
|
387
|
+
type: "error",
|
|
388
|
+
seq,
|
|
389
|
+
message: err instanceof Error ? err.message : String(err)
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function buildKernelSource() {
|
|
395
|
+
return `(${tableKernel.toString()})(self);`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/store/client.ts
|
|
399
|
+
import { parseTableViewState, serializeTableViewState } from "@bendyline/squisq/table";
|
|
400
|
+
var LocalKernelHost = class {
|
|
401
|
+
constructor() {
|
|
402
|
+
this.onResponse = () => {
|
|
403
|
+
};
|
|
404
|
+
const scope = {
|
|
405
|
+
onmessage: null,
|
|
406
|
+
postMessage: (message) => this.onResponse(message)
|
|
407
|
+
};
|
|
408
|
+
tableKernel(scope);
|
|
409
|
+
this.scope = scope;
|
|
410
|
+
}
|
|
411
|
+
post(message) {
|
|
412
|
+
this.scope.onmessage?.({ data: message });
|
|
413
|
+
}
|
|
414
|
+
terminate() {
|
|
415
|
+
this.scope.onmessage = null;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
var BlobWorkerHost = class {
|
|
419
|
+
constructor() {
|
|
420
|
+
this.onResponse = () => {
|
|
421
|
+
};
|
|
422
|
+
const url = URL.createObjectURL(new Blob([buildKernelSource()], { type: "text/javascript" }));
|
|
423
|
+
try {
|
|
424
|
+
this.worker = new Worker(url);
|
|
425
|
+
} finally {
|
|
426
|
+
URL.revokeObjectURL(url);
|
|
427
|
+
}
|
|
428
|
+
this.worker.onmessage = (event) => {
|
|
429
|
+
this.onResponse(event.data);
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
post(message, transfer) {
|
|
433
|
+
this.worker.postMessage(message, transfer ?? []);
|
|
434
|
+
}
|
|
435
|
+
terminate() {
|
|
436
|
+
this.worker.terminate();
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
function toPayload(column) {
|
|
440
|
+
if (column.kind === "number" || column.kind === "boolean") {
|
|
441
|
+
return {
|
|
442
|
+
payload: { name: column.name, kind: column.kind, data: column.data, valid: column.valid },
|
|
443
|
+
transfer: [column.data.buffer, column.valid.buffer]
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
payload: { name: column.name, kind: column.kind, data: column.codes, dict: column.dict },
|
|
448
|
+
transfer: [column.codes.buffer]
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
var TableStoreClient = class {
|
|
452
|
+
constructor(table, options = {}) {
|
|
453
|
+
this.seq = 0;
|
|
454
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
455
|
+
const columnar = buildColumnarTable(table);
|
|
456
|
+
this.schema = {
|
|
457
|
+
columns: columnar.columns.map((column) => ({ name: column.name, kind: column.kind })),
|
|
458
|
+
rowCount: columnar.rowCount
|
|
459
|
+
};
|
|
460
|
+
this.viewRowCount = columnar.rowCount;
|
|
461
|
+
this.host = options.forceLocal || typeof Worker === "undefined" ? new LocalKernelHost() : new BlobWorkerHost();
|
|
462
|
+
this.host.onResponse = (message) => {
|
|
463
|
+
const resolve = this.pending.get(message.seq);
|
|
464
|
+
if (resolve) {
|
|
465
|
+
this.pending.delete(message.seq);
|
|
466
|
+
resolve(message);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
const payloads = columnar.columns.map(toPayload);
|
|
470
|
+
this.ready = this.request(
|
|
471
|
+
(seq) => ({
|
|
472
|
+
type: "init",
|
|
473
|
+
seq,
|
|
474
|
+
columns: payloads.map((entry) => entry.payload),
|
|
475
|
+
rowCount: columnar.rowCount
|
|
476
|
+
}),
|
|
477
|
+
payloads.flatMap((entry) => entry.transfer)
|
|
478
|
+
).then(() => void 0);
|
|
479
|
+
}
|
|
480
|
+
request(build, transfer) {
|
|
481
|
+
const seq = ++this.seq;
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
this.pending.set(seq, (message) => {
|
|
484
|
+
if (message.type === "error") reject(new Error(message.message));
|
|
485
|
+
else resolve(message);
|
|
486
|
+
});
|
|
487
|
+
this.host.post(build(seq), transfer);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async describe() {
|
|
491
|
+
await this.ready;
|
|
492
|
+
return this.schema;
|
|
493
|
+
}
|
|
494
|
+
async setView(view) {
|
|
495
|
+
await this.ready;
|
|
496
|
+
const raw = serializeTableViewState(view);
|
|
497
|
+
const headers = this.schema.columns.map((column) => column.name);
|
|
498
|
+
const { view: resolved, issues } = parseTableViewState(raw.sort, raw.filter, headers);
|
|
499
|
+
const columnIndex = new Map(headers.map((name, index) => [name, index]));
|
|
500
|
+
const response = await this.request((seq) => ({
|
|
501
|
+
type: "setView",
|
|
502
|
+
seq,
|
|
503
|
+
sort: resolved.sort.map((term) => ({
|
|
504
|
+
col: columnIndex.get(term.column),
|
|
505
|
+
dir: term.dir
|
|
506
|
+
})),
|
|
507
|
+
filter: resolved.filter.map((clause) => ({
|
|
508
|
+
col: columnIndex.get(clause.column),
|
|
509
|
+
op: clause.op,
|
|
510
|
+
value: clause.value,
|
|
511
|
+
...clause.caseSensitive ? { caseSensitive: true } : {}
|
|
512
|
+
}))
|
|
513
|
+
}));
|
|
514
|
+
if (response.type !== "viewResult") throw new Error("unexpected kernel response");
|
|
515
|
+
this.viewRowCount = response.viewRowCount;
|
|
516
|
+
return { viewRowCount: response.viewRowCount, issues };
|
|
517
|
+
}
|
|
518
|
+
async rows(start, count) {
|
|
519
|
+
await this.ready;
|
|
520
|
+
const response = await this.request((seq) => ({ type: "rows", seq, start, count }));
|
|
521
|
+
if (response.type !== "rowsResult") throw new Error("unexpected kernel response");
|
|
522
|
+
return { start: response.start, rowIds: response.rowIds, cells: response.cells };
|
|
523
|
+
}
|
|
524
|
+
async applyEdits(edits) {
|
|
525
|
+
await this.ready;
|
|
526
|
+
const response = await this.request((seq) => ({
|
|
527
|
+
type: "applyEdits",
|
|
528
|
+
seq,
|
|
529
|
+
edits: edits.map((edit) => ({ rowId: edit.rowId, col: edit.col, value: edit.value }))
|
|
530
|
+
}));
|
|
531
|
+
if (response.type !== "editResult") throw new Error("unexpected kernel response");
|
|
532
|
+
return { staleView: response.staleView };
|
|
533
|
+
}
|
|
534
|
+
async distinct(col, limit) {
|
|
535
|
+
await this.ready;
|
|
536
|
+
const response = await this.request((seq) => ({ type: "distinct", seq, col, limit }));
|
|
537
|
+
if (response.type !== "distinctResult") throw new Error("unexpected kernel response");
|
|
538
|
+
return {
|
|
539
|
+
values: response.values,
|
|
540
|
+
totalDistinct: response.totalDistinct,
|
|
541
|
+
hasBlank: response.hasBlank
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** Row count under the current view (post-filter). */
|
|
545
|
+
get currentViewRowCount() {
|
|
546
|
+
return this.viewRowCount;
|
|
547
|
+
}
|
|
548
|
+
dispose() {
|
|
549
|
+
this.host.post({ type: "dispose" });
|
|
550
|
+
this.host.terminate();
|
|
551
|
+
this.pending.clear();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// src/store/journal.ts
|
|
556
|
+
var EditJournal = class {
|
|
557
|
+
constructor() {
|
|
558
|
+
/** Latest value per cell, keyed `rowId:col`. */
|
|
559
|
+
this.latest = /* @__PURE__ */ new Map();
|
|
560
|
+
this.undoStack = [];
|
|
561
|
+
this.redoStack = [];
|
|
562
|
+
}
|
|
563
|
+
get dirtyCount() {
|
|
564
|
+
return this.latest.size;
|
|
565
|
+
}
|
|
566
|
+
get canUndo() {
|
|
567
|
+
return this.undoStack.length > 0;
|
|
568
|
+
}
|
|
569
|
+
get canRedo() {
|
|
570
|
+
return this.redoStack.length > 0;
|
|
571
|
+
}
|
|
572
|
+
isDirty(rowId, col) {
|
|
573
|
+
return this.latest.has(`${rowId}:${col}`);
|
|
574
|
+
}
|
|
575
|
+
/** Record one committed batch of edits. Clears the redo stack. */
|
|
576
|
+
commit(entries) {
|
|
577
|
+
if (entries.length === 0) return;
|
|
578
|
+
this.undoStack.push(entries);
|
|
579
|
+
this.redoStack.length = 0;
|
|
580
|
+
for (const entry of entries) this.applyLatest(entry);
|
|
581
|
+
}
|
|
582
|
+
/** Pop the latest batch; returns the INVERSE edits to apply to the store. */
|
|
583
|
+
undo() {
|
|
584
|
+
const batch = this.undoStack.pop();
|
|
585
|
+
if (!batch) return [];
|
|
586
|
+
this.redoStack.push(batch);
|
|
587
|
+
const inverse = batch.map((entry) => ({
|
|
588
|
+
rowId: entry.rowId,
|
|
589
|
+
col: entry.col,
|
|
590
|
+
value: entry.prev
|
|
591
|
+
}));
|
|
592
|
+
this.rebuildLatest();
|
|
593
|
+
return inverse;
|
|
594
|
+
}
|
|
595
|
+
/** Re-apply the most recently undone batch. */
|
|
596
|
+
redo() {
|
|
597
|
+
const batch = this.redoStack.pop();
|
|
598
|
+
if (!batch) return [];
|
|
599
|
+
this.undoStack.push(batch);
|
|
600
|
+
this.rebuildLatest();
|
|
601
|
+
return batch.map((entry) => ({ rowId: entry.rowId, col: entry.col, value: entry.next }));
|
|
602
|
+
}
|
|
603
|
+
/** Net outstanding edits (what a Save must persist). */
|
|
604
|
+
entries() {
|
|
605
|
+
return [...this.latest.values()];
|
|
606
|
+
}
|
|
607
|
+
clear() {
|
|
608
|
+
this.latest.clear();
|
|
609
|
+
this.undoStack.length = 0;
|
|
610
|
+
this.redoStack.length = 0;
|
|
611
|
+
}
|
|
612
|
+
applyLatest(entry) {
|
|
613
|
+
const key = `${entry.rowId}:${entry.col}`;
|
|
614
|
+
const existing = this.latest.get(key);
|
|
615
|
+
const merged = existing ? { ...entry, prev: existing.prev } : { ...entry };
|
|
616
|
+
if (merged.prev === merged.next) this.latest.delete(key);
|
|
617
|
+
else this.latest.set(key, merged);
|
|
618
|
+
}
|
|
619
|
+
rebuildLatest() {
|
|
620
|
+
this.latest.clear();
|
|
621
|
+
for (const batch of this.undoStack) {
|
|
622
|
+
for (const entry of batch) this.applyLatest(entry);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
var journalCache = /* @__PURE__ */ new Map();
|
|
627
|
+
function journalFor(path, revision) {
|
|
628
|
+
const key = `${revision} ${path}`;
|
|
629
|
+
let journal = journalCache.get(key);
|
|
630
|
+
if (!journal) {
|
|
631
|
+
for (const existing of journalCache.keys()) {
|
|
632
|
+
if (existing.endsWith(` ${path}`) && existing !== key) journalCache.delete(existing);
|
|
633
|
+
}
|
|
634
|
+
journal = new EditJournal();
|
|
635
|
+
journalCache.set(key, journal);
|
|
636
|
+
}
|
|
637
|
+
return journal;
|
|
638
|
+
}
|
|
639
|
+
function discardJournal(path) {
|
|
640
|
+
for (const existing of [...journalCache.keys()]) {
|
|
641
|
+
if (existing.endsWith(` ${path}`)) journalCache.delete(existing);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/DataGrid.tsx
|
|
646
|
+
import {
|
|
647
|
+
useCallback,
|
|
648
|
+
useEffect,
|
|
649
|
+
useLayoutEffect,
|
|
650
|
+
useMemo,
|
|
651
|
+
useRef,
|
|
652
|
+
useState
|
|
653
|
+
} from "react";
|
|
654
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
655
|
+
import { serializeTableViewState as serializeTableViewState2 } from "@bendyline/squisq/table";
|
|
656
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
657
|
+
var ROW_HEIGHT = 28;
|
|
658
|
+
var PAGE_SIZE = 200;
|
|
659
|
+
var OVERSCAN = 12;
|
|
660
|
+
var DEFAULT_HEIGHT = 420;
|
|
661
|
+
var COPY_CELL_CAP = 5e4;
|
|
662
|
+
var DEFAULT_COL_WIDTH = 140;
|
|
663
|
+
function cellDisplay(value) {
|
|
664
|
+
if (value === null || value === void 0) return "";
|
|
665
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
666
|
+
return String(value);
|
|
667
|
+
}
|
|
668
|
+
function coerceInput(text, kind) {
|
|
669
|
+
const trimmed = text.trim();
|
|
670
|
+
if (trimmed === "") return null;
|
|
671
|
+
if (kind === "number") {
|
|
672
|
+
const value = Number(trimmed);
|
|
673
|
+
if (!Number.isFinite(value)) return { error: "not a number" };
|
|
674
|
+
return value;
|
|
675
|
+
}
|
|
676
|
+
if (kind === "boolean") {
|
|
677
|
+
if (/^(?:true|1)$/i.test(trimmed)) return true;
|
|
678
|
+
if (/^(?:false|0)$/i.test(trimmed)) return false;
|
|
679
|
+
return { error: "true or false" };
|
|
680
|
+
}
|
|
681
|
+
return text;
|
|
682
|
+
}
|
|
683
|
+
var TEXT_OP_CHOICES = [
|
|
684
|
+
{ op: "~", glyph: "~", label: "Contains" },
|
|
685
|
+
{ op: "!~", glyph: "!~", label: "Doesn't contain" },
|
|
686
|
+
{ op: "=", glyph: "=", label: "Equals" },
|
|
687
|
+
{ op: "!=", glyph: "\u2260", label: "Not equal" },
|
|
688
|
+
{ op: "^~", glyph: "^", label: "Starts with" },
|
|
689
|
+
{ op: "$~", glyph: "$", label: "Ends with" }
|
|
690
|
+
];
|
|
691
|
+
var COMPARE_OP_CHOICES = [
|
|
692
|
+
{ op: "=", glyph: "=", label: "Equals" },
|
|
693
|
+
{ op: "!=", glyph: "\u2260", label: "Not equal" },
|
|
694
|
+
{ op: "<", glyph: "<", label: "Less than" },
|
|
695
|
+
{ op: ">", glyph: ">", label: "Greater than" },
|
|
696
|
+
{ op: "<=", glyph: "\u2264", label: "At most" },
|
|
697
|
+
{ op: ">=", glyph: "\u2265", label: "At least" }
|
|
698
|
+
];
|
|
699
|
+
var UNARY_OP_CHOICES = [
|
|
700
|
+
{ op: "=", glyph: "\u2205", label: "Is empty", unary: true },
|
|
701
|
+
{ op: "!=", glyph: "\u2260\u2205", label: "Is not empty", unary: true }
|
|
702
|
+
];
|
|
703
|
+
function opChoicesFor(kind) {
|
|
704
|
+
if (kind === "number" || kind === "boolean") {
|
|
705
|
+
return [...COMPARE_OP_CHOICES, ...UNARY_OP_CHOICES];
|
|
706
|
+
}
|
|
707
|
+
return [
|
|
708
|
+
...TEXT_OP_CHOICES,
|
|
709
|
+
...COMPARE_OP_CHOICES.filter((choice) => choice.op !== "=" && choice.op !== "!="),
|
|
710
|
+
...UNARY_OP_CHOICES
|
|
711
|
+
];
|
|
712
|
+
}
|
|
713
|
+
function defaultOpFor(kind) {
|
|
714
|
+
return kind === "number" || kind === "boolean" ? "=" : "~";
|
|
715
|
+
}
|
|
716
|
+
function choiceMatches(choice, state) {
|
|
717
|
+
return choice.op === state.op && choice.unary === true === (state.unary === true);
|
|
718
|
+
}
|
|
719
|
+
function glyphFor(kind, state) {
|
|
720
|
+
return opChoicesFor(kind).find((choice) => choiceMatches(choice, state))?.glyph ?? TEXT_OP_CHOICES.find((choice) => choice.op === state.op)?.glyph ?? state.op;
|
|
721
|
+
}
|
|
722
|
+
var VALUE_MENU_LIMIT = 100;
|
|
723
|
+
var CASE_CAPABLE_OPS = ["=", "!=", "~", "!~", "^~", "$~"];
|
|
724
|
+
function normalizedRange(selection) {
|
|
725
|
+
return {
|
|
726
|
+
r0: Math.min(selection.anchor.row, selection.focus.row),
|
|
727
|
+
r1: Math.max(selection.anchor.row, selection.focus.row),
|
|
728
|
+
c0: Math.min(selection.anchor.col, selection.focus.col),
|
|
729
|
+
c1: Math.max(selection.anchor.col, selection.focus.col)
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function DataGrid({
|
|
733
|
+
provider,
|
|
734
|
+
journal,
|
|
735
|
+
view,
|
|
736
|
+
onViewChange,
|
|
737
|
+
viewPersisted = true,
|
|
738
|
+
onSave,
|
|
739
|
+
saving = false,
|
|
740
|
+
height = DEFAULT_HEIGHT,
|
|
741
|
+
readOnlyReason,
|
|
742
|
+
isCellLocked,
|
|
743
|
+
lockedReason,
|
|
744
|
+
formulaSupport,
|
|
745
|
+
onCellEdited,
|
|
746
|
+
extraDirtyCount = 0,
|
|
747
|
+
onDiscardExtra,
|
|
748
|
+
className
|
|
749
|
+
}) {
|
|
750
|
+
const [schema, setSchema] = useState(null);
|
|
751
|
+
const [viewRowCount, setViewRowCount] = useState(0);
|
|
752
|
+
const [issueNote, setIssueNote] = useState(null);
|
|
753
|
+
const [staleView, setStaleView] = useState(false);
|
|
754
|
+
const [selection, setSelection] = useState(null);
|
|
755
|
+
const [editing, setEditing] = useState(
|
|
756
|
+
null
|
|
757
|
+
);
|
|
758
|
+
const [colWidths, setColWidths] = useState({});
|
|
759
|
+
const [filterOps, setFilterOps] = useState({});
|
|
760
|
+
const [opMenuCol, setOpMenuCol] = useState(null);
|
|
761
|
+
const [opMenuPos, setOpMenuPos] = useState({ left: 0, top: 0 });
|
|
762
|
+
const [valueMenuPos, setValueMenuPos] = useState({ left: 0, top: 0 });
|
|
763
|
+
const [valueMenuCol, setValueMenuCol] = useState(null);
|
|
764
|
+
const [valueMenuData, setValueMenuData] = useState(null);
|
|
765
|
+
const [dirtyTick, setDirtyTick] = useState(0);
|
|
766
|
+
const [announce, setAnnounce] = useState("");
|
|
767
|
+
const [fetchTick, setFetchTick] = useState(0);
|
|
768
|
+
const bodyRef = useRef(null);
|
|
769
|
+
const rootRef = useRef(null);
|
|
770
|
+
const opMenuRef = useRef(null);
|
|
771
|
+
const valueMenuRef = useRef(null);
|
|
772
|
+
const focusRef = useRef(null);
|
|
773
|
+
const wasEditingRef = useRef(false);
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
if (wasEditingRef.current && !editing) focusRef.current?.focus();
|
|
776
|
+
wasEditingRef.current = editing !== null;
|
|
777
|
+
}, [editing]);
|
|
778
|
+
const cache = useRef({ version: 0, pages: /* @__PURE__ */ new Map() });
|
|
779
|
+
const inFlight = useRef(/* @__PURE__ */ new Set());
|
|
780
|
+
const selectionCells = useRef(/* @__PURE__ */ new Map());
|
|
781
|
+
const viewRef = useRef(view);
|
|
782
|
+
viewRef.current = view;
|
|
783
|
+
const editable = Boolean(journal && provider.applyEdits && !readOnlyReason);
|
|
784
|
+
const dirtyCount = journal?.dirtyCount ?? 0;
|
|
785
|
+
void dirtyTick;
|
|
786
|
+
void fetchTick;
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
let cancelled = false;
|
|
789
|
+
provider.describe().then(
|
|
790
|
+
(described) => {
|
|
791
|
+
if (!cancelled) setSchema(described);
|
|
792
|
+
},
|
|
793
|
+
() => void 0
|
|
794
|
+
);
|
|
795
|
+
return () => {
|
|
796
|
+
cancelled = true;
|
|
797
|
+
};
|
|
798
|
+
}, [provider]);
|
|
799
|
+
const applyView = useCallback(
|
|
800
|
+
async (next) => {
|
|
801
|
+
const result = await provider.setView(next);
|
|
802
|
+
cache.current = { version: cache.current.version + 1, pages: /* @__PURE__ */ new Map() };
|
|
803
|
+
inFlight.current.clear();
|
|
804
|
+
setViewRowCount(result.viewRowCount);
|
|
805
|
+
setStaleView(false);
|
|
806
|
+
setIssueNote(
|
|
807
|
+
result.issues.length > 0 ? result.issues.map((i) => i.message).join("; ") : null
|
|
808
|
+
);
|
|
809
|
+
setFetchTick((t) => t + 1);
|
|
810
|
+
},
|
|
811
|
+
[provider]
|
|
812
|
+
);
|
|
813
|
+
useEffect(() => {
|
|
814
|
+
void applyView(view);
|
|
815
|
+
}, [applyView, JSON.stringify(serializeTableViewState2(view))]);
|
|
816
|
+
const virtualizer = useVirtualizer({
|
|
817
|
+
count: viewRowCount,
|
|
818
|
+
getScrollElement: () => bodyRef.current,
|
|
819
|
+
estimateSize: () => ROW_HEIGHT,
|
|
820
|
+
overscan: OVERSCAN,
|
|
821
|
+
// Correct first paint before the scroll element is measured — and the
|
|
822
|
+
// only measurement available in layout-less environments (jsdom).
|
|
823
|
+
initialRect: { width: 800, height }
|
|
824
|
+
});
|
|
825
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
826
|
+
useEffect(() => {
|
|
827
|
+
if (viewRowCount === 0) return;
|
|
828
|
+
const version = cache.current.version;
|
|
829
|
+
const first = virtualItems[0]?.index ?? 0;
|
|
830
|
+
const last = virtualItems[virtualItems.length - 1]?.index ?? 0;
|
|
831
|
+
const firstPage = Math.floor(first / PAGE_SIZE);
|
|
832
|
+
const lastPage = Math.floor(last / PAGE_SIZE);
|
|
833
|
+
for (let page = firstPage; page <= lastPage; page++) {
|
|
834
|
+
if (cache.current.pages.has(page) || inFlight.current.has(page)) continue;
|
|
835
|
+
inFlight.current.add(page);
|
|
836
|
+
provider.rows(page * PAGE_SIZE, PAGE_SIZE).then(
|
|
837
|
+
(result) => {
|
|
838
|
+
inFlight.current.delete(page);
|
|
839
|
+
if (cache.current.version !== version) return;
|
|
840
|
+
cache.current.pages.set(page, {
|
|
841
|
+
rowIds: Array.from(result.rowIds),
|
|
842
|
+
cells: result.cells
|
|
843
|
+
});
|
|
844
|
+
setFetchTick((t) => t + 1);
|
|
845
|
+
},
|
|
846
|
+
() => inFlight.current.delete(page)
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}, [provider, virtualItems, viewRowCount, fetchTick]);
|
|
850
|
+
const cellAt = useCallback((row) => {
|
|
851
|
+
const page = cache.current.pages.get(Math.floor(row / PAGE_SIZE));
|
|
852
|
+
if (!page) return null;
|
|
853
|
+
const offset = row % PAGE_SIZE;
|
|
854
|
+
const rowId = page.rowIds[offset];
|
|
855
|
+
const cells = page.cells[offset];
|
|
856
|
+
if (rowId === void 0 || !cells) return null;
|
|
857
|
+
return { rowId, cells };
|
|
858
|
+
}, []);
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
if (!selection || !schema) return;
|
|
861
|
+
const { r0, r1 } = normalizedRange(selection);
|
|
862
|
+
if ((r1 - r0 + 1) * schema.columns.length > COPY_CELL_CAP) return;
|
|
863
|
+
let cancelled = false;
|
|
864
|
+
void (async () => {
|
|
865
|
+
const rows = /* @__PURE__ */ new Map();
|
|
866
|
+
for (let start = r0; start <= r1; start += PAGE_SIZE) {
|
|
867
|
+
const page = await provider.rows(start, Math.min(PAGE_SIZE, r1 - start + 1));
|
|
868
|
+
if (cancelled) return;
|
|
869
|
+
page.cells.forEach((cells, index) => rows.set(page.start + index, cells));
|
|
870
|
+
}
|
|
871
|
+
selectionCells.current = rows;
|
|
872
|
+
})();
|
|
873
|
+
return () => {
|
|
874
|
+
cancelled = true;
|
|
875
|
+
};
|
|
876
|
+
}, [provider, schema, selection]);
|
|
877
|
+
const handleCopy = useCallback(
|
|
878
|
+
(event) => {
|
|
879
|
+
if (!selection || !schema || editing) return;
|
|
880
|
+
const target = event.target;
|
|
881
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
|
|
882
|
+
event.preventDefault();
|
|
883
|
+
const { r0, r1, c0, c1 } = normalizedRange(selection);
|
|
884
|
+
const capRows = Math.floor(COPY_CELL_CAP / Math.max(1, c1 - c0 + 1));
|
|
885
|
+
const lastRow = Math.min(r1, r0 + capRows - 1);
|
|
886
|
+
const lines = [];
|
|
887
|
+
const htmlRows = [];
|
|
888
|
+
for (let row = r0; row <= lastRow; row++) {
|
|
889
|
+
const cells = selectionCells.current.get(row) ?? cellAt(row)?.cells;
|
|
890
|
+
const values = [];
|
|
891
|
+
for (let col = c0; col <= c1; col++) values.push(cellDisplay(cells?.[col] ?? null));
|
|
892
|
+
lines.push(values.join(" "));
|
|
893
|
+
htmlRows.push(`<tr>${values.map((v) => `<td>${escapeHtml(v)}</td>`).join("")}</tr>`);
|
|
894
|
+
}
|
|
895
|
+
event.clipboardData.setData("text/plain", lines.join("\n"));
|
|
896
|
+
event.clipboardData.setData("text/html", `<table>${htmlRows.join("")}</table>`);
|
|
897
|
+
if (lastRow < r1) {
|
|
898
|
+
setAnnounce(`Copy truncated to ${lastRow - r0 + 1} rows (${COPY_CELL_CAP} cell cap).`);
|
|
899
|
+
} else {
|
|
900
|
+
setAnnounce(`Copied ${lastRow - r0 + 1}\xD7${c1 - c0 + 1} cells.`);
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
[cellAt, editing, schema, selection]
|
|
904
|
+
);
|
|
905
|
+
const beginEdit = useCallback(
|
|
906
|
+
(pos, seed) => {
|
|
907
|
+
if (!editable || !schema) return;
|
|
908
|
+
const current = cellAt(pos.row);
|
|
909
|
+
if (current && isCellLocked?.(current.rowId, pos.col)) {
|
|
910
|
+
setAnnounce(lockedReason ?? "This cell is locked");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const formula = current ? formulaSupport?.getFormula(current.rowId, pos.col) : void 0;
|
|
914
|
+
setEditing({
|
|
915
|
+
...pos,
|
|
916
|
+
draft: seed ?? (formula !== void 0 ? `=${formula}` : cellDisplay(current?.cells[pos.col] ?? null))
|
|
917
|
+
});
|
|
918
|
+
},
|
|
919
|
+
[cellAt, editable, schema, isCellLocked, lockedReason, formulaSupport]
|
|
920
|
+
);
|
|
921
|
+
const applyCacheUpdates = useCallback((edits) => {
|
|
922
|
+
if (edits.length === 0) return;
|
|
923
|
+
const byRowId = /* @__PURE__ */ new Map();
|
|
924
|
+
for (const edit of edits) {
|
|
925
|
+
const list = byRowId.get(edit.rowId) ?? [];
|
|
926
|
+
list.push(edit);
|
|
927
|
+
byRowId.set(edit.rowId, list);
|
|
928
|
+
}
|
|
929
|
+
for (const page of cache.current.pages.values()) {
|
|
930
|
+
for (let offset = 0; offset < page.rowIds.length; offset++) {
|
|
931
|
+
const rowEdits = byRowId.get(page.rowIds[offset]);
|
|
932
|
+
if (!rowEdits) continue;
|
|
933
|
+
for (const edit of rowEdits) {
|
|
934
|
+
page.cells[offset][edit.col] = edit.value;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
setFetchTick((t) => t + 1);
|
|
939
|
+
}, []);
|
|
940
|
+
const commitEdit = useCallback(async () => {
|
|
941
|
+
if (!editing || !schema || !journal || !provider.applyEdits) return true;
|
|
942
|
+
if (formulaSupport && editing.draft.trim().startsWith("=")) {
|
|
943
|
+
const located2 = cellAt(editing.row);
|
|
944
|
+
if (!located2) {
|
|
945
|
+
setEditing(null);
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
const result2 = await formulaSupport.commitFormula(
|
|
949
|
+
located2.rowId,
|
|
950
|
+
editing.col,
|
|
951
|
+
editing.draft.trim().slice(1)
|
|
952
|
+
);
|
|
953
|
+
if (!result2.ok) {
|
|
954
|
+
setEditing({ ...editing, error: result2.error ?? "formula rejected" });
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
setEditing(null);
|
|
958
|
+
if (result2.updates && result2.updates.length > 0) {
|
|
959
|
+
const applied = await provider.applyEdits(result2.updates);
|
|
960
|
+
if (applied.staleView) setStaleView(true);
|
|
961
|
+
applyCacheUpdates(result2.updates);
|
|
962
|
+
}
|
|
963
|
+
setDirtyTick((t) => t + 1);
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
const kind = schema.columns[editing.col]?.kind ?? "string";
|
|
967
|
+
const coerced = coerceInput(editing.draft, kind);
|
|
968
|
+
if (typeof coerced === "object" && coerced !== null && "error" in coerced) {
|
|
969
|
+
setEditing({ ...editing, error: coerced.error });
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
const located = cellAt(editing.row);
|
|
973
|
+
if (!located) {
|
|
974
|
+
setEditing(null);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
const prev = located.cells[editing.col] ?? null;
|
|
978
|
+
const next = coerced;
|
|
979
|
+
setEditing(null);
|
|
980
|
+
if (prev === next) return true;
|
|
981
|
+
journal.commit([{ rowId: located.rowId, col: editing.col, prev, next }]);
|
|
982
|
+
located.cells[editing.col] = next;
|
|
983
|
+
const result = await provider.applyEdits([
|
|
984
|
+
{ rowId: located.rowId, col: editing.col, value: next }
|
|
985
|
+
]);
|
|
986
|
+
if (result.staleView) setStaleView(true);
|
|
987
|
+
const dependents = await onCellEdited?.({
|
|
988
|
+
rowId: located.rowId,
|
|
989
|
+
col: editing.col,
|
|
990
|
+
value: next
|
|
991
|
+
});
|
|
992
|
+
if (dependents && dependents.length > 0) {
|
|
993
|
+
const applied = await provider.applyEdits(dependents);
|
|
994
|
+
if (applied.staleView) setStaleView(true);
|
|
995
|
+
applyCacheUpdates(dependents);
|
|
996
|
+
}
|
|
997
|
+
setDirtyTick((t) => t + 1);
|
|
998
|
+
return true;
|
|
999
|
+
}, [applyCacheUpdates, cellAt, editing, formulaSupport, journal, onCellEdited, provider, schema]);
|
|
1000
|
+
const handlePaste = useCallback(
|
|
1001
|
+
(event) => {
|
|
1002
|
+
if (!selection || !schema || !journal || !provider.applyEdits || editing) return;
|
|
1003
|
+
const target = event.target;
|
|
1004
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
|
|
1005
|
+
const text = event.clipboardData.getData("text/plain");
|
|
1006
|
+
if (!text) return;
|
|
1007
|
+
event.preventDefault();
|
|
1008
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
1009
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
1010
|
+
const block = lines.map((line) => line.split(" "));
|
|
1011
|
+
const { r0, c0 } = normalizedRange(selection);
|
|
1012
|
+
const entries = [];
|
|
1013
|
+
let skipped = 0;
|
|
1014
|
+
for (let dr = 0; dr < block.length; dr++) {
|
|
1015
|
+
const row = r0 + dr;
|
|
1016
|
+
if (row >= viewRowCount) {
|
|
1017
|
+
skipped += block[dr].length;
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const located = cellAt(row);
|
|
1021
|
+
if (!located) {
|
|
1022
|
+
skipped += block[dr].length;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
for (let dc = 0; dc < block[dr].length; dc++) {
|
|
1026
|
+
const col = c0 + dc;
|
|
1027
|
+
const kind = schema.columns[col]?.kind;
|
|
1028
|
+
if (kind === void 0 || isCellLocked?.(located.rowId, col)) {
|
|
1029
|
+
skipped++;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const coerced = coerceInput(block[dr][dc], kind);
|
|
1033
|
+
if (typeof coerced === "object" && coerced !== null && "error" in coerced) {
|
|
1034
|
+
skipped++;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const prev = located.cells[col] ?? null;
|
|
1038
|
+
const next = coerced;
|
|
1039
|
+
if (prev === next) continue;
|
|
1040
|
+
entries.push({ rowId: located.rowId, col, prev, next });
|
|
1041
|
+
located.cells[col] = next;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (entries.length === 0) {
|
|
1045
|
+
if (skipped > 0) setAnnounce(`Nothing pasted (${skipped} cells skipped).`);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
journal.commit(entries);
|
|
1049
|
+
void (async () => {
|
|
1050
|
+
const edits = entries.map(({ rowId, col, next }) => ({ rowId, col, value: next }));
|
|
1051
|
+
const result = await provider.applyEdits(edits);
|
|
1052
|
+
if (result.staleView) setStaleView(true);
|
|
1053
|
+
const dependents = [];
|
|
1054
|
+
for (const edit of edits) {
|
|
1055
|
+
const extra = await onCellEdited?.(edit);
|
|
1056
|
+
if (extra) dependents.push(...extra);
|
|
1057
|
+
}
|
|
1058
|
+
if (dependents.length > 0) {
|
|
1059
|
+
const applied = await provider.applyEdits(dependents);
|
|
1060
|
+
if (applied.staleView) setStaleView(true);
|
|
1061
|
+
applyCacheUpdates(dependents);
|
|
1062
|
+
}
|
|
1063
|
+
setDirtyTick((t) => t + 1);
|
|
1064
|
+
setFetchTick((t) => t + 1);
|
|
1065
|
+
setAnnounce(
|
|
1066
|
+
`Pasted ${entries.length} cell${entries.length === 1 ? "" : "s"}` + (skipped > 0 ? ` (${skipped} skipped)` : "") + "."
|
|
1067
|
+
);
|
|
1068
|
+
})();
|
|
1069
|
+
},
|
|
1070
|
+
[
|
|
1071
|
+
applyCacheUpdates,
|
|
1072
|
+
cellAt,
|
|
1073
|
+
editing,
|
|
1074
|
+
isCellLocked,
|
|
1075
|
+
journal,
|
|
1076
|
+
onCellEdited,
|
|
1077
|
+
provider,
|
|
1078
|
+
schema,
|
|
1079
|
+
selection,
|
|
1080
|
+
viewRowCount
|
|
1081
|
+
]
|
|
1082
|
+
);
|
|
1083
|
+
const runJournal = useCallback(
|
|
1084
|
+
async (direction) => {
|
|
1085
|
+
if (!journal || !provider.applyEdits) return;
|
|
1086
|
+
const edits = direction === "undo" ? journal.undo() : journal.redo();
|
|
1087
|
+
if (edits.length === 0) return;
|
|
1088
|
+
const result = await provider.applyEdits(edits);
|
|
1089
|
+
if (result.staleView) setStaleView(true);
|
|
1090
|
+
cache.current.pages.clear();
|
|
1091
|
+
setDirtyTick((t) => t + 1);
|
|
1092
|
+
setFetchTick((t) => t + 1);
|
|
1093
|
+
},
|
|
1094
|
+
[journal, provider]
|
|
1095
|
+
);
|
|
1096
|
+
const cycleSort = useCallback(
|
|
1097
|
+
(columnName, additive) => {
|
|
1098
|
+
const current = viewRef.current;
|
|
1099
|
+
const existing = current.sort.find((term) => term.column === columnName);
|
|
1100
|
+
let nextTerms = additive ? [...current.sort] : [];
|
|
1101
|
+
if (!existing) {
|
|
1102
|
+
nextTerms = additive ? [...nextTerms, { column: columnName, dir: "asc" }] : [{ column: columnName, dir: "asc" }];
|
|
1103
|
+
} else if (existing.dir === "asc") {
|
|
1104
|
+
nextTerms = (additive ? current.sort : [existing]).map(
|
|
1105
|
+
(term) => term.column === columnName ? { ...term, dir: "desc" } : term
|
|
1106
|
+
);
|
|
1107
|
+
} else {
|
|
1108
|
+
nextTerms = (additive ? current.sort : []).filter((term) => term.column !== columnName);
|
|
1109
|
+
}
|
|
1110
|
+
onViewChange?.({ ...current, sort: nextTerms });
|
|
1111
|
+
},
|
|
1112
|
+
[onViewChange]
|
|
1113
|
+
);
|
|
1114
|
+
const setColumnFilter = useCallback(
|
|
1115
|
+
(columnName, text, op, caseSensitive, unary = false) => {
|
|
1116
|
+
const current = viewRef.current;
|
|
1117
|
+
const others = current.filter.filter((clause) => clause.column !== columnName);
|
|
1118
|
+
onViewChange?.({
|
|
1119
|
+
...current,
|
|
1120
|
+
filter: unary ? [...others, { column: columnName, op, value: "" }] : text.trim() === "" ? others : [
|
|
1121
|
+
...others,
|
|
1122
|
+
{
|
|
1123
|
+
column: columnName,
|
|
1124
|
+
op,
|
|
1125
|
+
value: text,
|
|
1126
|
+
...caseSensitive && CASE_CAPABLE_OPS.includes(op) ? { caseSensitive: true } : {}
|
|
1127
|
+
}
|
|
1128
|
+
]
|
|
1129
|
+
});
|
|
1130
|
+
},
|
|
1131
|
+
[onViewChange]
|
|
1132
|
+
);
|
|
1133
|
+
const opStateFor = useCallback(
|
|
1134
|
+
(col, columnName) => {
|
|
1135
|
+
const local = filterOps[col];
|
|
1136
|
+
if (local) return local;
|
|
1137
|
+
const clause = viewRef.current.filter.find((entry) => entry.column === columnName);
|
|
1138
|
+
if (clause) {
|
|
1139
|
+
return {
|
|
1140
|
+
op: clause.op,
|
|
1141
|
+
caseSensitive: clause.caseSensitive === true,
|
|
1142
|
+
...clause.value === "" && (clause.op === "=" || clause.op === "!=") ? { unary: true } : {}
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return { op: defaultOpFor(schema?.columns[col]?.kind ?? "string"), caseSensitive: false };
|
|
1146
|
+
},
|
|
1147
|
+
[filterOps, schema]
|
|
1148
|
+
);
|
|
1149
|
+
const chooseFilterOp = useCallback(
|
|
1150
|
+
(col, columnName, next) => {
|
|
1151
|
+
setFilterOps((prev) => ({ ...prev, [col]: next }));
|
|
1152
|
+
setOpMenuCol(null);
|
|
1153
|
+
const clause = viewRef.current.filter.find((entry) => entry.column === columnName);
|
|
1154
|
+
if (next.unary) {
|
|
1155
|
+
setColumnFilter(columnName, "", next.op, false, true);
|
|
1156
|
+
} else if (clause && clause.value.trim() !== "") {
|
|
1157
|
+
setColumnFilter(columnName, clause.value, next.op, next.caseSensitive);
|
|
1158
|
+
} else if (clause) {
|
|
1159
|
+
setColumnFilter(columnName, "", next.op, next.caseSensitive);
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
[setColumnFilter]
|
|
1163
|
+
);
|
|
1164
|
+
const clearColumnFilter = useCallback(
|
|
1165
|
+
(col, columnName) => {
|
|
1166
|
+
const prior = opStateFor(col, columnName);
|
|
1167
|
+
setFilterOps((prev) => ({
|
|
1168
|
+
...prev,
|
|
1169
|
+
[col]: prior.unary ? { op: defaultOpFor(schema?.columns[col]?.kind ?? "string"), caseSensitive: false } : { op: prior.op, caseSensitive: prior.caseSensitive }
|
|
1170
|
+
}));
|
|
1171
|
+
setOpMenuCol(null);
|
|
1172
|
+
setColumnFilter(columnName, "", prior.op, prior.caseSensitive);
|
|
1173
|
+
},
|
|
1174
|
+
[opStateFor, schema, setColumnFilter]
|
|
1175
|
+
);
|
|
1176
|
+
const menuAnchorPos = useCallback((anchor) => {
|
|
1177
|
+
const rootRect = rootRef.current?.getBoundingClientRect();
|
|
1178
|
+
const rect = anchor.getBoundingClientRect();
|
|
1179
|
+
if (!rootRect) return { left: 0, top: 0 };
|
|
1180
|
+
return { left: rect.left - rootRect.left, top: rect.bottom - rootRect.top + 2 };
|
|
1181
|
+
}, []);
|
|
1182
|
+
const toggleOpMenu = useCallback(
|
|
1183
|
+
(col, anchor) => {
|
|
1184
|
+
setValueMenuCol(null);
|
|
1185
|
+
setOpMenuPos(menuAnchorPos(anchor));
|
|
1186
|
+
setOpMenuCol((open) => open === col ? null : col);
|
|
1187
|
+
},
|
|
1188
|
+
[menuAnchorPos]
|
|
1189
|
+
);
|
|
1190
|
+
const openValueMenu = useCallback(
|
|
1191
|
+
(col, anchor) => {
|
|
1192
|
+
setOpMenuCol(null);
|
|
1193
|
+
if (valueMenuCol === col) {
|
|
1194
|
+
setValueMenuCol(null);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
setValueMenuPos(menuAnchorPos(anchor));
|
|
1198
|
+
setValueMenuCol(col);
|
|
1199
|
+
void provider.distinct?.(col, VALUE_MENU_LIMIT).then((result) => {
|
|
1200
|
+
setValueMenuData({ col, result });
|
|
1201
|
+
});
|
|
1202
|
+
},
|
|
1203
|
+
[menuAnchorPos, provider, valueMenuCol]
|
|
1204
|
+
);
|
|
1205
|
+
useLayoutEffect(() => {
|
|
1206
|
+
const root = rootRef.current;
|
|
1207
|
+
if (!root) return;
|
|
1208
|
+
for (const menu of [opMenuRef.current, valueMenuRef.current]) {
|
|
1209
|
+
if (!menu) continue;
|
|
1210
|
+
const maxLeft = root.clientWidth - menu.offsetWidth - 4;
|
|
1211
|
+
const maxTop = root.clientHeight - menu.offsetHeight - 4;
|
|
1212
|
+
menu.style.left = `${Math.max(4, Math.min(parseFloat(menu.style.left) || 0, maxLeft))}px`;
|
|
1213
|
+
menu.style.top = `${Math.max(4, Math.min(parseFloat(menu.style.top) || 0, maxTop))}px`;
|
|
1214
|
+
}
|
|
1215
|
+
}, [opMenuCol, valueMenuCol, valueMenuData]);
|
|
1216
|
+
const chooseFilterValue = useCallback(
|
|
1217
|
+
(col, columnName, value) => {
|
|
1218
|
+
setFilterOps((prev) => ({ ...prev, [col]: { op: "=", caseSensitive: false } }));
|
|
1219
|
+
setValueMenuCol(null);
|
|
1220
|
+
setColumnFilter(columnName, value, "=", false);
|
|
1221
|
+
},
|
|
1222
|
+
[setColumnFilter]
|
|
1223
|
+
);
|
|
1224
|
+
const moveFocus = useCallback(
|
|
1225
|
+
(rowDelta, colDelta, extend, absolute) => {
|
|
1226
|
+
if (!schema) return;
|
|
1227
|
+
setSelection((prev) => {
|
|
1228
|
+
const from = prev?.focus ?? { row: 0, col: 0 };
|
|
1229
|
+
const next = {
|
|
1230
|
+
row: Math.max(0, Math.min(viewRowCount - 1, absolute?.row ?? from.row + rowDelta)),
|
|
1231
|
+
col: Math.max(
|
|
1232
|
+
0,
|
|
1233
|
+
Math.min(schema.columns.length - 1, absolute?.col ?? from.col + colDelta)
|
|
1234
|
+
)
|
|
1235
|
+
};
|
|
1236
|
+
virtualizer.scrollToIndex(next.row);
|
|
1237
|
+
return extend && prev ? { anchor: prev.anchor, focus: next } : { anchor: next, focus: next };
|
|
1238
|
+
});
|
|
1239
|
+
},
|
|
1240
|
+
[schema, viewRowCount, virtualizer]
|
|
1241
|
+
);
|
|
1242
|
+
const handleKeyDown = useCallback(
|
|
1243
|
+
(event) => {
|
|
1244
|
+
if (!schema) return;
|
|
1245
|
+
const target = event.target;
|
|
1246
|
+
const inTextEntry = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target !== null && target.isContentEditable;
|
|
1247
|
+
if (inTextEntry && !editing) return;
|
|
1248
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
1249
|
+
if (editing) {
|
|
1250
|
+
if (event.key === "Escape") {
|
|
1251
|
+
setEditing(null);
|
|
1252
|
+
event.preventDefault();
|
|
1253
|
+
} else if (event.key === "Enter") {
|
|
1254
|
+
void commitEdit().then((ok) => ok && moveFocus(1, 0, false));
|
|
1255
|
+
event.preventDefault();
|
|
1256
|
+
} else if (event.key === "Tab") {
|
|
1257
|
+
void commitEdit().then((ok) => ok && moveFocus(0, event.shiftKey ? -1 : 1, false));
|
|
1258
|
+
event.preventDefault();
|
|
1259
|
+
}
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const pageRows = Math.max(1, Math.floor(height / ROW_HEIGHT) - 1);
|
|
1263
|
+
switch (event.key) {
|
|
1264
|
+
case "ArrowDown":
|
|
1265
|
+
moveFocus(
|
|
1266
|
+
meta ? Number.POSITIVE_INFINITY : 1,
|
|
1267
|
+
0,
|
|
1268
|
+
event.shiftKey,
|
|
1269
|
+
meta ? { row: viewRowCount - 1 } : void 0
|
|
1270
|
+
);
|
|
1271
|
+
break;
|
|
1272
|
+
case "ArrowUp":
|
|
1273
|
+
moveFocus(meta ? 0 : -1, 0, event.shiftKey, meta ? { row: 0 } : void 0);
|
|
1274
|
+
break;
|
|
1275
|
+
case "ArrowRight":
|
|
1276
|
+
moveFocus(
|
|
1277
|
+
0,
|
|
1278
|
+
meta ? 0 : 1,
|
|
1279
|
+
event.shiftKey,
|
|
1280
|
+
meta ? { col: schema.columns.length - 1 } : void 0
|
|
1281
|
+
);
|
|
1282
|
+
break;
|
|
1283
|
+
case "ArrowLeft":
|
|
1284
|
+
moveFocus(0, meta ? 0 : -1, event.shiftKey, meta ? { col: 0 } : void 0);
|
|
1285
|
+
break;
|
|
1286
|
+
case "PageDown":
|
|
1287
|
+
moveFocus(pageRows, 0, event.shiftKey);
|
|
1288
|
+
break;
|
|
1289
|
+
case "PageUp":
|
|
1290
|
+
moveFocus(-pageRows, 0, event.shiftKey);
|
|
1291
|
+
break;
|
|
1292
|
+
case "Home":
|
|
1293
|
+
moveFocus(0, 0, event.shiftKey, meta ? { row: 0, col: 0 } : { col: 0 });
|
|
1294
|
+
break;
|
|
1295
|
+
case "End":
|
|
1296
|
+
moveFocus(
|
|
1297
|
+
0,
|
|
1298
|
+
0,
|
|
1299
|
+
event.shiftKey,
|
|
1300
|
+
meta ? { row: viewRowCount - 1, col: schema.columns.length - 1 } : { col: schema.columns.length - 1 }
|
|
1301
|
+
);
|
|
1302
|
+
break;
|
|
1303
|
+
case "Enter":
|
|
1304
|
+
case "F2":
|
|
1305
|
+
if (selection) beginEdit(selection.focus);
|
|
1306
|
+
break;
|
|
1307
|
+
case "a":
|
|
1308
|
+
case "A":
|
|
1309
|
+
if (meta) {
|
|
1310
|
+
setSelection({
|
|
1311
|
+
anchor: { row: 0, col: 0 },
|
|
1312
|
+
focus: { row: viewRowCount - 1, col: schema.columns.length - 1 }
|
|
1313
|
+
});
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
if (selection && !meta) beginEdit(selection.focus, event.key);
|
|
1317
|
+
else return;
|
|
1318
|
+
break;
|
|
1319
|
+
case "z":
|
|
1320
|
+
case "Z":
|
|
1321
|
+
if (meta) {
|
|
1322
|
+
void runJournal(event.shiftKey ? "redo" : "undo");
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
if (selection) beginEdit(selection.focus, event.key);
|
|
1326
|
+
else return;
|
|
1327
|
+
break;
|
|
1328
|
+
default:
|
|
1329
|
+
if (selection && event.key.length === 1 && !meta && !event.altKey) {
|
|
1330
|
+
beginEdit(selection.focus, event.key);
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
event.preventDefault();
|
|
1336
|
+
},
|
|
1337
|
+
[
|
|
1338
|
+
beginEdit,
|
|
1339
|
+
commitEdit,
|
|
1340
|
+
editing,
|
|
1341
|
+
height,
|
|
1342
|
+
moveFocus,
|
|
1343
|
+
runJournal,
|
|
1344
|
+
schema,
|
|
1345
|
+
selection,
|
|
1346
|
+
viewRowCount
|
|
1347
|
+
]
|
|
1348
|
+
);
|
|
1349
|
+
const startResize = useCallback((col, startX, startWidth) => {
|
|
1350
|
+
const onMove = (event) => {
|
|
1351
|
+
setColWidths((widths) => ({
|
|
1352
|
+
...widths,
|
|
1353
|
+
[col]: Math.max(60, startWidth + (event.clientX - startX))
|
|
1354
|
+
}));
|
|
1355
|
+
};
|
|
1356
|
+
const onUp = () => {
|
|
1357
|
+
window.removeEventListener("pointermove", onMove);
|
|
1358
|
+
window.removeEventListener("pointerup", onUp);
|
|
1359
|
+
};
|
|
1360
|
+
window.addEventListener("pointermove", onMove);
|
|
1361
|
+
window.addEventListener("pointerup", onUp);
|
|
1362
|
+
}, []);
|
|
1363
|
+
const gridTemplate = useMemo(() => {
|
|
1364
|
+
const count = schema?.columns.length ?? 0;
|
|
1365
|
+
return Array.from(
|
|
1366
|
+
{ length: count },
|
|
1367
|
+
(_, col) => `${colWidths[col] ?? DEFAULT_COL_WIDTH}px`
|
|
1368
|
+
).join(" ");
|
|
1369
|
+
}, [colWidths, schema]);
|
|
1370
|
+
const range = selection ? normalizedRange(selection) : null;
|
|
1371
|
+
const filterValueFor = (name) => view.filter.find((clause) => clause.column === name)?.value ?? "";
|
|
1372
|
+
return /* @__PURE__ */ jsxs(
|
|
1373
|
+
"div",
|
|
1374
|
+
{
|
|
1375
|
+
ref: rootRef,
|
|
1376
|
+
className: `squisq-grid${className ? ` ${className}` : ""}`,
|
|
1377
|
+
role: "grid",
|
|
1378
|
+
"aria-rowcount": viewRowCount + 1,
|
|
1379
|
+
"aria-colcount": schema?.columns.length ?? 0,
|
|
1380
|
+
"aria-multiselectable": "true",
|
|
1381
|
+
onKeyDown: handleKeyDown,
|
|
1382
|
+
onCopy: handleCopy,
|
|
1383
|
+
onPaste: handlePaste,
|
|
1384
|
+
onMouseDownCapture: (event) => {
|
|
1385
|
+
const target = event.target;
|
|
1386
|
+
if (opMenuCol !== null && !target?.closest(".squisq-grid-opmenu, .squisq-grid-opbutton")) {
|
|
1387
|
+
setOpMenuCol(null);
|
|
1388
|
+
}
|
|
1389
|
+
if (valueMenuCol !== null && !target?.closest(".squisq-grid-valuemenu, .squisq-grid-valuebutton")) {
|
|
1390
|
+
setValueMenuCol(null);
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
onKeyDownCapture: (event) => {
|
|
1394
|
+
if (event.key !== "Escape") return;
|
|
1395
|
+
if (opMenuCol !== null) setOpMenuCol(null);
|
|
1396
|
+
if (valueMenuCol !== null) setValueMenuCol(null);
|
|
1397
|
+
},
|
|
1398
|
+
children: [
|
|
1399
|
+
/* @__PURE__ */ jsxs(
|
|
1400
|
+
"div",
|
|
1401
|
+
{
|
|
1402
|
+
className: "squisq-grid-scroller",
|
|
1403
|
+
ref: bodyRef,
|
|
1404
|
+
style: { height },
|
|
1405
|
+
onScroll: () => {
|
|
1406
|
+
if (opMenuCol !== null) setOpMenuCol(null);
|
|
1407
|
+
if (valueMenuCol !== null) setValueMenuCol(null);
|
|
1408
|
+
},
|
|
1409
|
+
children: [
|
|
1410
|
+
/* @__PURE__ */ jsx(
|
|
1411
|
+
"div",
|
|
1412
|
+
{
|
|
1413
|
+
className: "squisq-grid-header",
|
|
1414
|
+
role: "row",
|
|
1415
|
+
style: { gridTemplateColumns: gridTemplate },
|
|
1416
|
+
children: schema?.columns.map((column, col) => {
|
|
1417
|
+
const term = view.sort.find((entry) => entry.column === column.name);
|
|
1418
|
+
const ariaSort = term ? term.dir === "asc" ? "ascending" : "descending" : "none";
|
|
1419
|
+
return /* @__PURE__ */ jsxs(
|
|
1420
|
+
"div",
|
|
1421
|
+
{
|
|
1422
|
+
role: "columnheader",
|
|
1423
|
+
"aria-sort": ariaSort,
|
|
1424
|
+
className: "squisq-grid-headercell",
|
|
1425
|
+
children: [
|
|
1426
|
+
/* @__PURE__ */ jsxs(
|
|
1427
|
+
"button",
|
|
1428
|
+
{
|
|
1429
|
+
type: "button",
|
|
1430
|
+
className: "squisq-grid-sortbutton",
|
|
1431
|
+
onClick: (event) => cycleSort(column.name, event.shiftKey),
|
|
1432
|
+
title: `Sort by ${column.name}`,
|
|
1433
|
+
children: [
|
|
1434
|
+
/* @__PURE__ */ jsx("span", { className: "squisq-grid-colname", children: column.name }),
|
|
1435
|
+
term && /* @__PURE__ */ jsx("span", { className: "squisq-grid-sortmark", "aria-hidden": "true", children: term.dir === "asc" ? "\u25B2" : "\u25BC" })
|
|
1436
|
+
]
|
|
1437
|
+
}
|
|
1438
|
+
),
|
|
1439
|
+
/* @__PURE__ */ jsx("div", { className: "squisq-grid-filterrow", children: (() => {
|
|
1440
|
+
const opState = opStateFor(col, column.name);
|
|
1441
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1442
|
+
/* @__PURE__ */ jsxs(
|
|
1443
|
+
"button",
|
|
1444
|
+
{
|
|
1445
|
+
type: "button",
|
|
1446
|
+
className: `squisq-grid-opbutton${opState.caseSensitive ? " squisq-grid-opbutton--cs" : ""}`,
|
|
1447
|
+
"aria-label": `Filter operator for ${column.name}`,
|
|
1448
|
+
"aria-expanded": opMenuCol === col,
|
|
1449
|
+
title: `${opChoicesFor(column.kind).find((c) => choiceMatches(c, opState))?.label ?? opState.op}${opState.caseSensitive ? " (case-sensitive)" : ""}`,
|
|
1450
|
+
onClick: (event) => toggleOpMenu(col, event.currentTarget),
|
|
1451
|
+
children: [
|
|
1452
|
+
/* @__PURE__ */ jsx("span", { className: "squisq-grid-opglyph", children: glyphFor(column.kind, opState) }),
|
|
1453
|
+
/* @__PURE__ */ jsx("span", { className: "squisq-grid-opcaret", "aria-hidden": "true", children: "\u25BE" })
|
|
1454
|
+
]
|
|
1455
|
+
}
|
|
1456
|
+
),
|
|
1457
|
+
/* @__PURE__ */ jsx(
|
|
1458
|
+
"input",
|
|
1459
|
+
{
|
|
1460
|
+
className: "squisq-grid-filterinput",
|
|
1461
|
+
"aria-label": `Filter ${column.name}`,
|
|
1462
|
+
placeholder: opState.unary ? opState.op === "=" ? "(empty)" : "(not empty)" : "filter",
|
|
1463
|
+
disabled: opState.unary === true,
|
|
1464
|
+
value: filterValueFor(column.name),
|
|
1465
|
+
onChange: (event) => setColumnFilter(
|
|
1466
|
+
column.name,
|
|
1467
|
+
event.target.value,
|
|
1468
|
+
opState.op,
|
|
1469
|
+
opState.caseSensitive
|
|
1470
|
+
)
|
|
1471
|
+
}
|
|
1472
|
+
),
|
|
1473
|
+
provider.distinct && /* @__PURE__ */ jsx(
|
|
1474
|
+
"button",
|
|
1475
|
+
{
|
|
1476
|
+
type: "button",
|
|
1477
|
+
className: "squisq-grid-valuebutton",
|
|
1478
|
+
"aria-label": `Filter ${column.name} by value`,
|
|
1479
|
+
"aria-expanded": valueMenuCol === col,
|
|
1480
|
+
title: "Filter by value",
|
|
1481
|
+
onClick: (event) => openValueMenu(col, event.currentTarget),
|
|
1482
|
+
children: "\u25BE"
|
|
1483
|
+
}
|
|
1484
|
+
)
|
|
1485
|
+
] });
|
|
1486
|
+
})() }),
|
|
1487
|
+
/* @__PURE__ */ jsx(
|
|
1488
|
+
"div",
|
|
1489
|
+
{
|
|
1490
|
+
className: "squisq-grid-resizer",
|
|
1491
|
+
onPointerDown: (event) => {
|
|
1492
|
+
event.preventDefault();
|
|
1493
|
+
startResize(col, event.clientX, colWidths[col] ?? DEFAULT_COL_WIDTH);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
)
|
|
1497
|
+
]
|
|
1498
|
+
},
|
|
1499
|
+
col
|
|
1500
|
+
);
|
|
1501
|
+
})
|
|
1502
|
+
}
|
|
1503
|
+
),
|
|
1504
|
+
/* @__PURE__ */ jsx("div", { className: "squisq-grid-body", style: { height: virtualizer.getTotalSize() }, children: virtualItems.map((item) => {
|
|
1505
|
+
const located = cellAt(item.index);
|
|
1506
|
+
return /* @__PURE__ */ jsx(
|
|
1507
|
+
"div",
|
|
1508
|
+
{
|
|
1509
|
+
role: "row",
|
|
1510
|
+
"aria-rowindex": item.index + 2,
|
|
1511
|
+
className: "squisq-grid-row",
|
|
1512
|
+
style: {
|
|
1513
|
+
transform: `translateY(${item.start}px)`,
|
|
1514
|
+
gridTemplateColumns: gridTemplate,
|
|
1515
|
+
height: ROW_HEIGHT
|
|
1516
|
+
},
|
|
1517
|
+
children: schema?.columns.map((column, col) => {
|
|
1518
|
+
const inRange = range && item.index >= range.r0 && item.index <= range.r1 && col >= range.c0 && col <= range.c1;
|
|
1519
|
+
const isFocus = selection && selection.focus.row === item.index && selection.focus.col === col;
|
|
1520
|
+
const isEditing = editing && editing.row === item.index && editing.col === col;
|
|
1521
|
+
const dirty = located && journal ? journal.isDirty(located.rowId, col) : false;
|
|
1522
|
+
const locked = editable && located ? isCellLocked?.(located.rowId, col) ?? false : false;
|
|
1523
|
+
const formula = located && formulaSupport ? formulaSupport.getFormula(located.rowId, col) : void 0;
|
|
1524
|
+
const classes = [
|
|
1525
|
+
"squisq-grid-cell",
|
|
1526
|
+
column.kind === "number" ? "squisq-grid-cell--num" : "",
|
|
1527
|
+
inRange ? "squisq-grid-cell--selected" : "",
|
|
1528
|
+
isFocus ? "squisq-grid-cell--focus" : "",
|
|
1529
|
+
dirty ? "squisq-grid-cell--dirty" : "",
|
|
1530
|
+
locked ? "squisq-grid-cell--locked" : "",
|
|
1531
|
+
formula !== void 0 ? "squisq-grid-cell--formula" : ""
|
|
1532
|
+
].filter(Boolean).join(" ");
|
|
1533
|
+
return /* @__PURE__ */ jsx(
|
|
1534
|
+
"div",
|
|
1535
|
+
{
|
|
1536
|
+
role: "gridcell",
|
|
1537
|
+
"aria-colindex": col + 1,
|
|
1538
|
+
"aria-selected": inRange ? "true" : void 0,
|
|
1539
|
+
...dirty ? { "aria-description": "edited, unsaved" } : {},
|
|
1540
|
+
...locked && lockedReason ? { title: lockedReason } : formula !== void 0 ? { title: `=${formula}` } : {},
|
|
1541
|
+
...locked ? { "aria-readonly": "true" } : {},
|
|
1542
|
+
className: classes,
|
|
1543
|
+
tabIndex: isFocus ? 0 : -1,
|
|
1544
|
+
ref: isFocus ? focusRef : void 0,
|
|
1545
|
+
onMouseDown: (event) => {
|
|
1546
|
+
const pos = { row: item.index, col };
|
|
1547
|
+
setSelection(
|
|
1548
|
+
(prev) => event.shiftKey && prev ? { anchor: prev.anchor, focus: pos } : { anchor: pos, focus: pos }
|
|
1549
|
+
);
|
|
1550
|
+
},
|
|
1551
|
+
onDoubleClick: () => beginEdit({ row: item.index, col }),
|
|
1552
|
+
children: isEditing ? /* @__PURE__ */ jsx(
|
|
1553
|
+
"input",
|
|
1554
|
+
{
|
|
1555
|
+
className: `squisq-grid-editor${editing.error ? " squisq-grid-editor--error" : ""}`,
|
|
1556
|
+
autoFocus: true,
|
|
1557
|
+
value: editing.draft,
|
|
1558
|
+
title: editing.error,
|
|
1559
|
+
onChange: (event) => setEditing({ ...editing, draft: event.target.value, error: void 0 }),
|
|
1560
|
+
onBlur: () => void commitEdit()
|
|
1561
|
+
}
|
|
1562
|
+
) : cellDisplay(located?.cells[col] ?? null)
|
|
1563
|
+
},
|
|
1564
|
+
col
|
|
1565
|
+
);
|
|
1566
|
+
})
|
|
1567
|
+
},
|
|
1568
|
+
item.key
|
|
1569
|
+
);
|
|
1570
|
+
}) })
|
|
1571
|
+
]
|
|
1572
|
+
}
|
|
1573
|
+
),
|
|
1574
|
+
opMenuCol !== null && schema?.columns[opMenuCol] && (() => {
|
|
1575
|
+
const col = opMenuCol;
|
|
1576
|
+
const column = schema.columns[col];
|
|
1577
|
+
const opState = opStateFor(col, column.name);
|
|
1578
|
+
const caseCapable = column.kind !== "number" && column.kind !== "boolean";
|
|
1579
|
+
const filterActive = view.filter.some((clause) => clause.column === column.name);
|
|
1580
|
+
return /* @__PURE__ */ jsxs(
|
|
1581
|
+
"div",
|
|
1582
|
+
{
|
|
1583
|
+
ref: opMenuRef,
|
|
1584
|
+
className: "squisq-grid-opmenu",
|
|
1585
|
+
role: "menu",
|
|
1586
|
+
style: { left: opMenuPos.left, top: opMenuPos.top },
|
|
1587
|
+
children: [
|
|
1588
|
+
filterActive && /* @__PURE__ */ jsx(
|
|
1589
|
+
"button",
|
|
1590
|
+
{
|
|
1591
|
+
type: "button",
|
|
1592
|
+
role: "menuitem",
|
|
1593
|
+
className: "squisq-grid-opoption squisq-grid-opclear",
|
|
1594
|
+
onClick: () => clearColumnFilter(col, column.name),
|
|
1595
|
+
children: "(Clear filter)"
|
|
1596
|
+
}
|
|
1597
|
+
),
|
|
1598
|
+
opChoicesFor(column.kind).map((choice) => /* @__PURE__ */ jsxs(
|
|
1599
|
+
"button",
|
|
1600
|
+
{
|
|
1601
|
+
type: "button",
|
|
1602
|
+
role: "menuitemradio",
|
|
1603
|
+
"aria-checked": choiceMatches(choice, opState),
|
|
1604
|
+
className: `squisq-grid-opoption${choiceMatches(choice, opState) ? " squisq-grid-opoption--active" : ""}`,
|
|
1605
|
+
onClick: () => chooseFilterOp(col, column.name, {
|
|
1606
|
+
op: choice.op,
|
|
1607
|
+
caseSensitive: opState.caseSensitive,
|
|
1608
|
+
...choice.unary ? { unary: true } : {}
|
|
1609
|
+
}),
|
|
1610
|
+
children: [
|
|
1611
|
+
/* @__PURE__ */ jsx("span", { className: "squisq-grid-opglyph", children: choice.glyph }),
|
|
1612
|
+
choice.label
|
|
1613
|
+
]
|
|
1614
|
+
},
|
|
1615
|
+
`${choice.op}${choice.unary ? "0" : ""}`
|
|
1616
|
+
)),
|
|
1617
|
+
caseCapable && /* @__PURE__ */ jsxs("label", { className: "squisq-grid-opcase", children: [
|
|
1618
|
+
/* @__PURE__ */ jsx(
|
|
1619
|
+
"input",
|
|
1620
|
+
{
|
|
1621
|
+
type: "checkbox",
|
|
1622
|
+
checked: opState.caseSensitive,
|
|
1623
|
+
onChange: (event) => chooseFilterOp(col, column.name, {
|
|
1624
|
+
op: opState.op,
|
|
1625
|
+
caseSensitive: event.target.checked,
|
|
1626
|
+
...opState.unary ? { unary: true } : {}
|
|
1627
|
+
})
|
|
1628
|
+
}
|
|
1629
|
+
),
|
|
1630
|
+
"Case sensitive"
|
|
1631
|
+
] })
|
|
1632
|
+
]
|
|
1633
|
+
}
|
|
1634
|
+
);
|
|
1635
|
+
})(),
|
|
1636
|
+
valueMenuCol !== null && schema?.columns[valueMenuCol] && (() => {
|
|
1637
|
+
const col = valueMenuCol;
|
|
1638
|
+
const column = schema.columns[col];
|
|
1639
|
+
const opState = opStateFor(col, column.name);
|
|
1640
|
+
const filterActive = view.filter.some((clause) => clause.column === column.name);
|
|
1641
|
+
return /* @__PURE__ */ jsx(
|
|
1642
|
+
"div",
|
|
1643
|
+
{
|
|
1644
|
+
ref: valueMenuRef,
|
|
1645
|
+
className: "squisq-grid-valuemenu",
|
|
1646
|
+
role: "menu",
|
|
1647
|
+
style: { left: valueMenuPos.left, top: valueMenuPos.top },
|
|
1648
|
+
children: valueMenuData?.col === col ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1649
|
+
filterActive && /* @__PURE__ */ jsx(
|
|
1650
|
+
"button",
|
|
1651
|
+
{
|
|
1652
|
+
type: "button",
|
|
1653
|
+
role: "menuitem",
|
|
1654
|
+
className: "squisq-grid-opoption squisq-grid-opclear",
|
|
1655
|
+
onClick: () => {
|
|
1656
|
+
setValueMenuCol(null);
|
|
1657
|
+
clearColumnFilter(col, column.name);
|
|
1658
|
+
},
|
|
1659
|
+
children: "(All)"
|
|
1660
|
+
}
|
|
1661
|
+
),
|
|
1662
|
+
valueMenuData.result.hasBlank && /* @__PURE__ */ jsx(
|
|
1663
|
+
"button",
|
|
1664
|
+
{
|
|
1665
|
+
type: "button",
|
|
1666
|
+
role: "menuitemradio",
|
|
1667
|
+
"aria-checked": filterActive && opState.unary === true,
|
|
1668
|
+
className: `squisq-grid-opoption${filterActive && opState.unary === true ? " squisq-grid-opoption--active" : ""}`,
|
|
1669
|
+
onClick: () => {
|
|
1670
|
+
setValueMenuCol(null);
|
|
1671
|
+
chooseFilterOp(col, column.name, {
|
|
1672
|
+
op: "=",
|
|
1673
|
+
caseSensitive: false,
|
|
1674
|
+
unary: true
|
|
1675
|
+
});
|
|
1676
|
+
},
|
|
1677
|
+
children: "(Blanks)"
|
|
1678
|
+
}
|
|
1679
|
+
),
|
|
1680
|
+
valueMenuData.result.values.map((value) => {
|
|
1681
|
+
const active = filterActive && !opState.unary && opState.op === "=" && filterValueFor(column.name) === value;
|
|
1682
|
+
return /* @__PURE__ */ jsx(
|
|
1683
|
+
"button",
|
|
1684
|
+
{
|
|
1685
|
+
type: "button",
|
|
1686
|
+
role: "menuitemradio",
|
|
1687
|
+
"aria-checked": active,
|
|
1688
|
+
className: `squisq-grid-opoption squisq-grid-valueoption${active ? " squisq-grid-opoption--active" : ""}`,
|
|
1689
|
+
title: value,
|
|
1690
|
+
onClick: () => chooseFilterValue(col, column.name, value),
|
|
1691
|
+
children: value
|
|
1692
|
+
},
|
|
1693
|
+
value
|
|
1694
|
+
);
|
|
1695
|
+
}),
|
|
1696
|
+
valueMenuData.result.totalDistinct > valueMenuData.result.values.length && /* @__PURE__ */ jsxs("div", { className: "squisq-grid-valuemenu-note", children: [
|
|
1697
|
+
"showing ",
|
|
1698
|
+
valueMenuData.result.values.length,
|
|
1699
|
+
" of",
|
|
1700
|
+
" ",
|
|
1701
|
+
valueMenuData.result.totalDistinct.toLocaleString(),
|
|
1702
|
+
" values"
|
|
1703
|
+
] })
|
|
1704
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "squisq-grid-valuemenu-note", children: "loading\u2026" })
|
|
1705
|
+
}
|
|
1706
|
+
);
|
|
1707
|
+
})(),
|
|
1708
|
+
/* @__PURE__ */ jsxs("div", { className: "squisq-grid-footer", children: [
|
|
1709
|
+
/* @__PURE__ */ jsxs("span", { className: "squisq-grid-status", "aria-live": "polite", children: [
|
|
1710
|
+
announce || `${viewRowCount.toLocaleString()} row${viewRowCount === 1 ? "" : "s"}${schema && viewRowCount !== schema.rowCount ? ` (of ${schema.rowCount.toLocaleString()})` : ""}${schema ? `, ${schema.columns.length} column${schema.columns.length === 1 ? "" : "s"}` : ""}`,
|
|
1711
|
+
issueNote ? ` \xB7 ${issueNote}` : "",
|
|
1712
|
+
!viewPersisted && (view.sort.length > 0 || view.filter.length > 0) ? " \xB7 view not saved to document" : ""
|
|
1713
|
+
] }),
|
|
1714
|
+
staleView && /* @__PURE__ */ jsx(
|
|
1715
|
+
"button",
|
|
1716
|
+
{
|
|
1717
|
+
type: "button",
|
|
1718
|
+
className: "squisq-grid-refresh",
|
|
1719
|
+
onClick: () => void applyView(viewRef.current),
|
|
1720
|
+
children: "order may be outdated \u2014 refresh"
|
|
1721
|
+
}
|
|
1722
|
+
),
|
|
1723
|
+
readOnlyReason && /* @__PURE__ */ jsx("span", { className: "squisq-grid-readonly", children: readOnlyReason }),
|
|
1724
|
+
editable && dirtyCount + extraDirtyCount > 0 && /* @__PURE__ */ jsxs("span", { className: "squisq-grid-dirtybar", children: [
|
|
1725
|
+
(dirtyCount + extraDirtyCount).toLocaleString(),
|
|
1726
|
+
" unsaved edit",
|
|
1727
|
+
dirtyCount + extraDirtyCount === 1 ? "" : "s",
|
|
1728
|
+
/* @__PURE__ */ jsx(
|
|
1729
|
+
"button",
|
|
1730
|
+
{
|
|
1731
|
+
type: "button",
|
|
1732
|
+
className: "squisq-grid-save",
|
|
1733
|
+
disabled: saving,
|
|
1734
|
+
onClick: () => void onSave?.(),
|
|
1735
|
+
children: saving ? "Saving\u2026" : "Save"
|
|
1736
|
+
}
|
|
1737
|
+
),
|
|
1738
|
+
/* @__PURE__ */ jsx(
|
|
1739
|
+
"button",
|
|
1740
|
+
{
|
|
1741
|
+
type: "button",
|
|
1742
|
+
className: "squisq-grid-discard",
|
|
1743
|
+
disabled: saving,
|
|
1744
|
+
onClick: () => {
|
|
1745
|
+
void (async () => {
|
|
1746
|
+
while (journal?.canUndo) await runJournal("undo");
|
|
1747
|
+
if (onDiscardExtra) {
|
|
1748
|
+
await onDiscardExtra();
|
|
1749
|
+
cache.current.pages.clear();
|
|
1750
|
+
setFetchTick((t) => t + 1);
|
|
1751
|
+
setDirtyTick((t) => t + 1);
|
|
1752
|
+
}
|
|
1753
|
+
})();
|
|
1754
|
+
},
|
|
1755
|
+
children: "Discard"
|
|
1756
|
+
}
|
|
1757
|
+
)
|
|
1758
|
+
] })
|
|
1759
|
+
] })
|
|
1760
|
+
]
|
|
1761
|
+
}
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
function escapeHtml(text) {
|
|
1765
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1766
|
+
}
|
|
1767
|
+
export {
|
|
1768
|
+
DataGrid,
|
|
1769
|
+
EditJournal,
|
|
1770
|
+
LocalKernelHost,
|
|
1771
|
+
TableStoreClient,
|
|
1772
|
+
buildColumnarTable,
|
|
1773
|
+
buildKernelSource,
|
|
1774
|
+
columnCellValue,
|
|
1775
|
+
discardJournal,
|
|
1776
|
+
journalFor,
|
|
1777
|
+
tableKernel
|
|
1778
|
+
};
|