@openpresentation/opf-editor 0.11.3 → 0.14.2
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 +82 -58
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +15 -15
- package/dist/background-options.d.ts +2 -2
- package/dist/background-options.js +6 -6
- package/dist/block-controls.js +7 -7
- package/dist/block-convert.d.ts +7 -7
- package/dist/block-convert.js +23 -21
- package/dist/blocks.d.ts +8 -8
- package/dist/blocks.js +41 -40
- package/dist/canvas-fields.js +35 -12
- package/dist/canvas.d.ts +22 -30
- package/dist/canvas.js +57 -64
- package/dist/chart-data.d.ts +12 -10
- package/dist/chart-data.js +22 -21
- package/dist/chart-options-panel.d.ts +2 -2
- package/dist/chart-options-panel.js +82 -8
- package/dist/chart-options.d.ts +35 -9
- package/dist/chart-options.js +133 -40
- package/dist/checks.js +21 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +15 -15
- package/dist/content-actions.js +71 -70
- package/dist/content-controls.js +12 -12
- package/dist/data-grid.js +179 -10
- package/dist/data.d.ts +23 -2
- package/dist/data.js +40 -1
- package/dist/dataset-refs.d.ts +17 -0
- package/dist/dataset-refs.js +45 -0
- package/dist/design-controls.js +66 -47
- package/dist/design-options.d.ts +16 -14
- package/dist/design-options.js +86 -74
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +5 -7
- package/dist/export.js +40 -14
- package/dist/find-panel.js +5 -5
- package/dist/find-replace.d.ts +3 -3
- package/dist/find-replace.js +35 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +36 -52
- package/dist/grid-model.d.ts +99 -24
- package/dist/grid-model.js +577 -101
- package/dist/image-crop.d.ts +4 -4
- package/dist/image-crop.js +20 -20
- package/dist/image-cropper.js +3 -3
- package/dist/index.d.ts +28 -28
- package/dist/index.js +74 -90
- package/dist/json-options.js +17 -16
- package/dist/layout-handles.js +5 -5
- package/dist/layout-placeholders.js +4 -4
- package/dist/layout.d.ts +2 -2
- package/dist/layout.js +8 -7
- package/dist/numbering-panel.js +3 -5
- package/dist/numbering.d.ts +3 -5
- package/dist/numbering.js +14 -26
- package/dist/outline-view.js +24 -24
- package/dist/outline.d.ts +12 -12
- package/dist/outline.js +52 -50
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +19 -18
- package/dist/presentation-ids.js +2 -2
- package/dist/react.js +3 -3
- package/dist/review-panel.d.ts +30 -13
- package/dist/review-panel.js +154 -53
- package/dist/review.d.ts +36 -43
- package/dist/review.js +92 -53
- package/dist/rich-text-input.js +4 -3
- package/dist/rich-text-toolbar.js +5 -3
- package/dist/rich-text.js +5 -2
- package/dist/schema-inspector.d.ts +2 -2
- package/dist/schema-inspector.js +16 -11
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +25 -5
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +18 -18
- package/dist/slides.d.ts +20 -20
- package/dist/slides.js +99 -97
- package/dist/svelte.js +1 -1
- package/dist/switches.d.ts +10 -10
- package/dist/switches.js +136 -109
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +41 -30
- package/dist/table-structure.d.ts +10 -10
- package/dist/table-structure.js +21 -21
- package/dist/template-panel.d.ts +3 -3
- package/dist/template-panel.js +37 -13
- package/dist/templates.d.ts +22 -11
- package/dist/templates.js +48 -55
- package/dist/transfer.d.ts +4 -4
- package/dist/transfer.js +74 -54
- package/package.json +10 -6
- package/dist/font-defaults.js +0 -25
package/dist/grid-model.js
CHANGED
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
// The data model behind the data grid (RR-24): one table-shaped view of a chart's inline data and of a
|
|
2
2
|
// table, and every edit to it as one validated, undoable patch.
|
|
3
3
|
//
|
|
4
|
-
// A chart is `{ type, data: { columns, rows } }`: `columns` are labels
|
|
5
|
-
// first column holds the categories (the x axis, or
|
|
6
|
-
// named by its column label, in the order the renderer draws them.
|
|
7
|
-
//
|
|
8
|
-
// `{ columns?, rows }` whose cells are plain values,
|
|
9
|
-
// describes.
|
|
4
|
+
// A chart is `{ type, data: { columns, rows }, mapping? }`: `columns` are labels (a string, or a `DataColumn`
|
|
5
|
+
// `{ name, format? }`), `rows` are arrays aligned to them. By default the first column holds the categories (the x axis, or
|
|
6
|
+
// the slice names) and every further column is one series, named by its column label, in the order the renderer draws them.
|
|
7
|
+
// (A scatter chart reads its x values from the second column; a chart type that needs one column only reads the first.)
|
|
8
|
+
// `mapping` names the category, X and series columns instead. A table is `{ columns?, rows }` whose cells are plain values,
|
|
9
|
+
// rich runs or styled cells with spans, as table-options.js describes.
|
|
10
|
+
//
|
|
11
|
+
// RR-54: a chart (`data: { dataset, fields? }`) or table (`{ dataset, fields? }`) may take its data from a shared top-level
|
|
12
|
+
// dataset. The grid then edits `/datasets/<id>` through the `fields` selection: edits map back to the dataset's own column
|
|
13
|
+
// indices, a column added while `fields` is set goes to the dataset and to `fields`, and a deleted column leaves `fields` (the
|
|
14
|
+
// dataset keeps it). Renaming a column keeps every `fields` and `mapping` that names it in step.
|
|
10
15
|
//
|
|
11
16
|
// 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 `{
|
|
17
|
+
// change lines and columns, keep merged cells whole or refuse with a reason, and return a `{ presentation, patches,
|
|
13
18
|
// changed }` description like table-options.js does. Nothing here touches a DOM.
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
19
|
+
import { chartNumber, formatDataNumber, inlineChartData, inlineTableData, isXYChartType, numberFormatError } from "@openpresentation/opf";
|
|
20
|
+
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
21
|
+
import { checkedDocument, fail, same } from "./edit-helpers.js";
|
|
22
|
+
import { datasetUsage, isDatasetRef, walkDatasetItems } from "./dataset-refs.js";
|
|
16
23
|
import { richTextContent, updateRichTextInput } from "./rich-text.js";
|
|
17
24
|
import { formatGridNumber, isCanonicalNumber, parseDelimited, parseGridNumber, resolveNumberFormat, toDelimited } from "./grid-text.js";
|
|
18
25
|
import { applyTableStyleToTable, readTableStyleOfTable } from "./table-options.js";
|
|
26
|
+
import { checkFormat } from "./checks.js";
|
|
19
27
|
|
|
20
28
|
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
21
29
|
const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
|
|
22
|
-
|
|
30
|
+
// A header may be a DataColumn `{ name, format? }`: its text is `name`.
|
|
31
|
+
const isColumnObject = (cell) => isObject(cell) && Object.hasOwn(cell, "name") && !Object.hasOwn(cell, "value");
|
|
32
|
+
const valueOf = (cell) => (isStyled(cell) ? cell.value : isColumnObject(cell) ? cell.name : cell);
|
|
33
|
+
const withValue = (raw, value) => (isStyled(raw) ? { ...raw, value } : isColumnObject(raw) ? { ...raw, name: value === null || value === undefined ? "" : String(value) } : value);
|
|
34
|
+
const formatOf = (raw) => ((isStyled(raw) || isColumnObject(raw)) && typeof raw.format === "string" ? raw.format : undefined);
|
|
23
35
|
const clone = (value) => structuredClone(value);
|
|
36
|
+
const columnNameOf = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : "");
|
|
37
|
+
// A body row remembers which dataset row it came from, so a row that moves, sorts or is deleted keeps the columns `fields` hides.
|
|
38
|
+
const LINE_ORIGIN = Symbol("opf.grid.row");
|
|
39
|
+
const cloneLines = (lines) =>
|
|
40
|
+
lines.map((line) => {
|
|
41
|
+
const copy = clone(line);
|
|
42
|
+
if (line[LINE_ORIGIN] !== undefined) copy[LINE_ORIGIN] = line[LINE_ORIGIN];
|
|
43
|
+
return copy;
|
|
44
|
+
});
|
|
24
45
|
const spansOf = (raw) => ({ rs: isStyled(raw) ? (raw.rowSpan ?? 1) : 1, cs: isStyled(raw) ? (raw.colSpan ?? 1) : 1 });
|
|
25
46
|
|
|
26
47
|
/** Spreadsheet column letters: 0 is A, 25 is Z, 26 is AA. */
|
|
@@ -38,12 +59,34 @@ const decimalOf = (options = {}) => (options.decimal === "," || options.decimal
|
|
|
38
59
|
|
|
39
60
|
// --- locating ----------------------------------------------------------------------------------
|
|
40
61
|
|
|
62
|
+
const datasetsOf = (presentation) => (isObject(presentation) && isObject(presentation.datasets) ? presentation.datasets : {});
|
|
63
|
+
|
|
64
|
+
/** The dataset a chart or table reference points at, and the dataset columns its `fields` select (by index). */
|
|
65
|
+
function datasetOf(presentation, ref, path, refParts) {
|
|
66
|
+
const id = ref.dataset;
|
|
67
|
+
const datasets = datasetsOf(presentation);
|
|
68
|
+
const data = Object.hasOwn(datasets, id) ? datasets[id] : undefined;
|
|
69
|
+
if (!isObject(data) || !Array.isArray(data.columns) || !Array.isArray(data.rows))
|
|
70
|
+
throw fail("dataset-unavailable", `This item uses the shared dataset '${id}', which the document does not hold.`, { path, dataset: id });
|
|
71
|
+
const names = data.columns.map(columnNameOf);
|
|
72
|
+
const fields = Array.isArray(ref.fields) ? ref.fields : undefined;
|
|
73
|
+
let indices = names.map((_, index) => index);
|
|
74
|
+
if (fields) {
|
|
75
|
+
indices = fields.map((field) => names.indexOf(field));
|
|
76
|
+
const missing = fields.find((_, index) => indices[index] < 0);
|
|
77
|
+
if (missing !== undefined) throw fail("dataset-unavailable", `This item selects the column ${JSON.stringify(missing)}, which dataset '${id}' does not have.`, { path, dataset: id, field: missing });
|
|
78
|
+
}
|
|
79
|
+
return { data, dataset: { id, fields, indices, refParts, ref } };
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
/**
|
|
42
83
|
* 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
|
|
44
|
-
*
|
|
84
|
+
* Throws `grid-target-not-found` (also for a chart without inline columns and rows), `table-not-found` or `dataset-unavailable` (the shared dataset or one of its `fields` is missing).
|
|
85
|
+
* A chart or table that takes its data from a dataset (RR-54) is located at the dataset: `data` is the dataset, `dataParts`
|
|
86
|
+
* is `["datasets", id]` and `dataset` is `{ id, fields, indices, refParts, ref }` (`indices` maps each shown column to the
|
|
87
|
+
* dataset's own column).
|
|
45
88
|
*/
|
|
46
|
-
export function locateGridData(
|
|
89
|
+
export function locateGridData(presentation, path) {
|
|
47
90
|
let parts;
|
|
48
91
|
try {
|
|
49
92
|
parts = Array.isArray(path) ? [...path] : splitOpfPath(path);
|
|
@@ -51,27 +94,38 @@ export function locateGridData(document, path) {
|
|
|
51
94
|
throw fail("grid-target-not-found", "Choose a chart or a table.", { path });
|
|
52
95
|
}
|
|
53
96
|
if (parts.at(-1) === "data" && parts.at(-2) === "chart") parts = parts.slice(0, -1);
|
|
54
|
-
const owner = getValueAtPath(
|
|
97
|
+
const owner = getValueAtPath(presentation, parts);
|
|
55
98
|
if (parts.at(-1) === "chart") {
|
|
56
99
|
if (!isObject(owner) || !isObject(owner.data)) throw fail("grid-target-not-found", "This chart has no data.", { path });
|
|
57
100
|
const data = owner.data;
|
|
101
|
+
const chartType = typeof owner.type === "string" ? owner.type : undefined;
|
|
102
|
+
if (isDatasetRef(data)) {
|
|
103
|
+
const found = datasetOf(presentation, data, path, [...parts, "data"]);
|
|
104
|
+
return { kind: "chart", parts, dataParts: ["datasets", data.dataset], data: found.data, chartType, owner, dataset: found.dataset };
|
|
105
|
+
}
|
|
58
106
|
if (!Array.isArray(data.columns) || !Array.isArray(data.rows))
|
|
59
|
-
throw fail("
|
|
60
|
-
return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType
|
|
107
|
+
throw fail("grid-target-not-found", "This chart has no inline columns and rows. The data grid edits inline chart data.", { path });
|
|
108
|
+
return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType, owner };
|
|
61
109
|
}
|
|
62
110
|
if (parts.at(-1) === "table") {
|
|
111
|
+
if (isObject(owner) && isDatasetRef(owner) && !Array.isArray(owner.rows)) {
|
|
112
|
+
const found = datasetOf(presentation, owner, path, parts);
|
|
113
|
+
return { kind: "table", parts, dataParts: ["datasets", owner.dataset], data: found.data, owner, dataset: found.dataset };
|
|
114
|
+
}
|
|
63
115
|
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 };
|
|
116
|
+
return { kind: "table", parts, dataParts: parts, data: owner, owner };
|
|
65
117
|
}
|
|
66
118
|
throw fail("grid-target-not-found", "Choose a chart or a table (a path ending in .chart or .table).", { path });
|
|
67
119
|
}
|
|
68
120
|
|
|
69
121
|
/**
|
|
70
122
|
* 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
|
|
72
|
-
* reason }`.
|
|
123
|
+
* `…table.rows.1.2.value`, and so on. A chart whose data comes from a source (or from a dataset the document lacks) is
|
|
124
|
+
* `{ kind: "chart", path, editable: false, reason }`. A chart or table that takes its data from a shared dataset also has
|
|
125
|
+
* `dataset: { id, fields, count, items }` (`items` are the paths of every chart and table that uses the dataset).
|
|
126
|
+
* Returns undefined when the path is not inside a chart or table.
|
|
73
127
|
*/
|
|
74
|
-
export function resolveDataGridTarget(
|
|
128
|
+
export function resolveDataGridTarget(presentation, selectedPath) {
|
|
75
129
|
let parts;
|
|
76
130
|
try {
|
|
77
131
|
parts = splitOpfPath(selectedPath);
|
|
@@ -82,10 +136,15 @@ export function resolveDataGridTarget(document, selectedPath) {
|
|
|
82
136
|
if (parts[index] !== "chart" && parts[index] !== "table") continue;
|
|
83
137
|
const head = parts.slice(0, index + 1);
|
|
84
138
|
try {
|
|
85
|
-
const found = locateGridData(
|
|
86
|
-
|
|
139
|
+
const found = locateGridData(presentation, head);
|
|
140
|
+
const target = { kind: found.kind, path: head.join(".") };
|
|
141
|
+
if (found.dataset) {
|
|
142
|
+
const items = datasetUsage(presentation, found.dataset.id).map((entry) => entry.path);
|
|
143
|
+
target.dataset = { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), count: items.length, items };
|
|
144
|
+
}
|
|
145
|
+
return target;
|
|
87
146
|
} catch (error) {
|
|
88
|
-
if (error.code === "
|
|
147
|
+
if (error.code === "dataset-unavailable") return { kind: parts[index], path: head.join("."), editable: false, reason: error.message };
|
|
89
148
|
}
|
|
90
149
|
}
|
|
91
150
|
return undefined;
|
|
@@ -94,14 +153,29 @@ export function resolveDataGridTarget(document, selectedPath) {
|
|
|
94
153
|
// --- lines -------------------------------------------------------------------------------------
|
|
95
154
|
|
|
96
155
|
function toModel(found) {
|
|
97
|
-
const { kind, data } = found;
|
|
98
|
-
const hasHeader = kind === "chart" ? true : Array.isArray(data.columns);
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
const { kind, data, dataset } = found;
|
|
157
|
+
const hasHeader = kind === "chart" || Boolean(dataset) ? true : Array.isArray(data.columns);
|
|
158
|
+
let lines;
|
|
159
|
+
if (dataset) {
|
|
160
|
+
// The dataset as the item shows it: only the columns `fields` selects, in that order. Each body row remembers its dataset row.
|
|
161
|
+
const pick = (row) => dataset.indices.map((index) => (Array.isArray(row) && index < row.length ? clone(row[index]) : null));
|
|
162
|
+
lines = [clone(dataset.indices.map((index) => data.columns[index])), ...data.rows.map((row, r) => Object.assign(pick(row), { [LINE_ORIGIN]: r }))];
|
|
163
|
+
} else lines = [...(hasHeader ? [clone(data.columns)] : []), ...clone(data.rows).map((row) => (Array.isArray(row) ? row : [row]))];
|
|
164
|
+
const mapping = kind === "chart" && isObject(found.owner?.mapping) ? clone(found.owner.mapping) : undefined;
|
|
165
|
+
// `datasetNames` are every column of the shared dataset, also the ones `fields` hides: a new column's name must differ from them.
|
|
166
|
+
const model = { kind, hasHeader, lines, width: 0, chartType: found.chartType, dataset, mapping, colOrigin: [], origNames: [], datasetNames: dataset ? data.columns.map(columnNameOf) : [] };
|
|
101
167
|
model.width = widthOf(model);
|
|
168
|
+
// Where each column came from, so renames, deletions and moves can be followed after an operation (null: a new column).
|
|
169
|
+
model.colOrigin = Array.from({ length: model.width }, (_, index) => index);
|
|
170
|
+
model.origNames = namesOfModel(model);
|
|
102
171
|
return model;
|
|
103
172
|
}
|
|
104
173
|
|
|
174
|
+
/** The header texts of a model (the column names `fields` and `mapping` address). */
|
|
175
|
+
function namesOfModel(model) {
|
|
176
|
+
return model.hasHeader ? Array.from({ length: model.width }, (_, column) => String(valueOf(model.lines[0]?.[column]) ?? "")) : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
105
179
|
function fromModel(model, original) {
|
|
106
180
|
const rest = Object.fromEntries(Object.entries(original).filter(([key]) => key !== "columns" && key !== "rows"));
|
|
107
181
|
const out = {};
|
|
@@ -113,13 +187,25 @@ function fromModel(model, original) {
|
|
|
113
187
|
const widthOf = (model) => model.lines.reduce((most, line) => Math.max(most, line.length), 0);
|
|
114
188
|
const offset = (model) => (model.hasHeader ? 1 : 0);
|
|
115
189
|
const bodyCount = (model) => model.lines.length - offset(model);
|
|
116
|
-
|
|
190
|
+
// Column names must be unique where a dataset, `fields` or a chart `mapping` addresses them, so a new column of such a
|
|
191
|
+
// target gets a distinct placeholder name instead of "".
|
|
192
|
+
const needsNames = (model) => Boolean(model.dataset) || model.mapping !== undefined;
|
|
193
|
+
function uniqueName(model, taken = []) {
|
|
194
|
+
const used = new Set([...namesOfModel(model), ...model.datasetNames, ...taken]);
|
|
195
|
+
let n = 1;
|
|
196
|
+
while (used.has(n === 1 ? "New column" : `New column ${n}`)) n += 1;
|
|
197
|
+
return n === 1 ? "New column" : `New column ${n}`;
|
|
198
|
+
}
|
|
199
|
+
// What a new, empty cell holds: a chart's and a dataset's cells are null (a gap), a table's are "".
|
|
200
|
+
const emptyCell = (model) => (model.kind === "chart" || model.dataset ? null : "");
|
|
201
|
+
const filler = (model, u) => (model.hasHeader && u === 0 ? (needsNames(model) ? uniqueName(model) : "") : emptyCell(model));
|
|
117
202
|
|
|
118
203
|
function padLines(model) {
|
|
119
204
|
model.width = widthOf(model);
|
|
120
205
|
model.lines.forEach((line, u) => {
|
|
121
206
|
while (line.length < model.width) line.push(filler(model, u));
|
|
122
207
|
});
|
|
208
|
+
while (model.colOrigin.length < model.width) model.colOrigin.push(null);
|
|
123
209
|
}
|
|
124
210
|
|
|
125
211
|
const addressOf = (model, u) => (model.hasHeader && u === 0 ? { section: "header", row: 0 } : { section: "body", row: u - offset(model) });
|
|
@@ -208,17 +294,86 @@ export function cellText(raw, kind, decimal = ".") {
|
|
|
208
294
|
return String(value);
|
|
209
295
|
}
|
|
210
296
|
|
|
211
|
-
const looksNumeric = (value) => typeof value === "number" ? Number.isFinite(value) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
|
|
212
297
|
const isScatter = (type) => typeof type === "string" && /^(scatter|bubble)/.test(type);
|
|
213
298
|
|
|
214
|
-
|
|
215
|
-
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A number as the slide draws it: the column's format (a table body cell's own format wins) through core's `formatDataNumber`, or undefined when
|
|
302
|
+
* the cell is not a number, has no valid format, or the format changes nothing. A chart cell is read by `chartNumber`, as the renderer does, and
|
|
303
|
+
* only in a column the chart plots as numbers; a table cell is formatted only when it holds a number. The cell's `text` (the raw value) is never
|
|
304
|
+
* replaced: editing and copying use it.
|
|
305
|
+
*/
|
|
306
|
+
function displayText(model, raw, column, role, text) {
|
|
307
|
+
const format = (model.kind === "table" ? formatOf(raw) : undefined) ?? (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined);
|
|
308
|
+
if (format === undefined || numberFormatError(format) !== undefined) return undefined;
|
|
309
|
+
const value = valueOf(raw);
|
|
310
|
+
let number = null;
|
|
311
|
+
if (model.kind === "chart") number = isNumericRole(role) && (typeof value === "number" || typeof value === "string") ? chartNumber(value) : null;
|
|
312
|
+
else if (typeof value === "number" && Number.isFinite(value)) number = value;
|
|
313
|
+
if (number === null) return undefined;
|
|
314
|
+
const shown = formatDataNumber(number, format);
|
|
315
|
+
return shown === text ? undefined : shown;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The default X column of an XY chart (core's rule): the second column, or the first when the category is the second, and only
|
|
320
|
+
* with three or more columns. With two, the second column is the one series, plotted against row numbers.
|
|
321
|
+
*/
|
|
322
|
+
const defaultX = (category, count) => (count > 2 ? (category === 1 ? 0 : 1) : undefined);
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The columns a chart plots, by index: `{ category, x, series }`. `mapping` names them (`{ category?, x?, series? }`); a missing or
|
|
326
|
+
* unknown name falls back to core's positional default. `x` is only set for an XY chart.
|
|
327
|
+
*/
|
|
328
|
+
function mappingColumns(count, xy, mapping, names) {
|
|
329
|
+
const known = (name) => (typeof name === "string" ? names.indexOf(name) : -1);
|
|
330
|
+
const map = isObject(mapping) ? mapping : {};
|
|
331
|
+
let category = known(map.category);
|
|
332
|
+
if (category < 0) category = 0;
|
|
333
|
+
let x;
|
|
334
|
+
if (xy) {
|
|
335
|
+
x = known(map.x);
|
|
336
|
+
if (x < 0 || x === category) x = defaultX(category, count);
|
|
337
|
+
if (x !== undefined && (x >= count || x === category)) x = undefined;
|
|
338
|
+
}
|
|
339
|
+
let series;
|
|
340
|
+
if (Array.isArray(map.series)) {
|
|
341
|
+
series = [];
|
|
342
|
+
for (const name of map.series) {
|
|
343
|
+
const index = known(name);
|
|
344
|
+
if (index >= 0 && index !== category && index !== x && !series.includes(index)) series.push(index);
|
|
345
|
+
}
|
|
346
|
+
} else series = Array.from({ length: count }, (_, index) => index).filter((index) => index !== category && index !== x);
|
|
347
|
+
// An X column needs a series beside it; otherwise core plots it as the series against row numbers.
|
|
348
|
+
if (x !== undefined && !series.length) {
|
|
349
|
+
series = [x];
|
|
350
|
+
x = undefined;
|
|
351
|
+
}
|
|
352
|
+
return { category, x, series };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* What each column of a chart is for: "category" (column A), "series", and for a scatter chart with three or more columns
|
|
357
|
+
* "label" and "x". With a `mapping` (and the column `names` it addresses) the roles follow it: "category" (the label column of a
|
|
358
|
+
* chart with no X axis), "label" and "x" for an XY chart, "series" for a plotted column and "other" for a column nothing plots.
|
|
359
|
+
*/
|
|
360
|
+
export function chartColumnRoles(columnCount, chartType, mapping, names) {
|
|
361
|
+
if (isObject(mapping) && Array.isArray(names) && columnCount > 0) {
|
|
362
|
+
const xy = isXYChartType(chartType);
|
|
363
|
+
const { category, x, series } = mappingColumns(columnCount, xy, mapping, names);
|
|
364
|
+
const roles = Array(columnCount).fill("other");
|
|
365
|
+
for (const index of series) roles[index] = "series";
|
|
366
|
+
roles[category] = xy ? "label" : "category";
|
|
367
|
+
if (x !== undefined) roles[x] = "x";
|
|
368
|
+
return roles;
|
|
369
|
+
}
|
|
216
370
|
if (columnCount <= 1) return Array(columnCount).fill("series");
|
|
217
371
|
if (isScatter(chartType) && columnCount >= 3) return ["label", "x", ...Array(columnCount - 2).fill("series")];
|
|
218
372
|
return ["category", ...Array(columnCount - 1).fill("series")];
|
|
219
373
|
}
|
|
220
374
|
|
|
221
|
-
const
|
|
375
|
+
const rolesOf = (model) => (model.kind === "chart" ? chartColumnRoles(model.width, model.chartType, model.mapping, namesOfModel(model)) : []);
|
|
376
|
+
const isNumericRole = (role) => role === undefined || role === "series" || role === "x";
|
|
222
377
|
|
|
223
378
|
// --- describing --------------------------------------------------------------------------------
|
|
224
379
|
|
|
@@ -228,13 +383,13 @@ const isNumericColumn = (model, column) => model.kind === "chart" && !["category
|
|
|
228
383
|
* cell has `covered: true` and its anchor in `owner`; its `text` is "". `warnings` lists data problems the document
|
|
229
384
|
* already has (text in a series column the renderer draws as a gap, an empty or repeated series name, a missing category).
|
|
230
385
|
*/
|
|
231
|
-
export function describeDataGrid(
|
|
232
|
-
const found = locateGridData(
|
|
386
|
+
export function describeDataGrid(presentation, path, options = {}) {
|
|
387
|
+
const found = locateGridData(presentation, path);
|
|
233
388
|
const decimal = decimalOf(options);
|
|
234
389
|
const model = toModel(found);
|
|
235
390
|
padLines(model);
|
|
236
391
|
const owners = ownersOf(model);
|
|
237
|
-
const roles =
|
|
392
|
+
const roles = rolesOf(model);
|
|
238
393
|
const warnings = [];
|
|
239
394
|
const names = new Map();
|
|
240
395
|
const lines = model.lines.map((line, u) =>
|
|
@@ -256,21 +411,27 @@ export function describeDataGrid(document, path, options = {}) {
|
|
|
256
411
|
};
|
|
257
412
|
if (Array.isArray(value)) cell.runs = value;
|
|
258
413
|
if (isStyled(raw) && raw.style) cell.style = raw.style;
|
|
414
|
+
if (!owner && address.section === "body") {
|
|
415
|
+
const display = displayText(model, raw, column, roles[column], cell.text);
|
|
416
|
+
if (display !== undefined) cell.display = display;
|
|
417
|
+
}
|
|
418
|
+
if (address.section === "header" && formatOf(raw) !== undefined) cell.format = formatOf(raw);
|
|
259
419
|
if (owner) cell.owner = { line: owner.u, column: owner.c };
|
|
260
420
|
if (model.kind === "chart") {
|
|
261
421
|
let warning;
|
|
262
422
|
if (u === 0) {
|
|
263
423
|
const name = String(value ?? "");
|
|
264
|
-
if (column
|
|
424
|
+
if (roles[column] !== "category" && roles[column] !== "label") {
|
|
265
425
|
if (name.trim() === "") warning = "This series has no name.";
|
|
266
426
|
else if (names.has(name)) warning = `Another series is also named "${name}".`;
|
|
267
427
|
names.set(name, column);
|
|
268
428
|
}
|
|
269
429
|
} else if (roles[column] === "category" || roles[column] === "label") {
|
|
270
430
|
if (roles[column] === "category" && (value === null || value === undefined || value === "")) warning = "This row has no category label.";
|
|
431
|
+
} else if (roles[column] === "other") {
|
|
432
|
+
// A column the chart does not plot may hold anything.
|
|
271
433
|
} 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 &&
|
|
273
|
-
warning = "This is not a number, so the chart draws a gap here.";
|
|
434
|
+
else if (value !== null && value !== undefined && chartNumber(value) === null && String(value).trim() !== "") warning = "This is not a number, so the chart draws a gap here.";
|
|
274
435
|
if (warning) {
|
|
275
436
|
cell.warning = warning;
|
|
276
437
|
warnings.push({ section: address.section, row: address.row, column, message: `${describeAddress({ ...address, column })}: ${warning}` });
|
|
@@ -287,6 +448,10 @@ export function describeDataGrid(document, path, options = {}) {
|
|
|
287
448
|
rowCount: bodyCount(model),
|
|
288
449
|
columnCount: model.width,
|
|
289
450
|
columnRoles: roles,
|
|
451
|
+
columnFormats: Array.from({ length: model.width }, (_, column) => (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined)),
|
|
452
|
+
...(model.hasHeader ? { columnNames: namesOfModel(model) } : {}),
|
|
453
|
+
...(found.dataset ? { dataset: { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), items: datasetUsage(presentation, found.dataset.id).map((entry) => entry.path) } } : {}),
|
|
454
|
+
...(model.mapping ? { mapping: clone(model.mapping) } : {}),
|
|
290
455
|
lines,
|
|
291
456
|
warnings,
|
|
292
457
|
merges: rectsOf(model).map((rect) => ({ ...addressOf(model, rect.u), column: rect.c, rowSpan: rect.rs, colSpan: rect.cs })),
|
|
@@ -336,24 +501,164 @@ export function gridPatches(parts, before, after) {
|
|
|
336
501
|
return patches;
|
|
337
502
|
}
|
|
338
503
|
|
|
339
|
-
|
|
340
|
-
|
|
504
|
+
/** Like {@link gridPatches}, and also for a value that is added (`before` is undefined) or removed (`after` is undefined). */
|
|
505
|
+
function valuePatches(parts, before, after) {
|
|
506
|
+
if (before === undefined && after === undefined) return [];
|
|
507
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
508
|
+
if (before === undefined) return [{ op: "add", path: pointer, value: clone(after) }];
|
|
509
|
+
if (after === undefined) return [{ op: "test", path: pointer, value: clone(before) }, { op: "remove", path: pointer }];
|
|
510
|
+
return gridPatches(parts, before, after);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- following columns across an operation -------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
/** The column renames an operation made: a Map from the old name to the new one, for the columns that survived with another name. */
|
|
516
|
+
function renamesOf(model) {
|
|
517
|
+
const names = namesOfModel(model);
|
|
518
|
+
const renames = new Map();
|
|
519
|
+
model.colOrigin.forEach((origin, column) => {
|
|
520
|
+
if (origin === null || origin === undefined) return;
|
|
521
|
+
const from = model.origNames[origin];
|
|
522
|
+
const to = names[column];
|
|
523
|
+
if (from !== undefined && to !== undefined && from !== to) renames.set(from, to);
|
|
524
|
+
});
|
|
525
|
+
return renames;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* A chart `mapping` after an operation: a renamed column keeps its role, a column that no longer exists leaves the mapping
|
|
530
|
+
* (an emptied `series` is dropped, which restores "every other column"). A name the data never had is left alone. Returns
|
|
531
|
+
* undefined when nothing is left.
|
|
532
|
+
*/
|
|
533
|
+
function reconcileMapping(mapping, renames, before, after) {
|
|
534
|
+
if (!isObject(mapping)) return mapping;
|
|
535
|
+
const keep = (name) => {
|
|
536
|
+
const next = renames.has(name) ? renames.get(name) : name;
|
|
537
|
+
if (after.includes(next)) return next;
|
|
538
|
+
return before.includes(name) ? undefined : name;
|
|
539
|
+
};
|
|
540
|
+
const out = {};
|
|
541
|
+
for (const key of ["category", "x"]) {
|
|
542
|
+
if (typeof mapping[key] !== "string") continue;
|
|
543
|
+
const name = keep(mapping[key]);
|
|
544
|
+
if (name !== undefined) out[key] = name;
|
|
545
|
+
}
|
|
546
|
+
if (Array.isArray(mapping.series)) {
|
|
547
|
+
const series = mapping.series.map(keep).filter((name) => name !== undefined);
|
|
548
|
+
if (series.length) out.series = series;
|
|
549
|
+
}
|
|
550
|
+
return Object.keys(out).length ? out : undefined;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The changes to an inline chart or table: its data object, and the chart's `mapping` when a rename or deletion touches it. */
|
|
554
|
+
function inlineChanges(found, model, style) {
|
|
555
|
+
const after = fromModel(model, found.data);
|
|
556
|
+
if (style && style.preset !== "custom") applyTableStyleToTable(after, style.preset);
|
|
557
|
+
const changes = [{ parts: found.dataParts, before: found.data, after }];
|
|
558
|
+
if (found.kind === "chart" && found.owner.mapping !== undefined) {
|
|
559
|
+
const next = reconcileMapping(found.owner.mapping, renamesOf(model), model.origNames, namesOfModel(model));
|
|
560
|
+
if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
|
|
561
|
+
}
|
|
562
|
+
return changes;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The changes to a chart or table that shows a dataset: the dataset itself (the shown columns mapped back to its own column
|
|
567
|
+
* indices, each row to the dataset row it came from, so columns `fields` hides keep their data), the item's `fields` and `mapping`,
|
|
568
|
+
* and the `fields` and `mapping` of every other item that shares the dataset when a column is renamed. A column that
|
|
569
|
+
* `fields` selects leaves only `fields` when deleted; without `fields` it leaves the dataset, which another item's `fields` or
|
|
570
|
+
* `mapping` that names it forbids.
|
|
571
|
+
*/
|
|
572
|
+
function datasetChanges(presentation, found, model) {
|
|
573
|
+
const { dataset, data } = found;
|
|
574
|
+
padLines(model);
|
|
575
|
+
const header = model.lines[0];
|
|
576
|
+
const names = namesOfModel(model);
|
|
577
|
+
const body = model.lines.slice(1);
|
|
578
|
+
const origin = model.colOrigin;
|
|
579
|
+
let columns;
|
|
580
|
+
let rows;
|
|
581
|
+
if (!dataset.fields) {
|
|
582
|
+
columns = header.map((raw) => raw);
|
|
583
|
+
rows = body.map((line) => line.map((raw) => raw));
|
|
584
|
+
} else {
|
|
585
|
+
const survivors = new Map();
|
|
586
|
+
origin.forEach((o, column) => {
|
|
587
|
+
if (o !== null && o !== undefined) survivors.set(dataset.indices[o], column);
|
|
588
|
+
});
|
|
589
|
+
columns = data.columns.map((column, index) => (survivors.has(index) ? header[survivors.get(index)] : column));
|
|
590
|
+
const position = new Map();
|
|
591
|
+
origin.forEach((o, column) => {
|
|
592
|
+
if (o !== null && o !== undefined) position.set(column, dataset.indices[o]);
|
|
593
|
+
else {
|
|
594
|
+
position.set(column, columns.length);
|
|
595
|
+
columns.push(header[column]);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
rows = body.map((line) => {
|
|
599
|
+
const source = line[LINE_ORIGIN] !== undefined && Array.isArray(data.rows[line[LINE_ORIGIN]]) ? [...data.rows[line[LINE_ORIGIN]]] : [];
|
|
600
|
+
while (source.length < columns.length) source.push(null);
|
|
601
|
+
line.forEach((raw, column) => {
|
|
602
|
+
source[position.get(column)] = raw;
|
|
603
|
+
});
|
|
604
|
+
return source;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const renames = renamesOf(model);
|
|
608
|
+
const others = [];
|
|
609
|
+
const ownPath = found.parts.join(".");
|
|
610
|
+
walkDatasetItems(presentation, (entry) => {
|
|
611
|
+
if (entry.id === dataset.id && entry.parts.join(".") !== ownPath) others.push(entry);
|
|
612
|
+
});
|
|
613
|
+
if (!dataset.fields) {
|
|
614
|
+
const gone = model.origNames.filter((_, index) => !origin.includes(index));
|
|
615
|
+
for (const name of gone) {
|
|
616
|
+
const users = others.filter((entry) => (Array.isArray(entry.ref.fields) && entry.ref.fields.includes(name)) || (isObject(entry.item.mapping) && [entry.item.mapping.category, entry.item.mapping.x, ...(entry.item.mapping.series ?? [])].includes(name)));
|
|
617
|
+
if (users.length)
|
|
618
|
+
throw fail("dataset-column-in-use", `The column ${JSON.stringify(name)} is used by ${users.length === 1 ? "another chart or table" : `${users.length} other charts and tables`} that share the dataset '${dataset.id}'. Remove it there first, or use a copy of the data here.`, { column: name, items: users.map((entry) => entry.parts.join(".")) });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const changes = [{ parts: found.dataParts, before: data, after: { ...data, columns, rows } }];
|
|
622
|
+
if (dataset.fields) changes.push({ parts: [...dataset.refParts, "fields"], before: dataset.ref.fields, after: names });
|
|
623
|
+
if (found.kind === "chart" && found.owner.mapping !== undefined) {
|
|
624
|
+
const next = reconcileMapping(found.owner.mapping, renames, model.origNames, names);
|
|
625
|
+
if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
|
|
626
|
+
}
|
|
627
|
+
if (renames.size) {
|
|
628
|
+
for (const entry of others) {
|
|
629
|
+
if (Array.isArray(entry.ref.fields) && entry.ref.fields.some((name) => renames.has(name)))
|
|
630
|
+
changes.push({ parts: [...entry.refParts, "fields"], before: entry.ref.fields, after: entry.ref.fields.map((name) => (renames.has(name) ? renames.get(name) : name)) });
|
|
631
|
+
if (entry.kind === "chart" && isObject(entry.item.mapping)) {
|
|
632
|
+
const mapping = entry.item.mapping;
|
|
633
|
+
const rename = (name) => (renames.has(name) ? renames.get(name) : name);
|
|
634
|
+
const next = { ...mapping };
|
|
635
|
+
if (typeof mapping.category === "string") next.category = rename(mapping.category);
|
|
636
|
+
if (typeof mapping.x === "string") next.x = rename(mapping.x);
|
|
637
|
+
if (Array.isArray(mapping.series)) next.series = mapping.series.map(rename);
|
|
638
|
+
if (!same(mapping, next)) changes.push({ parts: [...entry.parts, "mapping"], before: mapping, after: next });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return changes;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function transact(presentation, path, action, mutate, extra = {}) {
|
|
646
|
+
const found = locateGridData(presentation, path);
|
|
341
647
|
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
648
|
const model = toModel(found);
|
|
343
649
|
const problemsBefore = mergeProblems(model).length;
|
|
344
650
|
const info = mutate(model, found) ?? {};
|
|
345
|
-
const style = info.restyle && found.kind === "table" ? readTableStyleOfTable(found.data) : undefined;
|
|
651
|
+
const style = info.restyle && found.kind === "table" && !found.dataset ? readTableStyleOfTable(found.data) : undefined;
|
|
346
652
|
if (info.touchesStructure) assertMerges(model, problemsBefore, info.what ?? "This change");
|
|
347
653
|
if (style && style.preset !== "custom") padLines(model);
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
const patches = gridPatches(found.dataParts, found.data, after);
|
|
654
|
+
const changes = found.dataset ? datasetChanges(presentation, found, model) : inlineChanges(found, model, style);
|
|
655
|
+
const patches = changes.flatMap((entry) => valuePatches(entry.parts, entry.before, entry.after));
|
|
351
656
|
const changed = patches.length > 0;
|
|
352
|
-
const before =
|
|
353
|
-
const result = changed ? checkedDocument(
|
|
657
|
+
const before = checkFormat(presentation);
|
|
658
|
+
const result = changed ? checkedDocument(presentation, patches, before) : presentation;
|
|
354
659
|
// `restyle`, `touchesStructure` and `what` steer this function only; the rest of what the operation reports is the caller's.
|
|
355
660
|
const { restyle: _restyle, touchesStructure: _structure, what: _what, ...summary } = info;
|
|
356
|
-
return { action, ...summary, kind: found.kind, path: found.parts.join("."),
|
|
661
|
+
return { action, ...summary, kind: found.kind, path: found.parts.join("."), ...(found.dataset ? { dataset: found.dataset.id } : {}), presentation: clone(result), patches, changed };
|
|
357
662
|
}
|
|
358
663
|
|
|
359
664
|
// --- row and column structure ------------------------------------------------------------------
|
|
@@ -370,7 +675,7 @@ function insertRows(model, at, count) {
|
|
|
370
675
|
const u = at + offset(model);
|
|
371
676
|
const crossing = rectsOf(model).filter((rect) => rect.u < u && u < rect.u + rect.rs);
|
|
372
677
|
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))));
|
|
678
|
+
const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" || model.dataset ? null : referenceCell(model, reference, column))));
|
|
374
679
|
model.lines.splice(u, 0, ...fresh);
|
|
375
680
|
for (const rect of crossing) {
|
|
376
681
|
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs + count, rect.cs);
|
|
@@ -403,10 +708,14 @@ function insertColumns(model, at, count) {
|
|
|
403
708
|
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
709
|
const crossing = rectsOf(model).filter((rect) => rect.c < at && at < rect.c + rect.cs);
|
|
405
710
|
const reference = at - 1 >= 0 ? at - 1 : at < model.width ? at : -1;
|
|
711
|
+
// New columns of a dataset (or of a chart with a mapping) are named at once: names must stay unique.
|
|
712
|
+
const named = [];
|
|
713
|
+
if (model.hasHeader && needsNames(model)) for (let i = 0; i < count; i += 1) named.push(uniqueName(model, named));
|
|
406
714
|
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)));
|
|
715
|
+
const fresh = Array.from({ length: count }, (_, i) => (model.hasHeader && u === 0 && needsNames(model) ? named[i] : model.kind === "chart" || model.dataset || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
|
|
408
716
|
line.splice(at, 0, ...fresh);
|
|
409
717
|
});
|
|
718
|
+
model.colOrigin.splice(at, 0, ...Array(count).fill(null));
|
|
410
719
|
for (const rect of crossing) {
|
|
411
720
|
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs, rect.cs + count);
|
|
412
721
|
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;
|
|
@@ -422,6 +731,7 @@ function deleteColumn(model, x) {
|
|
|
422
731
|
else model.lines[rect.u][x + 1] = withSpans(anchor, rect.rs, rect.cs - 1);
|
|
423
732
|
}
|
|
424
733
|
for (const line of model.lines) line.splice(x, 1);
|
|
734
|
+
model.colOrigin.splice(x, 1);
|
|
425
735
|
model.width -= 1;
|
|
426
736
|
}
|
|
427
737
|
|
|
@@ -453,6 +763,7 @@ function moveColumns(model, from, count, to) {
|
|
|
453
763
|
throw fail("grid-column-out-of-range", "That move leaves the table.", { from, count, to });
|
|
454
764
|
if (from === to) return;
|
|
455
765
|
for (const line of model.lines) moveBlock(line, from, count, to);
|
|
766
|
+
moveBlock(model.colOrigin, from, count, to);
|
|
456
767
|
}
|
|
457
768
|
|
|
458
769
|
// --- sorting -----------------------------------------------------------------------------------
|
|
@@ -529,6 +840,7 @@ function sortRows(model, column, options) {
|
|
|
529
840
|
|
|
530
841
|
function setHeader(model, enabled, options) {
|
|
531
842
|
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.", {});
|
|
843
|
+
if (model.dataset) throw fail("grid-dataset-shared", `A table that shows the dataset '${model.dataset.id}' always has a header row: the dataset's column names.`, { dataset: model.dataset.id });
|
|
532
844
|
if (enabled === model.hasHeader) return {};
|
|
533
845
|
padLines(model);
|
|
534
846
|
if (enabled) {
|
|
@@ -547,17 +859,23 @@ function setHeader(model, enabled, options) {
|
|
|
547
859
|
model.hasHeader = true;
|
|
548
860
|
return { header: true, use, restyle: true, touchesStructure: true, what: "A header row" };
|
|
549
861
|
}
|
|
862
|
+
// A DataColumn header (`{ name, format }`) is not a body cell: it becomes its name. Its column format has nowhere to go and is dropped.
|
|
863
|
+
const dropped = model.lines[0].filter((raw) => isColumnObject(raw) && formatOf(raw) !== undefined).length;
|
|
864
|
+
model.lines[0] = model.lines[0].map((raw) => (isColumnObject(raw) ? raw.name : raw));
|
|
550
865
|
model.hasHeader = false;
|
|
551
|
-
return { header: false, restyle: true };
|
|
866
|
+
return { header: false, restyle: true, ...(dropped ? { droppedFormats: dropped } : {}) };
|
|
552
867
|
}
|
|
553
868
|
|
|
554
869
|
// --- transpose ---------------------------------------------------------------------------------
|
|
555
870
|
|
|
556
871
|
function transposeChart(model) {
|
|
557
872
|
if (model.kind !== "chart") throw fail("grid-wrong-kind", "Only chart data can be transposed.", {});
|
|
873
|
+
if (model.dataset) throw fail("grid-dataset-shared", `Swapping categories and series would reshape the shared dataset '${model.dataset.id}' for every chart and table that uses it. Use a copy of the data first.`, { dataset: model.dataset.id });
|
|
558
874
|
padLines(model);
|
|
559
875
|
if (model.width < 2) throw fail("chart-transpose-empty", "Transposing needs at least one series column besides the categories.", {});
|
|
560
|
-
const [
|
|
876
|
+
const [headers, ...rows] = model.lines;
|
|
877
|
+
// A column's number format belongs to its numbers: the old series names become category labels, as text.
|
|
878
|
+
const names = headers.map((cell) => String(valueOf(cell) ?? ""));
|
|
561
879
|
let relabelled = 0;
|
|
562
880
|
const categories = rows.map((row) => {
|
|
563
881
|
const category = row[0];
|
|
@@ -568,6 +886,8 @@ function transposeChart(model) {
|
|
|
568
886
|
const lines = [[names[0], ...categories], ...names.slice(1).map((name, index) => [name, ...rows.map((row) => row[index + 1] ?? null)])];
|
|
569
887
|
model.lines = lines;
|
|
570
888
|
model.width = widthOf(model);
|
|
889
|
+
// Every column is new: a mapping that named the old columns no longer applies.
|
|
890
|
+
model.colOrigin = Array(model.width).fill(null);
|
|
571
891
|
return { rowsBefore: rows.length, columnsBefore: names.length, relabelled };
|
|
572
892
|
}
|
|
573
893
|
|
|
@@ -575,20 +895,29 @@ function transposeChart(model) {
|
|
|
575
895
|
|
|
576
896
|
function growTo(model, lines, width) {
|
|
577
897
|
padLines(model);
|
|
578
|
-
while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => (model
|
|
898
|
+
while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => emptyCell(model)));
|
|
579
899
|
while (model.width < width) {
|
|
580
900
|
model.lines.forEach((line, u) => line.push(filler(model, u)));
|
|
581
901
|
model.width += 1;
|
|
902
|
+
model.colOrigin.push(null);
|
|
582
903
|
}
|
|
583
904
|
}
|
|
584
905
|
|
|
585
|
-
|
|
906
|
+
// The column names of a shared dataset (and of a chart with a mapping) are how `fields` and `mapping` address columns: never blank.
|
|
907
|
+
const blankNameError = (model, address, text) =>
|
|
908
|
+
address.section === "header" && model.hasHeader && needsNames(model) && String(text ?? "").trim() === ""
|
|
909
|
+
? `A column name cannot be empty here: ${model.dataset ? "the shared dataset" : "the chart's column mapping"} addresses columns by name.`
|
|
910
|
+
: undefined;
|
|
911
|
+
|
|
912
|
+
function newRaw(model, raw, text, decimal, address, roles) {
|
|
586
913
|
const value = valueOf(raw);
|
|
914
|
+
const blank = blankNameError(model, address, text);
|
|
915
|
+
if (blank) return { error: blank };
|
|
587
916
|
// Text that is already what the cell shows is left alone: a number kept as text stays text, runs keep their styling.
|
|
588
917
|
const shown = cellText(raw, model.kind, decimal);
|
|
589
918
|
if (Array.isArray(value)) {
|
|
590
919
|
if (text.replace(/\r\n?/g, "\n") === shown.replace(/\r\n?/g, "\n")) return { skip: true };
|
|
591
|
-
return { raw:
|
|
920
|
+
return { raw: withValue(raw, updateRichTextInput(value, text)) };
|
|
592
921
|
}
|
|
593
922
|
if (text === shown) return { skip: true };
|
|
594
923
|
let next;
|
|
@@ -596,17 +925,21 @@ function newRaw(model, raw, text, decimal, address) {
|
|
|
596
925
|
if (model.hasHeader && address.section === "header") next = text;
|
|
597
926
|
else next = text === "" ? "" : isCanonicalNumber(text) ? Number(text) : text;
|
|
598
927
|
} else if (address.section === "header") next = text;
|
|
599
|
-
else if (
|
|
928
|
+
else if (isNumericRole(roles[address.column])) {
|
|
929
|
+
// A chart number is read in the grid's number format and kept as a number. Text that is not a number is refused with
|
|
930
|
+
// a reason; it is never stored as a guess (core's chartNumber would draw it as a gap).
|
|
600
931
|
const parsed = parseGridNumber(text, { decimal });
|
|
601
932
|
if (parsed.error) return { error: parsed.error };
|
|
602
933
|
next = parsed.empty ? null : parsed.value;
|
|
603
934
|
if (next === null && (value === null || value === undefined)) return { skip: true };
|
|
604
|
-
} else next = text === "" ? null : text;
|
|
605
|
-
|
|
935
|
+
} else if (roles[address.column] === "other") next = text === "" ? null : isCanonicalNumber(text) ? Number(text) : text;
|
|
936
|
+
else next = text === "" ? null : text;
|
|
937
|
+
return { raw: withValue(raw, next) };
|
|
606
938
|
}
|
|
607
939
|
|
|
608
940
|
function applyEdits(model, edits, decimal, owners) {
|
|
609
941
|
const issues = [];
|
|
942
|
+
const roles = rolesOf(model);
|
|
610
943
|
for (const edit of edits) {
|
|
611
944
|
const address = { section: edit.section, row: edit.row ?? 0, column: edit.column };
|
|
612
945
|
try {
|
|
@@ -620,11 +953,14 @@ function applyEdits(model, edits, decimal, owners) {
|
|
|
620
953
|
const value = edit.value;
|
|
621
954
|
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
955
|
if (typeof value === "number" && !Number.isFinite(value)) throw fail("invalid-grid-value", "A cell cannot hold Infinity or NaN.", { cell: address });
|
|
623
|
-
model.
|
|
956
|
+
if (model.dataset && address.section === "header" && typeof value !== "string") throw fail("invalid-grid-value", "A dataset column name is text.", { cell: address });
|
|
957
|
+
const blank = blankNameError(model, address, value);
|
|
958
|
+
if (blank && !(valueOf(raw) === "" && value === "")) throw fail("invalid-grid-value", blank, { cell: address });
|
|
959
|
+
model.lines[u][address.column] = withValue(raw, value);
|
|
624
960
|
continue;
|
|
625
961
|
}
|
|
626
962
|
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);
|
|
963
|
+
const outcome = newRaw(model, raw, edit.text, decimal, address, roles);
|
|
628
964
|
if (outcome.error) issues.push({ ...address, message: `${describeAddress(address)}: ${outcome.error}` });
|
|
629
965
|
else if (!outcome.skip) model.lines[u][address.column] = outcome.raw;
|
|
630
966
|
} catch (error) {
|
|
@@ -651,7 +987,7 @@ function pasteRows(model, anchor, matrix, options) {
|
|
|
651
987
|
if (!Array.isArray(matrix) || !matrix.length) throw fail("invalid-grid-value", "There is nothing to paste.", {});
|
|
652
988
|
const first = decimalOf(options);
|
|
653
989
|
const attempt = (decimal) => {
|
|
654
|
-
const trial = { ...model, lines:
|
|
990
|
+
const trial = { ...model, lines: cloneLines(model.lines), colOrigin: [...model.colOrigin] };
|
|
655
991
|
const u0 = lineIndex(trial, anchor);
|
|
656
992
|
const width = matrix.reduce((most, row) => Math.max(most, row.length), 0);
|
|
657
993
|
growTo(trial, u0 + matrix.length, anchor.column + width);
|
|
@@ -678,6 +1014,7 @@ function pasteRows(model, anchor, matrix, options) {
|
|
|
678
1014
|
if (result.issues.length) throw refuse(result.issues);
|
|
679
1015
|
model.lines = result.trial.lines;
|
|
680
1016
|
model.width = result.trial.width;
|
|
1017
|
+
model.colOrigin = result.trial.colOrigin;
|
|
681
1018
|
return { cells: result.cells, decimal, rows: matrix.length, restyle: true, touchesStructure: model.kind === "table", what: "Pasting" };
|
|
682
1019
|
}
|
|
683
1020
|
|
|
@@ -685,8 +1022,8 @@ function pasteRows(model, anchor, matrix, options) {
|
|
|
685
1022
|
* The problems `edits` would have, without applying or validating the document (cheap enough to run on every keystroke): a list of
|
|
686
1023
|
* `{ section, row, column, message }`, empty when every edit is fine.
|
|
687
1024
|
*/
|
|
688
|
-
export function gridCellIssues(
|
|
689
|
-
const model = toModel(locateGridData(
|
|
1025
|
+
export function gridCellIssues(presentation, path, edits, options = {}) {
|
|
1026
|
+
const model = toModel(locateGridData(presentation, path));
|
|
690
1027
|
return applyEdits(model, edits, decimalOf(options), ownersOf(model));
|
|
691
1028
|
}
|
|
692
1029
|
|
|
@@ -699,8 +1036,8 @@ const countOf = (count) => {
|
|
|
699
1036
|
const kindOf = (options) => (options?.kind ? { kind: options.kind } : {});
|
|
700
1037
|
|
|
701
1038
|
/** 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(
|
|
703
|
-
return transact(
|
|
1039
|
+
export function prepareGridCells(presentation, path, edits, options = {}) {
|
|
1040
|
+
return transact(presentation, path, "set-cells", (model) => setCells(model, edits, options), kindOf(options));
|
|
704
1041
|
}
|
|
705
1042
|
|
|
706
1043
|
/**
|
|
@@ -709,54 +1046,54 @@ export function prepareGridCells(document, path, edits, options = {}) {
|
|
|
709
1046
|
* `options` (`decimal`, or `numberFormat` and `locale`); when that fails and the other format reads all of the paste, that is used and
|
|
710
1047
|
* reported as `decimal`. A table paste over a merged cell's covered positions is refused. One patch.
|
|
711
1048
|
*/
|
|
712
|
-
export function preparePaste(
|
|
1049
|
+
export function preparePaste(presentation, path, anchor, source, options = {}) {
|
|
713
1050
|
const rows = typeof source === "string" ? parseDelimited(source, { delimiter: options.delimiter }).rows : source;
|
|
714
|
-
return transact(
|
|
1051
|
+
return transact(presentation, path, "paste", (model) => pasteRows(model, anchor, rows, options), kindOf(options));
|
|
715
1052
|
}
|
|
716
1053
|
|
|
717
1054
|
/** 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(
|
|
719
|
-
return transact(
|
|
1055
|
+
export function prepareInsertRows(presentation, path, at, count = 1, options = {}) {
|
|
1056
|
+
return transact(presentation, path, "insert-rows", (model) => {
|
|
720
1057
|
insertRows(model, at, countOf(count));
|
|
721
1058
|
return { at, count, restyle: true, touchesStructure: true, what: "Inserting rows" };
|
|
722
1059
|
}, kindOf(options));
|
|
723
1060
|
}
|
|
724
1061
|
|
|
725
1062
|
/** 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(
|
|
727
|
-
return transact(
|
|
1063
|
+
export function prepareDeleteRows(presentation, path, indices, options = {}) {
|
|
1064
|
+
return transact(presentation, path, "delete-rows", (model) => {
|
|
728
1065
|
deleteRows(model, indices);
|
|
729
1066
|
return { indices: [...indices], restyle: true, touchesStructure: true, what: "Deleting rows" };
|
|
730
1067
|
}, kindOf(options));
|
|
731
1068
|
}
|
|
732
1069
|
|
|
733
1070
|
/** 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(
|
|
735
|
-
return transact(
|
|
1071
|
+
export function prepareMoveRows(presentation, path, from, to, count = 1, options = {}) {
|
|
1072
|
+
return transact(presentation, path, "move-rows", (model) => {
|
|
736
1073
|
moveRows(model, from, count, to);
|
|
737
1074
|
return { from, to, count, restyle: true, touchesStructure: true, what: "Moving rows" };
|
|
738
1075
|
}, kindOf(options));
|
|
739
1076
|
}
|
|
740
1077
|
|
|
741
1078
|
/** Insert `count` empty columns so the first is column `at`. A merged cell that spans the position grows. */
|
|
742
|
-
export function prepareInsertColumns(
|
|
743
|
-
return transact(
|
|
1079
|
+
export function prepareInsertColumns(presentation, path, at, count = 1, options = {}) {
|
|
1080
|
+
return transact(presentation, path, "insert-columns", (model) => {
|
|
744
1081
|
insertColumns(model, at, countOf(count));
|
|
745
1082
|
return { at, count, restyle: true, touchesStructure: true, what: "Inserting columns" };
|
|
746
1083
|
}, kindOf(options));
|
|
747
1084
|
}
|
|
748
1085
|
|
|
749
1086
|
/** 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(
|
|
751
|
-
return transact(
|
|
1087
|
+
export function prepareDeleteColumns(presentation, path, indices, options = {}) {
|
|
1088
|
+
return transact(presentation, path, "delete-columns", (model) => {
|
|
752
1089
|
deleteColumns(model, indices);
|
|
753
1090
|
return { indices: [...indices], restyle: true, touchesStructure: true, what: "Deleting columns" };
|
|
754
1091
|
}, kindOf(options));
|
|
755
1092
|
}
|
|
756
1093
|
|
|
757
1094
|
/** 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(
|
|
759
|
-
return transact(
|
|
1095
|
+
export function prepareMoveColumns(presentation, path, from, to, count = 1, options = {}) {
|
|
1096
|
+
return transact(presentation, path, "move-columns", (model) => {
|
|
760
1097
|
moveColumns(model, from, count, to);
|
|
761
1098
|
return { from, to, count, restyle: true, touchesStructure: true, what: "Moving columns" };
|
|
762
1099
|
}, kindOf(options));
|
|
@@ -767,20 +1104,153 @@ export function prepareMoveColumns(document, path, from, to, count = 1, options
|
|
|
767
1104
|
* 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
1105
|
* 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
1106
|
*/
|
|
770
|
-
export function prepareSortRows(
|
|
771
|
-
return transact(
|
|
1107
|
+
export function prepareSortRows(presentation, path, column, options = {}) {
|
|
1108
|
+
return transact(presentation, path, "sort-rows", (model) => ({ ...sortRows(model, column, options), column, direction: options.direction ?? "asc", restyle: true }), kindOf(options));
|
|
772
1109
|
}
|
|
773
1110
|
|
|
774
1111
|
/** 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(
|
|
776
|
-
return transact(
|
|
1112
|
+
export function prepareSetHeader(presentation, path, enabled, options = {}) {
|
|
1113
|
+
return transact(presentation, path, "set-header", (model) => setHeader(model, Boolean(enabled), options), { kind: "table" });
|
|
777
1114
|
}
|
|
778
1115
|
|
|
779
1116
|
/** 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(
|
|
781
|
-
return transact(
|
|
1117
|
+
export function prepareTranspose(presentation, path) {
|
|
1118
|
+
return transact(presentation, path, "transpose", (model) => transposeChart(model), { kind: "chart" });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// --- number format of a column (RR-54) ---------------------------------------------------------
|
|
1122
|
+
|
|
1123
|
+
/** Why `format` is not a valid number format ("#,##0", "0.0%", "$#,##0.00"), or undefined when it is valid or empty (empty clears the format). */
|
|
1124
|
+
export function columnFormatError(format) {
|
|
1125
|
+
if (format === undefined || format === null || format === "") return undefined;
|
|
1126
|
+
return numberFormatError(format);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The header `raw` with `format` set, or cleared (`undefined`). A string header becomes `{ name, format }` and goes back to a string when the format is cleared. */
|
|
1130
|
+
function withColumnFormat(raw, format) {
|
|
1131
|
+
if (isColumnObject(raw)) {
|
|
1132
|
+
const next = { ...raw };
|
|
1133
|
+
if (format === undefined) delete next.format;
|
|
1134
|
+
else next.format = format;
|
|
1135
|
+
return Object.keys(next).length === 1 ? next.name : next;
|
|
1136
|
+
}
|
|
1137
|
+
if (isStyled(raw)) {
|
|
1138
|
+
const next = { ...raw };
|
|
1139
|
+
if (format === undefined) delete next.format;
|
|
1140
|
+
else next.format = format;
|
|
1141
|
+
return Object.keys(next).length === 1 ? next.value : next;
|
|
1142
|
+
}
|
|
1143
|
+
if (format === undefined) return raw;
|
|
1144
|
+
if (Array.isArray(raw)) return { value: raw, format };
|
|
1145
|
+
return { name: raw === null || raw === undefined ? "" : String(raw), format };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function setColumnFormat(model, column, format) {
|
|
1149
|
+
if (!model.hasHeader) throw fail("grid-no-header", "This table has no header row, so there is no column heading to hold a number format. Turn on the header row first.", {});
|
|
1150
|
+
padLines(model);
|
|
1151
|
+
if (!Number.isInteger(column) || column < 0 || column >= model.width) throw fail("grid-column-out-of-range", "Choose a column.", { column });
|
|
1152
|
+
if (format !== undefined && format !== null && typeof format !== "string") throw fail("number-format-invalid", 'A number format is text such as "#,##0" or "0.0%".', { format });
|
|
1153
|
+
const next = typeof format === "string" && format !== "" ? format : undefined;
|
|
1154
|
+
const error = columnFormatError(next);
|
|
1155
|
+
if (error) throw fail("number-format-invalid", error, { format: next });
|
|
1156
|
+
if (ownersOf(model).has(`0:${column}`)) throw fail("table-cell-covered", `${describeAddress({ section: "header", row: 0, column })} is covered by a merged cell. Choose the merged heading.`, { column });
|
|
1157
|
+
model.lines[0][column] = withColumnFormat(model.lines[0][column], next);
|
|
1158
|
+
return { column, format: next ?? null };
|
|
782
1159
|
}
|
|
783
1160
|
|
|
1161
|
+
/** Set or clear (`null` or "") the number format of column `column` (its header cell). A string header becomes `{ name, format }` and returns to a string when the format is cleared. Refused with the reason when the format is invalid or the table has no header row. */
|
|
1162
|
+
export function prepareGridColumnFormat(presentation, path, column, format, options = {}) {
|
|
1163
|
+
return transact(presentation, path, "set-column-format", (model) => setColumnFormat(model, column, format), kindOf(options));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// --- chart mapping (RR-54) -----------------------------------------------------------------------
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* The columns a chart plots and how they got that role: `{ path, xy, columns: [{ name, format?, role }], category, x?, series,
|
|
1170
|
+
* authored? }`. `category`, `x` and `series` are column names (the mapping's, or core's positional default); `xy` is true for a chart
|
|
1171
|
+
* with an X axis (scatter); `authored` is the chart's own `mapping`, when it has one. Roles are "category", "label", "x", "series"
|
|
1172
|
+
* and "other" (a column nothing plots).
|
|
1173
|
+
*/
|
|
1174
|
+
export function describeChartMapping(presentation, path) {
|
|
1175
|
+
const found = locateGridData(presentation, path);
|
|
1176
|
+
if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
|
|
1177
|
+
const model = toModel(found);
|
|
1178
|
+
padLines(model);
|
|
1179
|
+
const names = namesOfModel(model);
|
|
1180
|
+
const xy = isXYChartType(found.chartType);
|
|
1181
|
+
const { category, x, series } = mappingColumns(names.length, xy, model.mapping, names);
|
|
1182
|
+
const roles = chartColumnRoles(names.length, found.chartType, { ...(model.mapping ?? {}) }, names);
|
|
1183
|
+
return {
|
|
1184
|
+
path: found.parts.join("."),
|
|
1185
|
+
xy,
|
|
1186
|
+
columns: names.map((name, index) => ({ name, ...(formatOf(model.lines[0][index]) ? { format: formatOf(model.lines[0][index]) } : {}), role: roles[index] })),
|
|
1187
|
+
category: names[category],
|
|
1188
|
+
...(x === undefined ? {} : { x: names[x] }),
|
|
1189
|
+
series: series.map((index) => names[index]),
|
|
1190
|
+
...(model.mapping ? { authored: clone(model.mapping) } : {}),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Set a chart's category, X and series columns by name. `wanted` is `{ category?, x?, series? }`; what it leaves out keeps its
|
|
1196
|
+
* current value. A field that equals the default (the first column is the category, the second the X column, every other column a
|
|
1197
|
+
* series, in order) is not written, and `chart.mapping` is removed when nothing is left. One patch.
|
|
1198
|
+
*/
|
|
1199
|
+
export function prepareChartMapping(presentation, path, wanted = {}) {
|
|
1200
|
+
const found = locateGridData(presentation, path);
|
|
1201
|
+
if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
|
|
1202
|
+
const model = toModel(found);
|
|
1203
|
+
padLines(model);
|
|
1204
|
+
const names = namesOfModel(model);
|
|
1205
|
+
const xy = isXYChartType(found.chartType);
|
|
1206
|
+
const current = mappingColumns(names.length, xy, model.mapping, names);
|
|
1207
|
+
const pick = (name, what) => {
|
|
1208
|
+
const index = typeof name === "string" ? names.indexOf(name) : -1;
|
|
1209
|
+
if (index < 0) throw fail("chart-mapping-unknown-column", `The ${what} column ${JSON.stringify(name)} is not a column of this chart's data. Choose ${names.map((entry) => JSON.stringify(entry)).join(", ")}.`, { column: name });
|
|
1210
|
+
return index;
|
|
1211
|
+
};
|
|
1212
|
+
if (wanted.x !== undefined && !xy) throw fail("chart-mapping-x-unsupported", `A '${found.chartType ?? "chart"}' chart has no X axis, so it has no X column. Only a scatter chart does.`, { x: wanted.x });
|
|
1213
|
+
const category = wanted.category !== undefined ? pick(wanted.category, "category") : current.category;
|
|
1214
|
+
let x;
|
|
1215
|
+
if (xy) x = wanted.x !== undefined ? pick(wanted.x, "X") : current.x === category || current.x === undefined ? defaultX(category, names.length) : current.x;
|
|
1216
|
+
if (xy && x === category) throw fail("chart-mapping-conflict", "The X column and the category column must be different columns.", { category: names[category] });
|
|
1217
|
+
if (xy && x !== undefined && x >= names.length) throw fail("chart-mapping-conflict", "An XY chart needs a column for X besides the category.", {});
|
|
1218
|
+
const defaultSeries = names.map((_, index) => index).filter((index) => index !== category && index !== x);
|
|
1219
|
+
// Without its own `series`, a chart plots every other column: that follows a new category or X column.
|
|
1220
|
+
let series = wanted.series !== undefined ? wanted.series.map((name) => pick(name, "series")) : Array.isArray(model.mapping?.series) ? current.series : defaultSeries;
|
|
1221
|
+
series = series.filter((index, position) => index !== category && index !== x && series.indexOf(index) === position);
|
|
1222
|
+
if (!series.length) throw fail("chart-mapping-no-series", "Choose at least one series column to plot.", {});
|
|
1223
|
+
const next = {};
|
|
1224
|
+
if (category !== 0) next.category = names[category];
|
|
1225
|
+
if (xy && x !== undefined && x !== defaultX(category, names.length)) next.x = names[x];
|
|
1226
|
+
if (series.length !== defaultSeries.length || series.some((index, position) => index !== defaultSeries[position])) next.series = series.map((index) => names[index]);
|
|
1227
|
+
const after = Object.keys(next).length ? next : undefined;
|
|
1228
|
+
const before = checkFormat(presentation);
|
|
1229
|
+
const patches = valuePatches([...found.parts, "mapping"], found.owner.mapping, after);
|
|
1230
|
+
const changed = patches.length > 0;
|
|
1231
|
+
const result = changed ? checkedDocument(presentation, patches, before) : presentation;
|
|
1232
|
+
return { action: "set-mapping", kind: "chart", path: found.parts.join("."), mapping: after ? clone(after) : null, presentation: clone(result), patches, changed };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// --- shared datasets (RR-54) -----------------------------------------------------------------------
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Replace a chart's or table's dataset reference with its own inline copy of the data (the columns `fields` selects, formats and
|
|
1239
|
+
* the dataset's `source` kept). The dataset stays in the document for the other items. One patch; after it the item's edits no longer
|
|
1240
|
+
* reach the shared dataset.
|
|
1241
|
+
*/
|
|
1242
|
+
export function prepareDetachDataset(presentation, path) {
|
|
1243
|
+
const found = locateGridData(presentation, path);
|
|
1244
|
+
if (!found.dataset) throw fail("grid-not-dataset", "This chart or table does not use a shared dataset.", { path });
|
|
1245
|
+
const inline = found.kind === "chart" ? inlineChartData(found.owner, presentation).data : inlineTableData(found.owner, presentation);
|
|
1246
|
+
const pointer = opfPathToJsonPointer(found.dataset.refParts);
|
|
1247
|
+
const patches = [{ op: "test", path: pointer, value: clone(found.dataset.ref) }, { op: "replace", path: pointer, value: clone(inline) }];
|
|
1248
|
+
const result = checkedDocument(presentation, patches, checkFormat(presentation));
|
|
1249
|
+
return { action: "detach-dataset", kind: found.kind, path: found.parts.join("."), dataset: found.dataset.id, presentation: clone(result), patches, changed: true };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export { datasetUsage };
|
|
1253
|
+
|
|
784
1254
|
// --- copy --------------------------------------------------------------------------------------
|
|
785
1255
|
|
|
786
1256
|
/**
|
|
@@ -788,8 +1258,8 @@ export function prepareTranspose(document, path) {
|
|
|
788
1258
|
* another `delimiter`). `range` is `{ from, to }` of `{ section, row, column }` addresses; omit it for the whole grid. A merged cell's text is
|
|
789
1259
|
* in its first position and its covered positions are empty, as a spreadsheet copies them.
|
|
790
1260
|
*/
|
|
791
|
-
export function gridRangeText(
|
|
792
|
-
const grid = describeDataGrid(
|
|
1261
|
+
export function gridRangeText(presentation, path, range, options = {}) {
|
|
1262
|
+
const grid = describeDataGrid(presentation, path, options);
|
|
793
1263
|
const find = (address) => {
|
|
794
1264
|
const u = address.section === "header" ? 0 : address.row + (grid.hasHeader ? 1 : 0);
|
|
795
1265
|
if (address.section === "header" && !grid.hasHeader) throw fail("grid-cell-not-found", "This table has no header row.", { cell: address });
|
|
@@ -809,28 +1279,34 @@ export function gridRangeText(document, path, range, options = {}) {
|
|
|
809
1279
|
|
|
810
1280
|
function commit(editor, prepared, meta = {}) {
|
|
811
1281
|
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
812
|
-
const {
|
|
813
|
-
void
|
|
814
|
-
if (!prepared.changed) return { ...summary,
|
|
1282
|
+
const { presentation, patches, ...summary } = prepared;
|
|
1283
|
+
void presentation;
|
|
1284
|
+
if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
815
1285
|
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "data-grid", action: prepared.action, path: prepared.path });
|
|
816
1286
|
return { ...change, ...summary };
|
|
817
1287
|
}
|
|
818
1288
|
|
|
819
1289
|
const run = (editor, prepare, options = {}) => {
|
|
820
1290
|
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.
|
|
1291
|
+
return commit(editor, prepare(editor.presentation), options.meta);
|
|
822
1292
|
};
|
|
823
1293
|
|
|
824
1294
|
/** Set cells as one undoable transaction. See {@link prepareGridCells}. */
|
|
825
|
-
export const setGridCells = (editor, path, edits, options = {}) => run(editor, (
|
|
1295
|
+
export const setGridCells = (editor, path, edits, options = {}) => run(editor, (presentation) => prepareGridCells(presentation, path, edits, options), options);
|
|
826
1296
|
/** Paste text or rows as one undoable transaction. See {@link preparePaste}. */
|
|
827
|
-
export const pasteGridText = (editor, path, anchor, source, options = {}) => run(editor, (
|
|
828
|
-
export const insertGridRows = (editor, path, at, count = 1, options = {}) => run(editor, (
|
|
829
|
-
export const deleteGridRows = (editor, path, indices, options = {}) => run(editor, (
|
|
830
|
-
export const moveGridRows = (editor, path, from, to, count = 1, options = {}) => run(editor, (
|
|
831
|
-
export const insertGridColumns = (editor, path, at, count = 1, options = {}) => run(editor, (
|
|
832
|
-
export const deleteGridColumns = (editor, path, indices, options = {}) => run(editor, (
|
|
833
|
-
export const moveGridColumns = (editor, path, from, to, count = 1, options = {}) => run(editor, (
|
|
834
|
-
export const sortGridRows = (editor, path, column, options = {}) => run(editor, (
|
|
835
|
-
export const setGridHeader = (editor, path, enabled, options = {}) => run(editor, (
|
|
836
|
-
export const transposeGridData = (editor, path, options = {}) => run(editor, (
|
|
1297
|
+
export const pasteGridText = (editor, path, anchor, source, options = {}) => run(editor, (presentation) => preparePaste(presentation, path, anchor, source, options), options);
|
|
1298
|
+
export const insertGridRows = (editor, path, at, count = 1, options = {}) => run(editor, (presentation) => prepareInsertRows(presentation, path, at, count, options), options);
|
|
1299
|
+
export const deleteGridRows = (editor, path, indices, options = {}) => run(editor, (presentation) => prepareDeleteRows(presentation, path, indices, options), options);
|
|
1300
|
+
export const moveGridRows = (editor, path, from, to, count = 1, options = {}) => run(editor, (presentation) => prepareMoveRows(presentation, path, from, to, count, options), options);
|
|
1301
|
+
export const insertGridColumns = (editor, path, at, count = 1, options = {}) => run(editor, (presentation) => prepareInsertColumns(presentation, path, at, count, options), options);
|
|
1302
|
+
export const deleteGridColumns = (editor, path, indices, options = {}) => run(editor, (presentation) => prepareDeleteColumns(presentation, path, indices, options), options);
|
|
1303
|
+
export const moveGridColumns = (editor, path, from, to, count = 1, options = {}) => run(editor, (presentation) => prepareMoveColumns(presentation, path, from, to, count, options), options);
|
|
1304
|
+
export const sortGridRows = (editor, path, column, options = {}) => run(editor, (presentation) => prepareSortRows(presentation, path, column, options), options);
|
|
1305
|
+
export const setGridHeader = (editor, path, enabled, options = {}) => run(editor, (presentation) => prepareSetHeader(presentation, path, enabled, options), options);
|
|
1306
|
+
export const transposeGridData = (editor, path, options = {}) => run(editor, (presentation) => prepareTranspose(presentation, path), options);
|
|
1307
|
+
/** Set (or clear, with `null` or "") the number format of a column's header as one undoable transaction. Throws `number-format-invalid` with the reason. See {@link columnFormatError}. */
|
|
1308
|
+
export const setGridColumnFormat = (editor, path, column, format, options = {}) => run(editor, (presentation) => prepareGridColumnFormat(presentation, path, column, format, options), options);
|
|
1309
|
+
/** Set a chart's category, X and series columns as one undoable transaction. See {@link prepareChartMapping}. */
|
|
1310
|
+
export const setChartMapping = (editor, path, wanted, options = {}) => run(editor, (presentation) => prepareChartMapping(presentation, path, wanted), options);
|
|
1311
|
+
/** Give a chart or table its own copy of a shared dataset's data, as one undoable transaction. See {@link prepareDetachDataset}. */
|
|
1312
|
+
export const detachGridDataset = (editor, path, options = {}) => run(editor, (presentation) => prepareDetachDataset(presentation, path), options);
|