@openpresentation/opf-editor 0.10.5 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
// The data model behind the data grid (RR-24): one table-shaped view of a chart's inline data and of a
|
|
2
|
+
// table, and every edit to it as one validated, undoable patch.
|
|
3
|
+
//
|
|
4
|
+
// A chart is `{ type, data: { columns, rows } }`: `columns` are labels, `rows` are arrays aligned to them. The
|
|
5
|
+
// first column holds the categories (the x axis, or the slice names) and every further column is one series,
|
|
6
|
+
// named by its column label, in the order the renderer draws them. (A scatter chart reads its x values from the
|
|
7
|
+
// second column when it has three or more; a chart type that needs one column only reads the first.) A table is
|
|
8
|
+
// `{ columns?, rows }` whose cells are plain values, rich runs or styled cells with spans, as table-options.js
|
|
9
|
+
// describes.
|
|
10
|
+
//
|
|
11
|
+
// Both are read as "lines": the header line (when there is one) followed by the body rows. The operations below
|
|
12
|
+
// change lines and columns, keep merged cells whole or refuse with a reason, and return a `{ document, patches,
|
|
13
|
+
// changed }` description like table-options.js does. Nothing here touches a DOM.
|
|
14
|
+
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
|
|
15
|
+
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
16
|
+
import { richTextContent, updateRichTextInput } from "./rich-text.js";
|
|
17
|
+
import { formatGridNumber, isCanonicalNumber, parseDelimited, parseGridNumber, resolveNumberFormat, toDelimited } from "./grid-text.js";
|
|
18
|
+
import { applyTableStyleToTable, readTableStyleOfTable } from "./table-options.js";
|
|
19
|
+
|
|
20
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
|
|
22
|
+
const valueOf = (cell) => (isStyled(cell) ? cell.value : cell);
|
|
23
|
+
const clone = (value) => structuredClone(value);
|
|
24
|
+
const spansOf = (raw) => ({ rs: isStyled(raw) ? (raw.rowSpan ?? 1) : 1, cs: isStyled(raw) ? (raw.colSpan ?? 1) : 1 });
|
|
25
|
+
|
|
26
|
+
/** Spreadsheet column letters: 0 is A, 25 is Z, 26 is AA. */
|
|
27
|
+
export function columnLabel(index) {
|
|
28
|
+
let label = "";
|
|
29
|
+
let n = index + 1;
|
|
30
|
+
while (n > 0) {
|
|
31
|
+
label = String.fromCharCode(65 + ((n - 1) % 26)) + label;
|
|
32
|
+
n = Math.floor((n - 1) / 26);
|
|
33
|
+
}
|
|
34
|
+
return label;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const decimalOf = (options = {}) => (options.decimal === "," || options.decimal === "." ? options.decimal : resolveNumberFormat(options.numberFormat ?? ".", options.locale).decimal);
|
|
38
|
+
|
|
39
|
+
// --- locating ----------------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the data a path points at. A chart path ends in `chart` (or `chart.data`) and a table path in `table`.
|
|
43
|
+
* Throws `grid-target-not-found`, `table-not-found` or `chart-data-source` (a chart that reads its data from a source
|
|
44
|
+
* has no inline data to edit).
|
|
45
|
+
*/
|
|
46
|
+
export function locateGridData(document, path) {
|
|
47
|
+
let parts;
|
|
48
|
+
try {
|
|
49
|
+
parts = Array.isArray(path) ? [...path] : splitOpfPath(path);
|
|
50
|
+
} catch {
|
|
51
|
+
throw fail("grid-target-not-found", "Choose a chart or a table.", { path });
|
|
52
|
+
}
|
|
53
|
+
if (parts.at(-1) === "data" && parts.at(-2) === "chart") parts = parts.slice(0, -1);
|
|
54
|
+
const owner = getValueAtPath(document, parts);
|
|
55
|
+
if (parts.at(-1) === "chart") {
|
|
56
|
+
if (!isObject(owner) || !isObject(owner.data)) throw fail("grid-target-not-found", "This chart has no data.", { path });
|
|
57
|
+
const data = owner.data;
|
|
58
|
+
if (!Array.isArray(data.columns) || !Array.isArray(data.rows))
|
|
59
|
+
throw fail("chart-data-source", "This chart reads its data from a source (src), not from inline columns and rows. The data grid edits inline chart data.", { path });
|
|
60
|
+
return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType: typeof owner.type === "string" ? owner.type : undefined };
|
|
61
|
+
}
|
|
62
|
+
if (parts.at(-1) === "table") {
|
|
63
|
+
if (!isObject(owner) || !Array.isArray(owner.rows)) throw fail("table-not-found", "Choose a table (a path ending in .table).", { path });
|
|
64
|
+
return { kind: "table", parts, dataParts: parts, data: owner };
|
|
65
|
+
}
|
|
66
|
+
throw fail("grid-target-not-found", "Choose a chart or a table (a path ending in .chart or .table).", { path });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The chart or table a selection path belongs to: `{ kind, path }` for `…chart`, `…chart.data.rows.1.0`, `…table`,
|
|
71
|
+
* `…table.rows.1.2.value`, and so on. A chart whose data comes from a source is `{ kind: "chart", path, editable: false,
|
|
72
|
+
* reason }`. Returns undefined when the path is not inside a chart or table.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDataGridTarget(document, selectedPath) {
|
|
75
|
+
let parts;
|
|
76
|
+
try {
|
|
77
|
+
parts = splitOpfPath(selectedPath);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
for (let index = parts.length - 1; index >= 1; index -= 1) {
|
|
82
|
+
if (parts[index] !== "chart" && parts[index] !== "table") continue;
|
|
83
|
+
const head = parts.slice(0, index + 1);
|
|
84
|
+
try {
|
|
85
|
+
const found = locateGridData(document, head);
|
|
86
|
+
return { kind: found.kind, path: head.join(".") };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code === "chart-data-source") return { kind: "chart", path: head.join("."), editable: false, reason: error.message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- lines -------------------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function toModel(found) {
|
|
97
|
+
const { kind, data } = found;
|
|
98
|
+
const hasHeader = kind === "chart" ? true : Array.isArray(data.columns);
|
|
99
|
+
const lines = [...(hasHeader ? [clone(data.columns)] : []), ...clone(data.rows).map((row) => (Array.isArray(row) ? row : [row]))];
|
|
100
|
+
const model = { kind, hasHeader, lines, width: 0, chartType: found.chartType };
|
|
101
|
+
model.width = widthOf(model);
|
|
102
|
+
return model;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fromModel(model, original) {
|
|
106
|
+
const rest = Object.fromEntries(Object.entries(original).filter(([key]) => key !== "columns" && key !== "rows"));
|
|
107
|
+
const out = {};
|
|
108
|
+
if (model.hasHeader) out.columns = model.lines[0];
|
|
109
|
+
out.rows = model.lines.slice(model.hasHeader ? 1 : 0);
|
|
110
|
+
return { ...out, ...rest };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const widthOf = (model) => model.lines.reduce((most, line) => Math.max(most, line.length), 0);
|
|
114
|
+
const offset = (model) => (model.hasHeader ? 1 : 0);
|
|
115
|
+
const bodyCount = (model) => model.lines.length - offset(model);
|
|
116
|
+
const filler = (model, u) => (model.kind === "chart" && !(model.hasHeader && u === 0) ? null : "");
|
|
117
|
+
|
|
118
|
+
function padLines(model) {
|
|
119
|
+
model.width = widthOf(model);
|
|
120
|
+
model.lines.forEach((line, u) => {
|
|
121
|
+
while (line.length < model.width) line.push(filler(model, u));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const addressOf = (model, u) => (model.hasHeader && u === 0 ? { section: "header", row: 0 } : { section: "body", row: u - offset(model) });
|
|
126
|
+
|
|
127
|
+
function lineIndex(model, address) {
|
|
128
|
+
if (address.section === "header") {
|
|
129
|
+
if (!model.hasHeader) throw fail("grid-cell-not-found", "This table has no header row.", { cell: address });
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isInteger(address.row) || address.row < 0) throw fail("grid-cell-not-found", "Choose an existing row.", { cell: address });
|
|
133
|
+
return address.row + offset(model);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A cell's description for messages: "Header, column B" or "Row 3, column A". */
|
|
137
|
+
export function describeAddress(address) {
|
|
138
|
+
return `${address.section === "header" ? "Header" : `Row ${address.row + 1}`}, column ${columnLabel(address.column)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- merges ------------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function rectsOf(model) {
|
|
144
|
+
const out = [];
|
|
145
|
+
model.lines.forEach((line, u) =>
|
|
146
|
+
line.forEach((raw, c) => {
|
|
147
|
+
const { rs, cs } = spansOf(raw);
|
|
148
|
+
if (rs > 1 || cs > 1) out.push({ u, c, rs, cs });
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ownersOf(model) {
|
|
155
|
+
const owners = new Map();
|
|
156
|
+
for (const rect of rectsOf(model))
|
|
157
|
+
for (let y = rect.u; y < rect.u + rect.rs; y += 1)
|
|
158
|
+
for (let x = rect.c; x < rect.c + rect.cs; x += 1) if (y !== rect.u || x !== rect.c) owners.set(`${y}:${x}`, rect);
|
|
159
|
+
return owners;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function withSpans(raw, rs, cs) {
|
|
163
|
+
const merged = isStyled(raw) ? { ...raw } : { value: raw };
|
|
164
|
+
if (rs > 1) merged.rowSpan = rs;
|
|
165
|
+
else delete merged.rowSpan;
|
|
166
|
+
if (cs > 1) merged.colSpan = cs;
|
|
167
|
+
else delete merged.colSpan;
|
|
168
|
+
const keys = Object.keys(merged);
|
|
169
|
+
return keys.length === 1 && keys[0] === "value" ? merged.value : merged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Problems in the merged cells of a model: a span that leaves the grid, crosses another, or covers a cell that is not null. */
|
|
173
|
+
function mergeProblems(model) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
const claimed = new Set();
|
|
176
|
+
for (const rect of rectsOf(model)) {
|
|
177
|
+
const where = describeAddress({ ...addressOf(model, rect.u), column: rect.c });
|
|
178
|
+
if (rect.u + rect.rs > model.lines.length || rect.c + rect.cs > model.width) problems.push(`The merged cell at ${where} would leave the table.`);
|
|
179
|
+
if (model.hasHeader && rect.u === 0 && rect.rs > 1) problems.push(`The merged header cell at ${where} would span into the body.`);
|
|
180
|
+
for (let y = rect.u; y < rect.u + rect.rs; y += 1)
|
|
181
|
+
for (let x = rect.c; x < rect.c + rect.cs; x += 1) {
|
|
182
|
+
if (y === rect.u && x === rect.c) continue;
|
|
183
|
+
const key = `${y}:${x}`;
|
|
184
|
+
if (claimed.has(key)) problems.push(`Merged cells overlap at ${describeAddress({ ...addressOf(model, y), column: x })}.`);
|
|
185
|
+
claimed.add(key);
|
|
186
|
+
if (model.lines[y]?.[x] !== null) problems.push(`The merged cell at ${where} would cover text at ${describeAddress({ ...addressOf(model, y), column: x })}.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return problems;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertMerges(model, before, what) {
|
|
193
|
+
const problems = mergeProblems(model);
|
|
194
|
+
if (problems.length > before) throw fail("table-merge-conflict", `${what} would break a merged cell. ${problems[0]} Split the merged cells first.`, { problems });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- cell text ---------------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The text a cell shows. A table cell shows exactly what the slide draws (a number as JavaScript writes it); a chart
|
|
201
|
+
* cell shows its number in the grid's number format. Rich runs show their text, gaps and covered cells are "".
|
|
202
|
+
*/
|
|
203
|
+
export function cellText(raw, kind, decimal = ".") {
|
|
204
|
+
const value = valueOf(raw);
|
|
205
|
+
if (value === null || value === undefined) return "";
|
|
206
|
+
if (Array.isArray(value)) return richTextContent(value);
|
|
207
|
+
if (typeof value === "number") return kind === "chart" ? formatGridNumber(value, { decimal }) : String(value);
|
|
208
|
+
return String(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const looksNumeric = (value) => typeof value === "number" ? Number.isFinite(value) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
|
|
212
|
+
const isScatter = (type) => typeof type === "string" && /^(scatter|bubble)/.test(type);
|
|
213
|
+
|
|
214
|
+
/** What each column of a chart is for: "category" (column A), "series", and for a scatter chart with three or more columns "label" and "x". */
|
|
215
|
+
export function chartColumnRoles(columnCount, chartType) {
|
|
216
|
+
if (columnCount <= 1) return Array(columnCount).fill("series");
|
|
217
|
+
if (isScatter(chartType) && columnCount >= 3) return ["label", "x", ...Array(columnCount - 2).fill("series")];
|
|
218
|
+
return ["category", ...Array(columnCount - 1).fill("series")];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isNumericColumn = (model, column) => model.kind === "chart" && !["category", "label"].includes(chartColumnRoles(model.width, model.chartType)[column] ?? "series");
|
|
222
|
+
|
|
223
|
+
// --- describing --------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Describe a chart or table as a grid for display: `lines` (the header line first when `hasHeader`) of cells
|
|
227
|
+
* `{ line, column, section, row, text, rich, runs, rowSpan, colSpan, covered, owner, warning, style }`. A covered
|
|
228
|
+
* cell has `covered: true` and its anchor in `owner`; its `text` is "". `warnings` lists data problems the document
|
|
229
|
+
* already has (text in a series column the renderer draws as a gap, an empty or repeated series name, a missing category).
|
|
230
|
+
*/
|
|
231
|
+
export function describeDataGrid(document, path, options = {}) {
|
|
232
|
+
const found = locateGridData(document, path);
|
|
233
|
+
const decimal = decimalOf(options);
|
|
234
|
+
const model = toModel(found);
|
|
235
|
+
padLines(model);
|
|
236
|
+
const owners = ownersOf(model);
|
|
237
|
+
const roles = model.kind === "chart" ? chartColumnRoles(model.width, model.chartType) : [];
|
|
238
|
+
const warnings = [];
|
|
239
|
+
const names = new Map();
|
|
240
|
+
const lines = model.lines.map((line, u) =>
|
|
241
|
+
line.map((raw, column) => {
|
|
242
|
+
const address = addressOf(model, u);
|
|
243
|
+
const owner = owners.get(`${u}:${column}`);
|
|
244
|
+
const { rs, cs } = spansOf(raw);
|
|
245
|
+
const value = valueOf(raw);
|
|
246
|
+
const cell = {
|
|
247
|
+
line: u,
|
|
248
|
+
column,
|
|
249
|
+
section: address.section,
|
|
250
|
+
row: address.row,
|
|
251
|
+
text: owner ? "" : cellText(raw, model.kind, decimal),
|
|
252
|
+
rich: Array.isArray(value),
|
|
253
|
+
rowSpan: rs,
|
|
254
|
+
colSpan: cs,
|
|
255
|
+
covered: Boolean(owner),
|
|
256
|
+
};
|
|
257
|
+
if (Array.isArray(value)) cell.runs = value;
|
|
258
|
+
if (isStyled(raw) && raw.style) cell.style = raw.style;
|
|
259
|
+
if (owner) cell.owner = { line: owner.u, column: owner.c };
|
|
260
|
+
if (model.kind === "chart") {
|
|
261
|
+
let warning;
|
|
262
|
+
if (u === 0) {
|
|
263
|
+
const name = String(value ?? "");
|
|
264
|
+
if (column > 0 || model.width === 1) {
|
|
265
|
+
if (name.trim() === "") warning = "This series has no name.";
|
|
266
|
+
else if (names.has(name)) warning = `Another series is also named "${name}".`;
|
|
267
|
+
names.set(name, column);
|
|
268
|
+
}
|
|
269
|
+
} else if (roles[column] === "category" || roles[column] === "label") {
|
|
270
|
+
if (roles[column] === "category" && (value === null || value === undefined || value === "")) warning = "This row has no category label.";
|
|
271
|
+
} else if (typeof value === "boolean") warning = "A yes/no value is not a number, so the chart draws a gap here.";
|
|
272
|
+
else if (value !== null && value !== undefined && !(typeof value === "number" || looksNumeric(value)) && String(value).trim() !== "")
|
|
273
|
+
warning = "This is not a number, so the chart draws a gap here.";
|
|
274
|
+
if (warning) {
|
|
275
|
+
cell.warning = warning;
|
|
276
|
+
warnings.push({ section: address.section, row: address.row, column, message: `${describeAddress({ ...address, column })}: ${warning}` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return cell;
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
return {
|
|
283
|
+
kind: model.kind,
|
|
284
|
+
path: found.parts.join("."),
|
|
285
|
+
chartType: found.chartType,
|
|
286
|
+
hasHeader: model.hasHeader,
|
|
287
|
+
rowCount: bodyCount(model),
|
|
288
|
+
columnCount: model.width,
|
|
289
|
+
columnRoles: roles,
|
|
290
|
+
lines,
|
|
291
|
+
warnings,
|
|
292
|
+
merges: rectsOf(model).map((rect) => ({ ...addressOf(model, rect.u), column: rect.c, rowSpan: rect.rs, colSpan: rect.cs })),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- patches -----------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
const MAX_FINE_PATCHES = 48;
|
|
299
|
+
|
|
300
|
+
function diffInto(parts, before, after, ops) {
|
|
301
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return;
|
|
302
|
+
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
|
|
303
|
+
before.forEach((entry, index) => diffInto([...parts, String(index)], entry, after[index], ops));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (isObject(before) && isObject(after)) {
|
|
307
|
+
for (const key of Object.keys(before)) if (!Object.hasOwn(after, key)) ops.push({ op: "remove", parts: [...parts, key], before: before[key] });
|
|
308
|
+
for (const key of Object.keys(after)) {
|
|
309
|
+
if (!Object.hasOwn(before, key)) ops.push({ op: "add", parts: [...parts, key], value: after[key] });
|
|
310
|
+
else diffInto([...parts, key], before[key], after[key], ops);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
ops.push({ op: "replace", parts, before, value: after });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The smallest patch from `before` to `after` at `parts`: one guarded `replace` per changed cell (a single cell edit
|
|
319
|
+
* is one operation), or one guarded replacement of the whole value when many things changed, the shape did or a key was added or removed.
|
|
320
|
+
*/
|
|
321
|
+
export function gridPatches(parts, before, after) {
|
|
322
|
+
const ops = [];
|
|
323
|
+
diffInto(parts, before, after, ops);
|
|
324
|
+
if (!ops.length) return [];
|
|
325
|
+
// Adding or removing a key (the header row's `columns`) is one guarded replacement, so Undo restores the object with its keys in their old order.
|
|
326
|
+
if (ops.length > MAX_FINE_PATCHES || ops.some((entry) => entry.op !== "replace")) {
|
|
327
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
328
|
+
return [{ op: "test", path: pointer, value: clone(before) }, { op: "replace", path: pointer, value: clone(after) }];
|
|
329
|
+
}
|
|
330
|
+
const patches = [];
|
|
331
|
+
for (const entry of ops) {
|
|
332
|
+
const pointer = opfPathToJsonPointer(entry.parts);
|
|
333
|
+
if (entry.op !== "add") patches.push({ op: "test", path: pointer, value: clone(entry.before) });
|
|
334
|
+
patches.push(entry.op === "remove" ? { op: "remove", path: pointer } : { op: entry.op, path: pointer, value: clone(entry.value) });
|
|
335
|
+
}
|
|
336
|
+
return patches;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function transact(document, path, action, mutate, extra = {}) {
|
|
340
|
+
const found = locateGridData(document, path);
|
|
341
|
+
if (extra.kind && extra.kind !== found.kind) throw fail("grid-wrong-kind", `This operation works on a ${extra.kind}, and the path points at a ${found.kind}.`, { path });
|
|
342
|
+
const model = toModel(found);
|
|
343
|
+
const problemsBefore = mergeProblems(model).length;
|
|
344
|
+
const info = mutate(model, found) ?? {};
|
|
345
|
+
const style = info.restyle && found.kind === "table" ? readTableStyleOfTable(found.data) : undefined;
|
|
346
|
+
if (info.touchesStructure) assertMerges(model, problemsBefore, info.what ?? "This change");
|
|
347
|
+
if (style && style.preset !== "custom") padLines(model);
|
|
348
|
+
const after = fromModel(model, found.data);
|
|
349
|
+
if (style && style.preset !== "custom") applyTableStyleToTable(after, style.preset);
|
|
350
|
+
const patches = gridPatches(found.dataParts, found.data, after);
|
|
351
|
+
const changed = patches.length > 0;
|
|
352
|
+
const before = validateOpfDocument(document);
|
|
353
|
+
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
354
|
+
// `restyle`, `touchesStructure` and `what` steer this function only; the rest of what the operation reports is the caller's.
|
|
355
|
+
const { restyle: _restyle, touchesStructure: _structure, what: _what, ...summary } = info;
|
|
356
|
+
return { action, ...summary, kind: found.kind, path: found.parts.join("."), document: clone(result), patches, changed };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- row and column structure ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function referenceCell(model, line, column) {
|
|
362
|
+
const raw = line?.[column];
|
|
363
|
+
if (model.kind !== "table" || !isStyled(raw) || !raw.style) return "";
|
|
364
|
+
return Object.keys(raw.style).length ? { value: "", style: clone(raw.style) } : "";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function insertRows(model, at, count) {
|
|
368
|
+
if (!Number.isInteger(at) || at < 0 || at > bodyCount(model)) throw fail("grid-row-out-of-range", `A row can be inserted at positions 1 to ${bodyCount(model) + 1}.`, { at });
|
|
369
|
+
padLines(model);
|
|
370
|
+
const u = at + offset(model);
|
|
371
|
+
const crossing = rectsOf(model).filter((rect) => rect.u < u && u < rect.u + rect.rs);
|
|
372
|
+
const reference = u - 1 >= offset(model) ? model.lines[u - 1] : u < model.lines.length ? model.lines[u] : undefined;
|
|
373
|
+
const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" ? null : referenceCell(model, reference, column))));
|
|
374
|
+
model.lines.splice(u, 0, ...fresh);
|
|
375
|
+
for (const rect of crossing) {
|
|
376
|
+
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs + count, rect.cs);
|
|
377
|
+
for (let i = 0; i < count; i += 1) for (let x = rect.c; x < rect.c + rect.cs; x += 1) model.lines[u + i][x] = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function deleteRow(model, u) {
|
|
382
|
+
for (const rect of rectsOf(model)) {
|
|
383
|
+
if (!(rect.u <= u && u < rect.u + rect.rs) || rect.rs === 1) continue;
|
|
384
|
+
const anchor = model.lines[rect.u][rect.c];
|
|
385
|
+
if (rect.u < u) model.lines[rect.u][rect.c] = withSpans(anchor, rect.rs - 1, rect.cs);
|
|
386
|
+
// The merge's first row is deleted: the merged cell keeps its text and moves to the next row.
|
|
387
|
+
else model.lines[u + 1][rect.c] = withSpans(anchor, rect.rs - 1, rect.cs);
|
|
388
|
+
}
|
|
389
|
+
model.lines.splice(u, 1);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function deleteRows(model, indices) {
|
|
393
|
+
const unique = [...new Set(indices)].sort((a, b) => b - a);
|
|
394
|
+
if (!unique.length) throw fail("grid-row-out-of-range", "Choose at least one row.", {});
|
|
395
|
+
for (const index of unique) if (!Number.isInteger(index) || index < 0 || index >= bodyCount(model)) throw fail("grid-row-out-of-range", `Row ${index + 1} does not exist.`, { index });
|
|
396
|
+
if (unique.length >= bodyCount(model)) throw fail("grid-last-row", `${model.kind === "chart" ? "A chart" : "A table"} keeps at least one row.`, {});
|
|
397
|
+
padLines(model);
|
|
398
|
+
for (const index of unique) deleteRow(model, index + offset(model));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function insertColumns(model, at, count) {
|
|
402
|
+
padLines(model);
|
|
403
|
+
if (!Number.isInteger(at) || at < 0 || at > model.width) throw fail("grid-column-out-of-range", `A column can be inserted at positions 1 to ${model.width + 1}.`, { at });
|
|
404
|
+
const crossing = rectsOf(model).filter((rect) => rect.c < at && at < rect.c + rect.cs);
|
|
405
|
+
const reference = at - 1 >= 0 ? at - 1 : at < model.width ? at : -1;
|
|
406
|
+
model.lines.forEach((line, u) => {
|
|
407
|
+
const fresh = Array.from({ length: count }, () => (model.kind === "chart" || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
|
|
408
|
+
line.splice(at, 0, ...fresh);
|
|
409
|
+
});
|
|
410
|
+
for (const rect of crossing) {
|
|
411
|
+
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs, rect.cs + count);
|
|
412
|
+
for (let y = rect.u; y < rect.u + rect.rs; y += 1) for (let i = 0; i < count; i += 1) model.lines[y][at + i] = null;
|
|
413
|
+
}
|
|
414
|
+
model.width += count;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function deleteColumn(model, x) {
|
|
418
|
+
for (const rect of rectsOf(model)) {
|
|
419
|
+
if (!(rect.c <= x && x < rect.c + rect.cs) || rect.cs === 1) continue;
|
|
420
|
+
const anchor = model.lines[rect.u][rect.c];
|
|
421
|
+
if (rect.c < x) model.lines[rect.u][rect.c] = withSpans(anchor, rect.rs, rect.cs - 1);
|
|
422
|
+
else model.lines[rect.u][x + 1] = withSpans(anchor, rect.rs, rect.cs - 1);
|
|
423
|
+
}
|
|
424
|
+
for (const line of model.lines) line.splice(x, 1);
|
|
425
|
+
model.width -= 1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function deleteColumns(model, indices) {
|
|
429
|
+
padLines(model);
|
|
430
|
+
const unique = [...new Set(indices)].sort((a, b) => b - a);
|
|
431
|
+
if (!unique.length) throw fail("grid-column-out-of-range", "Choose at least one column.", {});
|
|
432
|
+
for (const index of unique) if (!Number.isInteger(index) || index < 0 || index >= model.width) throw fail("grid-column-out-of-range", `Column ${columnLabel(index)} does not exist.`, { index });
|
|
433
|
+
if (unique.length >= model.width) throw fail("grid-last-column", `${model.kind === "chart" ? "A chart" : "A table"} keeps at least one column.`, {});
|
|
434
|
+
for (const index of unique) deleteColumn(model, index);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function moveBlock(array, from, count, to) {
|
|
438
|
+
const moved = array.splice(from, count);
|
|
439
|
+
array.splice(to, 0, ...moved);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function moveRows(model, from, count, to) {
|
|
443
|
+
const max = bodyCount(model);
|
|
444
|
+
if (![from, count, to].every(Number.isInteger) || count < 1 || from < 0 || from + count > max || to < 0 || to + count > max)
|
|
445
|
+
throw fail("grid-row-out-of-range", "That move leaves the table.", { from, count, to });
|
|
446
|
+
if (from === to) return;
|
|
447
|
+
moveBlock(model.lines, from + offset(model), count, to + offset(model));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function moveColumns(model, from, count, to) {
|
|
451
|
+
padLines(model);
|
|
452
|
+
if (![from, count, to].every(Number.isInteger) || count < 1 || from < 0 || from + count > model.width || to < 0 || to + count > model.width)
|
|
453
|
+
throw fail("grid-column-out-of-range", "That move leaves the table.", { from, count, to });
|
|
454
|
+
if (from === to) return;
|
|
455
|
+
for (const line of model.lines) moveBlock(line, from, count, to);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- sorting -----------------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
461
|
+
|
|
462
|
+
function sortKey(raw, decimal, type) {
|
|
463
|
+
const value = valueOf(raw);
|
|
464
|
+
const text = cellText(raw, "table", decimal);
|
|
465
|
+
if (value === null || value === undefined || text.trim() === "") return { rank: 3, text };
|
|
466
|
+
if (typeof value === "number") return type === "text" ? { rank: 2, text } : { rank: 0, n: value, text };
|
|
467
|
+
if (typeof value === "string") {
|
|
468
|
+
if (type === "auto" || type === "number") {
|
|
469
|
+
const parsed = parseGridNumber(value, { decimal });
|
|
470
|
+
if (parsed.value !== undefined) return { rank: 0, n: parsed.value, text };
|
|
471
|
+
}
|
|
472
|
+
if ((type === "auto" || type === "date") && ISO_DATE.test(text.trim())) {
|
|
473
|
+
const time = Date.parse(text.trim().replace(" ", "T"));
|
|
474
|
+
if (Number.isFinite(time)) return { rank: 1, n: time, text };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return { rank: 2, text };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sortRows(model, column, options) {
|
|
481
|
+
const { direction = "asc", type = "auto", locale } = options;
|
|
482
|
+
if (direction !== "asc" && direction !== "desc") throw fail("invalid-sort", "Sort ascending or descending.", { direction });
|
|
483
|
+
if (!["auto", "text", "number", "date"].includes(type)) throw fail("invalid-sort", "Sort as auto, text, number or date.", { type });
|
|
484
|
+
padLines(model);
|
|
485
|
+
if (!Number.isInteger(column) || column < 0 || column >= model.width) throw fail("grid-column-out-of-range", "Choose a column to sort by.", { column });
|
|
486
|
+
const decimal = decimalOf(options);
|
|
487
|
+
const start = offset(model);
|
|
488
|
+
const count = bodyCount(model);
|
|
489
|
+
if (count < 2) return { sorted: false };
|
|
490
|
+
const owners = ownersOf(model);
|
|
491
|
+
const rects = rectsOf(model);
|
|
492
|
+
// Rows joined by a merged cell that spans rows move as one block, in their own order.
|
|
493
|
+
const joined = new Array(model.lines.length).fill(0).map((_, index) => index);
|
|
494
|
+
const find = (index) => (joined[index] === index ? index : (joined[index] = find(joined[index])));
|
|
495
|
+
for (const rect of rects) for (let y = rect.u + 1; y < rect.u + rect.rs; y += 1) joined[find(y)] = find(rect.u);
|
|
496
|
+
const blocks = [];
|
|
497
|
+
for (let u = start; u < model.lines.length; u += 1) {
|
|
498
|
+
const last = blocks.at(-1);
|
|
499
|
+
if (last && find(last.rows[0]) === find(u)) last.rows.push(u);
|
|
500
|
+
else blocks.push({ rows: [u] });
|
|
501
|
+
}
|
|
502
|
+
const effective = (u) => {
|
|
503
|
+
const raw = model.lines[u][column];
|
|
504
|
+
if (raw !== null) return raw;
|
|
505
|
+
const owner = owners.get(`${u}:${column}`);
|
|
506
|
+
return owner ? model.lines[owner.u][owner.c] : null;
|
|
507
|
+
};
|
|
508
|
+
const keys = blocks.map((block) => sortKey(effective(block.rows[0]), decimal, type));
|
|
509
|
+
const collator = new Intl.Collator(locale || undefined, { numeric: true, sensitivity: "base" });
|
|
510
|
+
const sign = direction === "desc" ? -1 : 1;
|
|
511
|
+
const order = blocks.map((_, index) => index).sort((a, b) => {
|
|
512
|
+
const left = keys[a];
|
|
513
|
+
const right = keys[b];
|
|
514
|
+
if (left.rank === 3 || right.rank === 3) return left.rank === right.rank ? a - b : left.rank === 3 ? 1 : -1;
|
|
515
|
+
let result;
|
|
516
|
+
if (left.rank !== right.rank) result = left.rank - right.rank;
|
|
517
|
+
else if (left.rank === 2) result = collator.compare(left.text, right.text);
|
|
518
|
+
else result = left.n - right.n;
|
|
519
|
+
return result === 0 ? a - b : sign * result;
|
|
520
|
+
});
|
|
521
|
+
const moved = order.some((index, position) => index !== position);
|
|
522
|
+
if (!moved) return { sorted: false };
|
|
523
|
+
const body = order.flatMap((index) => blocks[index].rows.map((u) => model.lines[u]));
|
|
524
|
+
model.lines.splice(start, count, ...body);
|
|
525
|
+
return { sorted: true, touchesStructure: true, what: "Sorting" };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- header ------------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
function setHeader(model, enabled, options) {
|
|
531
|
+
if (model.kind !== "table") throw fail("grid-wrong-kind", "Only a table has a header row to turn on or off. A chart's first line always names its series.", {});
|
|
532
|
+
if (enabled === model.hasHeader) return {};
|
|
533
|
+
padLines(model);
|
|
534
|
+
if (enabled) {
|
|
535
|
+
const use = options.use ?? (model.lines.length >= 2 ? "first-row" : "new");
|
|
536
|
+
if (use !== "first-row" && use !== "new") throw fail("invalid-header-source", "Use the first row, or add a new empty header row.", { use });
|
|
537
|
+
if (use === "first-row") {
|
|
538
|
+
if (model.lines.length < 2) throw fail("table-header-needs-rows", "A table needs a second row to keep as its body. Add a new empty header row instead.", {});
|
|
539
|
+
if (rectsOf(model).some((rect) => rect.u === 0 && rect.rs > 1)) throw fail("table-header-merge", "The first row has merged cells that span rows, and a header cannot span into the body. Split them first.", {});
|
|
540
|
+
// A header label is text, runs or a styled cell: numbers and yes/no values become their text.
|
|
541
|
+
model.lines[0] = model.lines[0].map((raw) => {
|
|
542
|
+
const value = valueOf(raw);
|
|
543
|
+
if (typeof value !== "number" && typeof value !== "boolean") return raw;
|
|
544
|
+
return isStyled(raw) ? { ...raw, value: String(value) } : String(value);
|
|
545
|
+
});
|
|
546
|
+
} else model.lines.unshift(Array.from({ length: model.width }, () => ""));
|
|
547
|
+
model.hasHeader = true;
|
|
548
|
+
return { header: true, use, restyle: true, touchesStructure: true, what: "A header row" };
|
|
549
|
+
}
|
|
550
|
+
model.hasHeader = false;
|
|
551
|
+
return { header: false, restyle: true };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- transpose ---------------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
function transposeChart(model) {
|
|
557
|
+
if (model.kind !== "chart") throw fail("grid-wrong-kind", "Only chart data can be transposed.", {});
|
|
558
|
+
padLines(model);
|
|
559
|
+
if (model.width < 2) throw fail("chart-transpose-empty", "Transposing needs at least one series column besides the categories.", {});
|
|
560
|
+
const [names, ...rows] = model.lines;
|
|
561
|
+
let relabelled = 0;
|
|
562
|
+
const categories = rows.map((row) => {
|
|
563
|
+
const category = row[0];
|
|
564
|
+
if (category === null || category === undefined) return "";
|
|
565
|
+
if (typeof category !== "string") relabelled += 1;
|
|
566
|
+
return String(category);
|
|
567
|
+
});
|
|
568
|
+
const lines = [[names[0], ...categories], ...names.slice(1).map((name, index) => [name, ...rows.map((row) => row[index + 1] ?? null)])];
|
|
569
|
+
model.lines = lines;
|
|
570
|
+
model.width = widthOf(model);
|
|
571
|
+
return { rowsBefore: rows.length, columnsBefore: names.length, relabelled };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- cell edits --------------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
function growTo(model, lines, width) {
|
|
577
|
+
padLines(model);
|
|
578
|
+
while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => (model.kind === "chart" ? null : "")));
|
|
579
|
+
while (model.width < width) {
|
|
580
|
+
model.lines.forEach((line, u) => line.push(filler(model, u)));
|
|
581
|
+
model.width += 1;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function newRaw(model, raw, text, decimal, address) {
|
|
586
|
+
const value = valueOf(raw);
|
|
587
|
+
// Text that is already what the cell shows is left alone: a number kept as text stays text, runs keep their styling.
|
|
588
|
+
const shown = cellText(raw, model.kind, decimal);
|
|
589
|
+
if (Array.isArray(value)) {
|
|
590
|
+
if (text.replace(/\r\n?/g, "\n") === shown.replace(/\r\n?/g, "\n")) return { skip: true };
|
|
591
|
+
return { raw: isStyled(raw) ? { ...raw, value: updateRichTextInput(value, text) } : updateRichTextInput(value, text) };
|
|
592
|
+
}
|
|
593
|
+
if (text === shown) return { skip: true };
|
|
594
|
+
let next;
|
|
595
|
+
if (model.kind === "table") {
|
|
596
|
+
if (model.hasHeader && address.section === "header") next = text;
|
|
597
|
+
else next = text === "" ? "" : isCanonicalNumber(text) ? Number(text) : text;
|
|
598
|
+
} else if (address.section === "header") next = text;
|
|
599
|
+
else if (isNumericColumn(model, address.column)) {
|
|
600
|
+
const parsed = parseGridNumber(text, { decimal });
|
|
601
|
+
if (parsed.error) return { error: parsed.error };
|
|
602
|
+
next = parsed.empty ? null : parsed.value;
|
|
603
|
+
if (next === null && (value === null || value === undefined)) return { skip: true };
|
|
604
|
+
} else next = text === "" ? null : text;
|
|
605
|
+
return { raw: isStyled(raw) ? { ...raw, value: next } : next };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function applyEdits(model, edits, decimal, owners) {
|
|
609
|
+
const issues = [];
|
|
610
|
+
for (const edit of edits) {
|
|
611
|
+
const address = { section: edit.section, row: edit.row ?? 0, column: edit.column };
|
|
612
|
+
try {
|
|
613
|
+
const u = lineIndex(model, address);
|
|
614
|
+
if (!Number.isInteger(address.column) || address.column < 0 || address.column >= model.width || u >= model.lines.length) throw fail("grid-cell-not-found", `${describeAddress(address)} does not exist.`, { cell: address });
|
|
615
|
+
if (owners.has(`${u}:${address.column}`)) throw fail("table-cell-covered", `${describeAddress(address)} is covered by a merged cell. Edit the merged cell, or split it first.`, { cell: address });
|
|
616
|
+
const line = model.lines[u];
|
|
617
|
+
while (line.length <= address.column) line.push(filler(model, u));
|
|
618
|
+
const raw = line[address.column];
|
|
619
|
+
if (Object.hasOwn(edit, "value")) {
|
|
620
|
+
const value = edit.value;
|
|
621
|
+
if (model.kind === "chart" && !(value === null || ["string", "number", "boolean"].includes(typeof value))) throw fail("invalid-grid-value", "A chart cell is text, a number, true or false, or empty.", { cell: address });
|
|
622
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw fail("invalid-grid-value", "A cell cannot hold Infinity or NaN.", { cell: address });
|
|
623
|
+
model.lines[u][address.column] = isStyled(raw) ? { ...raw, value } : value;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (typeof edit.text !== "string") throw fail("invalid-grid-value", "Give the cell's new text.", { cell: address });
|
|
627
|
+
const outcome = newRaw(model, raw, edit.text, decimal, address);
|
|
628
|
+
if (outcome.error) issues.push({ ...address, message: `${describeAddress(address)}: ${outcome.error}` });
|
|
629
|
+
else if (!outcome.skip) model.lines[u][address.column] = outcome.raw;
|
|
630
|
+
} catch (error) {
|
|
631
|
+
issues.push({ ...address, message: error.message });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return issues;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function refuse(issues) {
|
|
638
|
+
const first = issues[0];
|
|
639
|
+
const extra = issues.length > 1 ? ` (${issues.length - 1} more cell${issues.length > 2 ? "s" : ""} have problems too.)` : "";
|
|
640
|
+
return fail("invalid-grid-values", `${first.message}${extra}`, { issues });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function setCells(model, edits, options) {
|
|
644
|
+
if (!Array.isArray(edits) || !edits.length) throw fail("invalid-grid-value", "Give at least one cell edit.", {});
|
|
645
|
+
const issues = applyEdits(model, edits, decimalOf(options), ownersOf(model));
|
|
646
|
+
if (issues.length) throw refuse(issues);
|
|
647
|
+
return { cells: edits.length };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function pasteRows(model, anchor, matrix, options) {
|
|
651
|
+
if (!Array.isArray(matrix) || !matrix.length) throw fail("invalid-grid-value", "There is nothing to paste.", {});
|
|
652
|
+
const first = decimalOf(options);
|
|
653
|
+
const attempt = (decimal) => {
|
|
654
|
+
const trial = { ...model, lines: clone(model.lines) };
|
|
655
|
+
const u0 = lineIndex(trial, anchor);
|
|
656
|
+
const width = matrix.reduce((most, row) => Math.max(most, row.length), 0);
|
|
657
|
+
growTo(trial, u0 + matrix.length, anchor.column + width);
|
|
658
|
+
const owners = ownersOf(trial);
|
|
659
|
+
const edits = [];
|
|
660
|
+
matrix.forEach((row, r) => {
|
|
661
|
+
const address = addressOf(trial, u0 + r);
|
|
662
|
+
for (let c = 0; c < row.length; c += 1) edits.push({ section: address.section, row: address.row, column: anchor.column + c, text: row[c] ?? "" });
|
|
663
|
+
});
|
|
664
|
+
const issues = applyEdits(trial, edits, decimal, owners);
|
|
665
|
+
return { trial, issues, cells: edits.length };
|
|
666
|
+
};
|
|
667
|
+
let result = attempt(first);
|
|
668
|
+
let decimal = first;
|
|
669
|
+
if (result.issues.length && model.kind === "chart") {
|
|
670
|
+
// Pasted from a spreadsheet in the other number format: read the whole paste that way when that clears every problem.
|
|
671
|
+
const other = first === "." ? "," : ".";
|
|
672
|
+
const second = attempt(other);
|
|
673
|
+
if (!second.issues.length) {
|
|
674
|
+
result = second;
|
|
675
|
+
decimal = other;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (result.issues.length) throw refuse(result.issues);
|
|
679
|
+
model.lines = result.trial.lines;
|
|
680
|
+
model.width = result.trial.width;
|
|
681
|
+
return { cells: result.cells, decimal, rows: matrix.length, restyle: true, touchesStructure: model.kind === "table", what: "Pasting" };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The problems `edits` would have, without applying or validating the document (cheap enough to run on every keystroke): a list of
|
|
686
|
+
* `{ section, row, column, message }`, empty when every edit is fine.
|
|
687
|
+
*/
|
|
688
|
+
export function gridCellIssues(document, path, edits, options = {}) {
|
|
689
|
+
const model = toModel(locateGridData(document, path));
|
|
690
|
+
return applyEdits(model, edits, decimalOf(options), ownersOf(model));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// --- prepare* ----------------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
const countOf = (count) => {
|
|
696
|
+
if (!Number.isInteger(count) || count < 1 || count > 1000) throw fail("invalid-count", "Insert between 1 and 1000 at a time.", { count });
|
|
697
|
+
return count;
|
|
698
|
+
};
|
|
699
|
+
const kindOf = (options) => (options?.kind ? { kind: options.kind } : {});
|
|
700
|
+
|
|
701
|
+
/** Set cells from text (or typed `value`s): `edits` are `{ section, row, column, text | value }`. One patch; every problem is listed in `error.issues` and nothing is applied. */
|
|
702
|
+
export function prepareGridCells(document, path, edits, options = {}) {
|
|
703
|
+
return transact(document, path, "set-cells", (model) => setCells(model, edits, options), kindOf(options));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Paste rows of text at a cell. `source` is TSV/CSV text or an array of rows of strings. The block overwrites cells from `anchor`
|
|
708
|
+
* (`{ section, row, column }`) and adds the rows and columns it needs. Chart numbers are read in the number format of
|
|
709
|
+
* `options` (`decimal`, or `numberFormat` and `locale`); when that fails and the other format reads all of the paste, that is used and
|
|
710
|
+
* reported as `decimal`. A table paste over a merged cell's covered positions is refused. One patch.
|
|
711
|
+
*/
|
|
712
|
+
export function preparePaste(document, path, anchor, source, options = {}) {
|
|
713
|
+
const rows = typeof source === "string" ? parseDelimited(source, { delimiter: options.delimiter }).rows : source;
|
|
714
|
+
return transact(document, path, "paste", (model) => pasteRows(model, anchor, rows, options), kindOf(options));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Insert `count` empty rows so the first is body row `at` (0 is above the first row; the row count appends). A merged cell that spans the position grows. */
|
|
718
|
+
export function prepareInsertRows(document, path, at, count = 1, options = {}) {
|
|
719
|
+
return transact(document, path, "insert-rows", (model) => {
|
|
720
|
+
insertRows(model, at, countOf(count));
|
|
721
|
+
return { at, count, restyle: true, touchesStructure: true, what: "Inserting rows" };
|
|
722
|
+
}, kindOf(options));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Delete the body rows at `indices`. A merged cell that spans a deleted row shrinks and keeps its text. The last row cannot be deleted. */
|
|
726
|
+
export function prepareDeleteRows(document, path, indices, options = {}) {
|
|
727
|
+
return transact(document, path, "delete-rows", (model) => {
|
|
728
|
+
deleteRows(model, indices);
|
|
729
|
+
return { indices: [...indices], restyle: true, touchesStructure: true, what: "Deleting rows" };
|
|
730
|
+
}, kindOf(options));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Move `count` rows starting at body row `from` so the first is at `to`. Refused with the reason when it would split a merged cell. */
|
|
734
|
+
export function prepareMoveRows(document, path, from, to, count = 1, options = {}) {
|
|
735
|
+
return transact(document, path, "move-rows", (model) => {
|
|
736
|
+
moveRows(model, from, count, to);
|
|
737
|
+
return { from, to, count, restyle: true, touchesStructure: true, what: "Moving rows" };
|
|
738
|
+
}, kindOf(options));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Insert `count` empty columns so the first is column `at`. A merged cell that spans the position grows. */
|
|
742
|
+
export function prepareInsertColumns(document, path, at, count = 1, options = {}) {
|
|
743
|
+
return transact(document, path, "insert-columns", (model) => {
|
|
744
|
+
insertColumns(model, at, countOf(count));
|
|
745
|
+
return { at, count, restyle: true, touchesStructure: true, what: "Inserting columns" };
|
|
746
|
+
}, kindOf(options));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Delete the columns at `indices`. A merged cell that spans a deleted column shrinks and keeps its text. The last column cannot be deleted. */
|
|
750
|
+
export function prepareDeleteColumns(document, path, indices, options = {}) {
|
|
751
|
+
return transact(document, path, "delete-columns", (model) => {
|
|
752
|
+
deleteColumns(model, indices);
|
|
753
|
+
return { indices: [...indices], restyle: true, touchesStructure: true, what: "Deleting columns" };
|
|
754
|
+
}, kindOf(options));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Move `count` columns starting at `from` so the first is at `to`. Refused with the reason when it would split a merged cell. */
|
|
758
|
+
export function prepareMoveColumns(document, path, from, to, count = 1, options = {}) {
|
|
759
|
+
return transact(document, path, "move-columns", (model) => {
|
|
760
|
+
moveColumns(model, from, count, to);
|
|
761
|
+
return { from, to, count, restyle: true, touchesStructure: true, what: "Moving columns" };
|
|
762
|
+
}, kindOf(options));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Sort the body rows by a column, stably. Types: "auto" (default) puts numbers (read in the number format) before ISO dates
|
|
767
|
+
* before text, "text" compares everything as text with numbers inside the text in numeric order, "number" and "date" read that type only. Empty cells
|
|
768
|
+
* always sort last, in either direction. Rows joined by a merged cell that spans rows move as one block. Equal keys keep their order.
|
|
769
|
+
*/
|
|
770
|
+
export function prepareSortRows(document, path, column, options = {}) {
|
|
771
|
+
return transact(document, path, "sort-rows", (model) => ({ ...sortRows(model, column, options), column, direction: options.direction ?? "asc", restyle: true }), kindOf(options));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Turn a table's header row on or off. On uses the first row as the header (`use: "first-row"`, the default when there are two or more rows) or adds a new empty one (`use: "new"`). */
|
|
775
|
+
export function prepareSetHeader(document, path, enabled, options = {}) {
|
|
776
|
+
return transact(document, path, "set-header", (model) => setHeader(model, Boolean(enabled), options), { kind: "table" });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Swap a chart's categories and series: the first column's values become the series names and the series become the rows. */
|
|
780
|
+
export function prepareTranspose(document, path) {
|
|
781
|
+
return transact(document, path, "transpose", (model) => transposeChart(model), { kind: "chart" });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// --- copy --------------------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The text of a range of cells, ready to paste into a spreadsheet: rows of `describeDataGrid` cell text joined as TSV (or
|
|
788
|
+
* another `delimiter`). `range` is `{ from, to }` of `{ section, row, column }` addresses; omit it for the whole grid. A merged cell's text is
|
|
789
|
+
* in its first position and its covered positions are empty, as a spreadsheet copies them.
|
|
790
|
+
*/
|
|
791
|
+
export function gridRangeText(document, path, range, options = {}) {
|
|
792
|
+
const grid = describeDataGrid(document, path, options);
|
|
793
|
+
const find = (address) => {
|
|
794
|
+
const u = address.section === "header" ? 0 : address.row + (grid.hasHeader ? 1 : 0);
|
|
795
|
+
if (address.section === "header" && !grid.hasHeader) throw fail("grid-cell-not-found", "This table has no header row.", { cell: address });
|
|
796
|
+
return { u, c: address.column };
|
|
797
|
+
};
|
|
798
|
+
const from = range ? find(range.from) : { u: 0, c: 0 };
|
|
799
|
+
const to = range ? find(range.to) : { u: grid.lines.length - 1, c: grid.columnCount - 1 };
|
|
800
|
+
const rows = [];
|
|
801
|
+
for (let u = Math.min(from.u, to.u); u <= Math.max(from.u, to.u); u += 1) {
|
|
802
|
+
if (!grid.lines[u]) throw fail("grid-cell-not-found", "The range leaves the grid.", { range });
|
|
803
|
+
rows.push(grid.lines[u].slice(Math.min(from.c, to.c), Math.max(from.c, to.c) + 1).map((cell) => cell.text));
|
|
804
|
+
}
|
|
805
|
+
return toDelimited(rows, { delimiter: options.delimiter ?? "\t" });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// --- session forms -----------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
function commit(editor, prepared, meta = {}) {
|
|
811
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
812
|
+
const { document, patches, ...summary } = prepared;
|
|
813
|
+
void document;
|
|
814
|
+
if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
815
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "data-grid", action: prepared.action, path: prepared.path });
|
|
816
|
+
return { ...change, ...summary };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const run = (editor, prepare, options = {}) => {
|
|
820
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
821
|
+
return commit(editor, prepare(editor.document), options.meta);
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
/** Set cells as one undoable transaction. See {@link prepareGridCells}. */
|
|
825
|
+
export const setGridCells = (editor, path, edits, options = {}) => run(editor, (document) => prepareGridCells(document, path, edits, options), options);
|
|
826
|
+
/** Paste text or rows as one undoable transaction. See {@link preparePaste}. */
|
|
827
|
+
export const pasteGridText = (editor, path, anchor, source, options = {}) => run(editor, (document) => preparePaste(document, path, anchor, source, options), options);
|
|
828
|
+
export const insertGridRows = (editor, path, at, count = 1, options = {}) => run(editor, (document) => prepareInsertRows(document, path, at, count, options), options);
|
|
829
|
+
export const deleteGridRows = (editor, path, indices, options = {}) => run(editor, (document) => prepareDeleteRows(document, path, indices, options), options);
|
|
830
|
+
export const moveGridRows = (editor, path, from, to, count = 1, options = {}) => run(editor, (document) => prepareMoveRows(document, path, from, to, count, options), options);
|
|
831
|
+
export const insertGridColumns = (editor, path, at, count = 1, options = {}) => run(editor, (document) => prepareInsertColumns(document, path, at, count, options), options);
|
|
832
|
+
export const deleteGridColumns = (editor, path, indices, options = {}) => run(editor, (document) => prepareDeleteColumns(document, path, indices, options), options);
|
|
833
|
+
export const moveGridColumns = (editor, path, from, to, count = 1, options = {}) => run(editor, (document) => prepareMoveColumns(document, path, from, to, count, options), options);
|
|
834
|
+
export const sortGridRows = (editor, path, column, options = {}) => run(editor, (document) => prepareSortRows(document, path, column, options), options);
|
|
835
|
+
export const setGridHeader = (editor, path, enabled, options = {}) => run(editor, (document) => prepareSetHeader(document, path, enabled, options), options);
|
|
836
|
+
export const transposeGridData = (editor, path, options = {}) => run(editor, (document) => prepareTranspose(document, path), options);
|