@openpresentation/opf-editor 0.12.1 → 0.15.0

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 (103) hide show
  1. package/README.md +122 -80
  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 +16 -16
  6. package/dist/background-options.d.ts +12 -5
  7. package/dist/background-options.js +44 -19
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -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 +30 -31
  15. package/dist/canvas.js +64 -68
  16. package/dist/catalogs.d.ts +48 -0
  17. package/dist/catalogs.js +217 -0
  18. package/dist/chart-data.d.ts +10 -10
  19. package/dist/chart-data.js +21 -21
  20. package/dist/chart-options-panel.d.ts +2 -2
  21. package/dist/chart-options-panel.js +82 -8
  22. package/dist/chart-options.d.ts +35 -9
  23. package/dist/chart-options.js +133 -40
  24. package/dist/checks.js +23 -0
  25. package/dist/color-authoring.js +1 -1
  26. package/dist/content-actions.d.ts +17 -17
  27. package/dist/content-actions.js +74 -73
  28. package/dist/content-controls.js +21 -21
  29. package/dist/data-grid.js +7 -8
  30. package/dist/data.d.ts +4 -4
  31. package/dist/data.js +5 -5
  32. package/dist/dataset-refs.d.ts +2 -2
  33. package/dist/dataset-refs.js +5 -5
  34. package/dist/design-controls.d.ts +12 -5
  35. package/dist/design-controls.js +312 -111
  36. package/dist/design-options.d.ts +20 -18
  37. package/dist/design-options.js +91 -99
  38. package/dist/edit-helpers.js +8 -7
  39. package/dist/exact-source.js +5 -5
  40. package/dist/export.d.ts +7 -7
  41. package/dist/export.js +48 -17
  42. package/dist/find-panel.js +5 -5
  43. package/dist/find-replace.d.ts +3 -3
  44. package/dist/find-replace.js +23 -20
  45. package/dist/font-gate.js +49 -58
  46. package/dist/galleries.js +50 -101
  47. package/dist/grid-model.d.ts +25 -27
  48. package/dist/grid-model.js +92 -102
  49. package/dist/image-crop.d.ts +4 -4
  50. package/dist/image-crop.js +28 -29
  51. package/dist/image-cropper.js +3 -3
  52. package/dist/image-options.d.ts +45 -0
  53. package/dist/image-options.js +195 -0
  54. package/dist/index.d.ts +48 -31
  55. package/dist/index.js +154 -126
  56. package/dist/json-editor.js +2 -2
  57. package/dist/json-menu.js +2 -2
  58. package/dist/json-options.d.ts +3 -3
  59. package/dist/json-options.js +44 -54
  60. package/dist/layout-handles.js +5 -5
  61. package/dist/layout-placeholders.js +4 -4
  62. package/dist/layout.d.ts +2 -2
  63. package/dist/layout.js +8 -7
  64. package/dist/numbering-panel.js +3 -5
  65. package/dist/numbering.d.ts +3 -5
  66. package/dist/numbering.js +14 -26
  67. package/dist/outline-view.js +24 -24
  68. package/dist/outline.d.ts +12 -12
  69. package/dist/outline.js +52 -50
  70. package/dist/persistence.d.ts +1 -1
  71. package/dist/persistence.js +19 -18
  72. package/dist/presentation-ids.js +2 -2
  73. package/dist/react.js +3 -3
  74. package/dist/review-panel.d.ts +30 -13
  75. package/dist/review-panel.js +160 -53
  76. package/dist/review.d.ts +36 -43
  77. package/dist/review.js +92 -53
  78. package/dist/rich-text-input.js +4 -3
  79. package/dist/rich-text-toolbar.js +5 -3
  80. package/dist/rich-text.js +5 -2
  81. package/dist/schema-inspector.d.ts +2 -2
  82. package/dist/schema-inspector.js +16 -11
  83. package/dist/schema.d.ts +1 -1
  84. package/dist/schema.js +26 -6
  85. package/dist/slide-manager.d.ts +2 -2
  86. package/dist/slide-manager.js +19 -19
  87. package/dist/slides.d.ts +21 -21
  88. package/dist/slides.js +104 -101
  89. package/dist/svelte.js +2 -3
  90. package/dist/switches.d.ts +37 -22
  91. package/dist/switches.js +282 -171
  92. package/dist/table-options.d.ts +8 -8
  93. package/dist/table-options.js +28 -27
  94. package/dist/table-structure.d.ts +10 -10
  95. package/dist/table-structure.js +21 -21
  96. package/dist/template-panel.d.ts +3 -3
  97. package/dist/template-panel.js +37 -13
  98. package/dist/templates.d.ts +22 -11
  99. package/dist/templates.js +48 -55
  100. package/dist/transfer.d.ts +15 -4
  101. package/dist/transfer.js +107 -161
  102. package/package.json +12 -6
  103. package/dist/font-defaults.js +0 -25
@@ -1,17 +1,22 @@
1
- // Backgrounds (RR-06): every background form the schema has, with validation that explains itself.
2
- // A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck
3
- // or on one slide. `setBackground` is one undoable patch through the `backgrounds` switch, so the
4
- // preview, export and Undo behave like every other dimension.
1
+ // Backgrounds (RR-06, FA-22/FA-23): every background form the schema has, with validation that explains itself.
2
+ // A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck or on one slide. It is
3
+ // the only canvas fill and never moves content. The image form is flat (OPF 0.15): `{ type: "image", src, alt, fit, focus,
4
+ // opacity, recolor, overlay }`; an image source string (`asset:`, `https://`, `data:`, `./`, `../`) is the cover-image shorthand.
5
+ // `setBackground` is one undoable patch through the `backgrounds` switch, so the preview, export and Undo behave like every
6
+ // other dimension.
5
7
  import { prepareDimensionSwitch, switchDimension } from "./switches.js";
6
8
  import { fail } from "./edit-helpers.js";
9
+ import { normalizeFocus, normalizeOverlay, normalizeRecolor } from "./image-options.js";
7
10
 
8
11
  export const BACKGROUND_TYPES = Object.freeze(["theme", "solid", "gradient", "image", "pattern"]);
9
12
  export const THEME_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
