@openpresentation/opf-editor 0.11.3 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/block-convert.js +3 -2
- package/dist/chart-data.d.ts +2 -0
- package/dist/chart-data.js +1 -0
- package/dist/data-grid.js +177 -7
- package/dist/data.d.ts +21 -0
- package/dist/data.js +39 -0
- package/dist/dataset-refs.d.ts +17 -0
- package/dist/dataset-refs.js +45 -0
- package/dist/design-controls.js +5 -3
- package/dist/find-replace.js +14 -2
- package/dist/grid-model.d.ts +83 -6
- package/dist/grid-model.js +533 -47
- package/dist/switches.js +24 -7
- package/dist/table-options.js +13 -3
- package/dist/transfer.js +30 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -411,6 +411,19 @@ sortTableRows(editor, "slides.4.blocks.1.table", 1, { direction: "desc" });
|
|
|
411
411
|
setChartCells(editor, "slides.3.blocks.0.chart", [{ section: "body", row: 0, column: 1, text: "12,5" }], { decimal: "," });
|
|
412
412
|
```
|
|
413
413
|
|
|
414
|
+
Chart columns and table headers may be `DataColumn` objects (`{ name, format }`; the grid keeps the format and shows a formatted column's numbers as the slide draws them, while editing and copy use the raw value), a chart or table may show a shared top-level dataset (the grid edits `/datasets/<id>` through `fields` and says how many items share it), each column has a number format field and each chart a category, X and series mapping (RR-54):
|
|
415
|
+
|
|
416
|
+
```js
|
|
417
|
+
import { setGridColumnFormat, setChartMapping, detachGridDataset } from "@openpresentation/opf-editor/data-grid";
|
|
418
|
+
import { prepareDatasetImport, createDataContent } from "@openpresentation/opf-editor/data";
|
|
419
|
+
|
|
420
|
+
setGridColumnFormat(editor, "slides.3.blocks.0.chart", 1, "$#,##0.0"); // header "Revenue" becomes { name: "Revenue", format }; null clears it
|
|
421
|
+
setChartMapping(editor, "slides.3.blocks.0.chart", { category: "Region", series: ["Revenue"] });
|
|
422
|
+
detachGridDataset(editor, "slides.3.blocks.1.table"); // its own copy instead of the shared dataset
|
|
423
|
+
const stored = prepareDatasetImport(editor.document, createDataContent(csv, { as: "chart" }), { id: "revenue" });
|
|
424
|
+
editor.applyPatch([...stored.patches, { op: "add", path: "/slides/-", value: { id: "rev", title: "Revenue", ...stored.content } }]);
|
|
425
|
+
```
|
|
426
|
+
|
|
414
427
|
The number rules, paste and copy format, merged-cell behaviour, sorting, keyboard and accessibility are in [docs/data-grid.md](docs/data-grid.md).
|
|
415
428
|
|
|
416
429
|
## Design controls panel (RR-06)
|
package/dist/block-convert.js
CHANGED
|
@@ -103,7 +103,8 @@ export function metricGroupForSelection(document, selectedPath) {
|
|
|
103
103
|
*/
|
|
104
104
|
export function blockConversionTargets(document, path, options = {}) {
|
|
105
105
|
const found = readBlockContent(document, path);
|
|
106
|
-
|
|
106
|
+
// RR-54: a table that shows a shared dataset needs the document's datasets to convert to anything but a chart.
|
|
107
|
+
return found ? contentConversionTargets(found.owner, { document, ...options }) : [];
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/**
|
|
@@ -116,7 +117,7 @@ export function prepareBlockConversion(document, path, kind, options = {}) {
|
|
|
116
117
|
if (!found) throw refuse(CHOOSE, { path });
|
|
117
118
|
const pointer = opfPathToJsonPointer(found.path);
|
|
118
119
|
const from = found.kind;
|
|
119
|
-
const result = convertOwner(found.owner, kind, options);
|
|
120
|
+
const result = convertOwner(found.owner, kind, { document, ...options });
|
|
120
121
|
if (!result.changed) return { document: structuredClone(document), patches: [], path: found.path.join("."), changed: false, lossless: true, loss: [], from, to: kind };
|
|
121
122
|
const patches = [
|
|
122
123
|
{ op: "test", path: pointer, value: structuredClone(found.owner) },
|
package/dist/chart-data.d.ts
CHANGED
|
@@ -30,3 +30,5 @@ export declare function transposeChart(editor: EditorSession, chartPath: string,
|
|
|
30
30
|
export declare function renameChartSeries(editor: EditorSession, chartPath: string, column: number, name: string, options?: { meta?: Record<string, unknown> }): GridChange;
|
|
31
31
|
/** Rename a category: the first-column label of body row `row`. An empty name leaves it blank. */
|
|
32
32
|
export declare function renameChartCategory(editor: EditorSession, chartPath: string, row: number, name: string, options?: { meta?: Record<string, unknown> }): GridChange;
|
|
33
|
+
export { describeChartMapping, prepareChartMapping, setChartMapping, prepareDetachDataset, detachGridDataset } from "./grid-model.js";
|
|
34
|
+
export type { ChartMappingColumns, ChartMappingDescription } from "./grid-model.js";
|
package/dist/chart-data.js
CHANGED
|
@@ -99,3 +99,4 @@ export function renameChartSeries(editor, chartPath, column, name, options = {})
|
|
|
99
99
|
export function renameChartCategory(editor, chartPath, row, name, options = {}) {
|
|
100
100
|
return setGridCells(editor, chartPath, [{ section: "body", row, column: 0, value: name === "" || name === null ? null : String(name) }], { ...options, kind: "chart" });
|
|
101
101
|
}
|
|
102
|
+
export { describeChartMapping, prepareChartMapping, setChartMapping, prepareDetachDataset, detachGridDataset } from "./grid-model.js";
|
package/dist/data-grid.js
CHANGED
|
@@ -8,12 +8,19 @@
|
|
|
8
8
|
// Keyboard: arrows move (Shift extends), Home/End, Ctrl+Home/End, PageUp/PageDown, Enter or F2 edits, typing replaces the text,
|
|
9
9
|
// Enter/Shift+Enter and Tab/Shift+Tab commit and move, Escape cancels, Delete clears, Ctrl+C/X/V copy, cut and paste TSV, Ctrl+A selects
|
|
10
10
|
// all, Ctrl+Z / Ctrl+Y undo and redo. Outside an edit Tab leaves the grid. Row and column operations are in the toolbar.
|
|
11
|
+
//
|
|
12
|
+
// RR-54: a header may be a DataColumn (the grid shows its name and keeps its number format); the column format field sets or
|
|
13
|
+
// clears the selected column's format; a chart's "Chart columns" panel sets the category, X and series columns (`chart.mapping`);
|
|
14
|
+
// and a chart or table that shows a shared dataset edits the dataset (the status line says how many items share it).
|
|
11
15
|
import {
|
|
16
|
+
columnFormatError,
|
|
12
17
|
columnLabel,
|
|
13
18
|
deleteGridColumns,
|
|
14
19
|
deleteGridRows,
|
|
15
20
|
describeAddress,
|
|
21
|
+
describeChartMapping,
|
|
16
22
|
describeDataGrid,
|
|
23
|
+
detachGridDataset,
|
|
17
24
|
gridCellIssues,
|
|
18
25
|
insertGridColumns,
|
|
19
26
|
insertGridRows,
|
|
@@ -21,9 +28,12 @@ import {
|
|
|
21
28
|
moveGridRows,
|
|
22
29
|
pasteGridText,
|
|
23
30
|
resolveDataGridTarget,
|
|
31
|
+
setChartMapping,
|
|
24
32
|
setGridCells,
|
|
33
|
+
setGridColumnFormat,
|
|
25
34
|
setGridHeader,
|
|
26
35
|
sortGridRows,
|
|
36
|
+
supportsChartTableData,
|
|
27
37
|
transposeGridData,
|
|
28
38
|
} from "./grid-model.js";
|
|
29
39
|
import { NUMBER_FORMATS, parseDelimited, resolveNumberFormat, toDelimited } from "./grid-text.js";
|
|
@@ -66,6 +76,17 @@ const CSS = `
|
|
|
66
76
|
.opf-grid-status{margin:6px 0 0;font-size:11px;color:#3d5e47;min-height:1.2em}.opf-grid-error{margin:6px 0 0;font-size:11px;color:var(--g-error)}.opf-grid-error:empty{display:none}
|
|
67
77
|
.opf-grid details{margin:6px 0 0;font-size:11px}.opf-grid details summary{cursor:pointer}.opf-grid details textarea{display:block;width:100%;box-sizing:border-box;min-height:64px;margin:4px 0;font:11px ui-monospace,monospace}
|
|
68
78
|
.opf-grid-help{margin:6px 0 0;font-size:10px;color:var(--g-muted)}
|
|
79
|
+
.opf-grid-dataset,.opf-grid-format{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0 0 6px;font-size:11px;color:var(--g-muted)}
|
|
80
|
+
.opf-grid-dataset[hidden],.opf-grid-format[hidden],.opf-grid-mapping[hidden]{display:none!important}
|
|
81
|
+
.opf-grid-dataset strong{color:var(--g-text)}
|
|
82
|
+
.opf-grid-dataset button,.opf-grid-format button,.opf-grid-format input,.opf-grid-mapping select{font:inherit;min-height:28px;border:1px solid var(--g-border);border-radius:4px;background:#fff;color:inherit;padding:2px 8px}
|
|
83
|
+
.opf-grid-dataset button,.opf-grid-format button{cursor:pointer}.opf-grid-dataset button:hover,.opf-grid-format button:hover{background:#f1effa}
|
|
84
|
+
.opf-grid-format [aria-disabled=true]{opacity:.45;cursor:default}
|
|
85
|
+
.opf-grid-format input{width:150px}.opf-grid-format input[aria-invalid=true]{border-color:var(--g-error)}
|
|
86
|
+
.opf-grid-format-error{color:var(--g-error)}.opf-grid-format-error:empty{display:none}
|
|
87
|
+
.opf-grid-mapping{margin:6px 0 0;font-size:11px}.opf-grid-mapping label{display:inline-flex;gap:4px;align-items:center;margin:0 10px 4px 0}
|
|
88
|
+
.opf-grid-mapping fieldset{border:1px solid var(--g-border);border-radius:4px;margin:4px 0 0;padding:4px 8px}.opf-grid-mapping legend{font-size:11px;color:var(--g-muted)}
|
|
89
|
+
.opf-grid-mapping p{margin:4px 0 0;color:var(--g-muted)}
|
|
69
90
|
@media (forced-colors:active){.opf-grid td.opf-grid-active{outline:3px solid Highlight}.opf-grid td[aria-selected=true]{outline:1px solid Highlight}.opf-grid td[aria-invalid=true]{outline:2px solid LinkText}}
|
|
70
91
|
`;
|
|
71
92
|
|
|
@@ -142,7 +163,22 @@ export function createDataGrid(container, options = {}) {
|
|
|
142
163
|
const pasteButton = h("button", { type: "button", "aria-disabled": "false" }, "Paste at selected cell");
|
|
143
164
|
const pasteBox = h("details", { class: "opf-grid-paste" }, h("summary", {}, "Paste text"), h("label", { for: `${uid}-paste` }, "Text from a spreadsheet or CSV file"), pasteArea, pasteButton);
|
|
144
165
|
table.setAttribute("aria-describedby", help.id);
|
|
145
|
-
|
|
166
|
+
// A shared dataset: which one, how many items use it, and a way to stop sharing.
|
|
167
|
+
const datasetText = h("span", { "data-role": "dataset-note" });
|
|
168
|
+
const detachButton = h("button", { type: "button", "data-action": "detach-dataset", title: "Give this item its own copy of the data. The shared dataset stays for the other items." }, "Use a copy of the data");
|
|
169
|
+
const datasetRow = h("div", { class: "opf-grid-dataset", role: "note", hidden: true }, datasetText, detachButton);
|
|
170
|
+
// The number format of the selected column.
|
|
171
|
+
const formatInput = h("input", { type: "text", id: `${uid}-format`, "data-role": "column-format", list: `${uid}-formats`, autocomplete: "off", spellcheck: "false", placeholder: "General" });
|
|
172
|
+
const formatList = h("datalist", { id: `${uid}-formats` }, ["#,##0", "#,##0.0", "#,##0.00", "0%", "0.0%", "$#,##0", "$#,##0.00", "0.00"].map((value) => h("option", { value })));
|
|
173
|
+
const formatLabel = h("label", { for: formatInput.id }, "Column format");
|
|
174
|
+
const formatApply = h("button", { type: "button", "data-action": "apply-format", "aria-disabled": "false" }, "Apply");
|
|
175
|
+
const formatClear = h("button", { type: "button", "data-action": "clear-format", "aria-disabled": "false" }, "Clear");
|
|
176
|
+
const formatError = h("span", { class: "opf-grid-format-error", role: "alert", "data-role": "format-error" });
|
|
177
|
+
const formatRow = h("div", { class: "opf-grid-format", hidden: true }, formatLabel, formatInput, formatList, formatApply, formatClear, formatError);
|
|
178
|
+
// A chart's category, X and series columns.
|
|
179
|
+
const mappingBody = h("div", { "data-role": "mapping-body" });
|
|
180
|
+
const mappingBox = h("details", { class: "opf-grid-mapping", hidden: true }, h("summary", {}, "Chart columns"), mappingBody);
|
|
181
|
+
root.append(h("div", { class: "opf-grid-title" }, titleText, pathText), unavailable, datasetRow, toolbar, formatRow, scroll, mappingBox, notes, help, pasteBox, statusEl, errorEl);
|
|
146
182
|
container.append(root);
|
|
147
183
|
scroll.style.setProperty("--opf-grid-max-height", options.maxHeight ?? "320px");
|
|
148
184
|
|
|
@@ -306,7 +342,9 @@ export function createDataGrid(container, options = {}) {
|
|
|
306
342
|
rulerRow.append(corner);
|
|
307
343
|
for (let c = 0; c < grid.columnCount; c += 1) {
|
|
308
344
|
const role = roles[c];
|
|
309
|
-
const
|
|
345
|
+
const role_ = role === "category" ? "categories" : role === "series" ? "series" : role === "x" ? "x values" : role === "label" ? "labels" : role === "other" ? "not plotted" : "";
|
|
346
|
+
const format = grid.columnFormats?.[c];
|
|
347
|
+
const hint = [role_, format ? `format ${format}` : ""].filter(Boolean).join(" · ");
|
|
310
348
|
const th = h("th", { role: "columnheader", scope: "col", "aria-colindex": c + 2, "data-ruler": "column", "data-column": c, "aria-label": `Column ${columnLabel(c)}${hint ? `, ${hint}` : ""}` }, columnLabel(c), hint ? h("span", { class: "opf-grid-role" }, hint) : null);
|
|
311
349
|
rulerRow.append(th);
|
|
312
350
|
}
|
|
@@ -320,7 +358,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
320
358
|
for (const cell of line) {
|
|
321
359
|
if (cell.covered) continue;
|
|
322
360
|
const merged = cell.rowSpan > 1 || cell.colSpan > 1;
|
|
323
|
-
const numeric = target.kind === "chart" && !header && roles[cell.column] !== "category" && roles[cell.column] !== "label";
|
|
361
|
+
const numeric = target.kind === "chart" && !header && roles[cell.column] !== "category" && roles[cell.column] !== "label" && roles[cell.column] !== "other";
|
|
324
362
|
const classes = ["opf-grid-cell"];
|
|
325
363
|
if (header) classes.push("opf-grid-header");
|
|
326
364
|
if (numeric) classes.push("opf-grid-number");
|
|
@@ -345,7 +383,12 @@ export function createDataGrid(container, options = {}) {
|
|
|
345
383
|
title: cell.warning,
|
|
346
384
|
});
|
|
347
385
|
if (cell.runs) renderRuns(td, cell.runs);
|
|
348
|
-
else
|
|
386
|
+
else if (cell.display !== undefined) {
|
|
387
|
+
// The number as the slide draws it; the raw value is what editing and copying use.
|
|
388
|
+
td.textContent = cell.display;
|
|
389
|
+
td.classList.add("opf-grid-formatted");
|
|
390
|
+
if (!cell.warning) td.title = `Stored as ${cell.text}`;
|
|
391
|
+
} else td.textContent = cell.text;
|
|
349
392
|
tr.append(td);
|
|
350
393
|
}
|
|
351
394
|
tbody.append(tr);
|
|
@@ -394,12 +437,14 @@ export function createDataGrid(container, options = {}) {
|
|
|
394
437
|
transpose: target.kind !== "chart" || grid.columnCount < 2,
|
|
395
438
|
};
|
|
396
439
|
for (const [name, button] of buttons) button.setAttribute("aria-disabled", String(Boolean(disabled[name])));
|
|
397
|
-
|
|
440
|
+
const shared = Boolean(target.dataset);
|
|
441
|
+
buttons.get("transpose").hidden = target.kind !== "chart" || shared;
|
|
398
442
|
// The toolbar stays one tab stop even when the button that held it is hidden for this kind of content.
|
|
399
443
|
const visible = [...toolbar.querySelectorAll("button")].filter((button) => !button.hidden);
|
|
400
444
|
if (!visible.some((button) => button.tabIndex === 0) && visible.length) visible[0].tabIndex = 0;
|
|
401
|
-
headerLabel.hidden = target.kind !== "table";
|
|
445
|
+
headerLabel.hidden = target.kind !== "table" || shared;
|
|
402
446
|
headerBox.checked = grid.hasHeader;
|
|
447
|
+
updateFormatRow();
|
|
403
448
|
const nameRows = count > 1 ? `${count} rows` : "row";
|
|
404
449
|
buttons.get("delete-rows").textContent = count > 1 ? `Delete ${count} rows` : "Delete rows";
|
|
405
450
|
buttons.get("insert-row-above").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} above`);
|
|
@@ -437,11 +482,14 @@ export function createDataGrid(container, options = {}) {
|
|
|
437
482
|
scroll.hidden = toolbar.hidden = notes.hidden = pasteBox.hidden = help.hidden = next.editable === false;
|
|
438
483
|
if (next.editable === false) {
|
|
439
484
|
unavailable.textContent = next.reason;
|
|
485
|
+
datasetRow.hidden = formatRow.hidden = mappingBox.hidden = true;
|
|
440
486
|
grid = undefined;
|
|
441
487
|
return;
|
|
442
488
|
}
|
|
443
489
|
grid = describeDataGrid(editor.document, next.path, opts());
|
|
444
490
|
buildTable();
|
|
491
|
+
buildDatasetRow();
|
|
492
|
+
buildMapping();
|
|
445
493
|
const clamped = clampCell(selection.focus.u, selection.focus.c);
|
|
446
494
|
const anchor = clampCell(selection.anchor.u, selection.anchor.c);
|
|
447
495
|
const f = anchorOf(clamped.u, clamped.c);
|
|
@@ -457,7 +505,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
457
505
|
function dataSignature() {
|
|
458
506
|
if (!target || target.editable === false) return "";
|
|
459
507
|
try {
|
|
460
|
-
return JSON.stringify([target.path, editor.get(target.path), numberFormat]);
|
|
508
|
+
return JSON.stringify([target.path, editor.get(target.path), target.dataset ? editor.document.datasets?.[target.dataset.id] : undefined, numberFormat]);
|
|
461
509
|
} catch {
|
|
462
510
|
return "";
|
|
463
511
|
}
|
|
@@ -488,6 +536,128 @@ export function createDataGrid(container, options = {}) {
|
|
|
488
536
|
}
|
|
489
537
|
const unsubscribe = editor.subscribe(() => sync());
|
|
490
538
|
|
|
539
|
+
// --- shared dataset, number format, mapping (RR-54) -----------------------------------------------
|
|
540
|
+
function buildDatasetRow() {
|
|
541
|
+
const info = target?.dataset;
|
|
542
|
+
datasetRow.hidden = !info;
|
|
543
|
+
if (!info) return;
|
|
544
|
+
const used = info.count === 1 ? "used by 1 item" : `used by ${info.count} items`;
|
|
545
|
+
datasetText.replaceChildren("Shared dataset \u2018", h("strong", {}, info.id), `\u2019 \u2014 ${used}.${info.fields ? ` Showing ${info.fields.join(", ")}.` : ""} Edits change the dataset for every item that uses it.`);
|
|
546
|
+
datasetRow.setAttribute("aria-label", `Shared dataset ${info.id}, ${used}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const formatColumn = () => activeCell()?.column ?? 0;
|
|
550
|
+
// The column the field is for. It follows the selected column, but not while someone types in the field: a click on another cell
|
|
551
|
+
// moves the selection before the field's change event fires, and the format typed for one column must not land on the next.
|
|
552
|
+
let formatBound = 0;
|
|
553
|
+
function updateFormatRow() {
|
|
554
|
+
const visible = Boolean(grid?.hasHeader) && target?.editable !== false && supportsChartTableData;
|
|
555
|
+
formatRow.hidden = !visible;
|
|
556
|
+
if (!visible) return;
|
|
557
|
+
if (doc.activeElement !== formatInput) {
|
|
558
|
+
formatBound = formatColumn();
|
|
559
|
+
formatInput.value = grid.columnFormats?.[formatBound] ?? "";
|
|
560
|
+
formatInput.removeAttribute("aria-invalid");
|
|
561
|
+
formatError.textContent = "";
|
|
562
|
+
}
|
|
563
|
+
const column = formatBound;
|
|
564
|
+
const name = grid.columnNames?.[column];
|
|
565
|
+
formatLabel.textContent = `Format of column ${columnLabel(column)}${name ? ` (${name})` : ""}${target?.dataset ? " in the shared dataset" : ""}`;
|
|
566
|
+
formatClear.setAttribute("aria-disabled", String(grid.columnFormats?.[column] === undefined));
|
|
567
|
+
}
|
|
568
|
+
formatInput.addEventListener("input", () => {
|
|
569
|
+
const error = columnFormatError(formatInput.value);
|
|
570
|
+
formatError.textContent = error ?? "";
|
|
571
|
+
if (error) formatInput.setAttribute("aria-invalid", "true");
|
|
572
|
+
else formatInput.removeAttribute("aria-invalid");
|
|
573
|
+
});
|
|
574
|
+
function applyFormat(value) {
|
|
575
|
+
const column = formatBound;
|
|
576
|
+
const error = columnFormatError(value);
|
|
577
|
+
if (error) {
|
|
578
|
+
formatInput.setAttribute("aria-invalid", "true");
|
|
579
|
+
formatError.textContent = error;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
formatError.textContent = "";
|
|
583
|
+
formatInput.removeAttribute("aria-invalid");
|
|
584
|
+
if ((grid.columnFormats?.[column] ?? "") === value) return;
|
|
585
|
+
const cleared = value === "";
|
|
586
|
+
attempt(() => setGridColumnFormat(editor, target.path, column, cleared ? null : value, {}), cleared ? `Column ${columnLabel(column)} shows numbers as they are.` : `Column ${columnLabel(column)} shows numbers as ${value}.`, (failure) => {
|
|
587
|
+
formatError.textContent = messageOf(failure);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
formatInput.addEventListener("keydown", (event) => {
|
|
591
|
+
if (event.key === "Enter") {
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
applyFormat(formatInput.value);
|
|
594
|
+
} else if (event.key === "Escape") {
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
formatInput.value = grid?.columnFormats?.[formatBound] ?? "";
|
|
597
|
+
formatError.textContent = "";
|
|
598
|
+
formatInput.removeAttribute("aria-invalid");
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
formatInput.addEventListener("change", () => applyFormat(formatInput.value));
|
|
602
|
+
formatApply.addEventListener("click", () => applyFormat(formatInput.value));
|
|
603
|
+
formatClear.addEventListener("click", () => {
|
|
604
|
+
if (formatClear.getAttribute("aria-disabled") === "true") return;
|
|
605
|
+
formatInput.value = "";
|
|
606
|
+
applyFormat("");
|
|
607
|
+
});
|
|
608
|
+
detachButton.addEventListener("click", () => {
|
|
609
|
+
if (!target?.dataset) return;
|
|
610
|
+
const id = target.dataset.id;
|
|
611
|
+
attempt(() => detachGridDataset(editor, target.path, {}), `This ${target.kind} now has its own copy of dataset \u2018${id}\u2019.`);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
function buildMapping() {
|
|
615
|
+
mappingBody.replaceChildren();
|
|
616
|
+
mappingBox.hidden = !grid || target?.kind !== "chart" || !supportsChartTableData;
|
|
617
|
+
if (mappingBox.hidden) return;
|
|
618
|
+
let view;
|
|
619
|
+
try {
|
|
620
|
+
view = describeChartMapping(editor.document, target.path);
|
|
621
|
+
} catch {
|
|
622
|
+
mappingBox.hidden = true;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const names = view.columns.map((column) => column.name);
|
|
626
|
+
const selectOf = (label, value, onChange, allowed) => {
|
|
627
|
+
const select = h("select", { "aria-label": label }, names.map((name, index) => (allowed(index) ? h("option", { value: name }, name || `(column ${columnLabel(index)})`) : null)));
|
|
628
|
+
select.value = value;
|
|
629
|
+
select.addEventListener("change", () => onChange(select.value));
|
|
630
|
+
return h("label", {}, label, select);
|
|
631
|
+
};
|
|
632
|
+
const category = names.indexOf(view.category);
|
|
633
|
+
mappingBody.append(selectOf(view.xy ? "Label column" : "Category column", view.category, (name) => setMapping({ category: name }, `The category column is now ${JSON.stringify(name)}.`), () => true));
|
|
634
|
+
if (view.xy) mappingBody.append(selectOf("X column", view.x ?? "", (name) => setMapping({ x: name }, `The X column is now ${JSON.stringify(name)}.`), (index) => index !== category));
|
|
635
|
+
const plotted = new Set(view.series);
|
|
636
|
+
const boxes = view.columns.map((column, index) => {
|
|
637
|
+
if (column.name === view.category || column.name === view.x) return null;
|
|
638
|
+
const input = h("input", { type: "checkbox", value: column.name, checked: plotted.has(column.name) || undefined });
|
|
639
|
+
input.checked = plotted.has(column.name);
|
|
640
|
+
input.addEventListener("change", () => {
|
|
641
|
+
const wanted = [...view.series.filter((name) => name !== column.name), ...(input.checked ? [column.name] : [])];
|
|
642
|
+
// Series that run in column order stay in column order; series someone arranged keep their order and a column switched on goes after them.
|
|
643
|
+
const inColumnOrder = view.series.every((name, at) => at === 0 || names.indexOf(view.series[at - 1]) < names.indexOf(name));
|
|
644
|
+
const ordered = inColumnOrder ? names.filter((name) => wanted.includes(name)) : [...view.series.filter((name) => wanted.includes(name)), ...names.filter((name) => wanted.includes(name) && !view.series.includes(name))];
|
|
645
|
+
setMapping({ series: ordered }, input.checked ? `${column.name || `Column ${columnLabel(index)}`} is now plotted.` : `${column.name || `Column ${columnLabel(index)}`} is no longer plotted.`, () => {
|
|
646
|
+
input.checked = !input.checked;
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
return h("label", {}, input, column.name || `(column ${columnLabel(index)})`);
|
|
650
|
+
});
|
|
651
|
+
mappingBody.append(h("fieldset", {}, h("legend", {}, "Series (plotted columns)"), boxes));
|
|
652
|
+
mappingBody.append(h("p", {}, view.authored ? "This chart has its own column mapping. Choosing the default columns removes it." : "Default: the first column labels the categories and every other column is a series."));
|
|
653
|
+
}
|
|
654
|
+
function setMapping(wanted, message, onFail) {
|
|
655
|
+
attempt(() => setChartMapping(editor, target.path, wanted, {}), message, (failure) => {
|
|
656
|
+
onFail?.(failure);
|
|
657
|
+
buildMapping();
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
491
661
|
// --- editing --------------------------------------------------------------------------------------
|
|
492
662
|
function mountEditor(u, c, text, caret) {
|
|
493
663
|
const node = cellNode(u, c);
|
package/dist/data.d.ts
CHANGED
|
@@ -1,2 +1,23 @@
|
|
|
1
1
|
export {parseTabularData, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
|
|
2
2
|
export type {TabularData, DataImportOptions, DataContentOptions} from "@openpresentation/opf/data";
|
|
3
|
+
import type { JsonPatchOperation } from "./index.js";
|
|
4
|
+
|
|
5
|
+
/** The id pattern of a top-level dataset (the assets id pattern). */
|
|
6
|
+
export declare const DATASET_ID_PATTERN: RegExp;
|
|
7
|
+
export interface DatasetImportOptions {
|
|
8
|
+
/** The dataset id: letters, digits, ".", "_" or "-", starting with a letter or digit. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Provenance to record in the dataset's `source` (merged into an existing one). Engines never read it. */
|
|
11
|
+
source?: { src: string; retrieved?: string; sheet?: string; range?: string; description?: string };
|
|
12
|
+
}
|
|
13
|
+
export interface DatasetImport {
|
|
14
|
+
id: string;
|
|
15
|
+
/** The imported table or chart, holding `{ dataset: id }` instead of its rows. */
|
|
16
|
+
content: { table: { dataset: string } } | { chart: { type: string; data: { dataset: string } } };
|
|
17
|
+
/** Patches that write `datasets.<id>`; apply them with the patch that places `content`, in one call. */
|
|
18
|
+
patches: JsonPatchOperation[];
|
|
19
|
+
/** True when a dataset of that id existed and its columns and rows were replaced. */
|
|
20
|
+
replaced: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Store imported data (the result of `createDataContent`) as a shared dataset, as `opf import-data --dataset <id>` does. Throws `dataset-id-invalid`. */
|
|
23
|
+
export declare function prepareDatasetImport(document: unknown, content: { table: unknown } | { chart: { type: string; data: unknown } }, options: DatasetImportOptions): DatasetImport;
|
package/dist/data.js
CHANGED
|
@@ -1 +1,40 @@
|
|
|
1
1
|
export {parseTabularData, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
|
|
2
|
+
import { opfPathToJsonPointer } from "./index.js";
|
|
3
|
+
import { fail } from "./edit-helpers.js";
|
|
4
|
+
|
|
5
|
+
/** The id pattern of a top-level dataset (the assets id pattern). */
|
|
6
|
+
export const DATASET_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
7
|
+
|
|
8
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Store imported data as a shared dataset (RR-54), as `opf import-data --dataset <id>` does. `content` is what `createDataContent` returns
|
|
12
|
+
* (`{ table }` or `{ chart: { type, data } }`). Returns `{ id, content, patches, replaced }`: `content` is the same table or chart holding
|
|
13
|
+
* `{ dataset: id }` instead of the rows, and `patches` write `datasets.<id>` (the imported columns and rows replace those of an existing
|
|
14
|
+
* dataset of that id, which keeps its title, description and source; `source` is merged into its `source`). Apply `patches` and the patch that
|
|
15
|
+
* places `content` in one `applyPatch` call so the import is one undoable edit. Throws `dataset-id-invalid`.
|
|
16
|
+
*/
|
|
17
|
+
export function prepareDatasetImport(document, content, options = {}) {
|
|
18
|
+
const id = options.id;
|
|
19
|
+
if (typeof id !== "string" || !DATASET_ID_PATTERN.test(id)) throw fail("dataset-id-invalid", 'A dataset id starts with a letter or digit and holds letters, digits, ".", "_" or "-".', { id });
|
|
20
|
+
const isTable = isObject(content) && isObject(content.table);
|
|
21
|
+
if (!isTable && !(isObject(content) && isObject(content.chart) && isObject(content.chart.data))) throw fail("dataset-content-invalid", "Give the table or chart that createDataContent returned.", {});
|
|
22
|
+
const data = isTable ? content.table : content.chart.data;
|
|
23
|
+
const datasets = isObject(document) && isObject(document.datasets) ? document.datasets : undefined;
|
|
24
|
+
const previous = datasets && Object.hasOwn(datasets, id) && isObject(datasets[id]) ? datasets[id] : undefined;
|
|
25
|
+
const origin = isObject(options.source) && typeof options.source.src === "string" ? options.source : undefined;
|
|
26
|
+
const entry = {
|
|
27
|
+
...(previous ?? {}),
|
|
28
|
+
columns: structuredClone(data.columns),
|
|
29
|
+
rows: structuredClone(data.rows),
|
|
30
|
+
...(origin ? { source: { ...(isObject(previous?.source) ? previous.source : {}), ...origin } } : {}),
|
|
31
|
+
};
|
|
32
|
+
const pointer = opfPathToJsonPointer(["datasets", id]);
|
|
33
|
+
const patches = !datasets
|
|
34
|
+
? [{ op: "add", path: "/datasets", value: { [id]: entry } }]
|
|
35
|
+
: previous
|
|
36
|
+
? [{ op: "test", path: pointer, value: structuredClone(previous) }, { op: "replace", path: pointer, value: entry }]
|
|
37
|
+
: [{ op: "add", path: pointer, value: entry }];
|
|
38
|
+
const referenced = isTable ? { table: { dataset: id } } : { chart: { type: content.chart.type, data: { dataset: id } } };
|
|
39
|
+
return { id, content: referenced, patches, replaced: Boolean(previous) };
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A chart or table that references a dataset: `chart.data = { dataset, fields? }` or `table = { dataset, fields? }`. */
|
|
2
|
+
export interface DatasetItem {
|
|
3
|
+
kind: "chart" | "table";
|
|
4
|
+
id: string;
|
|
5
|
+
/** Path parts of the chart or table. */
|
|
6
|
+
parts: string[];
|
|
7
|
+
item: Record<string, any>;
|
|
8
|
+
/** Path parts of the object that holds `dataset` and `fields` (`chart.data`, or the table itself). */
|
|
9
|
+
refParts: string[];
|
|
10
|
+
ref: Record<string, any>;
|
|
11
|
+
}
|
|
12
|
+
/** The dataset reference test (core's `isDatasetRef`). */
|
|
13
|
+
export declare function isDatasetRef(value: unknown): boolean;
|
|
14
|
+
/** Visit every chart and table that references a dataset (slides, nested blocks and promoted regions). */
|
|
15
|
+
export declare function walkDatasetItems(document: unknown, visit: (entry: DatasetItem) => void): void;
|
|
16
|
+
/** The charts and tables that use dataset `id`, in document order. */
|
|
17
|
+
export declare function datasetUsage(document: unknown, id: string): Array<{ kind: "chart" | "table"; path: string; parts: string[] }>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Where charts and tables use a shared dataset (RR-54). A top-level `datasets` entry is referenced by a chart as
|
|
2
|
+
// `chart.data = { dataset, fields? }` and by a table as `table = { dataset, fields? }`. These helpers find those
|
|
3
|
+
// references anywhere in a document (slides, nested blocks and promoted regions), so the data grid can say how many items
|
|
4
|
+
// share a dataset and keep their `fields` and `mapping` names in step when a column is renamed.
|
|
5
|
+
|
|
6
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
const REGION_KEY = /^(?:top|middle|bottom|left|center|right)(?:[+:]|$)/;
|
|
8
|
+
|
|
9
|
+
/** The dataset reference test (the same as core's `isDatasetRef`): an object with a string `dataset`. */
|
|
10
|
+
export const isDatasetRef = (value) => isObject(value) && typeof value.dataset === "string";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Visit every chart and table that references a dataset. `visit({ kind, id, parts, item, refParts, ref })`:
|
|
14
|
+
* `item` is the chart or table object, `parts` its path, and `ref` the object that holds `dataset` and `fields`
|
|
15
|
+
* (`chart.data` for a chart, the table itself for a table) at `refParts`.
|
|
16
|
+
*/
|
|
17
|
+
export function walkDatasetItems(document, visit) {
|
|
18
|
+
if (!isObject(document) || !Array.isArray(document.slides)) return;
|
|
19
|
+
const walk = (payload, base, depth, ancestors) => {
|
|
20
|
+
if (isObject(payload.chart) && isDatasetRef(payload.chart.data))
|
|
21
|
+
visit({ kind: "chart", id: payload.chart.data.dataset, parts: [...base, "chart"], item: payload.chart, refParts: [...base, "chart", "data"], ref: payload.chart.data });
|
|
22
|
+
if (isObject(payload.table) && isDatasetRef(payload.table))
|
|
23
|
+
visit({ kind: "table", id: payload.table.dataset, parts: [...base, "table"], item: payload.table, refParts: [...base, "table"], ref: payload.table });
|
|
24
|
+
if (depth >= 32 || ancestors.includes(payload) || !Array.isArray(payload.blocks)) return;
|
|
25
|
+
const nested = [...ancestors, payload];
|
|
26
|
+
payload.blocks.forEach((block, index) => {
|
|
27
|
+
if (isObject(block)) walk(block, [...base, "blocks", String(index)], depth + 1, nested);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
document.slides.forEach((slide, index) => {
|
|
31
|
+
if (!isObject(slide)) return;
|
|
32
|
+
const base = ["slides", String(index)];
|
|
33
|
+
walk(slide, base, 0, []);
|
|
34
|
+
for (const [key, value] of Object.entries(slide)) if (isObject(value) && REGION_KEY.test(key)) walk(value, [...base, key], 1, [slide]);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The charts and tables that use dataset `id`: `[{ kind, path, parts }]` in document order. */
|
|
39
|
+
export function datasetUsage(document, id) {
|
|
40
|
+
const items = [];
|
|
41
|
+
walkDatasetItems(document, (entry) => {
|
|
42
|
+
if (entry.id === id) items.push({ kind: entry.kind, path: entry.parts.join("."), parts: entry.parts });
|
|
43
|
+
});
|
|
44
|
+
return items;
|
|
45
|
+
}
|
package/dist/design-controls.js
CHANGED
|
@@ -1009,9 +1009,11 @@ export function createDesignControls(container, options = {}) {
|
|
|
1009
1009
|
const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
|
|
1010
1010
|
dynamic.tablePath = tablePath;
|
|
1011
1011
|
dynamic.cell = parsed?.cell;
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1012
|
+
// A table that shows a shared dataset (RR-54) has no cell styles or merges: the table panel is for inline tables.
|
|
1013
|
+
const tableValue = tablePath ? editor.get(tablePath) : undefined;
|
|
1014
|
+
tableUi.details.hidden = !tablePath || !Array.isArray(tableValue?.rows);
|
|
1015
|
+
if (tablePath && !tableUi.details.hidden) {
|
|
1016
|
+
const table = tableValue;
|
|
1015
1017
|
tableUi.summary.textContent = `Table (${tablePath})`;
|
|
1016
1018
|
const style = readTableStyle(document_, tablePath);
|
|
1017
1019
|
dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
|
package/dist/find-replace.js
CHANGED
|
@@ -97,14 +97,16 @@ function payloadFields(fields, state, base, payload, prefix = "") {
|
|
|
97
97
|
const chart = payload.chart;
|
|
98
98
|
if (isObject(chart) && isObject(chart.data)) {
|
|
99
99
|
const { columns, rows } = chart.data;
|
|
100
|
-
|
|
100
|
+
// A column name that `mapping` addresses is not replaced: the mapping would stop finding it.
|
|
101
|
+
if (Array.isArray(columns) && !isObject(chart.mapping))
|
|
102
|
+
columns.forEach((value, index) => (isObject(value) ? field(fields, state, at("chart", "data", "columns", String(index), "name"), value.name, `${prefix}Chart label ${index + 1}`, "chart") : field(fields, state, at("chart", "data", "columns", String(index)), value, `${prefix}Chart label ${index + 1}`, "chart")));
|
|
101
103
|
if (Array.isArray(rows)) rows.forEach((row, r) => Array.isArray(row) && row.forEach((value, c) => {
|
|
102
104
|
if (typeof value === "string") field(fields, state, at("chart", "data", "rows", String(r), String(c)), value, `${prefix}Chart row ${r + 1}, column ${c + 1}`, "chart");
|
|
103
105
|
}));
|
|
104
106
|
}
|
|
105
107
|
const table = payload.table;
|
|
106
108
|
if (isObject(table)) {
|
|
107
|
-
if (Array.isArray(table.columns)) table.columns.forEach((cell, c) => cellFields(fields, state, at("table", "columns", String(c)), cell, `${prefix}Table header ${c + 1}`));
|
|
109
|
+
if (Array.isArray(table.columns)) table.columns.forEach((cell, c) => (isObject(cell) && !("value" in cell) ? field(fields, state, at("table", "columns", String(c), "name"), cell.name, `${prefix}Table header ${c + 1}`, "table") : cellFields(fields, state, at("table", "columns", String(c)), cell, `${prefix}Table header ${c + 1}`)));
|
|
108
110
|
if (Array.isArray(table.rows)) table.rows.forEach((row, r) => Array.isArray(row) && row.forEach((cell, c) => cellFields(fields, state, at("table", "rows", String(r), String(c)), cell, `${prefix}Table row ${r + 1}, column ${c + 1}`)));
|
|
109
111
|
}
|
|
110
112
|
if (isObject(payload.image)) field(fields, state, at("image", "alt"), payload.image.alt, `${prefix}Image alt text`, "alt");
|
|
@@ -139,6 +141,16 @@ export function collectSearchFields(document, options = {}) {
|
|
|
139
141
|
field(fields, deck, ["description"], document.description, "Presentation description", "deck");
|
|
140
142
|
furnitureFields(fields, deck, [], document.design);
|
|
141
143
|
}
|
|
144
|
+
// RR-54: the text cells of a shared dataset belong to the deck. Its column names are left alone (`fields` and `mapping` address them).
|
|
145
|
+
if (only === undefined && isObject(document.datasets)) {
|
|
146
|
+
const deck = { slideIndex: -1 };
|
|
147
|
+
for (const [id, dataset] of Object.entries(document.datasets)) {
|
|
148
|
+
if (!isObject(dataset) || !Array.isArray(dataset.rows)) continue;
|
|
149
|
+
dataset.rows.forEach((row, r) => Array.isArray(row) && row.forEach((value, c) => {
|
|
150
|
+
if (typeof value === "string") field(fields, deck, ["datasets", id, "rows", String(r), String(c)], value, `Dataset ${id}, row ${r + 1}, column ${c + 1}`, "dataset");
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
142
154
|
(Array.isArray(document.slides) ? document.slides : []).forEach((slide, slideIndex) => {
|
|
143
155
|
if (only !== undefined && slideIndex !== only) return;
|
|
144
156
|
if (!isObject(slide)) return;
|