@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/dist/switches.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // dimension that turns "switch this dimension to X" into a validated JSON Patch, and applies
3
3
  // it to an editor session as one undoable transaction. The preview recomposes and the PPTX
4
4
  // export follows from the same document, so no dimension needs a special refresh path.
5
+ import * as core from "@openpresentation/opf";
5
6
  import { catalogSchemaNames, catalogs as bundledCatalogs, schemas } from "@openpresentation/opf";
6
7
  import {
7
8
  OPFEditorError,
@@ -251,7 +252,7 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
251
252
  throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
252
253
  const chart = owner && getValueAtPath(document, [...owner, "chart"]);
253
254
  if (!chart || typeof chart !== "object") throw fail("chart-not-found", "This slide has no chart to switch. Insert a chart block first.", { slideIndex, path: options.path });
254
- patches = [...catalogRecordPatches(document, dimension, options), ...(chart.type === value ? [] : createValuePatch(document, [...owner, "chart", "type"], value))];
255
+ patches = [...catalogRecordPatches(document, dimension, options), ...(chart.type === value ? [] : [...createValuePatch(document, [...owner, "chart", "type"], value), ...staleMappingPatches(document, owner, chart, value)])];
255
256
  } else if (dimension === "blocks") {
256
257
  if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
257
258
  // convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
@@ -394,11 +395,27 @@ const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart",
394
395
  const MULTI_SERIES_CAPABLE = new Set(["barChart", "lineChart", "areaChart", "radarChart"]);
395
396
  const DISTRIBUTION_ELEMENTS = new Set(["histogramChart", "boxWhiskerChart", "mapChart"]);
396
397
 
397
- function chartDataShape(chart) {
398
- const data = chart?.data;
399
- if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) return undefined;
400
- // The renderers read the first column as the category label and every further column as a series.
401
- return { series: Math.max(0, data.columns.length - 1), categories: data.rows.length };
398
+ // RR-54: `mapping.x` names the X column of an XY chart; a type without an X axis ignores it (core warns chart-mapping-adapted). Switching to such a
399
+ // type removes it, and the whole mapping when nothing else is left, so the document does not keep a warning it cannot act on.
400
+ function staleMappingPatches(document, owner, chart, type) {
401
+ const mapping = chart.mapping;
402
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || mapping.x === undefined) return [];
403
+ if (typeof core.isXYChartType !== "function" || core.isXYChartType(type)) return [];
404
+ const { x: _x, ...rest } = mapping;
405
+ const parts = [...owner, "chart", "mapping"];
406
+ return Object.keys(rest).length ? createValuePatch(document, parts, rest) : [{ op: "remove", path: opfPathToJsonPointer(parts) }];
407
+ }
408
+
409
+ function chartDataShape(chart, document) {
410
+ // RR-54: inline data, a dataset reference and a series mapping all resolve to the columns the renderers read: the first column labels
411
+ // the categories and every further column is a series.
412
+ if (typeof core.resolveChartData !== "function") {
413
+ const data = chart?.data;
414
+ return data && Array.isArray(data.columns) && Array.isArray(data.rows) ? { series: Math.max(0, data.columns.length - 1), categories: data.rows.length } : undefined;
415
+ }
416
+ const resolved = core.resolveChartData(chart, document);
417
+ if (!resolved.ok) return undefined;
418
+ return { series: Math.max(0, resolved.columns.length - 1), categories: resolved.rows.length };
402
419
  }
403
420
 
