@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/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,7 +28,9 @@ import {
|
|
|
21
28
|
moveGridRows,
|
|
22
29
|
pasteGridText,
|
|
23
30
|
resolveDataGridTarget,
|
|
31
|
+
setChartMapping,
|
|
24
32
|
setGridCells,
|
|
33
|
+
setGridColumnFormat,
|
|
25
34
|
setGridHeader,
|
|
26
35
|
sortGridRows,
|
|
27
36
|
transposeGridData,
|
|
@@ -66,6 +75,17 @@ const CSS = `
|
|
|
66
75
|
.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
76
|
.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
77
|
.opf-grid-help{margin:6px 0 0;font-size:10px;color:var(--g-muted)}
|
|
78
|
+
.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)}
|
|
79
|
+
.opf-grid-dataset[hidden],.opf-grid-format[hidden],.opf-grid-mapping[hidden]{display:none!important}
|
|
80
|
+
.opf-grid-dataset strong{color:var(--g-text)}
|
|
81
|
+
.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}
|
|
82
|
+
.opf-grid-dataset button,.opf-grid-format button{cursor:pointer}.opf-grid-dataset button:hover,.opf-grid-format button:hover{background:#f1effa}
|
|
83
|
+
.opf-grid-format [aria-disabled=true]{opacity:.45;cursor:default}
|
|
84
|
+
.opf-grid-format input{width:150px}.opf-grid-format input[aria-invalid=true]{border-color:var(--g-error)}
|
|
85
|
+
.opf-grid-format-error{color:var(--g-error)}.opf-grid-format-error:empty{display:none}
|
|
86
|
+
.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}
|
|
87
|
+
.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)}
|
|
88
|
+
.opf-grid-mapping p{margin:4px 0 0;color:var(--g-muted)}
|
|
69
89
|
@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
90
|
`;
|
|
71
91
|
|
|
@@ -142,7 +162,22 @@ export function createDataGrid(container, options = {}) {
|
|
|
142
162
|
const pasteButton = h("button", { type: "button", "aria-disabled": "false" }, "Paste at selected cell");
|
|
143
163
|
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
164
|
table.setAttribute("aria-describedby", help.id);
|
|
145
|
-
|
|
165
|
+
// A shared dataset: which one, how many items use it, and a way to stop sharing.
|
|
166
|
+
const datasetText = h("span", { "data-role": "dataset-note" });
|
|
167
|
+
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");
|
|
168
|
+
const datasetRow = h("div", { class: "opf-grid-dataset", role: "note", hidden: true }, datasetText, detachButton);
|
|
169
|
+
// The number format of the selected column.
|
|
170
|
+
const formatInput = h("input", { type: "text", id: `${uid}-format`, "data-role": "column-format", list: `${uid}-formats`, autocomplete: "off", spellcheck: "false", placeholder: "General" });
|
|
171
|
+
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 })));
|
|
172
|
+
const formatLabel = h("label", { for: formatInput.id }, "Column format");
|
|
173
|
+
const formatApply = h("button", { type: "button", "data-action": "apply-format", "aria-disabled": "false" }, "Apply");
|
|
174
|
+
const formatClear = h("button", { type: "button", "data-action": "clear-format", "aria-disabled": "false" }, "Clear");
|
|
175
|
+
const formatError = h("span", { class: "opf-grid-format-error", role: "alert", "data-role": "format-error" });
|
|
176
|
+
const formatRow = h("div", { class: "opf-grid-format", hidden: true }, formatLabel, formatInput, formatList, formatApply, formatClear, formatError);
|
|
177
|
+
// A chart's category, X and series columns.
|
|
178
|
+
const mappingBody = h("div", { "data-role": "mapping-body" });
|
|
179
|
+
const mappingBox = h("details", { class: "opf-grid-mapping", hidden: true }, h("summary", {}, "Chart columns"), mappingBody);
|
|
180
|
+
root.append(h("div", { class: "opf-grid-title" }, titleText, pathText), unavailable, datasetRow, toolbar, formatRow, scroll, mappingBox, notes, help, pasteBox, statusEl, errorEl);
|
|
146
181
|
container.append(root);
|
|
147
182
|
scroll.style.setProperty("--opf-grid-max-height", options.maxHeight ?? "320px");
|
|
148
183
|
|
|
@@ -306,7 +341,9 @@ export function createDataGrid(container, options = {}) {
|
|
|
306
341
|
rulerRow.append(corner);
|
|
307
342
|
for (let c = 0; c < grid.columnCount; c += 1) {
|
|
308
343
|
const role = roles[c];
|
|
309
|
-
const
|
|
344
|
+
const role_ = role === "category" ? "categories" : role === "series" ? "series" : role === "x" ? "x values" : role === "label" ? "labels" : role === "other" ? "not plotted" : "";
|
|
345
|
+
const format = grid.columnFormats?.[c];
|
|
346
|
+
const hint = [role_, format ? `format ${format}` : ""].filter(Boolean).join(" · ");
|
|
310
347
|
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
348
|
rulerRow.append(th);
|
|
312
349
|
}
|
|
@@ -320,7 +357,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
320
357
|
for (const cell of line) {
|
|
321
358
|
if (cell.covered) continue;
|
|
322
359
|
const merged = cell.rowSpan > 1 || cell.colSpan > 1;
|
|
323
|
-
const numeric = target.kind === "chart" && !header && roles[cell.column] !== "category" && roles[cell.column] !== "label";
|
|
360
|
+
const numeric = target.kind === "chart" && !header && roles[cell.column] !== "category" && roles[cell.column] !== "label" && roles[cell.column] !== "other";
|
|
324
361
|
const classes = ["opf-grid-cell"];
|
|
325
362
|
if (header) classes.push("opf-grid-header");
|
|
326
363
|
if (numeric) classes.push("opf-grid-number");
|
|
@@ -345,7 +382,12 @@ export function createDataGrid(container, options = {}) {
|
|
|
345
382
|
title: cell.warning,
|
|
346
383
|
});
|
|
347
384
|
if (cell.runs) renderRuns(td, cell.runs);
|
|
348
|
-
else
|
|
385
|
+
else if (cell.display !== undefined) {
|
|
386
|
+
// The number as the slide draws it; the raw value is what editing and copying use.
|
|
387
|
+
td.textContent = cell.display;
|
|
388
|
+
td.classList.add("opf-grid-formatted");
|
|
389
|
+
if (!cell.warning) td.title = `Stored as ${cell.text}`;
|
|
390
|
+
} else td.textContent = cell.text;
|
|
349
391
|
tr.append(td);
|
|
350
392
|
}
|
|
351
393
|
tbody.append(tr);
|
|
@@ -394,12 +436,14 @@ export function createDataGrid(container, options = {}) {
|
|
|
394
436
|
transpose: target.kind !== "chart" || grid.columnCount < 2,
|
|
395
437
|
};
|
|
396
438
|
for (const [name, button] of buttons) button.setAttribute("aria-disabled", String(Boolean(disabled[name])));
|
|
397
|
-
|
|
439
|
+
const shared = Boolean(target.dataset);
|
|
440
|
+
buttons.get("transpose").hidden = target.kind !== "chart" || shared;
|
|
398
441
|
// The toolbar stays one tab stop even when the button that held it is hidden for this kind of content.
|
|
399
442
|
const visible = [...toolbar.querySelectorAll("button")].filter((button) => !button.hidden);
|
|
400
443
|
if (!visible.some((button) => button.tabIndex === 0) && visible.length) visible[0].tabIndex = 0;
|
|
401
|
-
headerLabel.hidden = target.kind !== "table";
|
|
444
|
+
headerLabel.hidden = target.kind !== "table" || shared;
|
|
402
445
|
headerBox.checked = grid.hasHeader;
|
|
446
|
+
updateFormatRow();
|
|
403
447
|
const nameRows = count > 1 ? `${count} rows` : "row";
|
|
404
448
|
buttons.get("delete-rows").textContent = count > 1 ? `Delete ${count} rows` : "Delete rows";
|
|
405
449
|
buttons.get("insert-row-above").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} above`);
|
|
@@ -411,7 +455,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
411
455
|
function resolveTarget() {
|
|
412
456
|
const path = getPath();
|
|
413
457
|
if (!path) return undefined;
|
|
414
|
-
return resolveDataGridTarget(editor.
|
|
458
|
+
return resolveDataGridTarget(editor.presentation, path);
|
|
415
459
|
}
|
|
416
460
|
|
|
417
461
|
function rebuild() {
|
|
@@ -437,11 +481,14 @@ export function createDataGrid(container, options = {}) {
|
|
|
437
481
|
scroll.hidden = toolbar.hidden = notes.hidden = pasteBox.hidden = help.hidden = next.editable === false;
|
|
438
482
|
if (next.editable === false) {
|
|
439
483
|
unavailable.textContent = next.reason;
|
|
484
|
+
datasetRow.hidden = formatRow.hidden = mappingBox.hidden = true;
|
|
440
485
|
grid = undefined;
|
|
441
486
|
return;
|
|
442
487
|
}
|
|
443
|
-
grid = describeDataGrid(editor.
|
|
488
|
+
grid = describeDataGrid(editor.presentation, next.path, opts());
|
|
444
489
|
buildTable();
|
|
490
|
+
buildDatasetRow();
|
|
491
|
+
buildMapping();
|
|
445
492
|
const clamped = clampCell(selection.focus.u, selection.focus.c);
|
|
446
493
|
const anchor = clampCell(selection.anchor.u, selection.anchor.c);
|
|
447
494
|
const f = anchorOf(clamped.u, clamped.c);
|
|
@@ -457,7 +504,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
457
504
|
function dataSignature() {
|
|
458
505
|
if (!target || target.editable === false) return "";
|
|
459
506
|
try {
|
|
460
|
-
return JSON.stringify([target.path, editor.get(target.path), numberFormat]);
|
|
507
|
+
return JSON.stringify([target.path, editor.get(target.path), target.dataset ? editor.presentation.datasets?.[target.dataset.id] : undefined, numberFormat]);
|
|
461
508
|
} catch {
|
|
462
509
|
return "";
|
|
463
510
|
}
|
|
@@ -488,6 +535,128 @@ export function createDataGrid(container, options = {}) {
|
|
|
488
535
|
}
|
|
489
536
|
const unsubscribe = editor.subscribe(() => sync());
|
|
490
537
|
|
|
538
|
+
// --- shared dataset, number format, mapping (RR-54) -----------------------------------------------
|
|
539
|
+
function buildDatasetRow() {
|
|
540
|
+
const info = target?.dataset;
|
|
541
|
+
datasetRow.hidden = !info;
|
|
542
|
+
if (!info) return;
|
|
543
|
+
const used = info.count === 1 ? "used by 1 item" : `used by ${info.count} items`;
|
|
544
|
+
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.`);
|
|
545
|
+
datasetRow.setAttribute("aria-label", `Shared dataset ${info.id}, ${used}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const formatColumn = () => activeCell()?.column ?? 0;
|
|
549
|
+
// The column the field is for. It follows the selected column, but not while someone types in the field: a click on another cell
|
|
550
|
+
// moves the selection before the field's change event fires, and the format typed for one column must not land on the next.
|
|
551
|
+
let formatBound = 0;
|
|
552
|
+
function updateFormatRow() {
|
|
553
|
+
const visible = Boolean(grid?.hasHeader) && target?.editable !== false;
|
|
554
|
+
formatRow.hidden = !visible;
|
|
555
|
+
if (!visible) return;
|
|
556
|
+
if (doc.activeElement !== formatInput) {
|
|
557
|
+
formatBound = formatColumn();
|
|
558
|
+
formatInput.value = grid.columnFormats?.[formatBound] ?? "";
|
|
559
|
+
formatInput.removeAttribute("aria-invalid");
|
|
560
|
+
formatError.textContent = "";
|
|
561
|
+
}
|
|
562
|
+
const column = formatBound;
|
|
563
|
+
const name = grid.columnNames?.[column];
|
|
564
|
+
formatLabel.textContent = `Format of column ${columnLabel(column)}${name ? ` (${name})` : ""}${target?.dataset ? " in the shared dataset" : ""}`;
|
|
565
|
+
formatClear.setAttribute("aria-disabled", String(grid.columnFormats?.[column] === undefined));
|
|
566
|
+
}
|
|
567
|
+
formatInput.addEventListener("input", () => {
|
|
568
|
+
const error = columnFormatError(formatInput.value);
|
|
569
|
+
formatError.textContent = error ?? "";
|
|
570
|
+
if (error) formatInput.setAttribute("aria-invalid", "true");
|
|
571
|
+
else formatInput.removeAttribute("aria-invalid");
|
|
572
|
+
});
|
|
573
|
+
function applyFormat(value) {
|
|
574
|
+
const column = formatBound;
|
|
575
|
+
const error = columnFormatError(value);
|
|
576
|
+
if (error) {
|
|
577
|
+
formatInput.setAttribute("aria-invalid", "true");
|
|
578
|
+
formatError.textContent = error;
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
formatError.textContent = "";
|
|
582
|
+
formatInput.removeAttribute("aria-invalid");
|
|
583
|
+
if ((grid.columnFormats?.[column] ?? "") === value) return;
|
|
584
|
+
const cleared = value === "";
|
|
585
|
+
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) => {
|
|
586
|
+
formatError.textContent = messageOf(failure);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
formatInput.addEventListener("keydown", (event) => {
|
|
590
|
+
if (event.key === "Enter") {
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
applyFormat(formatInput.value);
|
|
593
|
+
} else if (event.key === "Escape") {
|
|
594
|
+
event.preventDefault();
|
|
595
|
+
formatInput.value = grid?.columnFormats?.[formatBound] ?? "";
|
|
596
|
+
formatError.textContent = "";
|
|
597
|
+
formatInput.removeAttribute("aria-invalid");
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
formatInput.addEventListener("change", () => applyFormat(formatInput.value));
|
|
601
|
+
formatApply.addEventListener("click", () => applyFormat(formatInput.value));
|
|
602
|
+
formatClear.addEventListener("click", () => {
|
|
603
|
+
if (formatClear.getAttribute("aria-disabled") === "true") return;
|
|
604
|
+
formatInput.value = "";
|
|
605
|
+
applyFormat("");
|
|
606
|
+
});
|
|
607
|
+
detachButton.addEventListener("click", () => {
|
|
608
|
+
if (!target?.dataset) return;
|
|
609
|
+
const id = target.dataset.id;
|
|
610
|
+
attempt(() => detachGridDataset(editor, target.path, {}), `This ${target.kind} now has its own copy of dataset \u2018${id}\u2019.`);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
function buildMapping() {
|
|
614
|
+
mappingBody.replaceChildren();
|
|
615
|
+
mappingBox.hidden = !grid || target?.kind !== "chart";
|
|
616
|
+
if (mappingBox.hidden) return;
|
|
617
|
+
let view;
|
|
618
|
+
try {
|
|
619
|
+
view = describeChartMapping(editor.presentation, target.path);
|
|
620
|
+
} catch {
|
|
621
|
+
mappingBox.hidden = true;
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const names = view.columns.map((column) => column.name);
|
|
625
|
+
const selectOf = (label, value, onChange, allowed) => {
|
|
626
|
+
const select = h("select", { "aria-label": label }, names.map((name, index) => (allowed(index) ? h("option", { value: name }, name || `(column ${columnLabel(index)})`) : null)));
|
|
627
|
+
select.value = value;
|
|
628
|
+
select.addEventListener("change", () => onChange(select.value));
|
|
629
|
+
return h("label", {}, label, select);
|
|
630
|
+
};
|
|
631
|
+
const category = names.indexOf(view.category);
|
|
632
|
+
mappingBody.append(selectOf(view.xy ? "Label column" : "Category column", view.category, (name) => setMapping({ category: name }, `The category column is now ${JSON.stringify(name)}.`), () => true));
|
|
633
|
+
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));
|
|
634
|
+
const plotted = new Set(view.series);
|
|
635
|
+
const boxes = view.columns.map((column, index) => {
|
|
636
|
+
if (column.name === view.category || column.name === view.x) return null;
|
|
637
|
+
const input = h("input", { type: "checkbox", value: column.name, checked: plotted.has(column.name) || undefined });
|
|
638
|
+
input.checked = plotted.has(column.name);
|
|
639
|
+
input.addEventListener("change", () => {
|
|
640
|
+
const wanted = [...view.series.filter((name) => name !== column.name), ...(input.checked ? [column.name] : [])];
|
|
641
|
+
// Series that run in column order stay in column order; series someone arranged keep their order and a column switched on goes after them.
|
|
642
|
+
const inColumnOrder = view.series.every((name, at) => at === 0 || names.indexOf(view.series[at - 1]) < names.indexOf(name));
|
|
643
|
+
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))];
|
|
644
|
+
setMapping({ series: ordered }, input.checked ? `${column.name || `Column ${columnLabel(index)}`} is now plotted.` : `${column.name || `Column ${columnLabel(index)}`} is no longer plotted.`, () => {
|
|
645
|
+
input.checked = !input.checked;
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
return h("label", {}, input, column.name || `(column ${columnLabel(index)})`);
|
|
649
|
+
});
|
|
650
|
+
mappingBody.append(h("fieldset", {}, h("legend", {}, "Series (plotted columns)"), boxes));
|
|
651
|
+
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."));
|
|
652
|
+
}
|
|
653
|
+
function setMapping(wanted, message, onFail) {
|
|
654
|
+
attempt(() => setChartMapping(editor, target.path, wanted, {}), message, (failure) => {
|
|
655
|
+
onFail?.(failure);
|
|
656
|
+
buildMapping();
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
491
660
|
// --- editing --------------------------------------------------------------------------------------
|
|
492
661
|
function mountEditor(u, c, text, caret) {
|
|
493
662
|
const node = cellNode(u, c);
|
|
@@ -524,7 +693,7 @@ export function createDataGrid(container, options = {}) {
|
|
|
524
693
|
}
|
|
525
694
|
|
|
526
695
|
function checkLive(area, u, c) {
|
|
527
|
-
const issues = gridCellIssues(editor.
|
|
696
|
+
const issues = gridCellIssues(editor.presentation, target.path, [{ ...addr(u, c), text: area.value }], opts());
|
|
528
697
|
if (issues.length) {
|
|
529
698
|
area.setAttribute("aria-invalid", "true");
|
|
530
699
|
complain(issues[0].message);
|
package/dist/data.d.ts
CHANGED
|
@@ -1,2 +1,23 @@
|
|
|
1
|
-
export {parseTabularData,
|
|
2
|
-
export type {TabularData, DataImportOptions,
|
|
1
|
+
export {parseTabularData, importData, OPFDataImportError} from "@openpresentation/opf/data";
|
|
2
|
+
export type {TabularData, DataImportOptions, ImportDataOptions} 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 `importData`) as a shared dataset, as `opf import-data --dataset <id>` does. Throws `dataset-id-invalid`. */
|
|
23
|
+
export declare function prepareDatasetImport(presentation: unknown, content: { table: unknown } | { chart: { type: string; data: unknown } }, options: DatasetImportOptions): DatasetImport;
|
package/dist/data.js
CHANGED
|
@@ -1 +1,40 @@
|
|
|
1
|
-
export {parseTabularData,
|
|
1
|
+
export {parseTabularData, importData, 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 `importData` 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(presentation, 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 importData returned.", {});
|
|
22
|
+
const data = isTable ? content.table : content.chart.data;
|
|
23
|
+
const datasets = isObject(presentation) && isObject(presentation.datasets) ? presentation.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(presentation: unknown, visit: (entry: DatasetItem) => void): void;
|
|
16
|
+
/** The charts and tables that use dataset `id`, in document order. */
|
|
17
|
+
export declare function datasetUsage(presentation: 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(presentation, visit) {
|
|
18
|
+
if (!isObject(presentation) || !Array.isArray(presentation.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
|
+
presentation.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(presentation, id) {
|
|
40
|
+
const items = [];
|
|
41
|
+
walkDatasetItems(presentation, (entry) => {
|
|
42
|
+
if (entry.id === id) items.push({ kind: entry.kind, path: entry.parts.join("."), parts: entry.parts });
|
|
43
|
+
});
|
|
44
|
+
return items;
|
|
45
|
+
}
|