@openpresentation/opf-editor 0.12.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +71 -60
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +15 -15
  6. package/dist/background-options.d.ts +2 -2
  7. package/dist/background-options.js +6 -6
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +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 +22 -30
  15. package/dist/canvas.js +57 -64
  16. package/dist/chart-data.d.ts +10 -10
  17. package/dist/chart-data.js +21 -21
  18. package/dist/chart-options-panel.d.ts +2 -2
  19. package/dist/chart-options-panel.js +82 -8
  20. package/dist/chart-options.d.ts +35 -9
  21. package/dist/chart-options.js +133 -40
  22. package/dist/checks.js +21 -0
  23. package/dist/color-authoring.js +1 -1
  24. package/dist/content-actions.d.ts +15 -15
  25. package/dist/content-actions.js +71 -70
  26. package/dist/content-controls.js +12 -12
  27. package/dist/data-grid.js +7 -8
  28. package/dist/data.d.ts +4 -4
  29. package/dist/data.js +5 -5
  30. package/dist/dataset-refs.d.ts +2 -2
  31. package/dist/dataset-refs.js +5 -5
  32. package/dist/design-controls.js +61 -44
  33. package/dist/design-options.d.ts +16 -14
  34. package/dist/design-options.js +86 -74
  35. package/dist/edit-helpers.js +8 -7
  36. package/dist/exact-source.js +5 -5
  37. package/dist/export.d.ts +5 -7
  38. package/dist/export.js +40 -14
  39. package/dist/find-panel.js +5 -5
  40. package/dist/find-replace.d.ts +3 -3
  41. package/dist/find-replace.js +23 -20
  42. package/dist/font-gate.js +49 -58
  43. package/dist/galleries.js +36 -52
  44. package/dist/grid-model.d.ts +25 -27
  45. package/dist/grid-model.js +92 -102
  46. package/dist/image-crop.d.ts +4 -4
  47. package/dist/image-crop.js +20 -20
  48. package/dist/image-cropper.js +3 -3
  49. package/dist/index.d.ts +28 -28
  50. package/dist/index.js +74 -90
  51. package/dist/json-options.js +17 -16
  52. package/dist/layout-handles.js +5 -5
  53. package/dist/layout-placeholders.js +4 -4
  54. package/dist/layout.d.ts +2 -2
  55. package/dist/layout.js +8 -7
  56. package/dist/numbering-panel.js +3 -5
  57. package/dist/numbering.d.ts +3 -5
  58. package/dist/numbering.js +14 -26
  59. package/dist/outline-view.js +24 -24
  60. package/dist/outline.d.ts +12 -12
  61. package/dist/outline.js +52 -50
  62. package/dist/persistence.d.ts +1 -1
  63. package/dist/persistence.js +19 -18
  64. package/dist/presentation-ids.js +2 -2
  65. package/dist/react.js +3 -3
  66. package/dist/review-panel.d.ts +30 -13
  67. package/dist/review-panel.js +154 -53
  68. package/dist/review.d.ts +36 -43
  69. package/dist/review.js +92 -53
  70. package/dist/rich-text-input.js +4 -3
  71. package/dist/rich-text-toolbar.js +5 -3
  72. package/dist/rich-text.js +5 -2
  73. package/dist/schema-inspector.d.ts +2 -2
  74. package/dist/schema-inspector.js +16 -11
  75. package/dist/schema.d.ts +1 -1
  76. package/dist/schema.js +25 -5
  77. package/dist/slide-manager.d.ts +2 -2
  78. package/dist/slide-manager.js +18 -18
  79. package/dist/slides.d.ts +20 -20
  80. package/dist/slides.js +99 -97
  81. package/dist/svelte.js +1 -1
  82. package/dist/switches.d.ts +10 -10
  83. package/dist/switches.js +124 -114
  84. package/dist/table-options.d.ts +8 -8
  85. package/dist/table-options.js +28 -27
  86. package/dist/table-structure.d.ts +10 -10
  87. package/dist/table-structure.js +21 -21
  88. package/dist/template-panel.d.ts +3 -3
  89. package/dist/template-panel.js +37 -13
  90. package/dist/templates.d.ts +22 -11
  91. package/dist/templates.js +48 -55
  92. package/dist/transfer.d.ts +4 -4
  93. package/dist/transfer.js +49 -59
  94. package/package.json +8 -6
  95. package/dist/font-defaults.js +0 -25
@@ -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
+ }
package/dist/canvas.d.ts CHANGED
@@ -1,55 +1,47 @@
1
1
  import type { EditorSession } from "./index.js";
2
2
  import type { RenderSvgOptions } from "@openpresentation/opf-render";
3
+ import type { BrowserFontsHandle } from "@openpresentation/opf-render/fonts-browser";
3
4
  import type { SlideComposition } from "@openpresentation/opf/composition";