10
- export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "tile"]);
13
+ export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "stretch", "tile"]);
14
+ /** Image sources the background string shorthand accepts (the schema's ImageSource). */
15
+ export const IMAGE_SOURCE = /^(?:asset:|https:\/\/|data:|\.\/|\.\.\/)/;
11
16
  /** Scheme slots and roles a ColorRef may name (the schema's ColorRef enum), besides hex colors and `var:<id>`. */
12
17
  export const COLOR_NAMES = Object.freeze([
13
18
  "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "dark1", "dark2", "light1", "light2", "hyperlink", "followedHyperlink",
14
- "primary", "secondary", "accent", "background", "surface", "text", "textSecondary",
19
+ "primary", "secondary", "accent", "background", "surface", "surfaceAlt", "text", "textSecondary",
15
20
  ]);
16
21
  /**
17
22
  * The 54 DrawingML preset patterns (ECMA-376 ST_PresetPatternVal) by family. PPTX export writes them as
@@ -55,7 +60,8 @@ export function normalizeBackground(spec) {
55
60
  if (typeof spec === "string") {
56
61
  if (THEME_BACKGROUND_SLOTS.includes(spec)) return spec;
57
62
  if (HEX.test(spec)) return spec.toUpperCase();
58
- throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}) or a hex color.`, { spec });
63
+ if (IMAGE_SOURCE.test(spec.trim())) return spec.trim();
64
+ throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}), a hex color, or an image source starting with asset:, https://, data:, ./ or ../.`, { spec });
59
65
  }
60
66
  if (!isObject(spec)) throw fail("invalid-background", "Describe the background as { type, ... } or a theme slot / hex shorthand.", { spec });
61
67
  const opacity = opacityOf(spec);
@@ -83,10 +89,25 @@ export function normalizeBackground(spec) {
83
89
  return withOpacity({ type: "gradient", gradient: { ...(angle === undefined ? {} : { angle }), stops } });
84
90
  }
85
91
  case "image": {
86
- const image = spec.image;
87
- if (!isObject(image) || typeof image.src !== "string" || !image.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
88
- if (image.fit !== undefined && image.fit !== "" && !IMAGE_BACKGROUND_FITS.includes(image.fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
89
- return withOpacity({ type: "image", image: { src: image.src.trim(), ...(image.fit ? { fit: image.fit } : {}) } });
92
+ if (typeof spec.src !== "string" || !spec.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
93
+ const src = spec.src.trim();
94
+ if (!IMAGE_SOURCE.test(src)) throw fail("invalid-background", "An image source starts with asset:, https://, data:, ./ or ../.", { spec });
95
+ const fit = spec.fit === "" || spec.fit === null ? undefined : spec.fit;
96
+ if (fit !== undefined && !IMAGE_BACKGROUND_FITS.includes(fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
97
+ const alt = typeof spec.alt === "string" && spec.alt.trim() ? spec.alt.trim() : undefined;
98
+ const wrap = (call) => {
99
+ try {
100
+ return call();
101
+ } catch (error) {
102
+ throw fail("invalid-background", error.message, { spec });
103
+ }
104
+ };
105
+ const focus = spec.focus === undefined || spec.focus === null ? undefined : wrap(() => normalizeFocus(spec.focus));
106
+ const overlay = spec.overlay === undefined || spec.overlay === null ? undefined : wrap(() => normalizeOverlay(spec.overlay));
107
+ const recolor = spec.recolor === undefined || spec.recolor === null || spec.recolor === "" ? undefined : wrap(() => normalizeRecolor(spec.recolor));
108
+ const value = { type: "image", src, ...(alt ? { alt } : {}), ...(fit ? { fit } : {}), ...(focus ? { focus } : {}), ...(opacity === undefined ? {} : { opacity }), ...(recolor ? { recolor } : {}), ...(overlay ? { overlay } : {}) };
109
+ // A cover image with nothing else is the shorthand string.
110
+ return Object.keys(value).length === 2 ? src : value;
90
111
  }
91
112
  case "pattern": {
92
113
  const pattern = spec.pattern;
@@ -102,8 +123,8 @@ export function normalizeBackground(spec) {
102
123
  }
103
124
 
104
125
  /** Compute the patch that sets (or, for `null`, removes) the background, without touching a session. Options as `prepareDimensionSwitch` (`slideIndex`, `clearSlideOverrides`). */