404
421
  /**
@@ -415,7 +432,7 @@ export function compatibleChartTypes(document, options = {}) {
415
432
  const owner = options.path ? splitOpfPath(options.path) : document.slides?.[slideIndex] ? findChartOwner(document, slideIndex) : undefined;
416
433
  const chart = owner && getValueAtPath(document, [...owner, "chart"]);
417
434
  if (!chart || typeof chart !== "object") return [];
418
- const shape = chartDataShape(chart);
435
+ const shape = chartDataShape(chart, document);
419
436
  const result = [];
420
437
  for (const option of listSwitchOptions(document, "charts", options)) {
421
438
  const record = option.record;
@@ -32,7 +32,11 @@ const RULE_COLOR = "textSecondary";
32
32
 
33
33
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
34
34
  const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
35
- const valueOf = (cell) => (isStyled(cell) ? cell.value : cell);
35
+ // RR-54: a header may be a DataColumn `{ name, format? }`. Styling or merging it makes it the equivalent styled header `{ value: name, format?, ... }`
36
+ // (a styled header's `format` is the column's format, the same as a DataColumn's).
37
+ const isColumnObject = (cell) => isObject(cell) && Object.hasOwn(cell, "name") && !Object.hasOwn(cell, "value");
38
+ const asStyled = (cell) => (isColumnObject(cell) ? { value: cell.name, ...(typeof cell.format === "string" ? { format: cell.format } : {}) } : cell);
39
+ const valueOf = (cell) => (isStyled(cell) ? cell.value : isColumnObject(cell) ? cell.name : cell);
36
40
  const isEmptyValue = (value) => value === null || value === undefined || value === "" || (Array.isArray(value) && value.every((run) => (typeof run === "string" ? run === "" : run?.text === "")));
37
41
  const clone = (value) => structuredClone(value);
38
42
 
@@ -58,6 +62,8 @@ export function parseTableCellPath(path) {
58
62
  function tableAt(document, tablePath) {
59
63
  const parts = splitOpfPath(tablePath);
60
64
  const table = getValueAtPath(document, parts);
65
+ if (isObject(table) && !Array.isArray(table.rows) && typeof table.dataset === "string")
66
+ throw fail("table-dataset-backed", `This table shows the shared dataset '${table.dataset}', which holds no cell styles or merged cells. Use a copy of the data (in the data grid) to style it.`, { tablePath, dataset: table.dataset });
61
67
  if (!isObject(table) || !Array.isArray(table.rows)) throw fail("table-not-found", "Choose a table (a path ending in .table).", { tablePath });
62
68
  return { parts, table };
63
69
  }
@@ -143,14 +149,17 @@ function joinedValue(values) {
143
149
  return parts.map(plainText).join(" ");
144
150
  }
145
151
 
146
- function withCell(raw, patch) {
152
+ function withCell(cell, patch) {
147
153
  // A styled cell with no style and no spans collapses back to the plain value.
154
+ const raw = asStyled(cell);
148
155
  const merged = isStyled(raw) ? { ...raw } : { value: raw };
149
156
  for (const [key, value] of Object.entries(patch)) {
150
157
  if (value === undefined || value === null || (key === "style" && isObject(value) && !Object.keys(value).length)) delete merged[key];
151
158
  else merged[key] = value;
152
159
  }
153
160
  const keys = Object.keys(merged);
161
+ // A DataColumn header that gained no style or span stays a DataColumn.
162
+ if (isColumnObject(cell) && keys.every((key) => key === "value" || key === "format")) return cell;
154
163
  return keys.length === 1 && keys[0] === "value" ? merged.value : merged;
155
164
  }
156
165
 
@@ -226,7 +235,8 @@ export function prepareTableMerge(document, tablePath, cell, span, options = {})
226
235
  for (const entry of covered) entry.line[entry.column] = null;
227
236
  const anchorLine = lineOf(table, target);
228
237
  const raw = anchorLine[target.column];
229
- anchorLine[target.column] = withCell(isStyled(raw) ? { ...raw, value: anchorValue } : anchorValue, { colSpan: colSpan > 1 ? colSpan : undefined, rowSpan: rowSpan > 1 ? rowSpan : undefined });
238
+ const base = asStyled(raw);
239
+ anchorLine[target.column] = withCell(isStyled(base) ? { ...base, value: anchorValue } : anchorValue, { colSpan: colSpan > 1 ? colSpan : undefined, rowSpan: rowSpan > 1 ? rowSpan : undefined });
230
240
  return { merged: true, joined: filled.length > 0 };
231
241
  }, { action: "merge", cell, span: { colSpan, rowSpan } });
232
242
  }
package/dist/transfer.js CHANGED
@@ -187,6 +187,30 @@ export function prepareOpfImport(
187
187
  assetIds.add(next);
188
188
  assetMap.set(id, next);
189
189
  }
190
+ // RR-54: a chart or table that shows a shared dataset (`{ dataset: id }`) brings that dataset along. A dataset the deck already holds
191
+ // with the same content is shared; one with the same id and other content is stored under a free id and the references follow it.
192
+ const datasetMap = new Map();
193
+ const usedDatasets = new Set();
194
+ const collectDatasets = (value, key = "") => {
195
+ if (Array.isArray(value)) value.forEach((child) => collectDatasets(child, key));
196
+ else if (value && typeof value === "object") for (const [childKey, child] of Object.entries(value)) collectDatasets(child, childKey);
197
+ else if (key === "dataset" && typeof value === "string") usedDatasets.add(value);
198
+ };
199
+ collectDatasets(incoming.slides);
200
+ const datasetIds = new Set(Object.keys(document.datasets ?? {}));
201
+ for (const id of usedDatasets) {
202
+ const mine = incoming.datasets && Object.hasOwn(incoming.datasets, id) ? incoming.datasets[id] : undefined;
203
+ if (mine === undefined) continue;
204
+ if (datasetIds.has(id) && JSON.stringify(document.datasets[id]) === JSON.stringify(mine)) {
205
+ datasetMap.set(id, id);
206
+ continue;
207
+ }
208
+ let next = id,
209
+ n = 2;
210
+ while (datasetIds.has(next)) next = `${id}-${n++}`;
211
+ datasetIds.add(next);
212
+ datasetMap.set(id, next);
213
+ }
190
214
  const rewrite = (value, key = "") => {
191
215
  if (Array.isArray(value)) return value.map((child) => rewrite(child, key));
192
216
  if (value && typeof value === "object") {
@@ -210,6 +234,7 @@ export function prepareOpfImport(
210
234
  return result;
211
235
  }
212
236
  if (typeof value === "string") {
237
+ if (key === "dataset" && datasetMap.has(value)) return datasetMap.get(value);
213
238
  if (mappings[key]?.has(value)) return mappings[key].get(value);
214
239
  if (
215
240
  ["src", "image", "video", "poster"].includes(key) &&
@@ -243,6 +268,11 @@ export function prepareOpfImport(
243
268
  ? `asset:${assetMap.get(asset.slice(6))}`
244
269
  : asset;
245
270
  }
271
+ for (const [id, next] of datasetMap) {
272
+ if (document.datasets && Object.hasOwn(document.datasets, next)) continue;
273
+ document.datasets ??= {};
274
+ document.datasets[next] = clone(incoming.datasets[id]);
275
+ }
246
276
  const ids = new Set(collectReservedPresentationIds(document));
247
277
  for (const slide of rewritten.slides) remapSlideTreeIds(slide, ids);
248
278
  const index = Math.max(0, Math.min(document.slides.length, slideIndex + 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -230,6 +230,8 @@
230
230
  "test:template-panel-browser": "node test/template-panel-browser.mjs",
231
231
  "test:design-gaps-browser": "node test/design-gaps-browser.mjs",
232
232
  "test:data-grid-browser": "node test/data-grid-browser.mjs",
233
+ "test:data-grid-datasets-browser": "node test/data-grid-datasets-browser.mjs",
234
+ "test:data-import-dataset-browser": "node test/data-import-dataset-browser.mjs",
233
235
  "test:slides-browser": "node test/slides-browser.mjs",
234
236
  "test:persistence-browser": "node test/persistence-browser.mjs",
235
237
  "test:find-browser": "node test/find-replace-browser.mjs",
@@ -249,11 +251,11 @@
249
251
  "@codemirror/state": "6.7.6",
250
252
  "@codemirror/view": "6.43.13",
251
253
  "@lezer/highlight": "1.2.4",
252
- "@openpresentation/opf": "^0.12.2",
254
+ "@openpresentation/opf": "^0.13.0",
253
255
  "jsonc-parser": "3.3.1"
254
256
  },
255
257
  "peerDependencies": {
256
- "@openpresentation/opf-render": "^0.12.2"
258
+ "@openpresentation/opf-render": "^0.13.1"
257
259
  },
258
260
  "peerDependenciesMeta": {
259
261
  "@openpresentation/opf-render": {
@@ -295,8 +297,8 @@
295
297
  "@expo-google-fonts/noto-sans-thai": "0.4.2",
296
298
  "@expo-google-fonts/noto-serif-hebrew": "0.4.2",
297
299
  "@expo-google-fonts/noto-serif-tibetan": "0.4.2",
298
- "@openpresentation/opf-pptx": "^0.12.4",
299
- "@openpresentation/opf-render": "^0.12.2",
300
+ "@openpresentation/opf-pptx": "^0.13.2",
301
+ "@openpresentation/opf-render": "^0.13.1",
300
302
  "axe-core": "4.13.0",
301
303
  "esbuild": "0.28.2",
302
304
  "jszip": "3.10.2",