@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 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)
@@ -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
- return found ? contentConversionTargets(found.owner, options) : [];
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) },
@@ -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";
@@ -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
- root.append(h("div", { class: "opf-grid-title" }, titleText, pathText), unavailable, toolbar, scroll, notes, help, pasteBox, statusEl, errorEl);
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 hint = role === "category" ? "categories" : role === "series" ? "series" : role === "x" ? "x values" : role === "label" ? "labels" : "";
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 td.textContent = cell.text;
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
- buttons.get("transpose").hidden = target.kind !== "chart";
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
+ }
@@ -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
- tableUi.details.hidden = !tablePath;
1013
- if (tablePath) {
1014
- const table = editor.get(tablePath);
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 };
@@ -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
- if (Array.isArray(columns)) columns.forEach((value, index) => field(fields, state, at("chart", "data", "columns", String(index)), value, `${prefix}Chart label ${index + 1}`, "chart"));
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;