@pixerate/editor 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -2
- package/dist/agent/index.cjs +625 -2
- package/dist/agent/index.cjs.map +1 -1
- package/dist/agent/index.d.cts +286 -2
- package/dist/agent/index.d.ts +286 -2
- package/dist/agent/index.js +624 -1
- package/dist/agent/index.js.map +1 -1
- package/dist/canvas/index.cjs +1240 -22
- package/dist/canvas/index.cjs.map +1 -1
- package/dist/canvas/index.d.cts +515 -3
- package/dist/canvas/index.d.ts +515 -3
- package/dist/canvas/index.js +1219 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/chunk-3TYMNULT.js +1367 -0
- package/dist/chunk-3TYMNULT.js.map +1 -0
- package/dist/chunk-4QPLEUL2.cjs +4480 -0
- package/dist/chunk-4QPLEUL2.cjs.map +1 -0
- package/dist/chunk-4SUNJZWB.cjs +553 -0
- package/dist/chunk-4SUNJZWB.cjs.map +1 -0
- package/dist/chunk-A7DKIA72.js +2657 -0
- package/dist/chunk-A7DKIA72.js.map +1 -0
- package/dist/chunk-AXV2ZW34.cjs +118 -0
- package/dist/chunk-AXV2ZW34.cjs.map +1 -0
- package/dist/chunk-B2ZRLN4S.js +118 -0
- package/dist/chunk-B2ZRLN4S.js.map +1 -0
- package/dist/chunk-CYFJCKC4.js +4480 -0
- package/dist/chunk-CYFJCKC4.js.map +1 -0
- package/dist/chunk-GVW4P7BG.js +2706 -0
- package/dist/chunk-GVW4P7BG.js.map +1 -0
- package/dist/chunk-JACER6TE.cjs +1 -0
- package/dist/chunk-JACER6TE.cjs.map +1 -0
- package/dist/chunk-JYRIOFEN.cjs +2657 -0
- package/dist/chunk-JYRIOFEN.cjs.map +1 -0
- package/dist/chunk-KYRZDWM4.js +299 -0
- package/dist/chunk-KYRZDWM4.js.map +1 -0
- package/dist/chunk-LA7PEGU3.cjs +299 -0
- package/dist/chunk-LA7PEGU3.cjs.map +1 -0
- package/dist/chunk-N7FG6CBO.js +1 -0
- package/dist/chunk-N7FG6CBO.js.map +1 -0
- package/dist/chunk-OYXSKQFR.js +553 -0
- package/dist/chunk-OYXSKQFR.js.map +1 -0
- package/dist/chunk-TVRC4KVT.cjs +1367 -0
- package/dist/chunk-TVRC4KVT.cjs.map +1 -0
- package/dist/chunk-U6NDQFOK.cjs +2706 -0
- package/dist/chunk-U6NDQFOK.cjs.map +1 -0
- package/dist/controller-OUdxPY-X.d.cts +754 -0
- package/dist/controller-OUdxPY-X.d.ts +754 -0
- package/dist/extensions/index.cjs +144 -3
- package/dist/extensions/index.cjs.map +1 -1
- package/dist/extensions/index.d.cts +9 -144
- package/dist/extensions/index.d.ts +9 -144
- package/dist/extensions/index.js +145 -4
- package/dist/grammar/index.cjs +31 -3
- package/dist/grammar/index.cjs.map +1 -1
- package/dist/grammar/index.d.cts +239 -19
- package/dist/grammar/index.d.ts +239 -19
- package/dist/grammar/index.js +31 -3
- package/dist/history/index.d.cts +2 -41
- package/dist/history/index.d.ts +2 -41
- package/dist/image-editor/index.cjs +732 -497
- package/dist/image-editor/index.cjs.map +1 -1
- package/dist/image-editor/index.d.cts +105 -9
- package/dist/image-editor/index.d.ts +105 -9
- package/dist/image-editor/index.js +721 -486
- package/dist/image-editor/index.js.map +1 -1
- package/dist/index-Ng1pG7JE.d.ts +899 -0
- package/dist/index-k3mFbPx_.d.cts +899 -0
- package/dist/index.cjs +457 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -12
- package/dist/index.d.ts +99 -12
- package/dist/index.js +457 -20
- package/dist/index.js.map +1 -1
- package/dist/registry-iAHbqkYt.d.cts +128 -0
- package/dist/registry-iAHbqkYt.d.ts +128 -0
- package/dist/serializers/index.cjs +26 -3
- package/dist/serializers/index.cjs.map +1 -1
- package/dist/serializers/index.d.cts +119 -6
- package/dist/serializers/index.d.ts +119 -6
- package/dist/serializers/index.js +24 -1
- package/dist/spreadsheet/index.cjs +122 -2
- package/dist/spreadsheet/index.cjs.map +1 -1
- package/dist/spreadsheet/index.d.cts +371 -313
- package/dist/spreadsheet/index.d.ts +371 -313
- package/dist/spreadsheet/index.js +127 -7
- package/dist/tokens-CyLZmIei.d.cts +76 -0
- package/dist/tokens-CyLZmIei.d.ts +76 -0
- package/dist/track-changes-G8JcWB8b.d.cts +306 -0
- package/dist/track-changes-G8JcWB8b.d.ts +306 -0
- package/dist/types-BZgcDsC5.d.cts +42 -0
- package/dist/types-BZgcDsC5.d.ts +42 -0
- package/dist/{types-JV3S8VeR.d.cts → types-DMe0vFB1.d.cts} +1 -1
- package/dist/{types-JV3S8VeR.d.ts → types-DMe0vFB1.d.ts} +1 -1
- package/package.json +1 -1
- package/dist/chunk-7FWEWE6T.js +0 -1888
- package/dist/chunk-7FWEWE6T.js.map +0 -1
- package/dist/chunk-AIZUKWTL.js +0 -1715
- package/dist/chunk-AIZUKWTL.js.map +0 -1
- package/dist/chunk-GLSIZKOX.js +0 -17
- package/dist/chunk-GLSIZKOX.js.map +0 -1
- package/dist/chunk-LDES3EOS.cjs +0 -1888
- package/dist/chunk-LDES3EOS.cjs.map +0 -1
- package/dist/chunk-LLUAKTLW.cjs +0 -532
- package/dist/chunk-LLUAKTLW.cjs.map +0 -1
- package/dist/chunk-MPPXH7U5.cjs +0 -336
- package/dist/chunk-MPPXH7U5.cjs.map +0 -1
- package/dist/chunk-O7LOPQMH.js +0 -336
- package/dist/chunk-O7LOPQMH.js.map +0 -1
- package/dist/chunk-QGVMBCT5.js +0 -532
- package/dist/chunk-QGVMBCT5.js.map +0 -1
- package/dist/chunk-SEDR75VH.cjs +0 -17
- package/dist/chunk-SEDR75VH.cjs.map +0 -1
- package/dist/chunk-YQCI4CCN.cjs +0 -1715
- package/dist/chunk-YQCI4CCN.cjs.map +0 -1
- package/dist/mention-Dcevh3ff.d.cts +0 -32
- package/dist/mention-Dcevh3ff.d.ts +0 -32
- package/dist/tokens-7kRTEtPb.d.cts +0 -39
- package/dist/tokens-7kRTEtPb.d.ts +0 -39
|
@@ -0,0 +1,2706 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FormulaEvaluator,
|
|
3
|
+
WHOLE_COLUMN_END_ROW,
|
|
4
|
+
colNameToIndex,
|
|
5
|
+
getFormulaArgumentSpecIndex,
|
|
6
|
+
getFormulaFunction,
|
|
7
|
+
getFormulaSignature,
|
|
8
|
+
getFormulaSignatureParts,
|
|
9
|
+
indexToColName,
|
|
10
|
+
parseFormula,
|
|
11
|
+
searchFormulaFunctions,
|
|
12
|
+
tokenizeFormula
|
|
13
|
+
} from "./chunk-A7DKIA72.js";
|
|
14
|
+
|
|
15
|
+
// src/spreadsheet/formula/dependency-graph.ts
|
|
16
|
+
var columnDependencyKey = (col) => `${col.toUpperCase()}:`;
|
|
17
|
+
function extractCellDependencies(node) {
|
|
18
|
+
const deps = [];
|
|
19
|
+
const visit = (curr) => {
|
|
20
|
+
if (curr.type === "CellRef") {
|
|
21
|
+
deps.push({ col: curr.col, row: curr.row });
|
|
22
|
+
} else if (curr.type === "Range" && curr.endRow >= WHOLE_COLUMN_END_ROW) {
|
|
23
|
+
const startC = colNameToIndex(curr.startCol);
|
|
24
|
+
const endC = colNameToIndex(curr.endCol);
|
|
25
|
+
for (let c = Math.min(startC, endC); c <= Math.max(startC, endC); c++) {
|
|
26
|
+
deps.push({ col: indexToColName(c), row: 0 });
|
|
27
|
+
}
|
|
28
|
+
} else if (curr.type === "Range") {
|
|
29
|
+
const startC = colNameToIndex(curr.startCol);
|
|
30
|
+
const endC = colNameToIndex(curr.endCol);
|
|
31
|
+
const minC = Math.min(startC, endC);
|
|
32
|
+
const maxC = Math.max(startC, endC);
|
|
33
|
+
const minR = Math.min(curr.startRow, curr.endRow);
|
|
34
|
+
const maxR = Math.max(curr.startRow, curr.endRow);
|
|
35
|
+
for (let c = minC; c <= maxC; c++) {
|
|
36
|
+
for (let r = minR; r <= maxR; r++) {
|
|
37
|
+
deps.push({ col: indexToColName(c), row: r });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else if (curr.type === "BinaryOp") {
|
|
41
|
+
visit(curr.left);
|
|
42
|
+
visit(curr.right);
|
|
43
|
+
} else if (curr.type === "UnaryOp") {
|
|
44
|
+
visit(curr.argument);
|
|
45
|
+
} else if (curr.type === "FunctionCall") {
|
|
46
|
+
curr.args.forEach(visit);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
visit(node);
|
|
50
|
+
return deps;
|
|
51
|
+
}
|
|
52
|
+
var DependencyGraph = class {
|
|
53
|
+
// Map of cellKey (e.g. "B2") -> Set of cellKeys that this cell depends ON
|
|
54
|
+
dependencies = /* @__PURE__ */ new Map();
|
|
55
|
+
// Map of cellKey (e.g. "A1") -> Set of cellKeys that depend ON this cell (dependents)
|
|
56
|
+
dependents = /* @__PURE__ */ new Map();
|
|
57
|
+
setCellFormula(cellKey, formulaStr) {
|
|
58
|
+
const key = cellKey.toUpperCase();
|
|
59
|
+
const oldDeps = this.dependencies.get(key) || /* @__PURE__ */ new Set();
|
|
60
|
+
for (const dep of oldDeps) {
|
|
61
|
+
this.dependents.get(dep)?.delete(key);
|
|
62
|
+
}
|
|
63
|
+
this.dependencies.delete(key);
|
|
64
|
+
if (!formulaStr || !formulaStr.startsWith("=")) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const ast = parseFormula(formulaStr);
|
|
69
|
+
const deps = extractCellDependencies(ast);
|
|
70
|
+
const newDeps = /* @__PURE__ */ new Set();
|
|
71
|
+
for (const d of deps) {
|
|
72
|
+
const depKey = d.row === 0 ? columnDependencyKey(d.col) : `${d.col}${d.row}`.toUpperCase();
|
|
73
|
+
newDeps.add(depKey);
|
|
74
|
+
if (!this.dependents.has(depKey)) {
|
|
75
|
+
this.dependents.set(depKey, /* @__PURE__ */ new Set());
|
|
76
|
+
}
|
|
77
|
+
this.dependents.get(depKey).add(key);
|
|
78
|
+
}
|
|
79
|
+
this.dependencies.set(key, newDeps);
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Removes every registered formula and dependency. */
|
|
84
|
+
clear() {
|
|
85
|
+
this.dependencies.clear();
|
|
86
|
+
this.dependents.clear();
|
|
87
|
+
}
|
|
88
|
+
getDependencies(cellKey) {
|
|
89
|
+
return Array.from(this.dependencies.get(cellKey.toUpperCase()) || []);
|
|
90
|
+
}
|
|
91
|
+
getDependents(cellKey) {
|
|
92
|
+
return Array.from(this.dependents.get(cellKey.toUpperCase()) || []);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Detects if there is a cycle containing cellKey.
|
|
96
|
+
*/
|
|
97
|
+
hasCycle(cellKey) {
|
|
98
|
+
const key = cellKey.toUpperCase();
|
|
99
|
+
const visited = /* @__PURE__ */ new Set();
|
|
100
|
+
const recStack = /* @__PURE__ */ new Set();
|
|
101
|
+
const dfs = (node) => {
|
|
102
|
+
visited.add(node);
|
|
103
|
+
recStack.add(node);
|
|
104
|
+
const deps = this.dependencies.get(node) || /* @__PURE__ */ new Set();
|
|
105
|
+
for (const neighbor of deps) {
|
|
106
|
+
if (!visited.has(neighbor)) {
|
|
107
|
+
if (dfs(neighbor)) return true;
|
|
108
|
+
} else if (recStack.has(neighbor)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
recStack.delete(node);
|
|
113
|
+
return false;
|
|
114
|
+
};
|
|
115
|
+
return dfs(key);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Returns cells affected by updating cellKey in topological order.
|
|
119
|
+
*/
|
|
120
|
+
getEvaluationOrder(startCellKey) {
|
|
121
|
+
const start = startCellKey.toUpperCase();
|
|
122
|
+
const visited = /* @__PURE__ */ new Set();
|
|
123
|
+
const recStack = /* @__PURE__ */ new Set();
|
|
124
|
+
const order = [];
|
|
125
|
+
let cycleDetected = false;
|
|
126
|
+
const affected = /* @__PURE__ */ new Set();
|
|
127
|
+
const collectAffected = (node) => {
|
|
128
|
+
affected.add(node);
|
|
129
|
+
const directDependents = this.dependents.get(node) || /* @__PURE__ */ new Set();
|
|
130
|
+
for (const dep of directDependents) {
|
|
131
|
+
if (!affected.has(dep)) {
|
|
132
|
+
collectAffected(dep);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
collectAffected(start);
|
|
137
|
+
const visit = (node) => {
|
|
138
|
+
if (recStack.has(node)) {
|
|
139
|
+
cycleDetected = true;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (visited.has(node)) return;
|
|
143
|
+
recStack.add(node);
|
|
144
|
+
visited.add(node);
|
|
145
|
+
const deps = this.dependencies.get(node) || /* @__PURE__ */ new Set();
|
|
146
|
+
for (const neighbor of deps) {
|
|
147
|
+
if (affected.has(neighbor)) {
|
|
148
|
+
visit(neighbor);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
recStack.delete(node);
|
|
152
|
+
order.push(node);
|
|
153
|
+
};
|
|
154
|
+
for (const node of affected) {
|
|
155
|
+
if (!visited.has(node)) {
|
|
156
|
+
visit(node);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { order, hasCycle: cycleDetected };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Plans a recalculation over `cellKeys` (and, with `includeDependents`, every
|
|
163
|
+
* cell that transitively depends on them). Returns the keys in evaluation
|
|
164
|
+
* order (dependencies first) and the set of keys that are part of a cycle,
|
|
165
|
+
* which must be marked `#CYCLE!` instead of evaluated. Every member of a
|
|
166
|
+
* cycle is reported, not just the cell that closed it.
|
|
167
|
+
*/
|
|
168
|
+
getEvaluationPlan(cellKeys, options = {}) {
|
|
169
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
170
|
+
const queue = [];
|
|
171
|
+
for (const key of cellKeys) {
|
|
172
|
+
const upper = key.toUpperCase();
|
|
173
|
+
if (!nodes.has(upper)) {
|
|
174
|
+
nodes.add(upper);
|
|
175
|
+
queue.push(upper);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (options.includeDependents) {
|
|
179
|
+
while (queue.length > 0) {
|
|
180
|
+
const node = queue.pop();
|
|
181
|
+
const direct = [
|
|
182
|
+
...this.dependents.get(node) ?? [],
|
|
183
|
+
...this.dependents.get(columnDependencyKey(node.replace(/\d+$/, ""))) ?? []
|
|
184
|
+
];
|
|
185
|
+
for (const dep of direct) {
|
|
186
|
+
if (!nodes.has(dep)) {
|
|
187
|
+
nodes.add(dep);
|
|
188
|
+
queue.push(dep);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const neighbors = (node) => {
|
|
194
|
+
const result = [];
|
|
195
|
+
for (const dep of this.dependencies.get(node) ?? []) {
|
|
196
|
+
if (dep.endsWith(":")) {
|
|
197
|
+
const col = dep.slice(0, -1);
|
|
198
|
+
for (const candidate of nodes) {
|
|
199
|
+
if (candidate.replace(/\d+$/, "") === col) result.push(candidate);
|
|
200
|
+
}
|
|
201
|
+
} else if (nodes.has(dep)) {
|
|
202
|
+
result.push(dep);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
};
|
|
207
|
+
const order = [];
|
|
208
|
+
const cyclic = /* @__PURE__ */ new Set();
|
|
209
|
+
const index = /* @__PURE__ */ new Map();
|
|
210
|
+
const low = /* @__PURE__ */ new Map();
|
|
211
|
+
const stack = [];
|
|
212
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
213
|
+
let counter = 0;
|
|
214
|
+
for (const root of nodes) {
|
|
215
|
+
if (index.has(root)) continue;
|
|
216
|
+
const work = [];
|
|
217
|
+
const enter = (node) => {
|
|
218
|
+
index.set(node, counter);
|
|
219
|
+
low.set(node, counter);
|
|
220
|
+
counter++;
|
|
221
|
+
stack.push(node);
|
|
222
|
+
onStack.add(node);
|
|
223
|
+
work.push({ node, edges: neighbors(node), next: 0 });
|
|
224
|
+
};
|
|
225
|
+
enter(root);
|
|
226
|
+
while (work.length > 0) {
|
|
227
|
+
const frame = work[work.length - 1];
|
|
228
|
+
if (frame.next < frame.edges.length) {
|
|
229
|
+
const target = frame.edges[frame.next++];
|
|
230
|
+
if (!index.has(target)) {
|
|
231
|
+
enter(target);
|
|
232
|
+
} else if (onStack.has(target)) {
|
|
233
|
+
low.set(frame.node, Math.min(low.get(frame.node), index.get(target)));
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
work.pop();
|
|
238
|
+
if (work.length > 0) {
|
|
239
|
+
const parent = work[work.length - 1].node;
|
|
240
|
+
low.set(parent, Math.min(low.get(parent), low.get(frame.node)));
|
|
241
|
+
}
|
|
242
|
+
if (low.get(frame.node) === index.get(frame.node)) {
|
|
243
|
+
const component = [];
|
|
244
|
+
let member;
|
|
245
|
+
do {
|
|
246
|
+
member = stack.pop();
|
|
247
|
+
onStack.delete(member);
|
|
248
|
+
component.push(member);
|
|
249
|
+
} while (member !== frame.node);
|
|
250
|
+
const isCycle = component.length > 1 || frame.edges.includes(frame.node);
|
|
251
|
+
for (const key of component) {
|
|
252
|
+
order.push(key);
|
|
253
|
+
if (isCycle) cyclic.add(key);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { order, cyclic };
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// src/spreadsheet/formula/references.ts
|
|
263
|
+
var CELL_PART = /^(\$?)([A-Za-z]+)(\$?)(\d+)$/;
|
|
264
|
+
var COLUMN_PART = /^(\$?)([A-Za-z]+)$/;
|
|
265
|
+
var REF_CHAR = /[A-Za-z0-9_$.:]/;
|
|
266
|
+
function parsePart(text) {
|
|
267
|
+
const cell = CELL_PART.exec(text);
|
|
268
|
+
if (cell) {
|
|
269
|
+
return {
|
|
270
|
+
colAbs: cell[1],
|
|
271
|
+
col: colNameToIndex(cell[2]),
|
|
272
|
+
rowAbs: cell[3],
|
|
273
|
+
row: parseInt(cell[4], 10) - 1
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const column = COLUMN_PART.exec(text);
|
|
277
|
+
if (column) {
|
|
278
|
+
return { colAbs: column[1], col: colNameToIndex(column[2]), rowAbs: "", row: null };
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
function formatPart(part) {
|
|
283
|
+
const col = `${part.colAbs}${indexToColName(part.col)}`;
|
|
284
|
+
return part.row === null ? col : `${col}${part.rowAbs}${part.row + 1}`;
|
|
285
|
+
}
|
|
286
|
+
function shiftCoordinate(value, change) {
|
|
287
|
+
if (change.type === "insert") {
|
|
288
|
+
return value >= change.index ? value + 1 : value;
|
|
289
|
+
}
|
|
290
|
+
if (value === change.index) return null;
|
|
291
|
+
return value > change.index ? value - 1 : value;
|
|
292
|
+
}
|
|
293
|
+
function shiftSpan(lo, hi, change) {
|
|
294
|
+
if (change.type === "insert") {
|
|
295
|
+
return [lo >= change.index ? lo + 1 : lo, hi >= change.index ? hi + 1 : hi];
|
|
296
|
+
}
|
|
297
|
+
if (lo === change.index && hi === change.index) return null;
|
|
298
|
+
return [lo > change.index ? lo - 1 : lo, hi >= change.index ? hi - 1 : hi];
|
|
299
|
+
}
|
|
300
|
+
function rewriteReference(text, change) {
|
|
301
|
+
const [startText, endText] = text.split(":");
|
|
302
|
+
const start = parsePart(startText);
|
|
303
|
+
if (!start) return text;
|
|
304
|
+
const key = change.axis === "row" ? "row" : "col";
|
|
305
|
+
if (endText === void 0) {
|
|
306
|
+
if (start[key] === null) return text;
|
|
307
|
+
const shifted = shiftCoordinate(start[key], change);
|
|
308
|
+
if (shifted === null) return "#REF!";
|
|
309
|
+
return formatPart({ ...start, [key]: shifted });
|
|
310
|
+
}
|
|
311
|
+
const end = parsePart(endText);
|
|
312
|
+
if (!end) return text;
|
|
313
|
+
if (start[key] === null || end[key] === null) return text;
|
|
314
|
+
const startValue = start[key];
|
|
315
|
+
const endValue = end[key];
|
|
316
|
+
const span = shiftSpan(Math.min(startValue, endValue), Math.max(startValue, endValue), change);
|
|
317
|
+
if (span === null) return "#REF!";
|
|
318
|
+
const [lo, hi] = span;
|
|
319
|
+
const newStart = { ...start, [key]: startValue <= endValue ? lo : hi };
|
|
320
|
+
const newEnd = { ...end, [key]: startValue <= endValue ? hi : lo };
|
|
321
|
+
return `${formatPart(newStart)}:${formatPart(newEnd)}`;
|
|
322
|
+
}
|
|
323
|
+
function shiftFormulaReferences(formula, change) {
|
|
324
|
+
return mapFormulaReferences(formula, (text) => rewriteReference(text, change));
|
|
325
|
+
}
|
|
326
|
+
function mapFormulaReferences(formula, rewrite) {
|
|
327
|
+
if (!formula.startsWith("=")) return formula;
|
|
328
|
+
let tokens;
|
|
329
|
+
try {
|
|
330
|
+
tokens = tokenizeFormula(formula);
|
|
331
|
+
} catch {
|
|
332
|
+
return formula;
|
|
333
|
+
}
|
|
334
|
+
let result = "";
|
|
335
|
+
let cursor = 0;
|
|
336
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
337
|
+
const token = tokens[i];
|
|
338
|
+
if (token.type !== "CELL_REF" && token.type !== "RANGE") continue;
|
|
339
|
+
if (tokens[i + 1]?.type === "LPAREN") continue;
|
|
340
|
+
let end = token.position;
|
|
341
|
+
while (end < formula.length && REF_CHAR.test(formula[end])) end++;
|
|
342
|
+
const original = formula.slice(token.position, end);
|
|
343
|
+
result += formula.slice(cursor, token.position) + rewrite(original);
|
|
344
|
+
cursor = end;
|
|
345
|
+
}
|
|
346
|
+
return result + formula.slice(cursor);
|
|
347
|
+
}
|
|
348
|
+
function offsetFormulaReferences(formula, rowOffset, colOffset) {
|
|
349
|
+
if (rowOffset === 0 && colOffset === 0) return formula;
|
|
350
|
+
return mapFormulaReferences(formula, (text) => {
|
|
351
|
+
const parts = text.split(":").map(parsePart);
|
|
352
|
+
if (parts.some((p) => p === null)) return text;
|
|
353
|
+
const moved = [];
|
|
354
|
+
for (const part of parts) {
|
|
355
|
+
const col = part.colAbs ? part.col : part.col + colOffset;
|
|
356
|
+
const row = part.row === null || part.rowAbs ? part.row : part.row + rowOffset;
|
|
357
|
+
if (col < 0 || row !== null && row < 0) return "#REF!";
|
|
358
|
+
moved.push({ ...part, col, row });
|
|
359
|
+
}
|
|
360
|
+
return moved.map(formatPart).join(":");
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
var MAX_REMAP_RANGE_CELLS = 1e5;
|
|
364
|
+
function remapFormulaReferences(formula, moveCell) {
|
|
365
|
+
return mapFormulaReferences(formula, (text) => {
|
|
366
|
+
const parts = text.split(":").map(parsePart);
|
|
367
|
+
if (parts.some((p) => p === null)) return text;
|
|
368
|
+
const [start, end] = parts;
|
|
369
|
+
if (start.row === null || end && end.row === null) return text;
|
|
370
|
+
if (!end) {
|
|
371
|
+
const target = moveCell({ row: start.row, col: start.col });
|
|
372
|
+
return target ? formatPart({ ...start, row: target.row, col: target.col }) : text;
|
|
373
|
+
}
|
|
374
|
+
const lo = { row: Math.min(start.row, end.row), col: Math.min(start.col, end.col) };
|
|
375
|
+
const hi = { row: Math.max(start.row, end.row), col: Math.max(start.col, end.col) };
|
|
376
|
+
const area = (hi.row - lo.row + 1) * (hi.col - lo.col + 1);
|
|
377
|
+
if (area > MAX_REMAP_RANGE_CELLS) return text;
|
|
378
|
+
let minR = Infinity;
|
|
379
|
+
let maxR = -Infinity;
|
|
380
|
+
let minC = Infinity;
|
|
381
|
+
let maxC = -Infinity;
|
|
382
|
+
let movedAny = false;
|
|
383
|
+
const targets = /* @__PURE__ */ new Set();
|
|
384
|
+
for (let r = lo.row; r <= hi.row; r++) {
|
|
385
|
+
for (let c = lo.col; c <= hi.col; c++) {
|
|
386
|
+
const moved = moveCell({ row: r, col: c });
|
|
387
|
+
if (moved) movedAny = true;
|
|
388
|
+
const t = moved ?? { row: r, col: c };
|
|
389
|
+
targets.add(`${t.row}:${t.col}`);
|
|
390
|
+
minR = Math.min(minR, t.row);
|
|
391
|
+
maxR = Math.max(maxR, t.row);
|
|
392
|
+
minC = Math.min(minC, t.col);
|
|
393
|
+
maxC = Math.max(maxC, t.col);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!movedAny || targets.size !== area || (maxR - minR + 1) * (maxC - minC + 1) !== area) {
|
|
397
|
+
return text;
|
|
398
|
+
}
|
|
399
|
+
return `${formatPart({ ...start, row: minR, col: minC })}:${formatPart({ ...end, row: maxR, col: maxC })}`;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/spreadsheet/formula/caret.ts
|
|
404
|
+
var EMPTY = { prefix: null, call: null };
|
|
405
|
+
function getFormulaCaretContext(input, caret = input.length) {
|
|
406
|
+
const leading = input.length - input.trimStart().length;
|
|
407
|
+
if (input[leading] !== "=") return EMPTY;
|
|
408
|
+
const end = Math.max(0, Math.min(caret, input.length));
|
|
409
|
+
if (end <= leading) return EMPTY;
|
|
410
|
+
const stack = [];
|
|
411
|
+
let quote2 = null;
|
|
412
|
+
let inProperty = false;
|
|
413
|
+
for (let i = leading + 1; i < end; i++) {
|
|
414
|
+
const char = input[i];
|
|
415
|
+
if (quote2) {
|
|
416
|
+
if (char === "\\") i++;
|
|
417
|
+
else if (char === quote2) quote2 = null;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (inProperty) {
|
|
421
|
+
if (char === "]") inProperty = false;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (char === '"' || char === "'") {
|
|
425
|
+
quote2 = char;
|
|
426
|
+
} else if (char === "[") {
|
|
427
|
+
inProperty = true;
|
|
428
|
+
} else if (char === "(") {
|
|
429
|
+
const name = /([A-Za-z_][A-Za-z0-9_.]*)\s*$/.exec(input.slice(leading + 1, i));
|
|
430
|
+
const isFunction = name && !/^\$?[A-Za-z]+\$?\d+$/.test(name[1]) && !/[:$]$/.test(input.slice(0, i - name[0].length));
|
|
431
|
+
stack.push({ name: isFunction ? name[1].toUpperCase() : null, argIndex: 0, openParen: i });
|
|
432
|
+
} else if (char === ")") {
|
|
433
|
+
stack.pop();
|
|
434
|
+
} else if (char === "," && stack.length > 0) {
|
|
435
|
+
stack[stack.length - 1].argIndex++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (quote2 || inProperty) {
|
|
439
|
+
return { prefix: null, call: innermostCall(stack) };
|
|
440
|
+
}
|
|
441
|
+
let prefix = null;
|
|
442
|
+
const before = input.slice(leading + 1, end);
|
|
443
|
+
const word = /[A-Za-z_][A-Za-z0-9_.]*$/.exec(before);
|
|
444
|
+
if (word) {
|
|
445
|
+
const start = leading + 1 + word.index;
|
|
446
|
+
const preceding = input[start - 1] ?? "";
|
|
447
|
+
if (!/[:$0-9.]/.test(preceding)) {
|
|
448
|
+
prefix = { text: word[0], start, end };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { prefix, call: innermostCall(stack) };
|
|
452
|
+
}
|
|
453
|
+
function innermostCall(stack) {
|
|
454
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
455
|
+
const frame = stack[i];
|
|
456
|
+
if (frame.name) return { name: frame.name, argIndex: frame.argIndex, openParen: frame.openParen };
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
var toSuggestion = (def) => ({
|
|
461
|
+
name: def.name,
|
|
462
|
+
signature: getFormulaSignature(def),
|
|
463
|
+
description: def.description,
|
|
464
|
+
category: def.category
|
|
465
|
+
});
|
|
466
|
+
function getFormulaHints(input, caret = input.length, options = {}) {
|
|
467
|
+
const context = getFormulaCaretContext(input, caret);
|
|
468
|
+
let suggestions = [];
|
|
469
|
+
if (context.prefix) {
|
|
470
|
+
const query = context.prefix.text.toUpperCase();
|
|
471
|
+
suggestions = searchFormulaFunctions(query, { limit: void 0 }).filter((def) => def.name.startsWith(query)).slice(0, options.limit ?? 8).map(toSuggestion);
|
|
472
|
+
const followedByParen = input.slice(context.prefix.end).trimStart().startsWith("(");
|
|
473
|
+
if (followedByParen || suggestions.length === 1 && suggestions[0].name === query) suggestions = [];
|
|
474
|
+
}
|
|
475
|
+
let activeCall = null;
|
|
476
|
+
if (context.call) {
|
|
477
|
+
const def = getFormulaFunction(context.call.name);
|
|
478
|
+
if (def) {
|
|
479
|
+
const specIndex = getFormulaArgumentSpecIndex(def, context.call.argIndex);
|
|
480
|
+
activeCall = {
|
|
481
|
+
name: def.name,
|
|
482
|
+
signature: getFormulaSignature(def),
|
|
483
|
+
description: def.description,
|
|
484
|
+
parts: getFormulaSignatureParts(def, context.call.argIndex),
|
|
485
|
+
argIndex: context.call.argIndex,
|
|
486
|
+
argument: specIndex >= 0 ? def.args[specIndex] : null
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { context, suggestions, activeCall };
|
|
491
|
+
}
|
|
492
|
+
function applyFormulaSuggestion(input, caret, name) {
|
|
493
|
+
const context = getFormulaCaretContext(input, caret);
|
|
494
|
+
const start = context.prefix?.start ?? caret;
|
|
495
|
+
const end = context.prefix?.end ?? caret;
|
|
496
|
+
const hasParen = input[end] === "(";
|
|
497
|
+
const insert = hasParen ? name.toUpperCase() : `${name.toUpperCase()}(`;
|
|
498
|
+
const value = input.slice(0, start) + insert + input.slice(end);
|
|
499
|
+
return { value, caret: start + insert.length + (hasParen ? 1 : 0) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/spreadsheet/datasource/index.ts
|
|
503
|
+
var DataSourceBinder = class {
|
|
504
|
+
dataSources = /* @__PURE__ */ new Map();
|
|
505
|
+
register(dataSource) {
|
|
506
|
+
this.dataSources.set(dataSource.id, dataSource);
|
|
507
|
+
}
|
|
508
|
+
unregister(dataSourceId) {
|
|
509
|
+
this.dataSources.delete(dataSourceId);
|
|
510
|
+
}
|
|
511
|
+
getDataSource(id) {
|
|
512
|
+
return this.dataSources.get(id);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Generates or reconciles spreadsheet rows from a primary data source.
|
|
516
|
+
*/
|
|
517
|
+
syncRows(currentRows, dataSourceId) {
|
|
518
|
+
const ds = this.dataSources.get(dataSourceId);
|
|
519
|
+
if (!ds) return currentRows;
|
|
520
|
+
const records = ds.getRecords();
|
|
521
|
+
const existingRecordRowMap = /* @__PURE__ */ new Map();
|
|
522
|
+
const freeformRows = [];
|
|
523
|
+
for (const row of currentRows) {
|
|
524
|
+
if (row.type === "record" && row.recordId) {
|
|
525
|
+
existingRecordRowMap.set(row.recordId, row);
|
|
526
|
+
} else {
|
|
527
|
+
freeformRows.push(row);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const nextRows = [];
|
|
531
|
+
let rowIndex = 0;
|
|
532
|
+
for (const record of records) {
|
|
533
|
+
const existing = existingRecordRowMap.get(record.id);
|
|
534
|
+
nextRows.push({
|
|
535
|
+
...existing?.hidden ? { hidden: true } : {},
|
|
536
|
+
id: existing?.id || `row_${record.id}`,
|
|
537
|
+
index: rowIndex++,
|
|
538
|
+
height: existing?.height || 32,
|
|
539
|
+
recordId: record.id,
|
|
540
|
+
type: "record"
|
|
541
|
+
});
|
|
542
|
+
existingRecordRowMap.delete(record.id);
|
|
543
|
+
}
|
|
544
|
+
for (const freeform of freeformRows) {
|
|
545
|
+
nextRows.push({
|
|
546
|
+
...freeform,
|
|
547
|
+
index: rowIndex++
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return nextRows;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Resolves a record's bound field value.
|
|
554
|
+
*/
|
|
555
|
+
resolveBoundValue(dataSourceId, recordId, field) {
|
|
556
|
+
const ds = this.dataSources.get(dataSourceId);
|
|
557
|
+
if (!ds) return void 0;
|
|
558
|
+
const records = ds.getRecords();
|
|
559
|
+
const record = records.find((r) => r.id === recordId);
|
|
560
|
+
return record ? record[field] : void 0;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Dispatches an update to the underlying data source.
|
|
564
|
+
*/
|
|
565
|
+
async commitBoundValue(dataSourceId, recordId, field, value) {
|
|
566
|
+
const ds = this.dataSources.get(dataSourceId);
|
|
567
|
+
if (!ds) return;
|
|
568
|
+
await ds.updateRecord(recordId, field, value);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// src/spreadsheet/table.ts
|
|
573
|
+
function normalizeRange(range) {
|
|
574
|
+
return {
|
|
575
|
+
startRow: Math.min(range.startRow, range.endRow),
|
|
576
|
+
startCol: Math.min(range.startCol, range.endCol),
|
|
577
|
+
endRow: Math.max(range.startRow, range.endRow),
|
|
578
|
+
endCol: Math.max(range.startCol, range.endCol)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function rangeContains(range, row, col) {
|
|
582
|
+
const r = normalizeRange(range);
|
|
583
|
+
return row >= r.startRow && row <= r.endRow && col >= r.startCol && col <= r.endCol;
|
|
584
|
+
}
|
|
585
|
+
function rangesIntersect(a, b) {
|
|
586
|
+
const x = normalizeRange(a);
|
|
587
|
+
const y = normalizeRange(b);
|
|
588
|
+
return x.startRow <= y.endRow && y.startRow <= x.endRow && x.startCol <= y.endCol && y.startCol <= x.endCol;
|
|
589
|
+
}
|
|
590
|
+
function rangeContainsRange(outer, inner) {
|
|
591
|
+
const o = normalizeRange(outer);
|
|
592
|
+
const i = normalizeRange(inner);
|
|
593
|
+
return i.startRow >= o.startRow && i.endRow <= o.endRow && i.startCol >= o.startCol && i.endCol <= o.endCol;
|
|
594
|
+
}
|
|
595
|
+
function isSingleCellRange(range) {
|
|
596
|
+
const r = normalizeRange(range);
|
|
597
|
+
return r.startRow === r.endRow && r.startCol === r.endCol;
|
|
598
|
+
}
|
|
599
|
+
var NUMBER_PATTERN = /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i;
|
|
600
|
+
var ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
601
|
+
function isBlankValue(value) {
|
|
602
|
+
return value === null || value === void 0 || typeof value === "string" && value.trim() === "";
|
|
603
|
+
}
|
|
604
|
+
function toNumber(value) {
|
|
605
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
606
|
+
if (typeof value === "string") {
|
|
607
|
+
const trimmed = value.trim();
|
|
608
|
+
if (NUMBER_PATTERN.test(trimmed)) return parseFloat(trimmed);
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
var collator = typeof Intl !== "undefined" ? new Intl.Collator(void 0, { numeric: true, sensitivity: "base" }) : null;
|
|
613
|
+
function compareText(a, b) {
|
|
614
|
+
return collator ? collator.compare(a, b) : a < b ? -1 : a > b ? 1 : 0;
|
|
615
|
+
}
|
|
616
|
+
function compareCellValues(a, b, direction = "asc") {
|
|
617
|
+
const aBlank = isBlankValue(a);
|
|
618
|
+
const bBlank = isBlankValue(b);
|
|
619
|
+
if (aBlank || bBlank) return aBlank === bBlank ? 0 : aBlank ? 1 : -1;
|
|
620
|
+
const aNum = toNumber(a);
|
|
621
|
+
const bNum = toNumber(b);
|
|
622
|
+
let result;
|
|
623
|
+
if (aNum !== null && bNum !== null) result = aNum - bNum;
|
|
624
|
+
else if (aNum !== null) result = -1;
|
|
625
|
+
else if (bNum !== null) result = 1;
|
|
626
|
+
else result = compareText(String(a), String(b));
|
|
627
|
+
if (result === 0) return 0;
|
|
628
|
+
return direction === "desc" ? -Math.sign(result) : Math.sign(result);
|
|
629
|
+
}
|
|
630
|
+
function compareForFilter(value, operand) {
|
|
631
|
+
if (isBlankValue(value) || isBlankValue(operand)) return null;
|
|
632
|
+
const a = toNumber(value);
|
|
633
|
+
const b = toNumber(operand);
|
|
634
|
+
if (a !== null && b !== null) return a - b;
|
|
635
|
+
if (a !== null || b !== null) return null;
|
|
636
|
+
return compareText(String(value), String(operand));
|
|
637
|
+
}
|
|
638
|
+
function looselyEquals(value, operand) {
|
|
639
|
+
const a = toNumber(value);
|
|
640
|
+
const b = toNumber(operand);
|
|
641
|
+
if (a !== null && b !== null) return a === b;
|
|
642
|
+
return String(value ?? "").trim().toLowerCase() === String(operand ?? "").trim().toLowerCase();
|
|
643
|
+
}
|
|
644
|
+
function matchesFilterRule(value, rule) {
|
|
645
|
+
const text = String(value ?? "").toLowerCase();
|
|
646
|
+
switch (rule.operator) {
|
|
647
|
+
case "equals":
|
|
648
|
+
return looselyEquals(value, rule.value);
|
|
649
|
+
case "notEquals":
|
|
650
|
+
return !looselyEquals(value, rule.value);
|
|
651
|
+
case "contains":
|
|
652
|
+
return text.includes(String(rule.value ?? "").toLowerCase());
|
|
653
|
+
case "notContains":
|
|
654
|
+
return !text.includes(String(rule.value ?? "").toLowerCase());
|
|
655
|
+
case "greaterThan": {
|
|
656
|
+
const c = compareForFilter(value, rule.value);
|
|
657
|
+
return c !== null && c > 0;
|
|
658
|
+
}
|
|
659
|
+
case "greaterThanOrEqual": {
|
|
660
|
+
const c = compareForFilter(value, rule.value);
|
|
661
|
+
return c !== null && c >= 0;
|
|
662
|
+
}
|
|
663
|
+
case "lessThan": {
|
|
664
|
+
const c = compareForFilter(value, rule.value);
|
|
665
|
+
return c !== null && c < 0;
|
|
666
|
+
}
|
|
667
|
+
case "lessThanOrEqual": {
|
|
668
|
+
const c = compareForFilter(value, rule.value);
|
|
669
|
+
return c !== null && c <= 0;
|
|
670
|
+
}
|
|
671
|
+
case "between": {
|
|
672
|
+
const lo = compareForFilter(value, rule.value);
|
|
673
|
+
const hi = compareForFilter(value, rule.value2);
|
|
674
|
+
return lo !== null && hi !== null && lo >= 0 && hi <= 0;
|
|
675
|
+
}
|
|
676
|
+
case "isBlank":
|
|
677
|
+
return isBlankValue(value);
|
|
678
|
+
case "isNotBlank":
|
|
679
|
+
return !isBlankValue(value);
|
|
680
|
+
case "oneOf":
|
|
681
|
+
return (rule.values ?? []).some((candidate) => String(candidate) === String(value ?? ""));
|
|
682
|
+
default:
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function aggregateValues(values, aggregate) {
|
|
687
|
+
if (aggregate === "count") return values.filter((v) => !isBlankValue(v)).length;
|
|
688
|
+
const numbers = [];
|
|
689
|
+
for (const v of values) {
|
|
690
|
+
const n = toNumber(v);
|
|
691
|
+
if (n !== null) numbers.push(n);
|
|
692
|
+
}
|
|
693
|
+
if (numbers.length === 0) return aggregate === "sum" ? 0 : null;
|
|
694
|
+
switch (aggregate) {
|
|
695
|
+
case "sum":
|
|
696
|
+
return roundFloat(numbers.reduce((s, n) => s + n, 0));
|
|
697
|
+
case "average":
|
|
698
|
+
return roundFloat(numbers.reduce((s, n) => s + n, 0) / numbers.length);
|
|
699
|
+
case "min":
|
|
700
|
+
return numbers.reduce((m, n) => n < m ? n : m, numbers[0]);
|
|
701
|
+
case "max":
|
|
702
|
+
return numbers.reduce((m, n) => n > m ? n : m, numbers[0]);
|
|
703
|
+
default:
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function roundFloat(value) {
|
|
708
|
+
return Number.isInteger(value) ? value : parseFloat(value.toPrecision(12));
|
|
709
|
+
}
|
|
710
|
+
var DAY_MS = 864e5;
|
|
711
|
+
var TRAILING_NUMBER = /^(.*?)(\d+)(\D*)$/;
|
|
712
|
+
function classifyFillValue(raw) {
|
|
713
|
+
if (raw.trim() === "") return { kind: "blank" };
|
|
714
|
+
const num = toNumber(raw);
|
|
715
|
+
if (num !== null) return { kind: "number", value: num };
|
|
716
|
+
const date = ISO_DATE_PATTERN.exec(raw.trim());
|
|
717
|
+
if (date) {
|
|
718
|
+
const [year, month, day] = [Number(date[1]), Number(date[2]), Number(date[3])];
|
|
719
|
+
const time = Date.UTC(year, month - 1, day);
|
|
720
|
+
const check = new Date(time);
|
|
721
|
+
if (check.getUTCMonth() === month - 1 && check.getUTCDate() === day) {
|
|
722
|
+
return { kind: "date", time, year, month, day };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const textNumber = TRAILING_NUMBER.exec(raw);
|
|
726
|
+
if (textNumber && textNumber[1] !== "") {
|
|
727
|
+
return {
|
|
728
|
+
kind: "textNumber",
|
|
729
|
+
prefix: textNumber[1],
|
|
730
|
+
value: parseInt(textNumber[2], 10),
|
|
731
|
+
width: textNumber[2].length,
|
|
732
|
+
suffix: textNumber[3]
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
return { kind: "text", text: raw };
|
|
736
|
+
}
|
|
737
|
+
var pad = (n, width) => String(Math.abs(n)).padStart(width, "0");
|
|
738
|
+
function formatDate(time) {
|
|
739
|
+
const d = new Date(time);
|
|
740
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)}`;
|
|
741
|
+
}
|
|
742
|
+
function constantStep(values) {
|
|
743
|
+
const step = values[1] - values[0];
|
|
744
|
+
for (let i = 2; i < values.length; i++) {
|
|
745
|
+
if (Math.abs(values[i] - values[i - 1] - step) > 1e-9) return null;
|
|
746
|
+
}
|
|
747
|
+
return step;
|
|
748
|
+
}
|
|
749
|
+
function linearTrend(values, x) {
|
|
750
|
+
const n = values.length;
|
|
751
|
+
const meanX = (n - 1) / 2;
|
|
752
|
+
const meanY = values.reduce((s, v) => s + v, 0) / n;
|
|
753
|
+
let num = 0;
|
|
754
|
+
let den = 0;
|
|
755
|
+
values.forEach((v, i) => {
|
|
756
|
+
num += (i - meanX) * (v - meanY);
|
|
757
|
+
den += (i - meanX) ** 2;
|
|
758
|
+
});
|
|
759
|
+
const slope = den === 0 ? 0 : num / den;
|
|
760
|
+
return meanY + slope * (x - meanX);
|
|
761
|
+
}
|
|
762
|
+
function fillSeries(source, count) {
|
|
763
|
+
if (count <= 0) return [];
|
|
764
|
+
if (source.length === 0) return Array(count).fill("");
|
|
765
|
+
const repeat = () => Array.from({ length: count }, (_, i) => source[i % source.length]);
|
|
766
|
+
const parsed = source.map(classifyFillValue);
|
|
767
|
+
const kind = parsed[0].kind;
|
|
768
|
+
if (!parsed.every((p) => p.kind === kind)) return repeat();
|
|
769
|
+
const n = source.length;
|
|
770
|
+
if (kind === "number") {
|
|
771
|
+
if (n === 1) return repeat();
|
|
772
|
+
const values = parsed.map((p) => p.value);
|
|
773
|
+
return Array.from({ length: count }, (_, i) => String(roundFloat(linearTrend(values, n + i))));
|
|
774
|
+
}
|
|
775
|
+
if (kind === "date") {
|
|
776
|
+
const dates = parsed;
|
|
777
|
+
if (n === 1) {
|
|
778
|
+
return Array.from({ length: count }, (_, i) => formatDate(dates[0].time + (i + 1) * DAY_MS));
|
|
779
|
+
}
|
|
780
|
+
const monthIndex = dates.map((d) => d.year * 12 + (d.month - 1));
|
|
781
|
+
const monthStep = constantStep(monthIndex);
|
|
782
|
+
if (monthStep !== null && monthStep !== 0 && dates.every((d) => d.day === dates[0].day)) {
|
|
783
|
+
const last = monthIndex[n - 1];
|
|
784
|
+
return Array.from({ length: count }, (_, i) => {
|
|
785
|
+
const m = last + monthStep * (i + 1);
|
|
786
|
+
const year = Math.floor(m / 12);
|
|
787
|
+
const month = m - year * 12;
|
|
788
|
+
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
789
|
+
return formatDate(Date.UTC(year, month, Math.min(dates[0].day, daysInMonth)));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const dayStep = constantStep(dates.map((d) => Math.round(d.time / DAY_MS)));
|
|
793
|
+
if (dayStep === null) return repeat();
|
|
794
|
+
const lastTime = dates[n - 1].time;
|
|
795
|
+
return Array.from({ length: count }, (_, i) => formatDate(lastTime + dayStep * (i + 1) * DAY_MS));
|
|
796
|
+
}
|
|
797
|
+
if (kind === "textNumber") {
|
|
798
|
+
const items = parsed;
|
|
799
|
+
const { prefix, suffix } = items[0];
|
|
800
|
+
if (!items.every((t) => t.prefix === prefix && t.suffix === suffix)) return repeat();
|
|
801
|
+
const step = n === 1 ? 1 : constantStep(items.map((t) => t.value));
|
|
802
|
+
if (step === null) return repeat();
|
|
803
|
+
const last = items[n - 1];
|
|
804
|
+
return Array.from({ length: count }, (_, i) => {
|
|
805
|
+
const value = last.value + step * (i + 1);
|
|
806
|
+
const digits = value < 0 ? `-${pad(value, last.width)}` : pad(value, last.width);
|
|
807
|
+
return `${prefix}${digits}${suffix}`;
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return repeat();
|
|
811
|
+
}
|
|
812
|
+
function computeFillTarget(source, pointer) {
|
|
813
|
+
const s = normalizeRange(source);
|
|
814
|
+
const rowOverflow = pointer.row > s.endRow ? pointer.row - s.endRow : pointer.row < s.startRow ? s.startRow - pointer.row : 0;
|
|
815
|
+
const colOverflow = pointer.col > s.endCol ? pointer.col - s.endCol : pointer.col < s.startCol ? s.startCol - pointer.col : 0;
|
|
816
|
+
if (rowOverflow === 0 && colOverflow === 0) return null;
|
|
817
|
+
if (rowOverflow >= colOverflow) {
|
|
818
|
+
return pointer.row > s.endRow ? { ...s, endRow: pointer.row } : { ...s, startRow: pointer.row };
|
|
819
|
+
}
|
|
820
|
+
return pointer.col > s.endCol ? { ...s, endCol: pointer.col } : { ...s, startCol: pointer.col };
|
|
821
|
+
}
|
|
822
|
+
function getFillDirection(source, target) {
|
|
823
|
+
const s = normalizeRange(source);
|
|
824
|
+
const t = normalizeRange(target);
|
|
825
|
+
const sameCols = s.startCol === t.startCol && s.endCol === t.endCol;
|
|
826
|
+
const sameRows = s.startRow === t.startRow && s.endRow === t.endRow;
|
|
827
|
+
if (sameCols && t.startRow === s.startRow && t.endRow > s.endRow) return "down";
|
|
828
|
+
if (sameCols && t.endRow === s.endRow && t.startRow < s.startRow) return "up";
|
|
829
|
+
if (sameRows && t.startCol === s.startCol && t.endCol > s.endCol) return "right";
|
|
830
|
+
if (sameRows && t.endCol === s.endCol && t.startCol < s.startCol) return "left";
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/spreadsheet/csv.ts
|
|
835
|
+
var BOM = "\uFEFF";
|
|
836
|
+
function parseCsv(text, options = {}) {
|
|
837
|
+
const delimiter = options.delimiter ?? ",";
|
|
838
|
+
if (delimiter.length !== 1 || delimiter === '"' || delimiter === "\n" || delimiter === "\r") {
|
|
839
|
+
throw new Error(`Invalid CSV delimiter: ${JSON.stringify(delimiter)}`);
|
|
840
|
+
}
|
|
841
|
+
const input = text.startsWith(BOM) ? text.slice(1) : text;
|
|
842
|
+
const records = [];
|
|
843
|
+
let record = [];
|
|
844
|
+
let field = "";
|
|
845
|
+
let i = 0;
|
|
846
|
+
let recordStarted = false;
|
|
847
|
+
const endField = () => {
|
|
848
|
+
record.push(field);
|
|
849
|
+
field = "";
|
|
850
|
+
};
|
|
851
|
+
const endRecord = () => {
|
|
852
|
+
endField();
|
|
853
|
+
records.push(record);
|
|
854
|
+
record = [];
|
|
855
|
+
recordStarted = false;
|
|
856
|
+
};
|
|
857
|
+
let atFieldStart = true;
|
|
858
|
+
while (i < input.length) {
|
|
859
|
+
const char = input[i];
|
|
860
|
+
if (char === '"' && atFieldStart) {
|
|
861
|
+
const quoted = readQuoted(input, i, delimiter);
|
|
862
|
+
if (quoted.wellFormed || !options.requireWellFormedQuotes) {
|
|
863
|
+
recordStarted = true;
|
|
864
|
+
atFieldStart = false;
|
|
865
|
+
field += quoted.value;
|
|
866
|
+
i = quoted.end;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (char === delimiter) {
|
|
871
|
+
recordStarted = true;
|
|
872
|
+
endField();
|
|
873
|
+
atFieldStart = true;
|
|
874
|
+
i++;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (char === "\r" || char === "\n") {
|
|
878
|
+
endRecord();
|
|
879
|
+
atFieldStart = true;
|
|
880
|
+
i += char === "\r" && input[i + 1] === "\n" ? 2 : 1;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
recordStarted = true;
|
|
884
|
+
atFieldStart = false;
|
|
885
|
+
field += char;
|
|
886
|
+
i++;
|
|
887
|
+
}
|
|
888
|
+
if (recordStarted || field !== "" || record.length > 0) endRecord();
|
|
889
|
+
return records;
|
|
890
|
+
}
|
|
891
|
+
function readQuoted(input, start, delimiter) {
|
|
892
|
+
let value = "";
|
|
893
|
+
let i = start + 1;
|
|
894
|
+
while (i < input.length) {
|
|
895
|
+
if (input[i] === '"') {
|
|
896
|
+
if (input[i + 1] === '"') {
|
|
897
|
+
value += '"';
|
|
898
|
+
i += 2;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const next = input[i + 1];
|
|
902
|
+
const wellFormed = next === void 0 || next === delimiter || next === "\r" || next === "\n";
|
|
903
|
+
return { value, end: i + 1, wellFormed };
|
|
904
|
+
}
|
|
905
|
+
value += input[i];
|
|
906
|
+
i++;
|
|
907
|
+
}
|
|
908
|
+
return { value, end: i, wellFormed: false };
|
|
909
|
+
}
|
|
910
|
+
function escapeCsvField(value, delimiter = ",", quoteAll = false) {
|
|
911
|
+
if (quoteAll || value.includes(delimiter) || /["\r\n]/.test(value)) {
|
|
912
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
913
|
+
}
|
|
914
|
+
return value;
|
|
915
|
+
}
|
|
916
|
+
function serializeCsv(rows, options = {}) {
|
|
917
|
+
const delimiter = options.delimiter ?? ",";
|
|
918
|
+
const lineEnding = options.lineEnding ?? "\r\n";
|
|
919
|
+
const body = rows.map(
|
|
920
|
+
(row) => row.map((value) => escapeCsvField(value == null ? "" : String(value), delimiter, options.quoteAll)).join(delimiter)
|
|
921
|
+
).join(lineEnding);
|
|
922
|
+
return options.bom ? BOM + body : body;
|
|
923
|
+
}
|
|
924
|
+
function parseTsv(text) {
|
|
925
|
+
const normalized = text.startsWith(BOM) ? text.slice(1) : text;
|
|
926
|
+
if (!normalized.includes('"')) {
|
|
927
|
+
const body = normalized.replace(/\r\n?/g, "\n").replace(/\n$/, "");
|
|
928
|
+
return body === "" ? [] : body.split("\n").map((line) => line.split(" "));
|
|
929
|
+
}
|
|
930
|
+
return parseCsv(normalized, { delimiter: " ", requireWellFormedQuotes: true });
|
|
931
|
+
}
|
|
932
|
+
function serializeTsv(rows) {
|
|
933
|
+
return rows.map(
|
|
934
|
+
(row) => row.map((value) => {
|
|
935
|
+
const text = value == null ? "" : String(value);
|
|
936
|
+
return /[\t\r\n]/.test(text) || text.startsWith('"') ? `"${text.replace(/"/g, '""')}"` : text;
|
|
937
|
+
}).join(" ")
|
|
938
|
+
).join("\n");
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// src/spreadsheet/clipboard.ts
|
|
942
|
+
var SPREADSHEET_CLIPBOARD_MIME = "application/x.pixerate.spreadsheet+json";
|
|
943
|
+
var displayText = (value) => value === null || value === void 0 ? "" : String(value);
|
|
944
|
+
function escapeHtml(text) {
|
|
945
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
946
|
+
}
|
|
947
|
+
function serializeHtmlTable(values) {
|
|
948
|
+
const body = values.map(
|
|
949
|
+
(row) => `<tr>${row.map((v) => `<td>${escapeHtml(displayText(v)).replace(/\r?\n/g, "<br>")}</td>`).join("")}</tr>`
|
|
950
|
+
).join("");
|
|
951
|
+
return `<table><tbody>${body}</tbody></table>`;
|
|
952
|
+
}
|
|
953
|
+
var NAMED_ENTITIES = {
|
|
954
|
+
amp: "&",
|
|
955
|
+
lt: "<",
|
|
956
|
+
gt: ">",
|
|
957
|
+
quot: '"',
|
|
958
|
+
apos: "'",
|
|
959
|
+
nbsp: " "
|
|
960
|
+
};
|
|
961
|
+
function decodeEntities(text) {
|
|
962
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
|
|
963
|
+
if (entity[0] === "#") {
|
|
964
|
+
const code = entity[1] === "x" || entity[1] === "X" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
965
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match;
|
|
966
|
+
}
|
|
967
|
+
return NAMED_ENTITIES[entity.toLowerCase()] ?? match;
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
var LINE_BREAK = "\uE000";
|
|
971
|
+
function cellHtmlToText(html) {
|
|
972
|
+
const text = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(style|script)[\s\S]*?<\/\1\s*>/gi, "").replace(/<br\s*\/?>/gi, LINE_BREAK).replace(/<\/(p|div)\s*>/gi, LINE_BREAK).replace(/<[^>]*>/g, "").replace(/\s+/g, " ");
|
|
973
|
+
return decodeEntities(text).replace(/ /g, " ").split(LINE_BREAK).map((line) => line.trim()).join("\n").replace(/^\n+|\n+$/g, "");
|
|
974
|
+
}
|
|
975
|
+
function parseHtmlTable(html) {
|
|
976
|
+
const tableMatch = /<table[\s>][\s\S]*?<\/table\s*>/i.exec(html);
|
|
977
|
+
if (!tableMatch) return null;
|
|
978
|
+
const table = tableMatch[0];
|
|
979
|
+
const rows = [];
|
|
980
|
+
const occupied = /* @__PURE__ */ new Map();
|
|
981
|
+
const rowPattern = /<tr[\s>]([\s\S]*?)<\/tr\s*>/gi;
|
|
982
|
+
const cellPattern = /<(td|th)(\s[^>]*)?>([\s\S]*?)<\/\1\s*>/gi;
|
|
983
|
+
let rowMatch;
|
|
984
|
+
let rowIndex = 0;
|
|
985
|
+
while (rowMatch = rowPattern.exec(table)) {
|
|
986
|
+
const row = [];
|
|
987
|
+
const taken = occupied.get(rowIndex) ?? /* @__PURE__ */ new Set();
|
|
988
|
+
let col = 0;
|
|
989
|
+
let cellMatch;
|
|
990
|
+
cellPattern.lastIndex = 0;
|
|
991
|
+
while (cellMatch = cellPattern.exec(rowMatch[1])) {
|
|
992
|
+
while (taken.has(col)) row[col++] = "";
|
|
993
|
+
const attrs = cellMatch[2] ?? "";
|
|
994
|
+
const colspan = Math.max(1, parseInt(/colspan\s*=\s*["']?(\d+)/i.exec(attrs)?.[1] ?? "1", 10));
|
|
995
|
+
const rowspan = Math.max(1, parseInt(/rowspan\s*=\s*["']?(\d+)/i.exec(attrs)?.[1] ?? "1", 10));
|
|
996
|
+
row[col] = cellHtmlToText(cellMatch[3]);
|
|
997
|
+
for (let c = col; c < col + colspan; c++) {
|
|
998
|
+
if (c > col) row[c] = "";
|
|
999
|
+
for (let r = rowIndex + 1; r < rowIndex + rowspan; r++) {
|
|
1000
|
+
if (!occupied.has(r)) occupied.set(r, /* @__PURE__ */ new Set());
|
|
1001
|
+
occupied.get(r).add(c);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
col += colspan;
|
|
1005
|
+
}
|
|
1006
|
+
while (taken.has(col)) row[col++] = "";
|
|
1007
|
+
rows.push(Array.from(row, (v) => v ?? ""));
|
|
1008
|
+
rowIndex++;
|
|
1009
|
+
}
|
|
1010
|
+
return rows.length > 0 ? rows : null;
|
|
1011
|
+
}
|
|
1012
|
+
function createSpreadsheetClipboardData(cells, origin) {
|
|
1013
|
+
const values = cells.map((row) => row.map((cell) => cell.value));
|
|
1014
|
+
const payload = { type: "pixerate-spreadsheet", version: 1, origin, cells };
|
|
1015
|
+
return {
|
|
1016
|
+
"text/plain": serializeTsv(values.map((row) => row.map(displayText))),
|
|
1017
|
+
"text/html": serializeHtmlTable(values),
|
|
1018
|
+
[SPREADSHEET_CLIPBOARD_MIME]: JSON.stringify(payload)
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function readFormat(source, format) {
|
|
1022
|
+
try {
|
|
1023
|
+
if (typeof source.getData === "function") {
|
|
1024
|
+
return source.getData(format) ?? "";
|
|
1025
|
+
}
|
|
1026
|
+
return source[format] ?? "";
|
|
1027
|
+
} catch {
|
|
1028
|
+
return "";
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function parsePayload(json) {
|
|
1032
|
+
try {
|
|
1033
|
+
const payload = JSON.parse(json);
|
|
1034
|
+
if (payload?.type !== "pixerate-spreadsheet" || !Array.isArray(payload.cells) || !payload.cells.every((row) => Array.isArray(row)) || typeof payload.origin?.row !== "number" || typeof payload.origin?.col !== "number") {
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
return payload;
|
|
1038
|
+
} catch {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function readSpreadsheetClipboard(source) {
|
|
1043
|
+
const payload = parsePayload(readFormat(source, SPREADSHEET_CLIPBOARD_MIME));
|
|
1044
|
+
if (payload) {
|
|
1045
|
+
return {
|
|
1046
|
+
format: "internal",
|
|
1047
|
+
origin: payload.origin,
|
|
1048
|
+
rows: payload.cells.map((row) => row.map((cell) => typeof cell?.raw === "string" ? cell.raw : displayText(cell?.value)))
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
const html = readFormat(source, "text/html");
|
|
1052
|
+
if (html) {
|
|
1053
|
+
const rows = parseHtmlTable(html);
|
|
1054
|
+
if (rows) return { format: "html", rows };
|
|
1055
|
+
}
|
|
1056
|
+
const text = readFormat(source, "text/plain");
|
|
1057
|
+
if (text) {
|
|
1058
|
+
const rows = parseTsv(text);
|
|
1059
|
+
if (rows.length > 0) return { format: "text", rows };
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
function rebaseClipboardRows(rows, origin, target) {
|
|
1064
|
+
const rowOffset = target.row - origin.row;
|
|
1065
|
+
const colOffset = target.col - origin.col;
|
|
1066
|
+
return rows.map(
|
|
1067
|
+
(row) => row.map((raw) => raw.startsWith("=") ? offsetFormulaReferences(raw, rowOffset, colOffset) : raw)
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/spreadsheet/view.ts
|
|
1072
|
+
var DEFAULT_ROW_HEIGHT = 32;
|
|
1073
|
+
var DEFAULT_COLUMN_WIDTH = 130;
|
|
1074
|
+
var clampCount = (value, max) => Math.max(0, Math.min(Math.floor(value ?? 0), max));
|
|
1075
|
+
function getTableSections(doc) {
|
|
1076
|
+
const total = doc.rows.length;
|
|
1077
|
+
const headerRows = clampCount(doc.table?.headerRowCount, total);
|
|
1078
|
+
const footerRows = clampCount(doc.table?.footerRowCount, total - headerRows);
|
|
1079
|
+
return { headerRows, footerRows };
|
|
1080
|
+
}
|
|
1081
|
+
function getRowSection(doc, rowIndex) {
|
|
1082
|
+
const { headerRows, footerRows } = getTableSections(doc);
|
|
1083
|
+
if (rowIndex < headerRows) return "header";
|
|
1084
|
+
if (rowIndex >= doc.rows.length - footerRows) return "footer";
|
|
1085
|
+
return "body";
|
|
1086
|
+
}
|
|
1087
|
+
function rowPassesFilters(doc, rowIndex, getValue) {
|
|
1088
|
+
const filter = doc.table?.filter;
|
|
1089
|
+
if (!filter?.enabled || filter.rules.length === 0) return true;
|
|
1090
|
+
if (getRowSection(doc, rowIndex) !== "body") return true;
|
|
1091
|
+
return filter.rules.every((rule) => {
|
|
1092
|
+
const colIndex = doc.columns.findIndex((c) => c.id === rule.colId);
|
|
1093
|
+
if (colIndex === -1) return true;
|
|
1094
|
+
return matchesFilterRule(getValue(rowIndex, colIndex), rule);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function computeRowGroups(doc, getValue) {
|
|
1098
|
+
const grouping = doc.table?.grouping;
|
|
1099
|
+
if (!grouping) return [];
|
|
1100
|
+
const groupCol = doc.columns.findIndex((c) => c.id === grouping.colId);
|
|
1101
|
+
if (groupCol === -1) return [];
|
|
1102
|
+
const { headerRows, footerRows } = getTableSections(doc);
|
|
1103
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1104
|
+
for (let r = headerRows; r < doc.rows.length - footerRows; r++) {
|
|
1105
|
+
if (doc.rows[r].hidden || !rowPassesFilters(doc, r, getValue)) continue;
|
|
1106
|
+
const value = getValue(r, groupCol);
|
|
1107
|
+
const key = isBlankValue(value) ? "" : String(value).trim();
|
|
1108
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
1109
|
+
byKey.get(key).push(r);
|
|
1110
|
+
}
|
|
1111
|
+
const collapsed = new Set(grouping.collapsed);
|
|
1112
|
+
return Array.from(byKey.keys()).sort((a, b) => compareCellValues(a, b)).map((key) => {
|
|
1113
|
+
const rowIndices = byKey.get(key);
|
|
1114
|
+
const summaries = {};
|
|
1115
|
+
for (const summary of grouping.summaries) {
|
|
1116
|
+
const colIndex = doc.columns.findIndex((c) => c.id === summary.colId);
|
|
1117
|
+
if (colIndex === -1) continue;
|
|
1118
|
+
summaries[summary.colId] = aggregateValues(
|
|
1119
|
+
rowIndices.map((r) => getValue(r, colIndex)),
|
|
1120
|
+
summary.aggregate
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
return { key, label: key === "" ? "(blank)" : key, rowIndices, collapsed: collapsed.has(key), summaries };
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
function buildSpreadsheetView(doc, getValue) {
|
|
1127
|
+
const { headerRows, footerRows } = getTableSections(doc);
|
|
1128
|
+
const frozenRows = clampCount(doc.table?.frozenRowCount, doc.rows.length);
|
|
1129
|
+
const frozenCols = clampCount(doc.table?.frozenColumnCount, doc.columns.length);
|
|
1130
|
+
const headerCols = clampCount(doc.table?.headerColumnCount, doc.columns.length);
|
|
1131
|
+
const columns = [];
|
|
1132
|
+
let left = 0;
|
|
1133
|
+
let hiddenColumnCount = 0;
|
|
1134
|
+
doc.columns.forEach((column, index) => {
|
|
1135
|
+
if (column.hidden) {
|
|
1136
|
+
hiddenColumnCount++;
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const frozen = index < frozenCols;
|
|
1140
|
+
columns.push({ column, index, header: index < headerCols, frozen, stickyLeft: frozen ? left : 0 });
|
|
1141
|
+
if (frozen) left += column.width || DEFAULT_COLUMN_WIDTH;
|
|
1142
|
+
});
|
|
1143
|
+
const rows = [];
|
|
1144
|
+
let hiddenRowCount = 0;
|
|
1145
|
+
let filteredOutCount = 0;
|
|
1146
|
+
const pushRow = (index, section, groupKey) => {
|
|
1147
|
+
const row = doc.rows[index];
|
|
1148
|
+
rows.push({ kind: "row", row, index, section, frozen: false, stickyTop: 0, groupKey });
|
|
1149
|
+
};
|
|
1150
|
+
for (let r = 0; r < headerRows; r++) {
|
|
1151
|
+
if (doc.rows[r].hidden) hiddenRowCount++;
|
|
1152
|
+
else pushRow(r, "header");
|
|
1153
|
+
}
|
|
1154
|
+
const bodyEnd = doc.rows.length - footerRows;
|
|
1155
|
+
const groups = computeRowGroups(doc, getValue);
|
|
1156
|
+
for (let r = headerRows; r < bodyEnd; r++) {
|
|
1157
|
+
if (doc.rows[r].hidden) hiddenRowCount++;
|
|
1158
|
+
else if (!rowPassesFilters(doc, r, getValue)) filteredOutCount++;
|
|
1159
|
+
}
|
|
1160
|
+
const grouping = doc.table?.grouping;
|
|
1161
|
+
if (grouping && doc.columns.some((c) => c.id === grouping.colId)) {
|
|
1162
|
+
for (const group of groups) {
|
|
1163
|
+
rows.push({ kind: "group", group });
|
|
1164
|
+
if (!group.collapsed) group.rowIndices.forEach((r) => pushRow(r, "body", group.key));
|
|
1165
|
+
}
|
|
1166
|
+
} else {
|
|
1167
|
+
for (let r = headerRows; r < bodyEnd; r++) {
|
|
1168
|
+
if (!doc.rows[r].hidden && rowPassesFilters(doc, r, getValue)) pushRow(r, "body");
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
for (let r = bodyEnd; r < doc.rows.length; r++) {
|
|
1172
|
+
if (doc.rows[r].hidden) hiddenRowCount++;
|
|
1173
|
+
else pushRow(r, "footer");
|
|
1174
|
+
}
|
|
1175
|
+
let top = 0;
|
|
1176
|
+
for (const viewRow of rows) {
|
|
1177
|
+
if (viewRow.kind !== "row" || viewRow.index >= frozenRows) break;
|
|
1178
|
+
viewRow.frozen = true;
|
|
1179
|
+
viewRow.stickyTop = top;
|
|
1180
|
+
top += viewRow.row.height || DEFAULT_ROW_HEIGHT;
|
|
1181
|
+
}
|
|
1182
|
+
const rowOrder = rows.flatMap((r) => r.kind === "row" ? [r.index] : []);
|
|
1183
|
+
const columnOrder = columns.map((c) => c.index);
|
|
1184
|
+
const rowPos = new Map(rowOrder.map((index, pos) => [index, pos]));
|
|
1185
|
+
const colPos = new Map(columnOrder.map((index, pos) => [index, pos]));
|
|
1186
|
+
const spans = /* @__PURE__ */ new Map();
|
|
1187
|
+
const contiguousRun = (indices, positions) => {
|
|
1188
|
+
const visible = indices.filter((i) => positions.has(i)).sort((a, b) => positions.get(a) - positions.get(b));
|
|
1189
|
+
if (visible.length === 0) return null;
|
|
1190
|
+
let run = 1;
|
|
1191
|
+
while (run < visible.length && positions.get(visible[run]) === positions.get(visible[0]) + run) run++;
|
|
1192
|
+
return { first: visible[0], run };
|
|
1193
|
+
};
|
|
1194
|
+
for (const raw of doc.table?.merges ?? []) {
|
|
1195
|
+
const merge = normalizeRange(raw);
|
|
1196
|
+
const rowIdx = [];
|
|
1197
|
+
const colIdx = [];
|
|
1198
|
+
for (let r = merge.startRow; r <= merge.endRow; r++) rowIdx.push(r);
|
|
1199
|
+
for (let c = merge.startCol; c <= merge.endCol; c++) colIdx.push(c);
|
|
1200
|
+
const rowRun = contiguousRun(rowIdx, rowPos);
|
|
1201
|
+
const colRun = contiguousRun(colIdx, colPos);
|
|
1202
|
+
for (const r of rowIdx) for (const c of colIdx) spans.set(`${r}:${c}`, null);
|
|
1203
|
+
if (rowRun && colRun) {
|
|
1204
|
+
spans.set(`${rowRun.first}:${colRun.first}`, {
|
|
1205
|
+
rowSpan: rowRun.run,
|
|
1206
|
+
colSpan: colRun.run,
|
|
1207
|
+
source: { row: merge.startRow, col: merge.startCol },
|
|
1208
|
+
merge
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
rows,
|
|
1214
|
+
columns,
|
|
1215
|
+
groups,
|
|
1216
|
+
rowOrder,
|
|
1217
|
+
columnOrder,
|
|
1218
|
+
filteredOutCount,
|
|
1219
|
+
hiddenRowCount,
|
|
1220
|
+
hiddenColumnCount,
|
|
1221
|
+
getCellSpan(row, col) {
|
|
1222
|
+
const span = spans.get(`${row}:${col}`);
|
|
1223
|
+
if (span === void 0) return { rowSpan: 1, colSpan: 1, source: { row, col } };
|
|
1224
|
+
return span;
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/spreadsheet/controller.ts
|
|
1230
|
+
var nextIdSequence = 0;
|
|
1231
|
+
var SpreadsheetController = class {
|
|
1232
|
+
document;
|
|
1233
|
+
activeCell = null;
|
|
1234
|
+
selectedRange = null;
|
|
1235
|
+
editingCell = null;
|
|
1236
|
+
draftValue = "";
|
|
1237
|
+
binder = new DataSourceBinder();
|
|
1238
|
+
depGraph = new DependencyGraph();
|
|
1239
|
+
evaluator;
|
|
1240
|
+
primaryDataSourceId;
|
|
1241
|
+
onDocumentChange;
|
|
1242
|
+
onSelectionChange;
|
|
1243
|
+
onCellCommit;
|
|
1244
|
+
// History stack for undo/redo
|
|
1245
|
+
undoStack = [];
|
|
1246
|
+
redoStack = [];
|
|
1247
|
+
isApplyingHistory = false;
|
|
1248
|
+
// Set while importing TSV: history and recalculation are done once at the end.
|
|
1249
|
+
batching = false;
|
|
1250
|
+
pendingCommits = [];
|
|
1251
|
+
// Row being evaluated, so [property] references resolve per row.
|
|
1252
|
+
evaluatingRowIndex = null;
|
|
1253
|
+
constructor(options = {}) {
|
|
1254
|
+
const defaultCols = [
|
|
1255
|
+
{ id: "col_a", key: "A", title: "A", width: 120, type: "freeform" },
|
|
1256
|
+
{ id: "col_b", key: "B", title: "B", width: 120, type: "freeform" },
|
|
1257
|
+
{ id: "col_c", key: "C", title: "C", width: 120, type: "freeform" },
|
|
1258
|
+
{ id: "col_d", key: "D", title: "D", width: 120, type: "freeform" }
|
|
1259
|
+
];
|
|
1260
|
+
const defaultRows = options.primaryDataSourceId ? [] : Array.from({ length: 10 }, (_, i) => ({
|
|
1261
|
+
id: `row_${i + 1}`,
|
|
1262
|
+
index: i,
|
|
1263
|
+
height: 32,
|
|
1264
|
+
type: "freeform"
|
|
1265
|
+
}));
|
|
1266
|
+
this.document = {
|
|
1267
|
+
id: options.document?.id || `sheet_${Date.now()}`,
|
|
1268
|
+
name: options.document?.name || "Sheet 1",
|
|
1269
|
+
columns: options.document?.columns || defaultCols,
|
|
1270
|
+
rows: options.document?.rows || defaultRows,
|
|
1271
|
+
cells: options.document?.cells || {},
|
|
1272
|
+
createdAt: options.document?.createdAt || Date.now(),
|
|
1273
|
+
updatedAt: options.document?.updatedAt || Date.now(),
|
|
1274
|
+
...options.document?.table ? { table: options.document.table } : {}
|
|
1275
|
+
};
|
|
1276
|
+
this.primaryDataSourceId = options.primaryDataSourceId;
|
|
1277
|
+
this.onDocumentChange = options.onDocumentChange;
|
|
1278
|
+
this.onSelectionChange = options.onSelectionChange;
|
|
1279
|
+
this.onCellCommit = options.onCellCommit;
|
|
1280
|
+
if (options.dataSources) {
|
|
1281
|
+
options.dataSources.forEach((ds) => this.binder.register(ds));
|
|
1282
|
+
}
|
|
1283
|
+
this.evaluator = new FormulaEvaluator({
|
|
1284
|
+
resolveCell: (colStr, rowNum) => this.resolveCellByCoord(colStr, rowNum),
|
|
1285
|
+
resolveRange: (sCol, sRow, eCol, eRow) => this.resolveRangeByCoords(sCol, sRow, eCol, eRow),
|
|
1286
|
+
resolveProperty: (prop) => this.resolveCurrentRowProperty(prop)
|
|
1287
|
+
});
|
|
1288
|
+
this.syncWithDataSource();
|
|
1289
|
+
this.recalculateAll();
|
|
1290
|
+
}
|
|
1291
|
+
loadDocument(document) {
|
|
1292
|
+
this.document = JSON.parse(JSON.stringify(document));
|
|
1293
|
+
this.activeCell = null;
|
|
1294
|
+
this.selectedRange = null;
|
|
1295
|
+
this.editingCell = null;
|
|
1296
|
+
this.draftValue = "";
|
|
1297
|
+
this.undoStack = [];
|
|
1298
|
+
this.redoStack = [];
|
|
1299
|
+
this.syncWithDataSource();
|
|
1300
|
+
this.recalculateAll();
|
|
1301
|
+
this.notifyChange();
|
|
1302
|
+
}
|
|
1303
|
+
registerDataSource(ds) {
|
|
1304
|
+
this.binder.register(ds);
|
|
1305
|
+
this.syncWithDataSource();
|
|
1306
|
+
this.recalculateAll();
|
|
1307
|
+
}
|
|
1308
|
+
setPrimaryDataSource(dataSourceId) {
|
|
1309
|
+
this.primaryDataSourceId = dataSourceId;
|
|
1310
|
+
this.syncWithDataSource();
|
|
1311
|
+
this.recalculateAll();
|
|
1312
|
+
}
|
|
1313
|
+
syncWithDataSource() {
|
|
1314
|
+
if (!this.primaryDataSourceId) return;
|
|
1315
|
+
this.document.rows = this.binder.syncRows(this.document.rows, this.primaryDataSourceId);
|
|
1316
|
+
this.recomputeRowIndices();
|
|
1317
|
+
}
|
|
1318
|
+
recomputeRowIndices() {
|
|
1319
|
+
this.document.rows.forEach((r, idx) => {
|
|
1320
|
+
r.index = idx;
|
|
1321
|
+
});
|
|
1322
|
+
this.document.columns.forEach((c, idx) => {
|
|
1323
|
+
c.key = indexToColName(idx);
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
getColumn(colIdOrKey) {
|
|
1327
|
+
return this.document.columns.find(
|
|
1328
|
+
(c) => c.id === colIdOrKey || c.key.toUpperCase() === colIdOrKey.toUpperCase()
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
getRow(rowIdOrIndex) {
|
|
1332
|
+
if (typeof rowIdOrIndex === "number") {
|
|
1333
|
+
return this.document.rows[rowIdOrIndex];
|
|
1334
|
+
}
|
|
1335
|
+
return this.document.rows.find((r) => r.id === rowIdOrIndex);
|
|
1336
|
+
}
|
|
1337
|
+
getCell(rowId, colId) {
|
|
1338
|
+
const existing = this.document.cells[rowId]?.[colId];
|
|
1339
|
+
if (existing) return existing;
|
|
1340
|
+
const col = this.document.columns.find((c) => c.id === colId);
|
|
1341
|
+
const row = this.document.rows.find((r) => r.id === rowId);
|
|
1342
|
+
return this.getDerivedCell(row, col);
|
|
1343
|
+
}
|
|
1344
|
+
/** Value of a cell with no stored data: bound record value, column formula, or blank. */
|
|
1345
|
+
getDerivedCell(row, col) {
|
|
1346
|
+
if (col && row && col.type === "bound" && col.binding && row.recordId) {
|
|
1347
|
+
const boundVal = this.binder.resolveBoundValue(
|
|
1348
|
+
col.binding.dataSource,
|
|
1349
|
+
row.recordId,
|
|
1350
|
+
col.binding.field
|
|
1351
|
+
);
|
|
1352
|
+
return {
|
|
1353
|
+
raw: boundVal !== void 0 ? String(boundVal) : "",
|
|
1354
|
+
value: boundVal ?? "",
|
|
1355
|
+
format: col.format
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
if (col && col.type === "formula" && col.formula) {
|
|
1359
|
+
return {
|
|
1360
|
+
raw: col.formula,
|
|
1361
|
+
value: "",
|
|
1362
|
+
format: col.format
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
return { raw: "", value: "", format: col?.format };
|
|
1366
|
+
}
|
|
1367
|
+
setCellValue(rowId, colId, raw) {
|
|
1368
|
+
this.recordSnapshot();
|
|
1369
|
+
if (!this.document.cells[rowId]) {
|
|
1370
|
+
this.document.cells[rowId] = {};
|
|
1371
|
+
}
|
|
1372
|
+
const col = this.document.columns.find((c) => c.id === colId);
|
|
1373
|
+
const row = this.document.rows.find((r) => r.id === rowId);
|
|
1374
|
+
if (col?.type === "bound" && col.binding && row?.recordId) {
|
|
1375
|
+
this.binder.commitBoundValue(
|
|
1376
|
+
col.binding.dataSource,
|
|
1377
|
+
row.recordId,
|
|
1378
|
+
col.binding.field,
|
|
1379
|
+
raw
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
const cellKey = this.getCellKey(rowId, colId);
|
|
1383
|
+
this.depGraph.setCellFormula(cellKey, raw.startsWith("=") ? raw : null);
|
|
1384
|
+
this.document.cells[rowId][colId] = {
|
|
1385
|
+
raw,
|
|
1386
|
+
value: raw,
|
|
1387
|
+
format: col?.format
|
|
1388
|
+
};
|
|
1389
|
+
this.document.updatedAt = Date.now();
|
|
1390
|
+
if (this.batching) {
|
|
1391
|
+
this.pendingCommits.push([rowId, colId, raw]);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
this.recalculateCell(rowId, colId);
|
|
1395
|
+
this.notifyChange();
|
|
1396
|
+
const evaluatedVal = this.document.cells[rowId][colId]?.value;
|
|
1397
|
+
this.onCellCommit?.(rowId, colId, raw, evaluatedVal);
|
|
1398
|
+
}
|
|
1399
|
+
getCellKey(rowId, colId) {
|
|
1400
|
+
const row = this.document.rows.find((r) => r.id === rowId);
|
|
1401
|
+
const col = this.document.columns.find((c) => c.id === colId);
|
|
1402
|
+
if (!row || !col) return `${colId}_${rowId}`;
|
|
1403
|
+
return `${col.key}${row.index + 1}`.toUpperCase();
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Recalculates a cell and everything that depends on it, in dependency
|
|
1407
|
+
* order. Every cell in a reference cycle is marked `#CYCLE!`.
|
|
1408
|
+
*/
|
|
1409
|
+
recalculateCell(rowId, colId) {
|
|
1410
|
+
const cellKey = this.getCellKey(rowId, colId);
|
|
1411
|
+
this.runEvaluationPlan(this.depGraph.getEvaluationPlan([cellKey], { includeDependents: true }));
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Rebuilds the dependency graph from scratch and recalculates every formula
|
|
1415
|
+
* in dependency order.
|
|
1416
|
+
*/
|
|
1417
|
+
recalculateAll() {
|
|
1418
|
+
this.depGraph.clear();
|
|
1419
|
+
const formulaKeys = [];
|
|
1420
|
+
for (const row of this.document.rows) {
|
|
1421
|
+
for (const col of this.document.columns) {
|
|
1422
|
+
const cell = this.document.cells[row.id]?.[col.id] ?? this.getDerivedCell(row, col);
|
|
1423
|
+
if (cell.raw.startsWith("=")) {
|
|
1424
|
+
const cellKey = `${col.key}${row.index + 1}`.toUpperCase();
|
|
1425
|
+
this.depGraph.setCellFormula(cellKey, cell.raw);
|
|
1426
|
+
formulaKeys.push(cellKey);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
this.runEvaluationPlan(this.depGraph.getEvaluationPlan(formulaKeys));
|
|
1431
|
+
}
|
|
1432
|
+
runEvaluationPlan(plan) {
|
|
1433
|
+
for (const key of plan.order) {
|
|
1434
|
+
if (plan.cyclic.has(key)) {
|
|
1435
|
+
this.markCycle(key);
|
|
1436
|
+
} else {
|
|
1437
|
+
this.evalSingleCellByKey(key);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
locateCellKey(cellKey) {
|
|
1442
|
+
const match = cellKey.match(/^([A-Za-z]+)(\d+)$/);
|
|
1443
|
+
if (!match) return null;
|
|
1444
|
+
const col = this.document.columns.find((c) => c.key.toUpperCase() === match[1].toUpperCase());
|
|
1445
|
+
const row = this.document.rows[parseInt(match[2], 10) - 1];
|
|
1446
|
+
return col && row ? { row, col } : null;
|
|
1447
|
+
}
|
|
1448
|
+
markCycle(cellKey) {
|
|
1449
|
+
const located = this.locateCellKey(cellKey);
|
|
1450
|
+
if (!located) return;
|
|
1451
|
+
const { row, col } = located;
|
|
1452
|
+
const cell = this.getCell(row.id, col.id);
|
|
1453
|
+
if (!cell.raw.startsWith("=")) return;
|
|
1454
|
+
if (!this.document.cells[row.id]) this.document.cells[row.id] = {};
|
|
1455
|
+
const deps = this.depGraph.getDependencies(cellKey);
|
|
1456
|
+
const selfReference = deps.includes(cellKey) || deps.includes(`${col.key.toUpperCase()}:`);
|
|
1457
|
+
const errorMessage = selfReference ? "This formula can't reference its own cell." : "This formula is part of a circular reference: it depends on its own result through other cells.";
|
|
1458
|
+
this.document.cells[row.id][col.id] = { ...cell, value: "#CYCLE!", error: "#CYCLE!", errorMessage };
|
|
1459
|
+
}
|
|
1460
|
+
evalSingleCellByKey(cellKey) {
|
|
1461
|
+
const match = cellKey.match(/^([A-Za-z]+)(\d+)$/);
|
|
1462
|
+
if (!match) return;
|
|
1463
|
+
const colName = match[1].toUpperCase();
|
|
1464
|
+
const rowNum = parseInt(match[2], 10);
|
|
1465
|
+
const col = this.document.columns.find((c) => c.key.toUpperCase() === colName);
|
|
1466
|
+
const row = this.document.rows[rowNum - 1];
|
|
1467
|
+
if (!col || !row) return;
|
|
1468
|
+
const cell = this.getCell(row.id, col.id);
|
|
1469
|
+
if (!cell.raw.startsWith("=")) {
|
|
1470
|
+
if (cell.error === "#CYCLE!") {
|
|
1471
|
+
cell.error = void 0;
|
|
1472
|
+
cell.errorMessage = void 0;
|
|
1473
|
+
}
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
this.evaluatingRowIndex = rowNum - 1;
|
|
1478
|
+
const result = this.evaluator.evaluateWithDiagnostics(cell.raw);
|
|
1479
|
+
if (!this.document.cells[row.id]) {
|
|
1480
|
+
this.document.cells[row.id] = {};
|
|
1481
|
+
}
|
|
1482
|
+
this.document.cells[row.id][col.id] = {
|
|
1483
|
+
...cell,
|
|
1484
|
+
value: result.value,
|
|
1485
|
+
// `#ERROR!` (malformed formula, wrong argument count) keeps its
|
|
1486
|
+
// message in `error`, as before; other errors store their code.
|
|
1487
|
+
error: result.error === "#ERROR!" ? result.message : result.error,
|
|
1488
|
+
errorMessage: result.message
|
|
1489
|
+
};
|
|
1490
|
+
} finally {
|
|
1491
|
+
this.evaluatingRowIndex = null;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
resolveCellByCoord(colStr, rowNum) {
|
|
1495
|
+
const col = this.document.columns.find((c) => c.key.toUpperCase() === colStr.toUpperCase());
|
|
1496
|
+
const row = this.document.rows[rowNum - 1];
|
|
1497
|
+
if (!col || !row) return 0;
|
|
1498
|
+
return this.getCell(row.id, col.id).value ?? "";
|
|
1499
|
+
}
|
|
1500
|
+
resolveRangeByCoords(sCol, sRow, eCol, eRow) {
|
|
1501
|
+
const startC = colNameToIndex(sCol);
|
|
1502
|
+
const endC = colNameToIndex(eCol);
|
|
1503
|
+
const minC = Math.min(startC, endC);
|
|
1504
|
+
const maxC = Math.max(startC, endC);
|
|
1505
|
+
const minR = Math.min(sRow, eRow);
|
|
1506
|
+
const maxR = Math.min(Math.max(sRow, eRow), WHOLE_COLUMN_END_ROW, this.document.rows.length);
|
|
1507
|
+
const results = [];
|
|
1508
|
+
for (let c = minC; c <= maxC; c++) {
|
|
1509
|
+
const colName = indexToColName(c);
|
|
1510
|
+
const col = this.document.columns.find((colDef) => colDef.key.toUpperCase() === colName);
|
|
1511
|
+
if (!col) continue;
|
|
1512
|
+
for (let r = minR; r <= maxR; r++) {
|
|
1513
|
+
const row = this.document.rows[r - 1];
|
|
1514
|
+
if (!row) continue;
|
|
1515
|
+
results.push(this.getCell(row.id, col.id).value ?? "");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return results;
|
|
1519
|
+
}
|
|
1520
|
+
resolveCurrentRowProperty(prop) {
|
|
1521
|
+
const rowIndex = this.evaluatingRowIndex ?? this.activeCell?.row;
|
|
1522
|
+
if (rowIndex === void 0 || rowIndex === null) return 0;
|
|
1523
|
+
const row = this.document.rows[rowIndex];
|
|
1524
|
+
if (!row || !row.recordId || !this.primaryDataSourceId) return 0;
|
|
1525
|
+
return this.binder.resolveBoundValue(this.primaryDataSourceId, row.recordId, prop) ?? 0;
|
|
1526
|
+
}
|
|
1527
|
+
// --- Selection and Navigation ---
|
|
1528
|
+
selectCell(row, col, extendSelection = false) {
|
|
1529
|
+
const clampedRow = Math.max(0, Math.min(row, this.document.rows.length - 1));
|
|
1530
|
+
const clampedCol = Math.max(0, Math.min(col, this.document.columns.length - 1));
|
|
1531
|
+
const merge = extendSelection ? null : this.getMergeAt(clampedRow, clampedCol);
|
|
1532
|
+
const activeRow = merge ? merge.startRow : clampedRow;
|
|
1533
|
+
const activeCol = merge ? merge.startCol : clampedCol;
|
|
1534
|
+
const rowObj = this.document.rows[activeRow];
|
|
1535
|
+
const colObj = this.document.columns[activeCol];
|
|
1536
|
+
this.activeCell = {
|
|
1537
|
+
row: activeRow,
|
|
1538
|
+
col: activeCol,
|
|
1539
|
+
rowId: rowObj?.id,
|
|
1540
|
+
colId: colObj?.id
|
|
1541
|
+
};
|
|
1542
|
+
if (extendSelection && this.selectedRange) {
|
|
1543
|
+
this.selectedRange = this.expandRangeToMerges({
|
|
1544
|
+
startRow: this.selectedRange.startRow,
|
|
1545
|
+
startCol: this.selectedRange.startCol,
|
|
1546
|
+
endRow: clampedRow,
|
|
1547
|
+
endCol: clampedCol
|
|
1548
|
+
});
|
|
1549
|
+
} else if (merge) {
|
|
1550
|
+
this.selectedRange = { ...merge };
|
|
1551
|
+
} else {
|
|
1552
|
+
this.selectedRange = {
|
|
1553
|
+
startRow: clampedRow,
|
|
1554
|
+
startCol: clampedCol,
|
|
1555
|
+
endRow: clampedRow,
|
|
1556
|
+
endCol: clampedCol
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
this.onSelectionChange?.(this.activeCell, this.selectedRange);
|
|
1560
|
+
}
|
|
1561
|
+
selectRange(range) {
|
|
1562
|
+
this.selectedRange = range;
|
|
1563
|
+
this.onSelectionChange?.(this.activeCell, this.selectedRange);
|
|
1564
|
+
}
|
|
1565
|
+
startEditing(row, col, initialValue) {
|
|
1566
|
+
this.selectCell(row ?? this.activeCell?.row ?? 0, col ?? this.activeCell?.col ?? 0);
|
|
1567
|
+
const targetRow = this.activeCell?.row ?? 0;
|
|
1568
|
+
const targetCol = this.activeCell?.col ?? 0;
|
|
1569
|
+
const rowObj = this.document.rows[targetRow];
|
|
1570
|
+
const colObj = this.document.columns[targetCol];
|
|
1571
|
+
if (!rowObj || !colObj) return;
|
|
1572
|
+
this.editingCell = {
|
|
1573
|
+
row: targetRow,
|
|
1574
|
+
col: targetCol,
|
|
1575
|
+
rowId: rowObj.id,
|
|
1576
|
+
colId: colObj.id
|
|
1577
|
+
};
|
|
1578
|
+
const cell = this.getCell(rowObj.id, colObj.id);
|
|
1579
|
+
this.draftValue = initialValue !== void 0 ? initialValue : cell.raw;
|
|
1580
|
+
}
|
|
1581
|
+
commitEditing(valueToCommit) {
|
|
1582
|
+
if (!this.editingCell) return;
|
|
1583
|
+
const val = valueToCommit !== void 0 ? valueToCommit : this.draftValue;
|
|
1584
|
+
if (this.editingCell.rowId && this.editingCell.colId) {
|
|
1585
|
+
this.setCellValue(this.editingCell.rowId, this.editingCell.colId, val);
|
|
1586
|
+
}
|
|
1587
|
+
this.editingCell = null;
|
|
1588
|
+
this.draftValue = "";
|
|
1589
|
+
}
|
|
1590
|
+
cancelEditing() {
|
|
1591
|
+
this.editingCell = null;
|
|
1592
|
+
this.draftValue = "";
|
|
1593
|
+
}
|
|
1594
|
+
moveCursor(direction, extendSelection = false) {
|
|
1595
|
+
if (!this.activeCell) {
|
|
1596
|
+
this.selectCell(0, 0);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const { rowOrder, columnOrder } = this.getViewModel();
|
|
1600
|
+
let nextRow = this.activeCell.row;
|
|
1601
|
+
let nextCol = this.activeCell.col;
|
|
1602
|
+
const merge = extendSelection ? null : this.getMergeAt(nextRow, nextCol);
|
|
1603
|
+
switch (direction) {
|
|
1604
|
+
case "up":
|
|
1605
|
+
nextRow = stepInOrder(rowOrder, merge ? merge.startRow : nextRow, -1);
|
|
1606
|
+
break;
|
|
1607
|
+
case "down":
|
|
1608
|
+
nextRow = stepInOrder(rowOrder, merge ? merge.endRow : nextRow, 1);
|
|
1609
|
+
break;
|
|
1610
|
+
case "left":
|
|
1611
|
+
nextCol = stepInOrder(columnOrder, merge ? merge.startCol : nextCol, -1);
|
|
1612
|
+
break;
|
|
1613
|
+
case "right":
|
|
1614
|
+
nextCol = stepInOrder(columnOrder, merge ? merge.endCol : nextCol, 1);
|
|
1615
|
+
break;
|
|
1616
|
+
}
|
|
1617
|
+
this.selectCell(nextRow, nextCol, extendSelection);
|
|
1618
|
+
}
|
|
1619
|
+
// --- Grid Structure Mutations ---
|
|
1620
|
+
insertColumn(index, config) {
|
|
1621
|
+
this.recordSnapshot();
|
|
1622
|
+
const newId = config?.id || this.generateId("col");
|
|
1623
|
+
const newCol = {
|
|
1624
|
+
id: newId,
|
|
1625
|
+
key: indexToColName(index),
|
|
1626
|
+
title: config?.title || indexToColName(index),
|
|
1627
|
+
width: config?.width || 130,
|
|
1628
|
+
type: config?.type || "freeform",
|
|
1629
|
+
binding: config?.binding,
|
|
1630
|
+
formula: config?.formula,
|
|
1631
|
+
format: config?.format || "text",
|
|
1632
|
+
readOnly: config?.readOnly
|
|
1633
|
+
};
|
|
1634
|
+
this.shiftReferences({ axis: "col", index, type: "insert" });
|
|
1635
|
+
this.adjustTableForStructuralChange({ axis: "col", index, type: "insert" });
|
|
1636
|
+
this.document.columns.splice(index, 0, newCol);
|
|
1637
|
+
this.recomputeRowIndices();
|
|
1638
|
+
this.recalculateAll();
|
|
1639
|
+
this.notifyChange();
|
|
1640
|
+
}
|
|
1641
|
+
deleteColumn(colId) {
|
|
1642
|
+
this.recordSnapshot();
|
|
1643
|
+
const colIndex = this.document.columns.findIndex((c) => c.id === colId);
|
|
1644
|
+
if (colIndex === -1) return;
|
|
1645
|
+
this.shiftReferences({ axis: "col", index: colIndex, type: "delete" });
|
|
1646
|
+
this.adjustTableForStructuralChange({ axis: "col", index: colIndex, type: "delete" });
|
|
1647
|
+
this.document.columns = this.document.columns.filter((c) => c.id !== colId);
|
|
1648
|
+
for (const rowId of Object.keys(this.document.cells)) {
|
|
1649
|
+
delete this.document.cells[rowId][colId];
|
|
1650
|
+
}
|
|
1651
|
+
this.recomputeRowIndices();
|
|
1652
|
+
this.recalculateAll();
|
|
1653
|
+
this.notifyChange();
|
|
1654
|
+
}
|
|
1655
|
+
insertRow(index, config) {
|
|
1656
|
+
this.recordSnapshot();
|
|
1657
|
+
const newId = config?.id || this.generateId("row");
|
|
1658
|
+
const newRow = {
|
|
1659
|
+
id: newId,
|
|
1660
|
+
index,
|
|
1661
|
+
height: config?.height || 32,
|
|
1662
|
+
recordId: config?.recordId,
|
|
1663
|
+
type: config?.type || "freeform"
|
|
1664
|
+
};
|
|
1665
|
+
this.shiftReferences({ axis: "row", index, type: "insert" });
|
|
1666
|
+
this.adjustTableForStructuralChange({ axis: "row", index, type: "insert" });
|
|
1667
|
+
this.document.rows.splice(index, 0, newRow);
|
|
1668
|
+
this.recomputeRowIndices();
|
|
1669
|
+
this.recalculateAll();
|
|
1670
|
+
this.notifyChange();
|
|
1671
|
+
}
|
|
1672
|
+
deleteRow(rowId) {
|
|
1673
|
+
this.recordSnapshot();
|
|
1674
|
+
const rowIndex = this.document.rows.findIndex((r) => r.id === rowId);
|
|
1675
|
+
if (rowIndex === -1) return;
|
|
1676
|
+
this.shiftReferences({ axis: "row", index: rowIndex, type: "delete" });
|
|
1677
|
+
this.adjustTableForStructuralChange({ axis: "row", index: rowIndex, type: "delete" });
|
|
1678
|
+
this.document.rows = this.document.rows.filter((r) => r.id !== rowId);
|
|
1679
|
+
delete this.document.cells[rowId];
|
|
1680
|
+
this.recomputeRowIndices();
|
|
1681
|
+
this.recalculateAll();
|
|
1682
|
+
this.notifyChange();
|
|
1683
|
+
}
|
|
1684
|
+
generateId(prefix) {
|
|
1685
|
+
return `${prefix}_${Date.now()}_${(nextIdSequence++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Rewrites references in every cell formula (and, for column changes, every
|
|
1689
|
+
* column formula) so they keep pointing at the same cells.
|
|
1690
|
+
*/
|
|
1691
|
+
shiftReferences(change) {
|
|
1692
|
+
for (const rowCells of Object.values(this.document.cells)) {
|
|
1693
|
+
for (const cell of Object.values(rowCells)) {
|
|
1694
|
+
if (cell.raw?.startsWith("=")) {
|
|
1695
|
+
cell.raw = shiftFormulaReferences(cell.raw, change);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (change.axis === "col") {
|
|
1700
|
+
for (const col of this.document.columns) {
|
|
1701
|
+
if (col.formula?.startsWith("=")) {
|
|
1702
|
+
col.formula = shiftFormulaReferences(col.formula, change);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
setColumnWidth(colId, width) {
|
|
1708
|
+
const col = this.document.columns.find((c) => c.id === colId);
|
|
1709
|
+
if (col) {
|
|
1710
|
+
col.width = Math.max(30, width);
|
|
1711
|
+
this.notifyChange();
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
autoFitColumnWidth(colId, customWidth) {
|
|
1715
|
+
if (customWidth !== void 0) {
|
|
1716
|
+
this.setColumnWidth(colId, customWidth);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const col = this.document.columns.find((c) => c.id === colId);
|
|
1720
|
+
if (!col) return;
|
|
1721
|
+
let maxLen = (col.title || "").length + (col.key ? col.key.length + 3 : 0);
|
|
1722
|
+
for (const row of this.document.rows) {
|
|
1723
|
+
const cell = this.getCell(row.id, col.id);
|
|
1724
|
+
const valStr = cell.value != null ? String(cell.value) : "";
|
|
1725
|
+
if (valStr.length > maxLen) {
|
|
1726
|
+
maxLen = valStr.length;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
const estimatedWidth = Math.min(600, Math.max(50, Math.ceil(maxLen * 8.5 + 32)));
|
|
1730
|
+
this.setColumnWidth(colId, estimatedWidth);
|
|
1731
|
+
}
|
|
1732
|
+
// --- Clipboard TSV/CSV ---
|
|
1733
|
+
/**
|
|
1734
|
+
* Exports cell values as TSV: the whole sheet, or only `range` (e.g. the
|
|
1735
|
+
* current selection) when given.
|
|
1736
|
+
*/
|
|
1737
|
+
exportToTsv(range) {
|
|
1738
|
+
const rows = range ? this.document.rows.slice(
|
|
1739
|
+
Math.min(range.startRow, range.endRow),
|
|
1740
|
+
Math.max(range.startRow, range.endRow) + 1
|
|
1741
|
+
) : this.document.rows;
|
|
1742
|
+
const columns = range ? this.document.columns.slice(
|
|
1743
|
+
Math.min(range.startCol, range.endCol),
|
|
1744
|
+
Math.max(range.startCol, range.endCol) + 1
|
|
1745
|
+
) : this.document.columns;
|
|
1746
|
+
return rows.map(
|
|
1747
|
+
(row) => columns.map((col) => {
|
|
1748
|
+
const cell = this.getCell(row.id, col.id);
|
|
1749
|
+
return String(cell.value ?? "").replace(/\t/g, " ");
|
|
1750
|
+
}).join(" ")
|
|
1751
|
+
).join("\n");
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Pastes tab-separated values starting at (startRow, startCol), adding rows
|
|
1755
|
+
* and columns as needed. The whole paste is a single undo step.
|
|
1756
|
+
*/
|
|
1757
|
+
importFromTsv(tsvData, startRow = 0, startCol = 0) {
|
|
1758
|
+
const normalized = tsvData.replace(/\r\n?/g, "\n").replace(/\n$/, "");
|
|
1759
|
+
if (normalized === "") return;
|
|
1760
|
+
this.writeMatrix(normalized.split("\n").map((line) => line.split(" ")), startRow, startCol);
|
|
1761
|
+
}
|
|
1762
|
+
/** Adds rows/columns at the end until (rowIndex, colIndex) exists. */
|
|
1763
|
+
growGrid(rowIndex, colIndex) {
|
|
1764
|
+
while (rowIndex >= this.document.rows.length) {
|
|
1765
|
+
const index = this.document.rows.length;
|
|
1766
|
+
this.document.rows.push({ id: this.generateId("row"), index, height: 32, type: "freeform" });
|
|
1767
|
+
}
|
|
1768
|
+
while (colIndex >= this.document.columns.length) {
|
|
1769
|
+
const key = indexToColName(this.document.columns.length);
|
|
1770
|
+
this.document.columns.push({
|
|
1771
|
+
id: this.generateId("col"),
|
|
1772
|
+
key,
|
|
1773
|
+
title: key,
|
|
1774
|
+
width: 130,
|
|
1775
|
+
type: "freeform",
|
|
1776
|
+
format: "text"
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Writes raw inputs starting at (startRow, startCol), growing the grid as
|
|
1782
|
+
* needed, as a single undo step with one recalculation.
|
|
1783
|
+
*/
|
|
1784
|
+
writeMatrix(matrix, startRow, startCol) {
|
|
1785
|
+
if (matrix.length === 0) return;
|
|
1786
|
+
this.runBatch(() => {
|
|
1787
|
+
matrix.forEach((line, rIdx) => {
|
|
1788
|
+
line.forEach((val, cIdx) => {
|
|
1789
|
+
this.growGrid(startRow + rIdx, startCol + cIdx);
|
|
1790
|
+
const row = this.document.rows[startRow + rIdx];
|
|
1791
|
+
this.setCellValue(row.id, this.document.columns[startCol + cIdx].id, val);
|
|
1792
|
+
});
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Runs `fn` as one undo step: cell writes inside it are batched and
|
|
1798
|
+
* recalculated once, then listeners are notified.
|
|
1799
|
+
*/
|
|
1800
|
+
runBatch(fn) {
|
|
1801
|
+
this.recordSnapshot();
|
|
1802
|
+
this.batching = true;
|
|
1803
|
+
this.pendingCommits = [];
|
|
1804
|
+
try {
|
|
1805
|
+
fn();
|
|
1806
|
+
} finally {
|
|
1807
|
+
this.batching = false;
|
|
1808
|
+
}
|
|
1809
|
+
this.recomputeRowIndices();
|
|
1810
|
+
this.recalculateAll();
|
|
1811
|
+
this.document.updatedAt = Date.now();
|
|
1812
|
+
this.notifyChange();
|
|
1813
|
+
const commits = this.pendingCommits;
|
|
1814
|
+
this.pendingCommits = [];
|
|
1815
|
+
for (const [rowId, colId, raw] of commits) {
|
|
1816
|
+
this.onCellCommit?.(rowId, colId, raw, this.document.cells[rowId]?.[colId]?.value);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
// --- CSV ---
|
|
1820
|
+
/**
|
|
1821
|
+
* Exports the sheet (or `range`) as RFC 4180 CSV. Values are exported as
|
|
1822
|
+
* displayed unless `formulas` is set.
|
|
1823
|
+
*/
|
|
1824
|
+
exportToCsv(options = {}) {
|
|
1825
|
+
const matrix = this.readMatrix(options.range ?? null).map(
|
|
1826
|
+
(row) => row.map((cell) => options.formulas ? cell.raw : String(cell.value ?? ""))
|
|
1827
|
+
);
|
|
1828
|
+
return serializeCsv(matrix, {
|
|
1829
|
+
delimiter: options.delimiter,
|
|
1830
|
+
lineEnding: options.lineEnding,
|
|
1831
|
+
bom: options.bom
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Imports CSV text at (startRow, startCol) (default A1), growing the grid
|
|
1836
|
+
* as needed. The whole import is a single undo step.
|
|
1837
|
+
*/
|
|
1838
|
+
importFromCsv(csv, options = {}) {
|
|
1839
|
+
const matrix = parseCsv(csv, { delimiter: options.delimiter });
|
|
1840
|
+
this.writeMatrix(matrix, options.startRow ?? 0, options.startCol ?? 0);
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Writes already-parsed rows of raw inputs (e.g. from an import adapter)
|
|
1844
|
+
* starting at (startRow, startCol), as a single undo step.
|
|
1845
|
+
*/
|
|
1846
|
+
importRows(rows, options = {}) {
|
|
1847
|
+
this.writeMatrix(rows, options.startRow ?? 0, options.startCol ?? 0);
|
|
1848
|
+
}
|
|
1849
|
+
/** The cells of `range` (or the whole sheet), row by row. */
|
|
1850
|
+
readMatrix(range) {
|
|
1851
|
+
const r = range ? normalizeRange(range) : null;
|
|
1852
|
+
const rows = r ? this.document.rows.slice(r.startRow, r.endRow + 1) : this.document.rows;
|
|
1853
|
+
const columns = r ? this.document.columns.slice(r.startCol, r.endCol + 1) : this.document.columns;
|
|
1854
|
+
return rows.map((row) => columns.map((col) => this.getCell(row.id, col.id)));
|
|
1855
|
+
}
|
|
1856
|
+
// --- Spreadsheet clipboard ---
|
|
1857
|
+
/**
|
|
1858
|
+
* Builds clipboard data for `range` (default: the selection) as TSV,
|
|
1859
|
+
* an HTML table and the lossless internal JSON format. Write every entry
|
|
1860
|
+
* with `clipboardData.setData(type, data)` in a `copy` event.
|
|
1861
|
+
*/
|
|
1862
|
+
copyToClipboardData(range = this.selectedRange) {
|
|
1863
|
+
const r = range ? normalizeRange(range) : { startRow: 0, startCol: 0, endRow: this.document.rows.length - 1, endCol: this.document.columns.length - 1 };
|
|
1864
|
+
const cells = this.readMatrix(r).map((row) => row.map((cell) => ({ raw: cell.raw, value: cell.error || cell.value })));
|
|
1865
|
+
return createSpreadsheetClipboardData(cells, { row: r.startRow, col: r.startCol });
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Pastes clipboard data at `target` (default: the selection's top-left).
|
|
1869
|
+
* Prefers the internal format (formulas re-based to the target), then an
|
|
1870
|
+
* HTML table, then TSV. Returns false when there was nothing to paste.
|
|
1871
|
+
*/
|
|
1872
|
+
pasteClipboardData(source, target) {
|
|
1873
|
+
const parsed = readSpreadsheetClipboard(source);
|
|
1874
|
+
if (!parsed) return false;
|
|
1875
|
+
const at = target ?? this.getSelectionTopLeft();
|
|
1876
|
+
const rows = parsed.origin ? rebaseClipboardRows(parsed.rows, parsed.origin, at) : parsed.rows;
|
|
1877
|
+
this.writeMatrix(rows, at.row, at.col);
|
|
1878
|
+
const r = normalizeRange({
|
|
1879
|
+
startRow: at.row,
|
|
1880
|
+
startCol: at.col,
|
|
1881
|
+
endRow: at.row + rows.length - 1,
|
|
1882
|
+
endCol: at.col + rows.reduce((max, row) => Math.max(max, row.length), 1) - 1
|
|
1883
|
+
});
|
|
1884
|
+
this.selectRange(r);
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
getSelectionTopLeft() {
|
|
1888
|
+
if (this.selectedRange) {
|
|
1889
|
+
const r = normalizeRange(this.selectedRange);
|
|
1890
|
+
return { row: r.startRow, col: r.startCol };
|
|
1891
|
+
}
|
|
1892
|
+
return { row: this.activeCell?.row ?? 0, col: this.activeCell?.col ?? 0 };
|
|
1893
|
+
}
|
|
1894
|
+
// --- Table operations: shared helpers ---
|
|
1895
|
+
/** The table settings object, created on first use. */
|
|
1896
|
+
get table() {
|
|
1897
|
+
if (!this.document.table) this.document.table = {};
|
|
1898
|
+
return this.document.table;
|
|
1899
|
+
}
|
|
1900
|
+
/** Records an undo step, applies `fn`, then notifies listeners. */
|
|
1901
|
+
applyTableChange(fn, options = {}) {
|
|
1902
|
+
this.recordSnapshot();
|
|
1903
|
+
fn();
|
|
1904
|
+
if (options.recalculate) {
|
|
1905
|
+
this.recomputeRowIndices();
|
|
1906
|
+
this.recalculateAll();
|
|
1907
|
+
}
|
|
1908
|
+
this.document.updatedAt = Date.now();
|
|
1909
|
+
this.notifyChange();
|
|
1910
|
+
}
|
|
1911
|
+
/** First and last (inclusive) body row: rows between header and footer rows. */
|
|
1912
|
+
getBodyRowBounds() {
|
|
1913
|
+
const { headerRows, footerRows } = getTableSections(this.document);
|
|
1914
|
+
return { start: headerRows, end: this.document.rows.length - footerRows - 1 };
|
|
1915
|
+
}
|
|
1916
|
+
getCellValueAt = (rowIndex, colIndex) => {
|
|
1917
|
+
const row = this.document.rows[rowIndex];
|
|
1918
|
+
const col = this.document.columns[colIndex];
|
|
1919
|
+
if (!row || !col) return "";
|
|
1920
|
+
return this.getCell(row.id, col.id).value;
|
|
1921
|
+
};
|
|
1922
|
+
/** Rewrites every formula so references follow cells that moved. */
|
|
1923
|
+
remapAllFormulas(moveCell) {
|
|
1924
|
+
for (const rowCells of Object.values(this.document.cells)) {
|
|
1925
|
+
for (const cell of Object.values(rowCells)) {
|
|
1926
|
+
if (cell.raw?.startsWith("=")) {
|
|
1927
|
+
cell.raw = remapFormulaReferences(cell.raw, moveCell);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Keeps merges in place when rows/columns are inserted or deleted:
|
|
1934
|
+
* merges after the change shift, merges across it grow or shrink, and
|
|
1935
|
+
* merges reduced to a single cell are dropped.
|
|
1936
|
+
*/
|
|
1937
|
+
adjustTableForStructuralChange(change) {
|
|
1938
|
+
const merges = this.document.table?.merges;
|
|
1939
|
+
if (!merges?.length) return;
|
|
1940
|
+
const startKey = change.axis === "row" ? "startRow" : "startCol";
|
|
1941
|
+
const endKey = change.axis === "row" ? "endRow" : "endCol";
|
|
1942
|
+
const next = [];
|
|
1943
|
+
for (const merge of merges) {
|
|
1944
|
+
const m = { ...merge };
|
|
1945
|
+
if (change.type === "insert") {
|
|
1946
|
+
if (m[startKey] >= change.index) m[startKey]++;
|
|
1947
|
+
if (m[endKey] >= change.index) m[endKey]++;
|
|
1948
|
+
} else {
|
|
1949
|
+
if (m[startKey] === change.index && m[endKey] === change.index) continue;
|
|
1950
|
+
if (m[startKey] > change.index) m[startKey]--;
|
|
1951
|
+
if (m[endKey] >= change.index) m[endKey]--;
|
|
1952
|
+
}
|
|
1953
|
+
if (!isSingleCellRange(m)) next.push(m);
|
|
1954
|
+
}
|
|
1955
|
+
this.document.table.merges = next;
|
|
1956
|
+
}
|
|
1957
|
+
// --- Sort ---
|
|
1958
|
+
/**
|
|
1959
|
+
* Sorts rows by one or more columns (stable; numbers before text, blanks
|
|
1960
|
+
* last). Without a range (or with a range spanning every column) whole rows
|
|
1961
|
+
* move; with a range only the cells inside it move. Header and footer rows
|
|
1962
|
+
* never move. References follow the moved cells. Returns false when the
|
|
1963
|
+
* sort is rejected (no valid keys, a key outside the range, or merged
|
|
1964
|
+
* cells in the sorted area).
|
|
1965
|
+
*/
|
|
1966
|
+
sortRows(keys, options = {}) {
|
|
1967
|
+
const colCount = this.document.columns.length;
|
|
1968
|
+
const body = this.getBodyRowBounds();
|
|
1969
|
+
const range = options.range ? normalizeRange(options.range) : null;
|
|
1970
|
+
const startRow = Math.max(range?.startRow ?? body.start, body.start);
|
|
1971
|
+
const endRow = Math.min(range?.endRow ?? body.end, body.end);
|
|
1972
|
+
const startCol = range ? Math.max(0, range.startCol) : 0;
|
|
1973
|
+
const endCol = range ? Math.min(colCount - 1, range.endCol) : colCount - 1;
|
|
1974
|
+
if (endRow - startRow < 1 || startCol > endCol) return false;
|
|
1975
|
+
const keyCols = keys.map((key) => ({
|
|
1976
|
+
index: this.document.columns.findIndex((c) => c.id === key.colId),
|
|
1977
|
+
direction: key.direction ?? "asc"
|
|
1978
|
+
}));
|
|
1979
|
+
if (keyCols.length === 0 || keyCols.some((k) => k.index < startCol || k.index > endCol)) return false;
|
|
1980
|
+
const area = { startRow, startCol, endRow, endCol };
|
|
1981
|
+
if ((this.document.table?.merges ?? []).some((m) => rangesIntersect(m, area))) return false;
|
|
1982
|
+
const sourceRows = [];
|
|
1983
|
+
for (let r = startRow; r <= endRow; r++) sourceRows.push(r);
|
|
1984
|
+
const sorted = sourceRows.map((r) => ({ r, values: keyCols.map((k) => this.getCellValueAt(r, k.index)) })).sort((a, b) => {
|
|
1985
|
+
for (let i = 0; i < keyCols.length; i++) {
|
|
1986
|
+
const result = compareCellValues(a.values[i], b.values[i], keyCols[i].direction);
|
|
1987
|
+
if (result !== 0) return result;
|
|
1988
|
+
}
|
|
1989
|
+
return a.r - b.r;
|
|
1990
|
+
}).map((entry) => entry.r);
|
|
1991
|
+
if (sorted.every((r, i) => r === sourceRows[i])) return true;
|
|
1992
|
+
const newIndexOf = /* @__PURE__ */ new Map();
|
|
1993
|
+
sorted.forEach((oldRow, i) => newIndexOf.set(oldRow, startRow + i));
|
|
1994
|
+
const wholeRows = startCol === 0 && endCol === colCount - 1;
|
|
1995
|
+
this.applyTableChange(() => {
|
|
1996
|
+
this.remapAllFormulas(({ row, col }) => {
|
|
1997
|
+
const target = newIndexOf.get(row);
|
|
1998
|
+
if (target === void 0 || col < startCol || col > endCol) return null;
|
|
1999
|
+
return { row: target, col };
|
|
2000
|
+
});
|
|
2001
|
+
if (wholeRows) {
|
|
2002
|
+
const moved = sorted.map((r) => this.document.rows[r]);
|
|
2003
|
+
this.document.rows.splice(startRow, moved.length, ...moved);
|
|
2004
|
+
} else {
|
|
2005
|
+
const colIds = this.document.columns.slice(startCol, endCol + 1).map((c) => c.id);
|
|
2006
|
+
const snapshot = sorted.map((oldRow) => {
|
|
2007
|
+
const rowId = this.document.rows[oldRow].id;
|
|
2008
|
+
return colIds.map((colId) => this.document.cells[rowId]?.[colId]);
|
|
2009
|
+
});
|
|
2010
|
+
snapshot.forEach((cells, i) => {
|
|
2011
|
+
const rowId = this.document.rows[startRow + i].id;
|
|
2012
|
+
if (!this.document.cells[rowId]) this.document.cells[rowId] = {};
|
|
2013
|
+
colIds.forEach((colId, c) => {
|
|
2014
|
+
if (cells[c]) this.document.cells[rowId][colId] = cells[c];
|
|
2015
|
+
else delete this.document.cells[rowId][colId];
|
|
2016
|
+
});
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
}, { recalculate: true });
|
|
2020
|
+
return true;
|
|
2021
|
+
}
|
|
2022
|
+
// --- Filters ---
|
|
2023
|
+
getFilterState() {
|
|
2024
|
+
return this.document.table?.filter ?? { enabled: false, rules: [] };
|
|
2025
|
+
}
|
|
2026
|
+
/** Replaces all filter rules (and turns filters on when there are any). */
|
|
2027
|
+
setFilterRules(rules) {
|
|
2028
|
+
this.applyTableChange(() => {
|
|
2029
|
+
this.table.filter = {
|
|
2030
|
+
enabled: rules.length > 0,
|
|
2031
|
+
rules: rules.map((rule) => ({ ...rule, id: rule.id ?? this.generateFilterId() }))
|
|
2032
|
+
};
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
/** Adds a filter rule (AND-combined with the others) and turns filters on. Returns its id. */
|
|
2036
|
+
addFilterRule(rule) {
|
|
2037
|
+
const id = rule.id ?? this.generateFilterId();
|
|
2038
|
+
this.applyTableChange(() => {
|
|
2039
|
+
const current = this.getFilterState();
|
|
2040
|
+
this.table.filter = { enabled: true, rules: [...current.rules, { ...rule, id }] };
|
|
2041
|
+
});
|
|
2042
|
+
return id;
|
|
2043
|
+
}
|
|
2044
|
+
updateFilterRule(ruleId, changes) {
|
|
2045
|
+
const current = this.getFilterState();
|
|
2046
|
+
if (!current.rules.some((r) => r.id === ruleId)) return;
|
|
2047
|
+
this.applyTableChange(() => {
|
|
2048
|
+
this.table.filter = {
|
|
2049
|
+
...current,
|
|
2050
|
+
rules: current.rules.map((r) => r.id === ruleId ? { ...r, ...changes } : r)
|
|
2051
|
+
};
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
removeFilterRule(ruleId) {
|
|
2055
|
+
const current = this.getFilterState();
|
|
2056
|
+
if (!current.rules.some((r) => r.id === ruleId)) return;
|
|
2057
|
+
this.applyTableChange(() => {
|
|
2058
|
+
this.table.filter = { ...current, rules: current.rules.filter((r) => r.id !== ruleId) };
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
clearFilters() {
|
|
2062
|
+
if (this.getFilterState().rules.length === 0) return;
|
|
2063
|
+
this.applyTableChange(() => {
|
|
2064
|
+
this.table.filter = { enabled: false, rules: [] };
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
/** Turns filtering on or off, keeping the rules. */
|
|
2068
|
+
setFiltersEnabled(enabled) {
|
|
2069
|
+
const current = this.getFilterState();
|
|
2070
|
+
if (current.enabled === enabled) return;
|
|
2071
|
+
this.applyTableChange(() => {
|
|
2072
|
+
this.table.filter = { ...current, enabled };
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
toggleFilters() {
|
|
2076
|
+
this.setFiltersEnabled(!this.getFilterState().enabled);
|
|
2077
|
+
}
|
|
2078
|
+
generateFilterId() {
|
|
2079
|
+
return `filter_${Date.now()}_${(nextIdSequence++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
2080
|
+
}
|
|
2081
|
+
// --- Header, footer and frozen rows/columns ---
|
|
2082
|
+
setHeaderRowCount(count) {
|
|
2083
|
+
this.setTableCount("headerRowCount", count, this.document.rows.length);
|
|
2084
|
+
}
|
|
2085
|
+
setFooterRowCount(count) {
|
|
2086
|
+
this.setTableCount("footerRowCount", count, this.document.rows.length);
|
|
2087
|
+
}
|
|
2088
|
+
setHeaderColumnCount(count) {
|
|
2089
|
+
this.setTableCount("headerColumnCount", count, this.document.columns.length);
|
|
2090
|
+
}
|
|
2091
|
+
/** Keeps the first `count` rows visible while scrolling. 0 unfreezes. */
|
|
2092
|
+
setFrozenRowCount(count) {
|
|
2093
|
+
this.setTableCount("frozenRowCount", count, this.document.rows.length);
|
|
2094
|
+
}
|
|
2095
|
+
/** Keeps the first `count` columns visible while scrolling. 0 unfreezes. */
|
|
2096
|
+
setFrozenColumnCount(count) {
|
|
2097
|
+
this.setTableCount("frozenColumnCount", count, this.document.columns.length);
|
|
2098
|
+
}
|
|
2099
|
+
setTableCount(key, count, max) {
|
|
2100
|
+
const value = Math.max(0, Math.min(Math.floor(count), max));
|
|
2101
|
+
if ((this.document.table?.[key] ?? 0) === value) return;
|
|
2102
|
+
this.applyTableChange(() => {
|
|
2103
|
+
this.table[key] = value;
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
// --- Hide / unhide ---
|
|
2107
|
+
hideRows(indices) {
|
|
2108
|
+
this.setHidden("row", indices, true);
|
|
2109
|
+
}
|
|
2110
|
+
unhideRows(indices) {
|
|
2111
|
+
this.setHidden("row", indices, false);
|
|
2112
|
+
}
|
|
2113
|
+
hideColumns(indices) {
|
|
2114
|
+
this.setHidden("col", indices, true);
|
|
2115
|
+
}
|
|
2116
|
+
unhideColumns(indices) {
|
|
2117
|
+
this.setHidden("col", indices, false);
|
|
2118
|
+
}
|
|
2119
|
+
/** Unhides every hidden row and column (filters are unaffected). */
|
|
2120
|
+
unhideAll() {
|
|
2121
|
+
const rows = this.document.rows.flatMap((r, i) => r.hidden ? [i] : []);
|
|
2122
|
+
const cols = this.document.columns.flatMap((c, i) => c.hidden ? [i] : []);
|
|
2123
|
+
if (rows.length === 0 && cols.length === 0) return;
|
|
2124
|
+
this.applyTableChange(() => {
|
|
2125
|
+
rows.forEach((i) => delete this.document.rows[i].hidden);
|
|
2126
|
+
cols.forEach((i) => delete this.document.columns[i].hidden);
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
setHidden(axis, indices, hidden) {
|
|
2130
|
+
const items = axis === "row" ? this.document.rows : this.document.columns;
|
|
2131
|
+
const targets = indices.filter((i) => items[i] && !!items[i].hidden !== hidden);
|
|
2132
|
+
if (targets.length === 0) return;
|
|
2133
|
+
this.applyTableChange(() => {
|
|
2134
|
+
for (const i of targets) {
|
|
2135
|
+
if (hidden) items[i].hidden = true;
|
|
2136
|
+
else delete items[i].hidden;
|
|
2137
|
+
}
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
// --- Merged cells ---
|
|
2141
|
+
getMerges() {
|
|
2142
|
+
return this.document.table?.merges ?? [];
|
|
2143
|
+
}
|
|
2144
|
+
/** The merge containing (row, col), or null. */
|
|
2145
|
+
getMergeAt(row, col) {
|
|
2146
|
+
return this.getMerges().find((m) => rangeContains(m, row, col)) ?? null;
|
|
2147
|
+
}
|
|
2148
|
+
/** Grows `range` until no merge crosses its edge. Keeps the drag direction. */
|
|
2149
|
+
expandRangeToMerges(range) {
|
|
2150
|
+
let r = normalizeRange(range);
|
|
2151
|
+
let changed = true;
|
|
2152
|
+
while (changed) {
|
|
2153
|
+
changed = false;
|
|
2154
|
+
for (const m of this.getMerges()) {
|
|
2155
|
+
if (rangesIntersect(m, r) && !rangeContainsRange(r, m)) {
|
|
2156
|
+
r = {
|
|
2157
|
+
startRow: Math.min(r.startRow, m.startRow),
|
|
2158
|
+
startCol: Math.min(r.startCol, m.startCol),
|
|
2159
|
+
endRow: Math.max(r.endRow, m.endRow),
|
|
2160
|
+
endCol: Math.max(r.endCol, m.endCol)
|
|
2161
|
+
};
|
|
2162
|
+
changed = true;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
const rowsReversed = range.startRow > range.endRow;
|
|
2167
|
+
const colsReversed = range.startCol > range.endCol;
|
|
2168
|
+
return {
|
|
2169
|
+
startRow: rowsReversed ? r.endRow : r.startRow,
|
|
2170
|
+
endRow: rowsReversed ? r.startRow : r.endRow,
|
|
2171
|
+
startCol: colsReversed ? r.endCol : r.startCol,
|
|
2172
|
+
endCol: colsReversed ? r.startCol : r.endCol
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Merges `range` (default: the selection) into one cell anchored on its
|
|
2177
|
+
* top-left cell; the other cells are cleared. Merges fully inside the range
|
|
2178
|
+
* are absorbed. Returns false for single cells and for ranges that
|
|
2179
|
+
* partially overlap an existing merge.
|
|
2180
|
+
*/
|
|
2181
|
+
mergeCells(range = this.selectedRange) {
|
|
2182
|
+
if (!range) return false;
|
|
2183
|
+
const r = normalizeRange(range);
|
|
2184
|
+
if (isSingleCellRange(r) || r.startRow < 0 || r.startCol < 0 || r.endRow >= this.document.rows.length || r.endCol >= this.document.columns.length) {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
const merges = this.getMerges();
|
|
2188
|
+
if (merges.some((m) => rangesIntersect(m, r) && !rangeContainsRange(r, m))) return false;
|
|
2189
|
+
this.applyTableChange(() => {
|
|
2190
|
+
for (let row = r.startRow; row <= r.endRow; row++) {
|
|
2191
|
+
const rowId = this.document.rows[row].id;
|
|
2192
|
+
for (let col = r.startCol; col <= r.endCol; col++) {
|
|
2193
|
+
if (row === r.startRow && col === r.startCol) continue;
|
|
2194
|
+
delete this.document.cells[rowId]?.[this.document.columns[col].id];
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
this.table.merges = [...merges.filter((m) => !rangeContainsRange(r, m)), r];
|
|
2198
|
+
}, { recalculate: true });
|
|
2199
|
+
this.selectCell(r.startRow, r.startCol);
|
|
2200
|
+
return true;
|
|
2201
|
+
}
|
|
2202
|
+
/** Removes every merge intersecting `range` (default: the selection). */
|
|
2203
|
+
unmergeCells(range = this.selectedRange) {
|
|
2204
|
+
if (!range) return false;
|
|
2205
|
+
const merges = this.getMerges();
|
|
2206
|
+
const remaining = merges.filter((m) => !rangesIntersect(m, range));
|
|
2207
|
+
if (remaining.length === merges.length) return false;
|
|
2208
|
+
this.applyTableChange(() => {
|
|
2209
|
+
this.table.merges = remaining;
|
|
2210
|
+
});
|
|
2211
|
+
return true;
|
|
2212
|
+
}
|
|
2213
|
+
// --- Autofill ---
|
|
2214
|
+
/**
|
|
2215
|
+
* Fills `target` from `source` like dragging the fill handle. `target` is
|
|
2216
|
+
* the extended range (source included) and must extend `source` down, up,
|
|
2217
|
+
* right or left. Numbers and dates continue their series, text ending in a
|
|
2218
|
+
* number counts up, other text repeats, and formulas are copied with
|
|
2219
|
+
* their relative references shifted (`$` parts stay). Returns false when
|
|
2220
|
+
* the ranges don't line up.
|
|
2221
|
+
*/
|
|
2222
|
+
autofill(source, target) {
|
|
2223
|
+
const s = normalizeRange(source);
|
|
2224
|
+
const t = normalizeRange(target);
|
|
2225
|
+
const direction = getFillDirection(s, t);
|
|
2226
|
+
if (!direction || t.startRow < 0 || t.startCol < 0) return false;
|
|
2227
|
+
if (s.endRow >= this.document.rows.length || s.endCol >= this.document.columns.length) return false;
|
|
2228
|
+
const vertical = direction === "down" || direction === "up";
|
|
2229
|
+
const forward = direction === "down" || direction === "right";
|
|
2230
|
+
const sourceSteps = [];
|
|
2231
|
+
const [sLo, sHi] = vertical ? [s.startRow, s.endRow] : [s.startCol, s.endCol];
|
|
2232
|
+
for (let i = sLo; i <= sHi; i++) sourceSteps.push(i);
|
|
2233
|
+
if (!forward) sourceSteps.reverse();
|
|
2234
|
+
const fillSteps = [];
|
|
2235
|
+
if (forward) for (let i = sHi + 1; i <= (vertical ? t.endRow : t.endCol); i++) fillSteps.push(i);
|
|
2236
|
+
else for (let i = sLo - 1; i >= (vertical ? t.startRow : t.startCol); i--) fillSteps.push(i);
|
|
2237
|
+
const [lLo, lHi] = vertical ? [s.startCol, s.endCol] : [s.startRow, s.endRow];
|
|
2238
|
+
this.runBatch(() => {
|
|
2239
|
+
this.growGrid(vertical ? Math.max(t.endRow, 0) : s.endRow, vertical ? s.endCol : Math.max(t.endCol, 0));
|
|
2240
|
+
for (let line = lLo; line <= lHi; line++) {
|
|
2241
|
+
const at = (step) => vertical ? { row: step, col: line } : { row: line, col: step };
|
|
2242
|
+
const raws = sourceSteps.map((step) => {
|
|
2243
|
+
const p = at(step);
|
|
2244
|
+
return this.getCell(this.document.rows[p.row].id, this.document.columns[p.col].id).raw;
|
|
2245
|
+
});
|
|
2246
|
+
const hasFormula = raws.some((raw) => raw.startsWith("="));
|
|
2247
|
+
const series = hasFormula ? null : fillSeries(raws, fillSteps.length);
|
|
2248
|
+
fillSteps.forEach((step, i) => {
|
|
2249
|
+
const p = at(step);
|
|
2250
|
+
let raw;
|
|
2251
|
+
if (series) {
|
|
2252
|
+
raw = series[i];
|
|
2253
|
+
} else {
|
|
2254
|
+
const from = sourceSteps[i % sourceSteps.length];
|
|
2255
|
+
raw = raws[i % raws.length];
|
|
2256
|
+
if (raw.startsWith("=")) {
|
|
2257
|
+
const delta = step - from;
|
|
2258
|
+
raw = vertical ? offsetFormulaReferences(raw, delta, 0) : offsetFormulaReferences(raw, 0, delta);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
this.setCellValue(this.document.rows[p.row].id, this.document.columns[p.col].id, raw);
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
});
|
|
2265
|
+
this.selectRange(t);
|
|
2266
|
+
return true;
|
|
2267
|
+
}
|
|
2268
|
+
// --- Categories (row groups) ---
|
|
2269
|
+
/**
|
|
2270
|
+
* Groups body rows by the value in `colId`, with optional per-group
|
|
2271
|
+
* summaries (e.g. `[{ colId, aggregate: 'sum' }]`).
|
|
2272
|
+
*/
|
|
2273
|
+
groupRowsBy(colId, summaries = []) {
|
|
2274
|
+
if (!this.getColumn(colId)) return;
|
|
2275
|
+
this.applyTableChange(() => {
|
|
2276
|
+
this.table.grouping = { colId, summaries, collapsed: [] };
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
ungroupRows() {
|
|
2280
|
+
if (!this.document.table?.grouping) return;
|
|
2281
|
+
this.applyTableChange(() => {
|
|
2282
|
+
delete this.table.grouping;
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
/** Sets (or with `null`, removes) the group summary for a column. */
|
|
2286
|
+
setGroupSummary(colId, aggregate) {
|
|
2287
|
+
const grouping = this.document.table?.grouping;
|
|
2288
|
+
if (!grouping) return;
|
|
2289
|
+
this.applyTableChange(() => {
|
|
2290
|
+
const others = grouping.summaries.filter((s) => s.colId !== colId);
|
|
2291
|
+
this.table.grouping = {
|
|
2292
|
+
...grouping,
|
|
2293
|
+
summaries: aggregate ? [...others, { colId, aggregate }] : others
|
|
2294
|
+
};
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
setGroupCollapsed(key, collapsed) {
|
|
2298
|
+
const grouping = this.document.table?.grouping;
|
|
2299
|
+
if (!grouping || grouping.collapsed.includes(key) === collapsed) return;
|
|
2300
|
+
this.applyTableChange(() => {
|
|
2301
|
+
this.table.grouping = {
|
|
2302
|
+
...grouping,
|
|
2303
|
+
collapsed: collapsed ? [...grouping.collapsed, key] : grouping.collapsed.filter((k) => k !== key)
|
|
2304
|
+
};
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
toggleGroupCollapsed(key) {
|
|
2308
|
+
const grouping = this.document.table?.grouping;
|
|
2309
|
+
if (!grouping) return;
|
|
2310
|
+
this.setGroupCollapsed(key, !grouping.collapsed.includes(key));
|
|
2311
|
+
}
|
|
2312
|
+
/** The current row groups with their summaries (empty when not grouped). */
|
|
2313
|
+
getGroups() {
|
|
2314
|
+
return computeRowGroups(this.document, this.getCellValueAt);
|
|
2315
|
+
}
|
|
2316
|
+
// --- Transpose ---
|
|
2317
|
+
/**
|
|
2318
|
+
* Transposes `range` (default: the selection) in place around its top-left
|
|
2319
|
+
* cell: rows become columns. Cells left uncovered are cleared, the grid
|
|
2320
|
+
* grows if needed, and references follow the moved cells. Returns false
|
|
2321
|
+
* for single cells or when merged cells are involved.
|
|
2322
|
+
*/
|
|
2323
|
+
transposeRange(range = this.selectedRange) {
|
|
2324
|
+
if (!range) return false;
|
|
2325
|
+
const s = normalizeRange(range);
|
|
2326
|
+
if (isSingleCellRange(s)) return false;
|
|
2327
|
+
const height = s.endRow - s.startRow + 1;
|
|
2328
|
+
const width = s.endCol - s.startCol + 1;
|
|
2329
|
+
const t = { startRow: s.startRow, startCol: s.startCol, endRow: s.startRow + width - 1, endCol: s.startCol + height - 1 };
|
|
2330
|
+
if (this.getMerges().some((m) => rangesIntersect(m, s) || rangesIntersect(m, t))) return false;
|
|
2331
|
+
this.applyTableChange(() => {
|
|
2332
|
+
this.growGrid(t.endRow, t.endCol);
|
|
2333
|
+
this.remapAllFormulas(({ row, col }) => {
|
|
2334
|
+
if (!rangeContains(s, row, col)) return null;
|
|
2335
|
+
return { row: s.startRow + (col - s.startCol), col: s.startCol + (row - s.startRow) };
|
|
2336
|
+
});
|
|
2337
|
+
const rowId = (r) => this.document.rows[r].id;
|
|
2338
|
+
const colId = (c) => this.document.columns[c].id;
|
|
2339
|
+
const moved = [];
|
|
2340
|
+
for (let r = s.startRow; r <= s.endRow; r++) {
|
|
2341
|
+
for (let c = s.startCol; c <= s.endCol; c++) {
|
|
2342
|
+
moved.push({
|
|
2343
|
+
row: s.startRow + (c - s.startCol),
|
|
2344
|
+
col: s.startCol + (r - s.startRow),
|
|
2345
|
+
cell: this.document.cells[rowId(r)]?.[colId(c)]
|
|
2346
|
+
});
|
|
2347
|
+
delete this.document.cells[rowId(r)]?.[colId(c)];
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
for (const { row, col, cell } of moved) {
|
|
2351
|
+
if (!cell) continue;
|
|
2352
|
+
if (!this.document.cells[rowId(row)]) this.document.cells[rowId(row)] = {};
|
|
2353
|
+
this.document.cells[rowId(row)][colId(col)] = cell;
|
|
2354
|
+
}
|
|
2355
|
+
}, { recalculate: true });
|
|
2356
|
+
this.selectRange(t);
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
2359
|
+
// --- View model ---
|
|
2360
|
+
/**
|
|
2361
|
+
* What to render: visible rows (with group headers) and columns in display
|
|
2362
|
+
* order, frozen offsets and merged-cell spans. Filters, hidden rows/columns
|
|
2363
|
+
* and collapsed groups only affect this view, never the document.
|
|
2364
|
+
*/
|
|
2365
|
+
getViewModel() {
|
|
2366
|
+
return buildSpreadsheetView(this.document, this.getCellValueAt);
|
|
2367
|
+
}
|
|
2368
|
+
// --- Undo/Redo Snapshots ---
|
|
2369
|
+
recordSnapshot() {
|
|
2370
|
+
if (this.isApplyingHistory || this.batching) return;
|
|
2371
|
+
this.undoStack.push(JSON.stringify(this.document));
|
|
2372
|
+
if (this.undoStack.length > 50) this.undoStack.shift();
|
|
2373
|
+
this.redoStack = [];
|
|
2374
|
+
}
|
|
2375
|
+
undo() {
|
|
2376
|
+
if (this.undoStack.length === 0) return;
|
|
2377
|
+
this.isApplyingHistory = true;
|
|
2378
|
+
try {
|
|
2379
|
+
this.redoStack.push(JSON.stringify(this.document));
|
|
2380
|
+
this.document = JSON.parse(this.undoStack.pop());
|
|
2381
|
+
this.recalculateAll();
|
|
2382
|
+
this.notifyChange();
|
|
2383
|
+
} finally {
|
|
2384
|
+
this.isApplyingHistory = false;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
redo() {
|
|
2388
|
+
if (this.redoStack.length === 0) return;
|
|
2389
|
+
this.isApplyingHistory = true;
|
|
2390
|
+
try {
|
|
2391
|
+
this.undoStack.push(JSON.stringify(this.document));
|
|
2392
|
+
this.document = JSON.parse(this.redoStack.pop());
|
|
2393
|
+
this.recalculateAll();
|
|
2394
|
+
this.notifyChange();
|
|
2395
|
+
} finally {
|
|
2396
|
+
this.isApplyingHistory = false;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
notifyChange() {
|
|
2400
|
+
this.onDocumentChange?.(this.document);
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
function stepInOrder(order, from, delta) {
|
|
2404
|
+
if (order.length === 0) return from;
|
|
2405
|
+
const pos = order.indexOf(from);
|
|
2406
|
+
if (pos === -1) {
|
|
2407
|
+
const candidates = order.filter((i) => delta > 0 ? i > from : i < from);
|
|
2408
|
+
if (candidates.length === 0) return from;
|
|
2409
|
+
return candidates.reduce((best, i) => delta > 0 ? Math.min(best, i) : Math.max(best, i));
|
|
2410
|
+
}
|
|
2411
|
+
return order[Math.max(0, Math.min(order.length - 1, pos + delta))];
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/spreadsheet/menu.ts
|
|
2415
|
+
var quote = (value) => {
|
|
2416
|
+
const text = String(value ?? "");
|
|
2417
|
+
return text.length > 24 ? `"${text.slice(0, 23)}\u2026"` : `"${text}"`;
|
|
2418
|
+
};
|
|
2419
|
+
function getSpreadsheetMenuItems(controller, cell) {
|
|
2420
|
+
const doc = controller.document;
|
|
2421
|
+
const column = doc.columns[cell.col];
|
|
2422
|
+
const row = doc.rows[cell.row];
|
|
2423
|
+
if (!column || !row) return [];
|
|
2424
|
+
const selection = controller.selectedRange && rangeContains(controller.selectedRange, cell.row, cell.col) ? normalizeRange(controller.selectedRange) : { startRow: cell.row, startCol: cell.col, endRow: cell.row, endCol: cell.col };
|
|
2425
|
+
const colName = indexToColName(cell.col);
|
|
2426
|
+
const value = controller.getCell(row.id, column.id).value;
|
|
2427
|
+
const table = doc.table ?? {};
|
|
2428
|
+
const filter = controller.getFilterState();
|
|
2429
|
+
const grouping = table.grouping;
|
|
2430
|
+
const rows = Array.from({ length: selection.endRow - selection.startRow + 1 }, (_, i) => selection.startRow + i);
|
|
2431
|
+
const cols = Array.from({ length: selection.endCol - selection.startCol + 1 }, (_, i) => selection.startCol + i);
|
|
2432
|
+
const anyHidden = doc.rows.some((r) => r.hidden) || doc.columns.some((c) => c.hidden);
|
|
2433
|
+
const hasMerge = controller.getMerges().some((m) => rangesIntersect(m, selection));
|
|
2434
|
+
const sortRange = isSingleCellRange(selection) ? null : selection;
|
|
2435
|
+
const items = [
|
|
2436
|
+
{
|
|
2437
|
+
id: "sort-asc",
|
|
2438
|
+
label: `Sort ascending by column ${colName}`,
|
|
2439
|
+
run: () => controller.sortRows([{ colId: column.id, direction: "asc" }], { range: sortRange })
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
id: "sort-desc",
|
|
2443
|
+
label: `Sort descending by column ${colName}`,
|
|
2444
|
+
run: () => controller.sortRows([{ colId: column.id, direction: "desc" }], { range: sortRange })
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
id: "filter-equals",
|
|
2448
|
+
label: `Show only rows where ${colName} = ${quote(value)}`,
|
|
2449
|
+
separatorBefore: true,
|
|
2450
|
+
run: () => controller.addFilterRule({ colId: column.id, operator: "equals", value: String(value ?? "") })
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
id: "filter-toggle",
|
|
2454
|
+
label: filter.enabled ? "Turn filters off" : "Turn filters on",
|
|
2455
|
+
disabled: filter.rules.length === 0,
|
|
2456
|
+
run: () => controller.toggleFilters()
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
id: "filter-clear",
|
|
2460
|
+
label: "Clear filters",
|
|
2461
|
+
disabled: filter.rules.length === 0,
|
|
2462
|
+
run: () => controller.clearFilters()
|
|
2463
|
+
},
|
|
2464
|
+
{
|
|
2465
|
+
id: "freeze-rows",
|
|
2466
|
+
label: `Freeze rows 1\u2013${cell.row + 1}`,
|
|
2467
|
+
separatorBefore: true,
|
|
2468
|
+
run: () => controller.setFrozenRowCount(cell.row + 1)
|
|
2469
|
+
},
|
|
2470
|
+
{
|
|
2471
|
+
id: "freeze-columns",
|
|
2472
|
+
label: `Freeze columns A\u2013${colName}`,
|
|
2473
|
+
run: () => controller.setFrozenColumnCount(cell.col + 1)
|
|
2474
|
+
},
|
|
2475
|
+
{
|
|
2476
|
+
id: "unfreeze",
|
|
2477
|
+
label: "Unfreeze rows and columns",
|
|
2478
|
+
disabled: !table.frozenRowCount && !table.frozenColumnCount,
|
|
2479
|
+
run: () => {
|
|
2480
|
+
controller.setFrozenRowCount(0);
|
|
2481
|
+
controller.setFrozenColumnCount(0);
|
|
2482
|
+
}
|
|
2483
|
+
},
|
|
2484
|
+
{
|
|
2485
|
+
id: "header-rows",
|
|
2486
|
+
label: `Use rows 1\u2013${cell.row + 1} as header rows`,
|
|
2487
|
+
run: () => controller.setHeaderRowCount(cell.row + 1)
|
|
2488
|
+
},
|
|
2489
|
+
{
|
|
2490
|
+
id: "footer-rows",
|
|
2491
|
+
label: `Use rows ${cell.row + 1}\u2013${doc.rows.length} as footer rows`,
|
|
2492
|
+
run: () => controller.setFooterRowCount(doc.rows.length - cell.row)
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
id: "hide-rows",
|
|
2496
|
+
label: rows.length > 1 ? `Hide ${rows.length} rows` : `Hide row ${cell.row + 1}`,
|
|
2497
|
+
separatorBefore: true,
|
|
2498
|
+
run: () => controller.hideRows(rows)
|
|
2499
|
+
},
|
|
2500
|
+
{
|
|
2501
|
+
id: "hide-columns",
|
|
2502
|
+
label: cols.length > 1 ? `Hide ${cols.length} columns` : `Hide column ${colName}`,
|
|
2503
|
+
run: () => controller.hideColumns(cols)
|
|
2504
|
+
},
|
|
2505
|
+
{
|
|
2506
|
+
id: "unhide-all",
|
|
2507
|
+
label: "Unhide all rows and columns",
|
|
2508
|
+
disabled: !anyHidden,
|
|
2509
|
+
run: () => controller.unhideAll()
|
|
2510
|
+
},
|
|
2511
|
+
{
|
|
2512
|
+
id: "merge",
|
|
2513
|
+
label: "Merge cells",
|
|
2514
|
+
separatorBefore: true,
|
|
2515
|
+
disabled: isSingleCellRange(selection),
|
|
2516
|
+
run: () => {
|
|
2517
|
+
controller.mergeCells(selection);
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
{
|
|
2521
|
+
id: "unmerge",
|
|
2522
|
+
label: "Unmerge cells",
|
|
2523
|
+
disabled: !hasMerge,
|
|
2524
|
+
run: () => {
|
|
2525
|
+
controller.unmergeCells(selection);
|
|
2526
|
+
}
|
|
2527
|
+
},
|
|
2528
|
+
{
|
|
2529
|
+
id: "transpose",
|
|
2530
|
+
label: "Transpose",
|
|
2531
|
+
disabled: isSingleCellRange(selection),
|
|
2532
|
+
run: () => {
|
|
2533
|
+
controller.transposeRange(selection);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
];
|
|
2537
|
+
if (grouping) {
|
|
2538
|
+
items.push(
|
|
2539
|
+
{
|
|
2540
|
+
id: "ungroup",
|
|
2541
|
+
label: "Remove categories",
|
|
2542
|
+
separatorBefore: true,
|
|
2543
|
+
run: () => controller.ungroupRows()
|
|
2544
|
+
},
|
|
2545
|
+
...["sum", "count", "average"].map((aggregate) => ({
|
|
2546
|
+
id: `summary-${aggregate}`,
|
|
2547
|
+
label: `Summarize column ${colName}: ${aggregate.toUpperCase()}`,
|
|
2548
|
+
run: () => controller.setGroupSummary(column.id, aggregate)
|
|
2549
|
+
}))
|
|
2550
|
+
);
|
|
2551
|
+
} else {
|
|
2552
|
+
items.push({
|
|
2553
|
+
id: "group-by",
|
|
2554
|
+
label: `Categorize by column ${colName}`,
|
|
2555
|
+
separatorBefore: true,
|
|
2556
|
+
run: () => controller.groupRowsBy(column.id)
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
return items;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// src/spreadsheet/actions.ts
|
|
2563
|
+
var SPREADSHEET_TABLE_ACTIONS = [
|
|
2564
|
+
"sortRows",
|
|
2565
|
+
"getFilterState",
|
|
2566
|
+
"setFilterRules",
|
|
2567
|
+
"addFilterRule",
|
|
2568
|
+
"updateFilterRule",
|
|
2569
|
+
"removeFilterRule",
|
|
2570
|
+
"clearFilters",
|
|
2571
|
+
"setFiltersEnabled",
|
|
2572
|
+
"toggleFilters",
|
|
2573
|
+
"setHeaderRowCount",
|
|
2574
|
+
"setFooterRowCount",
|
|
2575
|
+
"setHeaderColumnCount",
|
|
2576
|
+
"setFrozenRowCount",
|
|
2577
|
+
"setFrozenColumnCount",
|
|
2578
|
+
"hideRows",
|
|
2579
|
+
"unhideRows",
|
|
2580
|
+
"hideColumns",
|
|
2581
|
+
"unhideColumns",
|
|
2582
|
+
"unhideAll",
|
|
2583
|
+
"getMerges",
|
|
2584
|
+
"getMergeAt",
|
|
2585
|
+
"mergeCells",
|
|
2586
|
+
"unmergeCells",
|
|
2587
|
+
"autofill",
|
|
2588
|
+
"groupRowsBy",
|
|
2589
|
+
"ungroupRows",
|
|
2590
|
+
"setGroupSummary",
|
|
2591
|
+
"setGroupCollapsed",
|
|
2592
|
+
"toggleGroupCollapsed",
|
|
2593
|
+
"getGroups",
|
|
2594
|
+
"transposeRange",
|
|
2595
|
+
"exportToCsv",
|
|
2596
|
+
"importFromCsv",
|
|
2597
|
+
"copyToClipboardData",
|
|
2598
|
+
"pasteClipboardData"
|
|
2599
|
+
];
|
|
2600
|
+
function bindSpreadsheetTableActions(controller) {
|
|
2601
|
+
const actions = {};
|
|
2602
|
+
for (const name of SPREADSHEET_TABLE_ACTIONS) {
|
|
2603
|
+
actions[name] = controller[name].bind(controller);
|
|
2604
|
+
}
|
|
2605
|
+
return actions;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// src/spreadsheet/import.ts
|
|
2609
|
+
function delimitedAdapter(id, label, delimiter, mimeTypes, extensions) {
|
|
2610
|
+
return {
|
|
2611
|
+
id,
|
|
2612
|
+
label,
|
|
2613
|
+
mimeTypes,
|
|
2614
|
+
extensions,
|
|
2615
|
+
target: "spreadsheet",
|
|
2616
|
+
async import(_input, context) {
|
|
2617
|
+
return { rows: parseCsv(await context.readText(), { delimiter }), delimiter };
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
var csvImportAdapter = delimitedAdapter(
|
|
2622
|
+
"csv",
|
|
2623
|
+
"CSV",
|
|
2624
|
+
",",
|
|
2625
|
+
["text/csv", "application/csv", "text/comma-separated-values"],
|
|
2626
|
+
[".csv"]
|
|
2627
|
+
);
|
|
2628
|
+
var tsvImportAdapter = delimitedAdapter(
|
|
2629
|
+
"tsv",
|
|
2630
|
+
"Tab-separated values",
|
|
2631
|
+
" ",
|
|
2632
|
+
["text/tab-separated-values"],
|
|
2633
|
+
[".tsv", ".tab"]
|
|
2634
|
+
);
|
|
2635
|
+
var SPREADSHEET_IMPORT_ADAPTERS = [
|
|
2636
|
+
csvImportAdapter,
|
|
2637
|
+
tsvImportAdapter
|
|
2638
|
+
];
|
|
2639
|
+
function registerSpreadsheetImportAdapters(registry) {
|
|
2640
|
+
const unregisters = SPREADSHEET_IMPORT_ADAPTERS.map(
|
|
2641
|
+
(adapter) => registry.register(adapter, { replace: true })
|
|
2642
|
+
);
|
|
2643
|
+
return () => unregisters.forEach((unregister) => unregister());
|
|
2644
|
+
}
|
|
2645
|
+
async function importFileIntoSpreadsheet(controller, input, options) {
|
|
2646
|
+
const { registry, startRow, startCol, ...rest } = options;
|
|
2647
|
+
const outcome = await registry.importFile(input, { ...rest, target: "spreadsheet" });
|
|
2648
|
+
controller.importRows(outcome.result.rows, { startRow, startCol });
|
|
2649
|
+
return outcome;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
export {
|
|
2653
|
+
columnDependencyKey,
|
|
2654
|
+
extractCellDependencies,
|
|
2655
|
+
DependencyGraph,
|
|
2656
|
+
shiftFormulaReferences,
|
|
2657
|
+
mapFormulaReferences,
|
|
2658
|
+
offsetFormulaReferences,
|
|
2659
|
+
remapFormulaReferences,
|
|
2660
|
+
getFormulaCaretContext,
|
|
2661
|
+
getFormulaHints,
|
|
2662
|
+
applyFormulaSuggestion,
|
|
2663
|
+
DataSourceBinder,
|
|
2664
|
+
normalizeRange,
|
|
2665
|
+
rangeContains,
|
|
2666
|
+
rangesIntersect,
|
|
2667
|
+
rangeContainsRange,
|
|
2668
|
+
isSingleCellRange,
|
|
2669
|
+
isBlankValue,
|
|
2670
|
+
toNumber,
|
|
2671
|
+
compareCellValues,
|
|
2672
|
+
matchesFilterRule,
|
|
2673
|
+
aggregateValues,
|
|
2674
|
+
roundFloat,
|
|
2675
|
+
fillSeries,
|
|
2676
|
+
computeFillTarget,
|
|
2677
|
+
getFillDirection,
|
|
2678
|
+
parseCsv,
|
|
2679
|
+
escapeCsvField,
|
|
2680
|
+
serializeCsv,
|
|
2681
|
+
parseTsv,
|
|
2682
|
+
serializeTsv,
|
|
2683
|
+
SPREADSHEET_CLIPBOARD_MIME,
|
|
2684
|
+
serializeHtmlTable,
|
|
2685
|
+
parseHtmlTable,
|
|
2686
|
+
createSpreadsheetClipboardData,
|
|
2687
|
+
readSpreadsheetClipboard,
|
|
2688
|
+
rebaseClipboardRows,
|
|
2689
|
+
DEFAULT_ROW_HEIGHT,
|
|
2690
|
+
DEFAULT_COLUMN_WIDTH,
|
|
2691
|
+
getTableSections,
|
|
2692
|
+
getRowSection,
|
|
2693
|
+
rowPassesFilters,
|
|
2694
|
+
computeRowGroups,
|
|
2695
|
+
buildSpreadsheetView,
|
|
2696
|
+
SpreadsheetController,
|
|
2697
|
+
getSpreadsheetMenuItems,
|
|
2698
|
+
SPREADSHEET_TABLE_ACTIONS,
|
|
2699
|
+
bindSpreadsheetTableActions,
|
|
2700
|
+
csvImportAdapter,
|
|
2701
|
+
tsvImportAdapter,
|
|
2702
|
+
SPREADSHEET_IMPORT_ADAPTERS,
|
|
2703
|
+
registerSpreadsheetImportAdapters,
|
|
2704
|
+
importFileIntoSpreadsheet
|
|
2705
|
+
};
|
|
2706
|
+
//# sourceMappingURL=chunk-GVW4P7BG.js.map
|