@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.
Files changed (95) hide show
  1. package/README.md +82 -58
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +15 -15
  6. package/dist/background-options.d.ts +2 -2
  7. package/dist/background-options.js +6 -6
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +23 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +22 -30
  15. package/dist/canvas.js +57 -64
  16. package/dist/chart-data.d.ts +12 -10
  17. package/dist/chart-data.js +22 -21
  18. package/dist/chart-options-panel.d.ts +2 -2
  19. package/dist/chart-options-panel.js +82 -8
  20. package/dist/chart-options.d.ts +35 -9
  21. package/dist/chart-options.js +133 -40
  22. package/dist/checks.js +21 -0
  23. package/dist/color-authoring.js +1 -1
  24. package/dist/content-actions.d.ts +15 -15
  25. package/dist/content-actions.js +71 -70
  26. package/dist/content-controls.js +12 -12
  27. package/dist/data-grid.js +179 -10
  28. package/dist/data.d.ts +23 -2
  29. package/dist/data.js +40 -1
  30. package/dist/dataset-refs.d.ts +17 -0
  31. package/dist/dataset-refs.js +45 -0
  32. package/dist/design-controls.js +66 -47
  33. package/dist/design-options.d.ts +16 -14
  34. package/dist/design-options.js +86 -74
  35. package/dist/edit-helpers.js +8 -7
  36. package/dist/exact-source.js +5 -5
  37. package/dist/export.d.ts +5 -7
  38. package/dist/export.js +40 -14
  39. package/dist/find-panel.js +5 -5
  40. package/dist/find-replace.d.ts +3 -3
  41. package/dist/find-replace.js +35 -20
  42. package/dist/font-gate.js +49 -58
  43. package/dist/galleries.js +36 -52
  44. package/dist/grid-model.d.ts +99 -24
  45. package/dist/grid-model.js +577 -101
  46. package/dist/image-crop.d.ts +4 -4
  47. package/dist/image-crop.js +20 -20
  48. package/dist/image-cropper.js +3 -3
  49. package/dist/index.d.ts +28 -28
  50. package/dist/index.js +74 -90
  51. package/dist/json-options.js +17 -16
  52. package/dist/layout-handles.js +5 -5
  53. package/dist/layout-placeholders.js +4 -4
  54. package/dist/layout.d.ts +2 -2
  55. package/dist/layout.js +8 -7
  56. package/dist/numbering-panel.js +3 -5
  57. package/dist/numbering.d.ts +3 -5
  58. package/dist/numbering.js +14 -26
  59. package/dist/outline-view.js +24 -24
  60. package/dist/outline.d.ts +12 -12
  61. package/dist/outline.js +52 -50
  62. package/dist/persistence.d.ts +1 -1
  63. package/dist/persistence.js +19 -18
  64. package/dist/presentation-ids.js +2 -2
  65. package/dist/react.js +3 -3
  66. package/dist/review-panel.d.ts +30 -13
  67. package/dist/review-panel.js +154 -53
  68. package/dist/review.d.ts +36 -43
  69. package/dist/review.js +92 -53
  70. package/dist/rich-text-input.js +4 -3
  71. package/dist/rich-text-toolbar.js +5 -3
  72. package/dist/rich-text.js +5 -2
  73. package/dist/schema-inspector.d.ts +2 -2
  74. package/dist/schema-inspector.js +16 -11
  75. package/dist/schema.d.ts +1 -1
  76. package/dist/schema.js +25 -5
  77. package/dist/slide-manager.d.ts +2 -2
  78. package/dist/slide-manager.js +18 -18
  79. package/dist/slides.d.ts +20 -20
  80. package/dist/slides.js +99 -97
  81. package/dist/svelte.js +1 -1
  82. package/dist/switches.d.ts +10 -10
  83. package/dist/switches.js +136 -109
  84. package/dist/table-options.d.ts +8 -8
  85. package/dist/table-options.js +41 -30
  86. package/dist/table-structure.d.ts +10 -10
  87. package/dist/table-structure.js +21 -21
  88. package/dist/template-panel.d.ts +3 -3
  89. package/dist/template-panel.js +37 -13
  90. package/dist/templates.d.ts +22 -11
  91. package/dist/templates.js +48 -55
  92. package/dist/transfer.d.ts +4 -4
  93. package/dist/transfer.js +74 -54
  94. package/package.json +10 -6
  95. 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
- root.append(h("div", { class: "opf-grid-title" }, titleText, pathText), unavailable, toolbar, scroll, notes, help, pasteBox, statusEl, errorEl);
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 hint = role === "category" ? "categories" : role === "series" ? "series" : role === "x" ? "x values" : role === "label" ? "labels" : "";
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 td.textContent = cell.text;
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
- buttons.get("transpose").hidden = target.kind !== "chart";
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.document, path);
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.document, next.path, opts());
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.document, target.path, [{ ...addr(u, c), text: area.value }], opts());
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, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
2
- export type {TabularData, DataImportOptions, DataContentOptions} from "@openpresentation/opf/data";
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, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
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
+ }