4
- /** Faces a document needs (script packages and vendored files) that are not loaded yet, and the way to load them. */
5
- export interface FontGate {
6
- /** Synchronous. Empty means the document can render now. Never throws. `renderOptions` are the options the host renders with (`catalogs`, ...); the canvas passes its current ones. */
7
- pending(document: unknown, renderOptions?: RenderSvgOptions): string[];
8
- /** Load every pending face (vendored faces first, then script faces). Rejects with a `fonts-unavailable` error; never retries by itself. */
9
- ensure(document: unknown, options?: { signal?: AbortSignal; renderOptions?: RenderSvgOptions }): Promise<void>;
10
- /** `whenFontsReady` on this gate. */
11
- run(document: unknown, handlers?: FontGateHandlers): Promise<void>;
12
- }
13
- export interface FontGateHandlers {
5
+ /**
6
+ * The fonts the canvas, the export and `whenFontsReady` take: the renderer's handle from `loadFonts()` (`@openpresentation/opf-render/fonts-browser`).
7
+ * Only `textMeasurement` is required to measure; `pending` and `ensure` make the canvas load script and vendored faces before it draws.
8
+ */
9
+ export type EditorFonts = Pick<BrowserFontsHandle, "textMeasurement"> & Partial<Pick<BrowserFontsHandle, "embeddedFonts" | "pending" | "ensure" | "registry">>;
10
+ export interface FontsReadyHandlers {
14
11
  /** Return false once the document was superseded; the late result is then dropped. */
15
12
  isCurrent?: () => boolean;
16
13
  loading?: (pending: string[]) => void;
17
14
  ready?: () => void;
18
15
  failed?: (error: Error) => void;
19
- /** The options the host renders with (`catalogs`, ...), passed to the gate. */
16
+ /** The options the host renders with (`catalogs`, ...), passed to the fonts handle. */
20
17
  renderOptions?: RenderSvgOptions;
21
18
  }
22
19
  export declare const FONTS_PENDING: "fonts-pending";
23
20
  export declare const FONTS_UNAVAILABLE: "fonts-unavailable";
24
- /** Wrap a browser font registry (`loadBrowserFontRegistry`). Registries without the lazy loaders gate nothing. */
25
- /**
26
- * `options.renderOptions` (an object, or a function called on every use) are the default render options passed to the registry
27
- * (`catalogs`, ...), under a call's own; the canvas passes its current `renderOptions` itself, so a gate needs none.
28
- */
29
- export declare function createFontGate(registry: object, options?: { renderOptions?: RenderSvgOptions | (() => RenderSvgOptions | undefined) }): FontGate;
30
21
  /**
31
- * Ensure fonts for a document, then run `ready` (synchronously when nothing is pending). The one
32
- * "ensure fonts, then render" helper: load failures and exceptions from `ready` go to `failed`.
22
+ * Ensure fonts for a presentation, then run `ready` (synchronously when nothing is pending). The one
23
+ * "ensure fonts, then render" helper: load failures and exceptions from `ready` go to `failed`. `fonts` is the renderer's fonts handle.
33
24
  */
34
25
  export declare function whenFontsReady(
35
- gate: Pick<FontGate, "pending" | "ensure"> | undefined,
36
- document: unknown,
37
- handlers?: FontGateHandlers,
26
+ fonts: Pick<EditorFonts, "pending" | "ensure"> | undefined,
27
+ presentation: unknown,
28
+ handlers?: FontsReadyHandlers,
38
29
  ): Promise<void>;
39
30
  export interface CanvasEditorOptions {
40
31
  editor?: EditorSession;
41
- document?: unknown;
32
+ presentation?: unknown;
42
33
  slideIndex?: number;
43
34
  /** Show keyboard-accessible dividers for resizing composition tracks. */
44
35
  layoutEditing?: boolean;
45
36
  /** Render options. The canvas draws the document as authored (`variables: false`: a template's `{{tokens}}` stay visible, so inline edits never overwrite them); pass `variables` to draw resolved values instead. */
46
37
  renderOptions?: RenderSvgOptions;
47
38
  /**
48
- * A font gate (`createFontGate(registry)`). With one, the canvas never renders a document whose faces are still
49
- * loading: it shows "Loading fonts…", loads them and then renders, on every path (editor changes, undo/redo,
50
- * imports, dimension switches, slide changes, drafts). Without one every document renders at once.
39
+ * The renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`). Its `textMeasurement` lays the slide out and
40
+ * its faces draw it. With its `pending` and `ensure` the canvas never renders a document whose faces are still loading: it shows
41
+ * "Loading fonts…", loads them and then renders, on every path (editor changes, undo/redo, imports, dimension switches, slide
42
+ * changes, drafts). Without a handle every document renders at once, laid out with core's portable text estimate.
51
43
  */
52
- fonts?: Pick<FontGate, "pending" | "ensure">;
44
+ fonts?: EditorFonts;
53
45
  /**
54
46
  * How a pointer enters text editing. "click" (default): a single press on editable text starts editing with the caret at the
55
47
  * pressed character, and a press-drag selects a range. "dblclick": a click selects, a double-click enters with the caret at the
@@ -68,7 +60,7 @@ export interface CanvasEditorOptions {
68
60
  editor: EditorSession;
69
61
  }) => void;
70
62
  onDraft?: (draft: {
71
- document: unknown;
63
+ presentation: unknown;
72
64
  path: string;
73
65
  value: unknown;
74
66
  }) => void;
@@ -78,7 +70,7 @@ export interface CanvasEditorOptions {
78
70
  /** Font loading progress of the canvas: `loading`, `error` (a face could not be loaded; the canvas offers a retry) and `ready`. */
79
71
  onFonts?: (event: { state: "loading"; pending: string[] } | { state: "error"; error: Error } | { state: "ready" }) => void;
80
72
  onRender?: (event: {
81
- document: unknown;
73
+ presentation: unknown;
82
74
  slideIndex: number;
83
75
  svg: SVGSVGElement;
84
76
  geometry: SlideComposition;
@@ -110,7 +102,7 @@ export interface CanvasEditor {
110
102
  editProperties(path: string): boolean;
111
103
  commit(): boolean;
112
104
  cancel(): void;
113
- render(document?: unknown): void;
105
+ render(presentation?: unknown): void;
114
106
  setSlide(index: number): boolean;
115
107
  /**
116
108
  * Select the content at `path`, or the closest enclosing content the canvas can select (a list item selects its list),