105
- export function prepareBackground(document, spec, options = {}) {
106
- return prepareDimensionSwitch(document, "backgrounds", spec === null ? null : normalizeBackground(spec), options);
126
+ export function prepareBackground(presentation, spec, options = {}) {
127
+ return prepareDimensionSwitch(presentation, "backgrounds", spec === null ? null : normalizeBackground(spec), options);
107
128
  }
108
129
 
109
130
  /** Set the background as one undoable transaction; `null` removes it so the theme's (or the deck's) shows again. */
@@ -113,21 +134,25 @@ export function setBackground(editor, spec, options = {}) {
113
134
 
114
135
  /**
115
136
  * The background that applies at a scope as a flat description for a form:
116
- * `{ type, slot?, color?, opacity?, angle?, stops?, src?, fit?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
137
+ * `{ type, slot?, color?, opacity?, angle?, stops?, src?, alt?, fit?, focus?, recolor?, overlay?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
117
138
  * `type` is undefined when nothing is set; `scope` is "slide" when the slide sets its own, else "deck".
118
139
  */
119
- export function readBackground(document, { slideIndex } = {}) {
120
- const own = slideIndex === undefined ? undefined : document.slides?.[slideIndex]?.design?.background;
121
- const value = own !== undefined ? own : document.design?.background;
140
+ export function readBackground(presentation, { slideIndex } = {}) {
141
+ const own = slideIndex === undefined ? undefined : presentation.slides?.[slideIndex]?.design?.background;
142
+ const value = own !== undefined ? own : presentation.design?.background;
122
143
  const scope = own !== undefined ? "slide" : "deck";
123
144
  if (value === undefined) return { scope };
124
- if (typeof value === "string") return THEME_BACKGROUND_SLOTS.includes(value) ? { type: "theme", slot: value, scope, value } : { type: "solid", color: value, scope, value };
145
+ if (typeof value === "string") {
146
+ if (THEME_BACKGROUND_SLOTS.includes(value)) return { type: "theme", slot: value, scope, value };
147
+ if (IMAGE_SOURCE.test(value)) return { type: "image", src: value, fit: "cover", scope, value };
148
+ return { type: "solid", color: value, scope, value };
149
+ }
125
150
  if (!isObject(value)) return { scope, value };
126
151
  const base = { type: value.type, opacity: value.opacity, scope, value };
127
152
  if (value.type === "theme") return { ...base, slot: value.slot };
128
153
  if (value.type === "solid") return { ...base, color: value.color };
129
154
  if (value.type === "gradient") return { ...base, angle: value.gradient?.angle, stops: structuredClone(value.gradient?.stops ?? []) };
130
- if (value.type === "image") return { ...base, src: value.image?.src, fit: value.image?.fit };
155
+ if (value.type === "image") return { ...base, src: value.src, alt: value.alt, fit: value.fit, focus: value.focus && { ...value.focus }, recolor: value.recolor && structuredClone(value.recolor), overlay: value.overlay && { ...value.overlay } };
131
156
  if (value.type === "pattern") return { ...base, preset: value.pattern?.preset, foregroundColor: value.pattern?.foregroundColor, backgroundColor: value.pattern?.backgroundColor };
132
157
  return base;
133
158
  }
@@ -13,18 +13,18 @@ export function createBlockControls(root, options) {
13
13
  function apply(prepared){
14
14
  if(!prepared?.changed){cancel();return true;}
15
15
  // Retain changes outside the guarded containers and preflight the exact candidate.
16
- const candidate=applyJsonPatch(editor.document,prepared.patches);
16
+ const candidate=applyJsonPatch(editor.presentation,prepared.patches);
17
17
  options.validate(candidate);
18
18
  committing=true;
19
19
  try{cancel();editor.applyPatch(prepared.patches,{source:'canvas-block-edit',rejectInvalid:true});options.onMove?.(prepared.path);([ ...layer.querySelectorAll("[data-block-path]")].find(node=>node.dataset.blockPath===prepared.path)??layer.querySelector('[data-block-add]'))?.focus();options.clearError?.();options.onCommit?.({path:prepared.path,editor});return true;}
20
20
  finally{committing=false;}
21
21
  }
22
- function move(from,to,index,base=editor.document){
22
+ function move(from,to,index,base=editor.presentation){
23
23
  try{return apply(prepareBlockMove(base,from,to,index));}catch(error){cancel();options.onError(error);return false;}
24
24
  }
25
25
  function open(path){
26
26
  if(!options.beforeEdit())return;
27
- cancel();returnFocus=doc.activeElement;path=opfPathToJsonPointer(path);const base=editor.document,parts=splitOpfPath(path),container=opfPathToJsonPointer(parts.slice(0,-2)),index=Number(parts.at(-1));
27
+ cancel();returnFocus=doc.activeElement;path=opfPathToJsonPointer(path);const base=editor.presentation,parts=splitOpfPath(path),container=opfPathToJsonPointer(parts.slice(0,-2)),index=Number(parts.at(-1));
28
28
  const source=getValueAtPath(base,[...parts.slice(0,-2),'blocks']);
29
29
  if(parts.at(-2)!=='blocks'||!Number.isInteger(index)||index<0||!Array.isArray(source)||index>=source.length){options.onError(new Error('Choose an existing content block.'));return;}
30
30
  const count=source.length;
@@ -56,7 +56,7 @@ export function createBlockControls(root, options) {
56
56
  }
57
57
  function openInsert(containerPath,index) {
58
58
  if(!options.beforeEdit())return;
59
- cancel();returnFocus=doc.activeElement;const base=editor.document;
59
+ cancel();returnFocus=doc.activeElement;const base=editor.presentation;
60
60
  const containers=listBlockContainers(base,{slideIndex:options.slideIndex(),includeImplicit:true});
61
61
  if(!containers.length){options.onError(new Error('No content container is available on this slide.'));return;}
62
62
  const requested=containerPath===undefined?containers[0].path:opfPathToJsonPointer(containerPath);
@@ -91,7 +91,7 @@ export function createBlockControls(root, options) {
91
91
  panel.append(add,close);panel.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cancel();}};
92
92
  layer.append(panel);kind.focus();
93
93
  }
94
- function update(document,nextGeometry){
94
+ function update(presentation,nextGeometry){
95
95
  geometry=nextGeometry;
96
96
  if(drag)return;
97
97
  cancel();layer.replaceChildren();cells=[];
@@ -99,7 +99,7 @@ export function createBlockControls(root, options) {
99
99
  const add=doc.createElement('button');add.type='button';add.textContent='+ Add content';add.setAttribute('aria-label','Add content');add.dataset.blockAdd='';
100
100
  add.style.cssText='position:absolute;right:8px;top:8px;pointer-events:auto;padding:5px 9px;background:#f6f3fc;color:#675394;border:1px solid #cfc4e7;border-radius:5px;font:12px system-ui;cursor:pointer';add.onclick=()=>openInsert();layer.append(add);
101
101
  for(const flow of geometry.flows??[]){
102
- const blocks=getValueAtPath(document,[...splitOpfPath(flow.path),'blocks']);
102
+ const blocks=getValueAtPath(presentation,[...splitOpfPath(flow.path),'blocks']);
103
103
  if(!Array.isArray(blocks)||blocks.length!==flow.itemCount)continue;
104
104
  blocks.forEach((block,index)=>{
105
105
  const col=flow.columns[index%flow.columns.length],row=flow.rows[Math.floor(index/flow.columns.length)];if(!col||!row)return;
@@ -116,7 +116,7 @@ export function createBlockControls(root, options) {
116
116
  node.onkeydown=event=>{if(['ArrowLeft','ArrowUp','ArrowRight','ArrowDown'].includes(event.key)){event.preventDefault();event.stopPropagation();if(!options.beforeEdit())return;const delta=['ArrowLeft','ArrowUp'].includes(event.key)?-1:1;if(index+delta>=0&&index+delta<blocks.length)move(path,container,index+(delta>0?2:-1));}};
117
117
  node.ondragstart=event=>{
118
118
  if(!options.beforeEdit()){event.preventDefault();return;}
119
- cancel();drag={from:path,container,index,base:editor.document,signature:signature(container),cells:[...cells],prepared:null};
119
+ cancel();drag={from:path,container,index,base:editor.presentation,signature:signature(container),cells:[...cells],prepared:null};
120
120
  event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',path);
121
121
  };
122
122
  node.ondragend=()=>{cancel();options.render();};layer.append(node);
@@ -32,7 +32,7 @@ export interface BlockConversionTarget {
32
32
  reason?: string;
33
33
  }
34
34
  export interface PreparedBlockConversion {
35
- document: unknown;
35
+ presentation: unknown;
36
36
  patches: JsonPatchOperation[];
37
37
  path: string;
38
38
  changed: boolean;
@@ -41,8 +41,8 @@ export interface PreparedBlockConversion {
41
41
  from: BlockKind;
42
42
  to: BlockKind;
43
43
  }
44
- export interface BlockConversionChange extends Omit<EditorChange, "document" | "patches"> {
45
- document: unknown;
44
+ export interface BlockConversionChange extends Omit<EditorChange, "presentation" | "patches"> {
45
+ presentation: unknown;
46
46
  patches: JsonPatchOperation[];
47
47
  lossless: boolean;
48
48
  loss: string[];
@@ -53,12 +53,12 @@ export interface BlockConversionChange extends Omit<EditorChange, "document" | "
53
53
  }
54
54
 
55
55
  /** The single content payload of the block at `path`, or undefined (image-only, groups and blocks with several fields have none to convert). */
56
- export declare function readBlockContent(document: unknown, path: string | string[]): BlockContent | undefined;
56
+ export declare function readBlockContent(presentation: unknown, path: string | string[]): BlockContent | undefined;
57
57
  /** The block that contains a selected path (slides.0.blocks.1.text maps to slides.0.blocks.1), or undefined. */
58
- export declare function blockPathForSelection(document: unknown, selectedPath: string): string | undefined;
58
+ export declare function blockPathForSelection(presentation: unknown, selectedPath: string): string | undefined;
59
59
  /** The kinds the block can convert to, each with its loss report or the reason it is unavailable. */
60
- export declare function blockConversionTargets(document: unknown, path: string | string[]): BlockConversionTarget[];
60
+ export declare function blockConversionTargets(presentation: unknown, path: string | string[]): BlockConversionTarget[];
61
61
  /** Compute the conversion patch without touching a session. Throws `block-not-convertible` for an unsupported pair or content that does not fit. */
62
- export declare function prepareBlockConversion(document: unknown, path: string | string[], kind: BlockKind, options?: ConvertOptions): PreparedBlockConversion;
62
+ export declare function prepareBlockConversion(presentation: unknown, path: string | string[], kind: BlockKind, options?: ConvertOptions): PreparedBlockConversion;
63
63
  /** Convert one block as a single undoable transaction. */
64
64
  export declare function convertBlock(editor: EditorSession, path: string | string[], kind: BlockKind, meta?: Record<string, unknown>, options?: ConvertOptions): BlockConversionChange;
@@ -5,9 +5,10 @@
5
5
  // transaction around them: it finds the block, turns the converted payload into one guarded patch, validates
6
6
  // the document, and applies it as one undoable step.
7
7
  import { CONTENT_CONVERSIONS, CONTENT_KIND_LABELS, OPFConversionError, contentConversionTargets, convertContent, readContent } from "@openpresentation/opf/convert";
8
- import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
8
+ import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
9
9
  import { listBlockContainers } from "./blocks.js";
10
10
  import { fail } from "./edit-helpers.js";
11
+ import { firstErrorMessage, errorFindings, checkFormat } from "./checks.js";
11
12
 
12
13
  /** Every content kind a block can hold, with its display label. */
13
14
  export const BLOCK_KIND_LABELS = CONTENT_KIND_LABELS;
@@ -32,7 +33,7 @@ function convertOwner(owner, kind, options) {
32
33
  * region that holds exactly one content field), or a group of metric blocks (a `blocks` array in a
33
34
  * slide, region or group). Returns undefined when there is none.
34
35
  */
35
- export function readBlockContent(document, path) {
36
+ export function readBlockContent(presentation, path) {
36
37
  let parts;
37
38
  try {
38
39
  parts = splitOpfPath(path);
@@ -41,7 +42,7 @@ export function readBlockContent(document, path) {
41
42
  }
42
43
  const pointer = opfPathToJsonPointer(parts);
43
44
  const explicit = parts.at(-2) === "blocks" && /^(0|[1-9][0-9]*)$/.test(parts.at(-1) ?? "");
44
- const owner = getValueAtPath(document, parts);
45
+ const owner = getValueAtPath(presentation, parts);
45
46
  if (!owner || typeof owner !== "object" || Array.isArray(owner)) return undefined;
46
47
  if (Array.isArray(owner.blocks)) {
47
48
  // Only a set of metrics converts as a group; any other group has nothing to offer.
@@ -49,7 +50,7 @@ export function readBlockContent(document, path) {
49
50
  return { path: parts, explicit, owner, key: "blocks", kind: "group", content: owner.blocks };
50
51
  }
51
52
  if (!explicit) {
52
- const implicit = listBlockContainers(document, { includeImplicit: true }).find((entry) => entry.path === pointer && entry.implicit);
53
+ const implicit = listBlockContainers(presentation, { includeImplicit: true }).find((entry) => entry.path === pointer && entry.implicit);
53
54
  if (!implicit || implicit.count !== 1) return undefined;
54
55
  }
55
56
  const info = readContent(owner);
@@ -58,7 +59,7 @@ export function readBlockContent(document, path) {
58
59
  }
59
60
 
60
61
  /** Whether `path` is inside a block and, if so, that block's path. A selection such as slides.0.blocks.1.text maps to slides.0.blocks.1. */
61
- export function blockPathForSelection(document, selectedPath) {
62
+ export function blockPathForSelection(presentation, selectedPath) {
62
63
  let parts;
63
64
  try {
64
65
  parts = splitOpfPath(selectedPath);
@@ -68,7 +69,7 @@ export function blockPathForSelection(document, selectedPath) {
68
69
  for (let length = parts.length; length >= 2; length -= 1) {
69
70
  const candidate = parts.slice(0, length);
70
71
  if (candidate[0] !== "slides") return undefined;
71
- const found = readBlockContent(document, candidate);
72
+ const found = readBlockContent(presentation, candidate);
72
73
  // A slide or region that holds one payload inline is a block only when the selection is that payload.
73
74
  if (found && found.kind !== "group" && (found.explicit || parts[length] === found.key)) return candidate.join(".");
74
75
  }
@@ -79,7 +80,7 @@ export function blockPathForSelection(document, selectedPath) {
79
80
  * The nearest group of metric blocks around a selection (`slides.0.blocks.2.metric` finds `slides.0` when the
80
81
  * slide's blocks are all metrics), or undefined. A group of metrics converts to a table as a whole.
81
82
  */
82
- export function metricGroupForSelection(document, selectedPath) {
83
+ export function metricGroupForSelection(presentation, selectedPath) {
83
84
  let parts;
84
85
  try {
85
86
  parts = splitOpfPath(selectedPath);
@@ -89,7 +90,7 @@ export function metricGroupForSelection(document, selectedPath) {
89
90
  if (parts[0] !== "slides") return undefined;
90
91
  for (let length = parts.length; length >= 2; length -= 1) {
91
92
  const candidate = parts.slice(0, length);
92
- const found = readBlockContent(document, candidate);
93
+ const found = readBlockContent(presentation, candidate);
93
94
  if (found?.kind === "group") return candidate.join(".");
94
95
  }
95
96
  return undefined;
@@ -101,10 +102,10 @@ export function metricGroupForSelection(document, selectedPath) {
101
102
  * Returns [] for a block with no convertible content (image, video, a group that is not a set of metrics, several fields).
102
103
  * `options` are core's conversion options (`looseWhen`, `fences`, `headings`, `columns`, `delimiter`, `header`).
103
104
  */
104
- export function blockConversionTargets(document, path, options = {}) {
105
- const found = readBlockContent(document, path);
105
+ export function blockConversionTargets(presentation, path, options = {}) {
106
+ const found = readBlockContent(presentation, path);
106
107
  // 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 }) : [];
108
+ return found ? contentConversionTargets(found.owner, { presentation: presentation, ...options }) : [];
108
109
  }
109
110
 
110
111
  /**
@@ -112,21 +113,21 @@ export function blockConversionTargets(document, path, options = {}) {
112
113
  * Throws `block-not-convertible` for a pair with no safe mapping or content that does not fit.
113
114
  * `prepared.loss` lists what the target cannot carry; `lossless` is true when nothing is lost.
114
115
  */
115
- export function prepareBlockConversion(document, path, kind, options = {}) {
116
- const found = readBlockContent(document, path);
116
+ export function prepareBlockConversion(presentation, path, kind, options = {}) {
117
+ const found = readBlockContent(presentation, path);
117
118
  if (!found) throw refuse(CHOOSE, { path });
118
119
  const pointer = opfPathToJsonPointer(found.path);
119
120
  const from = found.kind;
120
- const result = convertOwner(found.owner, kind, { document, ...options });
121
- if (!result.changed) return { document: structuredClone(document), patches: [], path: found.path.join("."), changed: false, lossless: true, loss: [], from, to: kind };
121
+ const result = convertOwner(found.owner, kind, { presentation: presentation, ...options });
122
+ if (!result.changed) return { presentation: structuredClone(presentation), patches: [], path: found.path.join("."), changed: false, lossless: true, loss: [], from, to: kind };
122
123
  const patches = [
123
124
  { op: "test", path: pointer, value: structuredClone(found.owner) },
124
125
  { op: "replace", path: pointer, value: result.payload },
125
126
  ];
126
- const next = applyJsonPatch(document, patches);
127
- const validation = validateOpfDocument(next);
128
- if (!validation.valid) throw fail("invalid-opf-edit", validation.errors[0]?.message ?? "The converted block is not valid OPF.", { issues: validation.errors, patches });
129
- return { document: next, patches, path: found.path.join("."), changed: true, lossless: result.lossless, loss: result.loss, from, to: kind };
127
+ const next = applyJsonPatch(presentation, patches);
128
+ const validation = checkFormat(next);
129
+ if (!validation.valid) throw fail("invalid-opf-edit", firstErrorMessage(validation, "The converted block is not valid OPF."), { issues: errorFindings(validation), patches });
130
+ return { presentation: next, patches, path: found.path.join("."), changed: true, lossless: result.lossless, loss: result.loss, from, to: kind };
130
131
  }
131
132
 
132
133
  /**
@@ -135,9 +136,9 @@ export function prepareBlockConversion(document, path, kind, options = {}) {
135
136
  */
136
137
  export function convertBlock(editor, path, kind, meta = {}, options = {}) {
137
138
  if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
138
- const prepared = prepareBlockConversion(editor.document, path, kind, options);
139
+ const prepared = prepareBlockConversion(editor.presentation, path, kind, options);
139
140
  const summary = { lossless: prepared.lossless, loss: prepared.loss, from: prepared.from, to: prepared.to, path: prepared.path, changed: prepared.changed };
140
- if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
141
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
141
142
  const change = editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "block-conversion", blockPath: prepared.path, from: prepared.from, to: prepared.to });
142
143
  return { ...change, ...summary };
143
144
  }
package/dist/blocks.d.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import type {JsonPatchOperation} from './index.js';
2
2
  /** Move a complete block; toIndex is the insertion position before removal. */
3
- export declare function prepareBlockMove(document:unknown,fromPath:string,toContainerPath:string,toIndex:number):{
4
- document:unknown;patches:JsonPatchOperation[];path:string;changed:boolean;
3
+ export declare function prepareBlockMove(presentation:unknown,fromPath:string,toContainerPath:string,toIndex:number):{
4
+ presentation:unknown;patches:JsonPatchOperation[];path:string;changed:boolean;
5
5
  };
6
- export declare function listBlockContainers(document:unknown,options?:{slideIndex?:number;includeImplicit?:boolean}):{path:string;label:string;count:number;implicit?:boolean}[];
6
+ export declare function listBlockContainers(presentation:unknown,options?:{slideIndex?:number;includeImplicit?:boolean}):{path:string;label:string;count:number;implicit?:boolean}[];
7
7
 
8
- export interface PreparedBlockChange { document:unknown;patches:JsonPatchOperation[];path:string;changed:boolean }
9
- export declare function prepareBlockInsert(document:unknown,containerPath:string,block:unknown,index?:number):PreparedBlockChange;
10
- export declare function prepareBlockDuplicate(document:unknown,path:string):PreparedBlockChange;
11
- export declare function prepareBlockRemove(document:unknown,path:string):PreparedBlockChange;
8
+ export interface PreparedBlockChange { presentation:unknown;patches:JsonPatchOperation[];path:string;changed:boolean }
9
+ export declare function prepareBlockInsert(presentation:unknown,containerPath:string,block:unknown,index?:number):PreparedBlockChange;
10
+ export declare function prepareBlockDuplicate(presentation:unknown,path:string):PreparedBlockChange;
11
+ export declare function prepareBlockRemove(presentation:unknown,path:string):PreparedBlockChange;
12
12
  /** Replace one complete block (or a slide/region's single content field). Replacement only: the old payload is discarded and nothing is converted between content types. Keeps `id` and `extensions` when the new block sets none. */
13
- export declare function prepareBlockReplace(document:unknown,path:string,block:unknown):PreparedBlockChange;
13
+ export declare function prepareBlockReplace(presentation:unknown,path:string,block:unknown):PreparedBlockChange;
14
14
  export type ContentBlockKind='text'|'list'|'chart'|'table'|'metric'|'quote'|'code'|'timeline'|'group'|'image'|'video';
15
15
  export declare function createContentBlock(kind:ContentBlockKind,options?:{source?:string}):Record<string,unknown>;
package/dist/blocks.js CHANGED
@@ -1,20 +1,21 @@
1
- import {splitOpfPath,opfPathToJsonPointer,getValueAtPath,applyJsonPatch,validateOpfDocument} from './index.js';
1
+ import { splitOpfPath, opfPathToJsonPointer, getValueAtPath, applyJsonPatch } from './index.js';
2
+ import { firstErrorMessage, checkFormat } from "./checks.js";
2
3
  const prefix=(a,b)=>a.length<=b.length&&a.every((part,i)=>part===b[i]);
3
4
 
4
5
  /** Move one complete block. The insertion index refers to the destination before removal. */
5
- export function prepareBlockMove(document,fromPath,toContainerPath,toIndex) {
6
+ export function prepareBlockMove(presentation,fromPath,toContainerPath,toIndex) {
6
7
  const from=splitOpfPath(fromPath),to=splitOpfPath(toContainerPath);
7
8
  if(from.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(from.at(-1)??''))throw new TypeError('Move a complete block using its blocks/index path.');
8
9
  const sourceIndex=Number(from.at(-1)),sourceContainer=from.slice(0,-2),sourceArray=from.slice(0,-1);
9
- const containers=new Set(listBlockContainers(document).map(item=>item.path));
10
+ const containers=new Set(listBlockContainers(presentation).map(item=>item.path));
10
11
  if(!containers.has(opfPathToJsonPointer(sourceContainer))||!containers.has(opfPathToJsonPointer(to)))throw new TypeError('Choose slide or group block containers.');
11
- const source=getValueAtPath(document,sourceArray),destination=getValueAtPath(document,[...to,'blocks']);
12
+ const source=getValueAtPath(presentation,sourceArray),destination=getValueAtPath(presentation,[...to,'blocks']);
12
13
  if(!Array.isArray(source)||sourceIndex>=source.length||!Array.isArray(destination))throw new RangeError('Source and destination must be existing block containers.');
13
14
  if(!Number.isInteger(toIndex)||toIndex<0||toIndex>destination.length)throw new RangeError('Destination insertion index is out of range.');
14
15
  if(prefix(from,to))throw new Error('A group cannot move into itself or one of its descendants.');
15
16
  const same=JSON.stringify(sourceContainer)===JSON.stringify(to);
16
17
  const index=same&&toIndex>sourceIndex?toIndex-1:toIndex;
17
- if(same&&index===sourceIndex)return {document:structuredClone(document),patches:[],path:opfPathToJsonPointer(from),changed:false};
18
+ if(same&&index===sourceIndex)return {presentation:structuredClone(presentation),patches:[],path:opfPathToJsonPointer(from),changed:false};
18
19
  if(!same&&source.length===1)throw new Error('This move would leave an empty block container. Move the group or add another block first.');
19
20
  // Removing a preceding sibling shifts a destination group's address.
20
21
  const rebased=[...to];
@@ -22,16 +23,16 @@ export function prepareBlockMove(document,fromPath,toContainerPath,toIndex) {
22
23
  rebased[sourceArray.length]=String(Number(to[sourceArray.length])-1);
23
24
  const target=[...rebased,'blocks',String(index)];
24
25
  const guards=[sourceContainer,...(same?[]:[to])].filter((path,i,all)=>!all.some((other,j)=>j!==i&&other.length<path.length&&prefix(other,path)));
25
- const patches=[...guards.map(path=>({op:'test',path:opfPathToJsonPointer(path),value:structuredClone(getValueAtPath(document,path))})),
26
+ const patches=[...guards.map(path=>({op:'test',path:opfPathToJsonPointer(path),value:structuredClone(getValueAtPath(presentation,path))})),
26
27
  {op:'remove',path:opfPathToJsonPointer(from)},
27
28
  {op:'add',path:opfPathToJsonPointer(target),value:structuredClone(source[sourceIndex])}];
28
- const next=applyJsonPatch(document,patches),validation=validateOpfDocument(next);
29
- if(!validation.valid)throw new Error(validation.errors[0]?.message??'The moved document is not valid OPF.');
30
- return {document:next,patches,path:opfPathToJsonPointer(target),changed:true};
29
+ const next=applyJsonPatch(presentation,patches),validation=checkFormat(next);
30
+ if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The moved document is not valid OPF.'));
31
+ return {presentation:next,patches,path:opfPathToJsonPointer(target),changed:true};
31
32
  }
32
33
 
33
34
  /** Discover actual block containers without interpreting imported content as instructions. */
34
- export function listBlockContainers(document,{slideIndex,includeImplicit=false}={}) {
35
+ export function listBlockContainers(presentation,{slideIndex,includeImplicit=false}={}) {
35
36
  const result=[];
36
37
  function visit(value,path,label,depth=0){
37
38
  if(!value||typeof value!=='object'||depth>32)return;
@@ -40,7 +41,7 @@ export function listBlockContainers(document,{slideIndex,includeImplicit=false}=
40
41
  value.blocks.forEach((block,i)=>visit(block,[...path,'blocks',String(i)],`${label} › Group ${i+1}`,depth+1));
41
42
  }
42
43
  }
43
- (document?.slides??[]).forEach((slide,i)=>{
44
+ (presentation?.slides??[]).forEach((slide,i)=>{
44
45
  if(slideIndex!==undefined&&slideIndex!==i)return;
45
46
  const path=['slides',String(i)],label=`Slide ${i+1}`;
46
47
  if(includeImplicit&&!Array.isArray(slide.blocks)&&!Object.keys(slide).some(isRegion))result.push({path:opfPathToJsonPointer(path),label,count:contentFields.filter(key=>Object.hasOwn(slide,key)).length,implicit:true});
@@ -53,20 +54,20 @@ export function listBlockContainers(document,{slideIndex,includeImplicit=false}=
53
54
 
54
55
  const contentFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
55
56
  const isRegion=key=>/^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/.test(key);
56
- function preparedChange(document,container,next,path) {
57
+ function preparedChange(presentation,container,next,path) {
57
58
  const pointer=opfPathToJsonPointer(container);
58
- const patches=[{op:'test',path:pointer,value:structuredClone(getValueAtPath(document,container))},
59
+ const patches=[{op:'test',path:pointer,value:structuredClone(getValueAtPath(presentation,container))},
59
60
  {op:'replace',path:pointer,value:next}];
60
- const result=applyJsonPatch(document,patches),validation=validateOpfDocument(result);
61
- if(!validation.valid)throw new Error(validation.errors[0]?.message??'The changed document is not valid OPF.');
62
- return {document:result,patches,path:opfPathToJsonPointer(path),changed:true};
61
+ const result=applyJsonPatch(presentation,patches),validation=checkFormat(result);
62
+ if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The changed document is not valid OPF.'));
63
+ return {presentation:result,patches,path:opfPathToJsonPointer(path),changed:true};
63
64
  }
64
65
  /** Insert into a slide, existing group or named region. Implicit payloads become blocks. */
65
- export function prepareBlockInsert(document,containerPath,block,index) {
66
+ export function prepareBlockInsert(presentation,containerPath,block,index) {
66
67
  const path=splitOpfPath(containerPath),pointer=opfPathToJsonPointer(path);
67
- if(!listBlockContainers(document,{includeImplicit:true}).some(c=>c.path===pointer))throw new TypeError('Choose a slide, group or named region content container.');
68
+ if(!listBlockContainers(presentation,{includeImplicit:true}).some(c=>c.path===pointer))throw new TypeError('Choose a slide, group or named region content container.');
68
69
  if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Insert an OPF content block object.');
69
- const next=structuredClone(getValueAtPath(document,path));
70
+ const next=structuredClone(getValueAtPath(presentation,path));
70
71
  if(!Array.isArray(next.blocks)){
71
72
  next.blocks=contentFields.filter(key=>Object.hasOwn(next,key)).map(key=>({...(next.type?{type:next.type}:{}),[key]:next[key]}));
72
73
  for(const key of [...contentFields,'type'])delete next[key];
@@ -74,12 +75,12 @@ export function prepareBlockInsert(document,containerPath,block,index) {
74
75
  const at=index??next.blocks.length;
75
76
  if(!Number.isInteger(at)||at<0||at>next.blocks.length)throw new RangeError('Insertion index is out of range.');
76
77
  next.blocks.splice(at,0,structuredClone(block));
77
- return preparedChange(document,path,next,[...path,'blocks',String(at)]);
78
+ return preparedChange(presentation,path,next,[...path,'blocks',String(at)]);
78
79
  }
79
- function checkedBlock(document,path) {
80
+ function checkedBlock(presentation,path) {
80
81
  const parts=splitOpfPath(path),container=parts.slice(0,-2),index=Number(parts.at(-1));
81
- if(parts.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'')||!listBlockContainers(document).some(c=>c.path===opfPathToJsonPointer(container)))throw new TypeError('Choose a complete block using its blocks/index path.');
82
- const blocks=getValueAtPath(document,[...container,'blocks']);
82
+ if(parts.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'')||!listBlockContainers(presentation).some(c=>c.path===opfPathToJsonPointer(container)))throw new TypeError('Choose a complete block using its blocks/index path.');
83
+ const blocks=getValueAtPath(presentation,[...container,'blocks']);
83
84
  if(index>=blocks.length)throw new RangeError('Block does not exist.');
84
85
  return {parts,container,index,blocks};
85
86
  }
@@ -90,21 +91,21 @@ function checkedBlock(document,path) {
90
91
  * region payload (exactly one content field, no `blocks`) is replaced in place, keeping its
91
92
  * other slide fields. The test guard fails if the block changed since it was read.
92
93
  */
93
- export function prepareBlockReplace(document,path,block) {
94
+ export function prepareBlockReplace(presentation,path,block) {
94
95
  if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Replace with an OPF content block object.');
95
96
  const parts=splitOpfPath(path),pointer=opfPathToJsonPointer(parts);
96
97
  const explicit=parts.at(-2)==='blocks'&&/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'');
97
98
  let old,next;
98
99
  if(explicit){
99
- const {index,blocks}=checkedBlock(document,path);
100
+ const {index,blocks}=checkedBlock(presentation,path);
100
101
  old=blocks[index];
101
102
  next=structuredClone(block);
102
103
  for(const key of ['id','extensions'])if(next[key]===undefined&&old[key]!==undefined)next[key]=structuredClone(old[key]);
103
104
  } else {
104
- const implicit=listBlockContainers(document,{includeImplicit:true}).find(c=>c.path===pointer&&c.implicit);
105
+ const implicit=listBlockContainers(presentation,{includeImplicit:true}).find(c=>c.path===pointer&&c.implicit);
105
106
  if(!implicit)throw new TypeError('Choose a complete block using its blocks/index path, or a slide or region with one content field.');
106
107
  if(implicit.count!==1)throw new TypeError('This slide or region holds several content fields. Choose one block, or insert and remove blocks.');
107
- old=getValueAtPath(document,parts);
108
+ old=getValueAtPath(presentation,parts);
108
109
  next=structuredClone(old);
109
110
  for(const key of [...contentFields,'type'])delete next[key];
110
111
  // The block's own id and extensions would overwrite the slide's; they are not carried over.
@@ -112,28 +113,28 @@ export function prepareBlockReplace(document,path,block) {
112
113
  Object.assign(next,payload);
113
114
  }
114
115
  const patches=[{op:'test',path:pointer,value:structuredClone(old)},{op:'replace',path:pointer,value:next}];
115
- const result=applyJsonPatch(document,patches),validation=validateOpfDocument(result);
116
- if(!validation.valid)throw new Error(validation.errors[0]?.message??'The replaced block is not valid OPF.');
117
- return {document:result,patches,path:pointer,changed:JSON.stringify(old)!==JSON.stringify(next)};
116
+ const result=applyJsonPatch(presentation,patches),validation=checkFormat(result);
117
+ if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The replaced block is not valid OPF.'));
118
+ return {presentation:result,patches,path:pointer,changed:JSON.stringify(old)!==JSON.stringify(next)};
118
119
  }
119
120
  /** Duplicate the entire block immediately after itself, preserving formatting and asset references. */
120
- export function prepareBlockDuplicate(document,path) {
121
- const {container,index,blocks}=checkedBlock(document,path);
122
- return prepareBlockInsert(document,opfPathToJsonPointer(container),blocks[index],index+1);
121
+ export function prepareBlockDuplicate(presentation,path) {
122
+ const {container,index,blocks}=checkedBlock(presentation,path);
123
+ return prepareBlockInsert(presentation,opfPathToJsonPointer(container),blocks[index],index+1);
123
124
  }
124
125
  /** Delete a block and prune empty ancestor groups; a slide itself is never deleted. */
125
- export function prepareBlockRemove(document,path) {
126
- let {parts,container,index,blocks}=checkedBlock(document,path);
126
+ export function prepareBlockRemove(presentation,path) {
127
+ let {parts,container,index,blocks}=checkedBlock(presentation,path);
127
128
  while(blocks.length===1&&container.length>2){
128
129
  if(container.length===3&&isRegion(container[2])){
129
- const slidePath=container.slice(0,2),next=structuredClone(getValueAtPath(document,slidePath));delete next[container[2]];
130
- return preparedChange(document,slidePath,next,slidePath);
130
+ const slidePath=container.slice(0,2),next=structuredClone(getValueAtPath(presentation,slidePath));delete next[container[2]];
131
+ return preparedChange(presentation,slidePath,next,slidePath);
131
132
  }
132
- ({parts,container,index,blocks}=checkedBlock(document,container));
133
+ ({parts,container,index,blocks}=checkedBlock(presentation,container));
133
134
  }
134
- const next=structuredClone(getValueAtPath(document,container));next.blocks.splice(index,1);
135
+ const next=structuredClone(getValueAtPath(presentation,container));next.blocks.splice(index,1);
135
136
  const selection=next.blocks.length?[...container,'blocks',String(Math.min(index,next.blocks.length-1))]:container;
136
- return preparedChange(document,container,next,selection);
137
+ return preparedChange(presentation,container,next,selection);
137
138
  }
138
139
 
139
140
  /** Small, schema-valid starting points. Media requires an explicit source. */
@@ -1,11 +1,6 @@
1
- import {
2
- applyJsonPatch,
3
- createValuePatch,
4
- validateOpfDocument,
5
- splitOpfPath,
6
- opfPathToJsonPointer,
7
- } from "./index.js";
1
+ import { applyJsonPatch, createValuePatch, splitOpfPath, opfPathToJsonPointer } from "./index.js";
8
2
  import {preserveTextLineEndings} from './text-input.js';
3
+ import { firstErrorMessage, checkFormat } from "./checks.js";
9
4
  export {preserveTextLineEndings} from './text-input.js';
10
5
  /** Scalar fields and collection paths use JSON Pointer to preserve arbitrary object keys. */
11
6
  export function getEditableFields(value, path) {
@@ -52,15 +47,43 @@ export function parseCanvasValue(text, type, original) {
52
47
  return type === 'string' && typeof original === 'string' ? preserveTextLineEndings(original,String(text)) : text;
53
48
  }
54
49
 
55
- export function createCanvasDraft(document, path, value) {
50
+ export function createCanvasDraft(presentation, path, value) {
56
51
  const draft = applyJsonPatch(
57
- document,
58
- createValuePatch(document, path, value),
52
+ presentation,
53
+ createValuePatch(presentation, path, value),
59
54
  );
60
- const validation = validateOpfDocument(draft);
55
+ const validation = checkFormat(draft);
61
56
  if (!validation.valid)
62
57
  throw new Error(
63
- validation.errors[0]?.message ?? "This change is not valid OPF.",
58
+ firstErrorMessage(validation, "This change is not valid OPF."),
64
59
  );
65
60
  return draft;
66
61
  }
62
+
63
+ /** The progress states of a timeline event (`TimelineEvent.status`). */
64
+ export const TIMELINE_STATUS_CHOICES = Object.freeze(["done", "current", "planned"]);
65
+ /** The fixed choices of a form field, or undefined for free text: an event's `status` inside a timeline payload. */
66
+ export function fieldChoices(field) {
67
+ const segments = splitOpfPath(field.path);
68
+ return segments.at(-1) === "status" && segments.at(-3) === "timeline"
69
+ ? TIMELINE_STATUS_CHOICES
70
+ : segments.at(-1) === "status" && segments.at(-3) === "events" && segments.at(-4) === "timeline"
71
+ ? TIMELINE_STATUS_CHOICES
72
+ : undefined;
73
+ }
74
+ /**
75
+ * A blank copy of `value`'s shape for the "Add" button: texts empty, numbers 0, booleans false, `type`, `mode` and
76
+ * `language` kept. A timeline event's `status` is left out, so a new event starts with no status instead of a copy.
77
+ */
78
+ export function emptyLike(value, key = "") {
79
+ if (Array.isArray(value)) return value.map((child) => emptyLike(child));
80
+ if (value && typeof value === "object")
81
+ return Object.fromEntries(
82
+ Object.entries(value)
83
+ .filter(([name]) => name !== "status")
84
+ .map(([name, child]) => [name, emptyLike(child, name)]),
85
+ );
86
+ if (typeof value === "number") return 0;
87
+ if (typeof value === "boolean") return false;
88
+ return ["type", "mode", "language"].includes(key) ? value : "";
89
+ }