@openpresentation/opf-editor 0.10.6 → 0.11.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.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { paginateSlide } from "@openpresentation/opf/pagination";
2
+ import { PatchError, applyPatch as applyCorePatch, applyPatchWithInverse, formatPointer, invertPatch, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
2
3
  import { collectReservedPresentationIds } from "./presentation-ids.js";
3
4
  import { DEFAULT_FONT_SCHEME, resolveFontSchemeReference } from "./font-defaults.js";
4
5
  import { composeSlide, resolveCanvasDimensions, resolveFontFamilies } from "@openpresentation/opf/composition";
@@ -54,7 +55,6 @@ export const runtimePolicy = Object.freeze({
54
55
  const DATA_OPF_PATH = "data-opf-path";
55
56
  const COMPONENT_ATTR = "data-opf-component";
56
57
  const SELECT_EVENT = "opfselect";
57
- const MISSING = Symbol("opf-editor.missing");
58
58
 
59
59
  export class OPFEditorError extends Error {
60
60
  constructor(code, message, details = {}) {
@@ -78,9 +78,7 @@ export function splitOpfPath(path) {
78
78
  }
79
79
 
80
80
  export function opfPathToJsonPointer(path) {
81
- const segments = splitOpfPath(path);
82
- if (!segments.length) return "";
83
- return `/${segments.map(escapePointerSegment).join("/")}`;
81
+ return formatPointer(splitOpfPath(path));
84
82
  }
85
83
 
86
84
  export function jsonPointerToOpfPath(pointer) {
@@ -88,12 +86,12 @@ export function jsonPointerToOpfPath(pointer) {
88
86
  }
89
87
 
90
88
  export function getValueAtPath(document, path, fallback) {
91
- const value = readAtPath(document, splitOpfPath(path));
92
- return value === MISSING ? fallback : value;
89
+ const found = readPointer(document, splitOpfPath(path));
90
+ return found.found ? found.value : fallback;
93
91
  }
94
92
 
95
93
  export function hasValueAtPath(document, path) {
96
- return readAtPath(document, splitOpfPath(path)) !== MISSING;
94
+ return readPointer(document, splitOpfPath(path)).found;
97
95
  }
98
96
 
99
97
  export function createValuePatch(document, path, value) {
@@ -107,45 +105,27 @@ export function createValuePatch(document, path, value) {
107
105
  ];
108
106
  }
109
107
 
108
+ // Patch semantics (RFC 6902, pointers, inverse patches) live in core's
109
+ // "@openpresentation/opf/patch"; the editor adds its path spellings (dotted OPF
110
+ // paths), its error type and its clone policy on top.
110
111
  export function applyJsonPatch(document, operations) {
111
112
  assertPatchOperations(operations);
112
- let next = clone(document);
113
- for (const operation of operations) {
114
- next = applyJsonPatchOperation(next, normalizeOperation(operation));
113
+ const patch = operations.map(normalizeOperation);
114
+ try {
115
+ return applyCorePatch(document, patch);
116
+ } catch (error) {
117
+ throw editorPatchError(error);
115
118
  }
116
- return next;
117
119
  }
118
120
 
119
121
  export function invertJsonPatch(document, operations) {
120
122
  assertPatchOperations(operations);
121
- let current = clone(document);
122
- const inverse = [];
123
-
124
- for (const operation of operations.map(normalizeOperation)) {
125
- const previous = readAtPath(current, parseJsonPointer(operation.path));
126
- if (operation.op !== "add" && previous === MISSING) {
127
- throw new OPFEditorError("patch-path-missing", `Cannot invert ${operation.op} for missing path ${operation.path}.`, {
128
- path: operation.path,
129
- operation
130
- });
131
- }
132
- const addReplacedObjectValue = operation.op === "add" && previous !== MISSING && !pathParentIsArray(current, operation.path);
133
- const inversePath = operation.op === "add" ? insertedPathForAdd(operation.path, previous, current) : operation.path;
134
-
135
- current = applyJsonPatchOperation(current, operation);
136
-
137
- if (addReplacedObjectValue) {
138
- inverse.unshift({ op: "replace", path: operation.path, value: clone(previous) });
139
- } else if (operation.op === "add") {
140
- inverse.unshift({ op: "remove", path: inversePath });
141
- } else if (operation.op === "replace") {
142
- inverse.unshift({ op: "replace", path: operation.path, value: clone(previous) });
143
- } else if (operation.op === "remove") {
144
- inverse.unshift({ op: "add", path: operation.path, value: clone(previous) });
145
- }
123
+ const patch = operations.map(normalizeOperation);
124
+ try {
125
+ return invertPatch(document, patch);
126
+ } catch (error) {
127
+ throw editorPatchError(error);
146
128
  }
147
-
148
- return inverse;
149
129
  }
150
130
 
151
131
  export function validateOpfDocument(document, validator = validatePresentation) {
@@ -178,8 +158,12 @@ export function createEditorSession(input, options = {}) {
178
158
  assertPatchOperations(operations);
179
159
  const patches = operations.map(normalizeOperation);
180
160
  const before = document;
181
- const inversePatches = invertJsonPatch(before, patches);
182
- const next = applyJsonPatch(before, patches);
161
+ let next, inversePatches;
162
+ try {
163
+ ({ document: next, inverse: inversePatches } = applyPatchWithInverse(before, patches));
164
+ } catch (error) {
165
+ throw editorPatchError(error);
166
+ }
183
167
  const nextValidation = validateOpfDocument(next, options.validate ?? validatePresentation);
184
168
 
185
169
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
@@ -282,6 +266,56 @@ export function createEditorSession(input, options = {}) {
282
266
  return setCatalogId(editor, path, catalogKind, id, meta);
283
267
  },
284
268
  applyPatch: commitPatch,
269
+ /**
270
+ * The undo and redo stacks as plain data (oldest entry first): `{ undo: [{ patches, inversePatches, meta }], redo: [...] }`.
271
+ * Together with `document` this is everything `restoreState` needs to bring a session back, for hosts that persist work.
272
+ */
273
+ exportHistory() {
274
+ const entry = (item) => ({ patches: clonePatchOperations(item.patches), inversePatches: clonePatchOperations(item.inversePatches), meta: clone(item.meta ?? {}) });
275
+ return { undo: undoStack.map(entry), redo: redoStack.map(entry) };
276
+ },
277
+ /**
278
+ * Replace the document, and optionally the undo and redo history, in one step (a session that was persisted and is being restored).
279
+ * The document is validated like any edit; the history is checked by replaying it (the inverse patches of the undo stack must walk
280
+ * back from the document, the redo stack must walk forward), and a history that does not fit is refused with `invalid-history`
281
+ * before anything changes, so a stale or damaged copy never corrupts the session. Emits one `restore` event.
282
+ */
283
+ restoreState(state, meta = {}) {
284
+ if (!state || typeof state !== "object") throw new OPFEditorError("invalid-state", "restoreState needs { document, undo?, redo? }.");
285
+ const next = parseInput(state.document);
286
+ const nextValidation = validateOpfDocument(next, options.validate ?? validatePresentation);
287
+ if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
288
+ throw new OPFEditorError("invalid-opf-edit", "The restored document is not valid OPF.", { issues: nextValidation.errors });
289
+ }
290
+ const undo = state.undo ?? [], redo = state.redo ?? [];
291
+ const wellFormed = (entries) => Array.isArray(entries) && entries.every((item) => item && Array.isArray(item.patches) && Array.isArray(item.inversePatches));
292
+ if (!wellFormed(undo) || !wellFormed(redo)) throw new OPFEditorError("invalid-history", "The undo history is not in the exported shape.");
293
+ try {
294
+ // The undo stack must walk back from the document and forward again to exactly the document; the redo stack must walk forward and
295
+ // back again. Inverse patches alone would apply to any document, so the round trip is what ties the history to this one.
296
+ let base = next;
297
+ for (let at = undo.length - 1; at >= 0; at -= 1) base = applyJsonPatch(base, undo[at].inversePatches.map(normalizeOperation));
298
+ let forward = base;
299
+ for (let at = 0; at < undo.length; at += 1) forward = applyJsonPatch(forward, undo[at].patches.map(normalizeOperation));
300
+ if (canonicalJson(forward) !== canonicalJson(next)) throw new Error("the undo history does not reproduce the document");
301
+ let ahead = next;
302
+ for (let at = redo.length - 1; at >= 0; at -= 1) ahead = applyJsonPatch(ahead, redo[at].patches.map(normalizeOperation));
303
+ let back = ahead;
304
+ for (let at = 0; at < redo.length; at += 1) back = applyJsonPatch(back, redo[at].inversePatches.map(normalizeOperation));
305
+ if (canonicalJson(back) !== canonicalJson(next)) throw new Error("the redo history does not return to the document");
306
+ } catch (error) {
307
+ throw new OPFEditorError("invalid-history", "The undo history does not belong to this document.", { cause: error instanceof Error ? error.message : String(error) });
308
+ }
309
+ const entry = (item) => ({ patches: item.patches.map(normalizeOperation), inversePatches: item.inversePatches.map(normalizeOperation), meta: clone(item.meta ?? {}) });
310
+ document = next;
311
+ validation = nextValidation;
312
+ undoStack.length = 0;
313
+ redoStack.length = 0;
314
+ undoStack.push(...undo.map(entry));
315
+ redoStack.push(...redo.map(entry));
316
+ emit({ type: "restore", patches: [], validation, meta });
317
+ return { document: clone(document), patches: [], validation };
318
+ },
285
319
  undo(meta = {}) {
286
320
  const entry = undoStack.pop();
287
321
  if (!entry) return null;
@@ -556,168 +590,67 @@ function clone(value) {
556
590
  return JSON.parse(JSON.stringify(value));
557
591
  }
558
592
 
559
- function clonePatchOperations(operations) {
560
- return operations.map((operation) => clone(operation));
561
- }
562
-
563
- function escapePointerSegment(segment) {
564
- return String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
593
+ /** JSON with object keys sorted, so two documents compare equal whatever order their keys were written in. */
594
+ function canonicalJson(value) {
595
+ return JSON.stringify(value, (key, item) => (item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map((name) => [name, item[name]])) : item));
565
596
  }
566
597
 
567
- function unescapePointerSegment(segment) {
568
- return segment.replaceAll("~1", "/").replaceAll("~0", "~");
598
+ function clonePatchOperations(operations) {
599
+ return operations.map((operation) => clone(operation));
569
600
  }
570
601
 
571
602
  function parseJsonPointer(pointer) {
572
- if (pointer === "") return [];
573
- if (typeof pointer !== "string" || !pointer.startsWith("/")) {
574
- throw new OPFEditorError("invalid-json-pointer", "JSON Patch paths must be JSON Pointers.", { path: pointer });
603
+ try {
604
+ return parsePointer(pointer);
605
+ } catch (error) {
606
+ throw editorPatchError(error);
575
607
  }
576
- return pointer.slice(1).split("/").map(unescapePointerSegment);
577
608
  }
578
609
 
579
610
  function normalizePatchPath(path) {
580
611
  return typeof path === "string" && path.startsWith("/") ? path : opfPathToJsonPointer(path);
581
612
  }
582
613
 
614
+ const PATCH_OPERATIONS = ["add", "replace", "remove", "move", "copy", "test"];
615
+
583
616
  function normalizeOperation(operation) {
584
617
  if (!operation || typeof operation !== "object") {
585
618
  throw new OPFEditorError("invalid-patch-operation", "JSON Patch operation must be an object.", { operation });
586
619
  }
587
- if (!["add", "replace", "remove", "test"].includes(operation.op)) {
620
+ if (!PATCH_OPERATIONS.includes(operation.op)) {
588
621
  throw new OPFEditorError("unsupported-patch-operation", `Unsupported JSON Patch operation: ${operation.op}.`, {
589
622
  operation
590
623
  });
591
624
  }
592
625
  if (operation.op === "test" && !Object.prototype.hasOwnProperty.call(operation, "value"))
593
626
  throw new OPFEditorError("invalid-patch-operation", "A test operation requires a value.");
594
- const normalized = {
595
- op: operation.op,
596
- path: normalizePatchPath(operation.path)
597
- };
598
- if (operation.op !== "remove") normalized.value = clone(operation.value);
627
+ const normalized = { op: operation.op };
628
+ if (operation.op === "move" || operation.op === "copy") {
629
+ if (operation.from === undefined) throw new OPFEditorError("invalid-patch-operation", `A ${operation.op} operation requires from.`, { operation });
630
+ normalized.from = normalizePatchPath(operation.from);
631
+ }
632
+ normalized.path = normalizePatchPath(operation.path);
633
+ if (operation.op === "add" || operation.op === "replace" || operation.op === "test") normalized.value = clone(operation.value);
599
634
  return normalized;
600
635
  }
601
636
 
637
+ // Core patch errors keep their stable codes; the editor reports them as OPFEditorError.
638
+ function editorPatchError(error) {
639
+ if (!(error instanceof PatchError)) return error;
640
+ const details = {};
641
+ if (error.path !== undefined) details.path = error.path;
642
+ if (error.operation !== undefined) details.operation = error.operation;
643
+ if (error.index !== undefined) details.index = error.index;
644
+ if (error.validation) details.issues = error.validation.errors;
645
+ return new OPFEditorError(error.code, error.message, details);
646
+ }
647
+
602
648
  function assertPatchOperations(operations) {
603
649
  if (!Array.isArray(operations)) {
604
650
  throw new OPFEditorError("invalid-patch", "JSON Patch must be an array of operations.", { operations });
605
651
  }
606
652
  }
607
653
 
608
- function readAtPath(document, segments) {
609
- let current = document;
610
- for (const segment of segments) {
611
- if (Array.isArray(current)) {
612
- if (segment === "-") return MISSING;
613
- const index = Number(segment);
614
- if (!Number.isInteger(index) || index < 0 || index >= current.length) return MISSING;
615
- current = current[index];
616
- } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) {
617
- current = current[segment];
618
- } else {
619
- return MISSING;
620
- }
621
- }
622
- return current;
623
- }
624
-
625
- function jsonEqual(a, b) {
626
- if (a === b) return true;
627
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object" || Array.isArray(a) !== Array.isArray(b)) return false;
628
- const keys = Object.keys(a);
629
- return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && jsonEqual(a[key], b[key]));
630
- }
631
- function applyJsonPatchOperation(document, operation) {
632
- const segments = parseJsonPointer(operation.path);
633
- if (operation.op === "test") {
634
- const current = readAtPath(document, segments);
635
- if (current === MISSING || !jsonEqual(current, operation.value))
636
- throw new OPFEditorError("patch-test-failed", `The value changed at ${operation.path || "/"}.`, {path: operation.path});
637
- return clone(document);
638
- }
639
- if (segments.length === 0) {
640
- if (operation.op === "remove") return undefined;
641
- return clone(operation.value);
642
- }
643
-
644
- const next = clone(document);
645
- const parent = readAtPath(next, segments.slice(0, -1));
646
- const key = segments.at(-1);
647
-
648
- if (parent === MISSING || parent === null || typeof parent !== "object") {
649
- throw new OPFEditorError("patch-parent-missing", `Patch parent does not exist for ${operation.path}.`, {
650
- path: operation.path,
651
- operation
652
- });
653
- }
654
-
655
- if (Array.isArray(parent)) applyArrayOperation(parent, key, operation);
656
- else applyObjectOperation(parent, key, operation);
657
-
658
- return next;
659
- }
660
-
661
- function insertedPathForAdd(path, previous, document) {
662
- if (previous !== MISSING || !path.endsWith("/-")) return path;
663
- const parentPath = path.slice(0, -2);
664
- const parent = readAtPath(document, parseJsonPointer(parentPath));
665
- if (!Array.isArray(parent)) return path;
666
- return `${parentPath}/${parent.length}`;
667
- }
668
-
669
- function pathParentIsArray(document, path) {
670
- const segments = parseJsonPointer(path);
671
- if (segments.length === 0) return false;
672
- const parent = readAtPath(document, segments.slice(0, -1));
673
- return Array.isArray(parent);
674
- }
675
-
676
- function applyArrayOperation(parent, key, operation) {
677
- if (!/^(0|[1-9][0-9]*)$/.test(key) && !(key === "-" && operation.op === "add")) {
678
- throw new OPFEditorError("invalid-array-index", `Invalid array index in patch path ${operation.path}.`, { path: operation.path });
679
- }
680
- const index = key === "-" ? parent.length : Number(key);
681
- if (!Number.isInteger(index) || index < 0 || index > parent.length) {
682
- throw new OPFEditorError("invalid-array-index", `Invalid array index in patch path ${operation.path}.`, {
683
- path: operation.path,
684
- operation
685
- });
686
- }
687
-
688
- if (operation.op === "add") {
689
- parent.splice(index, 0, clone(operation.value));
690
- return;
691
- }
692
-
693
- if (index >= parent.length) {
694
- throw new OPFEditorError("patch-path-missing", `Patch path does not exist: ${operation.path}.`, {
695
- path: operation.path,
696
- operation
697
- });
698
- }
699
-
700
- if (operation.op === "replace") parent[index] = clone(operation.value);
701
- else parent.splice(index, 1);
702
- }
703
-
704
- function applyObjectOperation(parent, key, operation) {
705
- if (operation.op === "add") {
706
- Object.defineProperty(parent, key, { value: clone(operation.value), writable: true, enumerable: true, configurable: true });
707
- return;
708
- }
709
-
710
- if (!Object.prototype.hasOwnProperty.call(parent, key)) {
711
- throw new OPFEditorError("patch-path-missing", `Patch path does not exist: ${operation.path}.`, {
712
- path: operation.path,
713
- operation
714
- });
715
- }
716
-
717
- if (operation.op === "replace") parent[key] = clone(operation.value);
718
- else delete parent[key];
719
- }
720
-
721
654
  function assertElementRoot(root) {
722
655
  if (!root || typeof root !== "object" || typeof root.querySelectorAll !== "function") {
723
656
  throw new OPFEditorError("invalid-root", "SVG trace binding requires a root element with querySelectorAll.");
@@ -0,0 +1,21 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ export interface NumberingPanelOptions {
4
+ editor: EditorSession;
5
+ /** The path the host has selected (a list, an entry of one, or anything inside one); the panel numbers that list. */
6
+ getTarget?: () => string | undefined;
7
+ /** The slide whose lists the panel offers when nothing in a list is selected. */
8
+ getSlideIndex?: () => number;
9
+ /** Called with every status or error message the panel shows. */
10
+ onStatus?: (message: string, info: { error: boolean }) => void;
11
+ }
12
+ export interface NumberingPanel {
13
+ element: HTMLElement;
14
+ /** Show another list (a path in it); with no argument, re-read the host's selection. */
15
+ setTarget(path?: string): void;
16
+ /** Redraw from the document (called on every document change). */
17
+ refresh(): void;
18
+ destroy(): void;
19
+ }
20
+ /** Mount the numbering control: number a list, pick the style, start and suffix, set them per level, and restart the count at an entry. Every change is one undoable edit. */
21
+ export declare function createNumberingPanel(container: HTMLElement, options: NumberingPanelOptions): NumberingPanel;
@@ -0,0 +1,200 @@
1
+ // List numbering panel (RR-33): the DOM control over the numbering model. It numbers a list (or turns numbering off), picks
2
+ // the style, start and suffix, optionally per list level, shows the markers the list will draw, and restarts the count at
3
+ // the selected entry. The panel owns no document state: every change is one validated, undoable edit through the session, and
4
+ // the panel redraws from the document. Importing this module does not need a DOM; mounting does.
5
+ import {
6
+ MAX_NUMBERING_LEVELS,
7
+ MAX_NUMBERING_START,
8
+ NUMBERING_STYLE_OPTIONS,
9
+ NUMBERING_SUFFIX_OPTIONS,
10
+ findNumberableLists,
11
+ numberingAvailable,
12
+ numberingState,
13
+ numberingValue,
14
+ setEntryStart,
15
+ setNumbering,
16
+ } from "./numbering.js";
17
+
18
+ let panelCounter = 0;
19
+
20
+ function h(doc, tag, attributes = {}, ...children) {
21
+ const element = doc.createElement(tag);
22
+ for (const [key, value] of Object.entries(attributes)) {
23
+ if (value === undefined || value === false) continue;
24
+ if (key === "class") element.className = value;
25
+ else if (key === "text") element.textContent = value;
26
+ else if (key.startsWith("on")) element.addEventListener(key.slice(2), value);
27
+ else element.setAttribute(key, value === true ? "" : String(value));
28
+ }
29
+ for (const child of children) if (child) element.append(child);
30
+ return element;
31
+ }
32
+
33
+ /**
34
+ * Mount the numbering control. Options: `editor` (a session), `getTarget()` (the path the host has selected: a list, an entry
35
+ * or anything inside one), `getSlideIndex()` (the slide whose lists are offered when nothing in a list is selected) and
36
+ * `onStatus(message, {error})`.
37
+ */
38
+ export function createNumberingPanel(container, options) {
39
+ if (!numberingAvailable()) throw new Error("The numbering control needs a core release that ships numbering.");
40
+ const { editor, getTarget, getSlideIndex, onStatus } = options;
41
+ const doc = container.ownerDocument;
42
+ const id = `opf-numbering-${++panelCounter}`;
43
+ let explicit;
44
+ let destroyed = false;
45
+ let drawn = "";
46
+
47
+ const root = h(doc, "section", { class: "opf-numbering-panel", "data-opf-component": "numbering-panel", "aria-label": "List numbering" });
48
+ const body = h(doc, "div", { class: "opf-numbering-body" });
49
+ const live = h(doc, "p", { class: "opf-numbering-live", role: "status", "aria-live": "polite" });
50
+ root.append(body, live);
51
+ container.append(root);
52
+
53
+ const say = (message, error = false) => {
54
+ live.textContent = message;
55
+ live.dataset.error = error ? "true" : "false";
56
+ onStatus?.(message, { error });
57
+ };
58
+
59
+ const currentPath = () => {
60
+ const slide = getSlideIndex?.() ?? 0;
61
+ const lists = findNumberableLists(editor.document, slide);
62
+ const wanted = explicit ?? getTarget?.();
63
+ if (wanted && numberingState(editor.document, wanted)) return { path: wanted, lists };
64
+ return { path: lists[0]?.path, lists };
65
+ };
66
+
67
+ const optionElements = (options, selected) => options.map((option) => h(doc, "option", { value: option.value, text: option.label, selected: option.value === selected }));
68
+
69
+ function levelRow(index, level, { label, enabled }) {
70
+ const prefix = `${id}-l${index}`;
71
+ const style = h(doc, "select", { id: `${prefix}-style`, "data-field": "style", "data-level": index, disabled: !enabled }, ...optionElements(NUMBERING_STYLE_OPTIONS, level.style));
72
+ const start = h(doc, "input", { id: `${prefix}-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: level.start, "data-field": "start", "data-level": index, disabled: !enabled });
73
+ const suffix = h(doc, "select", { id: `${prefix}-suffix`, "data-field": "suffix", "data-level": index, disabled: !enabled }, ...optionElements(NUMBERING_SUFFIX_OPTIONS, level.suffix));
74
+ return h(doc, "div", { class: "opf-numbering-level", role: "group", "aria-label": label },
75
+ h(doc, "span", { class: "opf-numbering-level-name", text: label }),
76
+ h(doc, "label", { for: style.id, text: "Style" }), style,
77
+ h(doc, "label", { for: start.id, text: "Start at" }), start,
78
+ h(doc, "label", { for: suffix.id, text: "After the number" }), suffix);
79
+ }
80
+
81
+ function readLevels(state) {
82
+ const rows = [...body.querySelectorAll(".opf-numbering-level")];
83
+ return rows.map((row) => ({
84
+ style: row.querySelector('[data-field="style"]').value,
85
+ start: Number(row.querySelector('[data-field="start"]').value),
86
+ suffix: row.querySelector('[data-field="suffix"]').value,
87
+ })).slice(0, state.perLevel ? MAX_NUMBERING_LEVELS : 1);
88
+ }
89
+
90
+ function commit(path, value, message) {
91
+ try {
92
+ setNumbering(editor, path, value);
93
+ say(message);
94
+ } catch (error) {
95
+ say(error?.issues?.[0]?.message ?? error?.message ?? String(error), true);
96
+ draw(true);
97
+ }
98
+ }
99
+
100
+ function draw(force = false) {
101
+ if (destroyed) return;
102
+ const { path, lists } = currentPath();
103
+ const state = path ? numberingState(editor.document, path) : undefined;
104
+ const signature = JSON.stringify([path, state, lists.map((entry) => [entry.path, entry.numbered])]);
105
+ if (!force && signature === drawn) return;
106
+ drawn = signature;
107
+ body.replaceChildren();
108
+ if (!state) {
109
+ body.append(h(doc, "p", { class: "opf-numbering-empty", text: "This slide has no list. Add a list or a bullets block to number it." }));
110
+ return;
111
+ }
112
+ const enabled = state.numbered;
113
+ if (lists.length > 1) {
114
+ const select = h(doc, "select", { id: `${id}-list`, "aria-label": "List" }, ...lists.map((entry) => h(doc, "option", { value: entry.path, text: `${entry.label} (${entry.count} ${entry.count === 1 ? "entry" : "entries"})`, selected: entry.path === state.path })));
115
+ select.addEventListener("change", () => { explicit = select.value; draw(true); });
116
+ body.append(h(doc, "div", { class: "opf-numbering-list" }, h(doc, "label", { for: select.id, text: "List" }), select));
117
+ }
118
+ const toggle = h(doc, "input", { id: `${id}-enabled`, type: "checkbox", checked: enabled });
119
+ toggle.checked = enabled;
120
+ toggle.addEventListener("change", () => {
121
+ if (toggle.checked) commit(state.path, numberingValue(state.levels), "Numbered the list.");
122
+ else commit(state.path, undefined, "Numbering turned off; the list shows bullets again.");
123
+ });
124
+ body.append(h(doc, "div", { class: "opf-numbering-toggle" }, toggle, h(doc, "label", { for: toggle.id, text: "Number this list" })));
125
+
126
+ const levels = state.perLevel ? Array.from({ length: Math.min(MAX_NUMBERING_LEVELS, Math.max(state.depth, state.levels.length)) }, (_, index) => state.levels[Math.min(index, state.levels.length - 1)]) : [state.levels[0]];
127
+ const rowsHost = h(doc, "div", { class: "opf-numbering-levels" });
128
+ levels.forEach((level, index) => rowsHost.append(levelRow(index, level, { label: state.perLevel ? `Level ${index + 1}` : "Every level", enabled })));
129
+ rowsHost.addEventListener("change", (event) => {
130
+ if (!event.target.closest(".opf-numbering-level")) return;
131
+ try {
132
+ commit(state.path, numberingValue(readLevels(state)), "Updated the numbering.");
133
+ } catch (error) {
134
+ say(error?.message ?? String(error), true);
135
+ draw(true);
136
+ }
137
+ });
138
+ body.append(rowsHost);
139
+
140
+ if (state.depth > 1 || state.perLevel) {
141
+ const perLevel = h(doc, "input", { id: `${id}-per-level`, type: "checkbox", disabled: !enabled });
142
+ perLevel.checked = state.perLevel;
143
+ perLevel.addEventListener("change", () => {
144
+ // Turning it on starts an outline (1. a. i.), because identical levels would be written as one entry again.
145
+ const cycle = ["arabic", "alpha-lower", "roman-lower", "alpha-upper", "roman-upper"];
146
+ const next = [{ ...state.levels[0] }];
147
+ for (let index = 1; index < Math.min(MAX_NUMBERING_LEVELS, Math.max(2, state.depth)); index++) {
148
+ const previous = next[index - 1].style;
149
+ const style = cycle.slice((index - 1) % cycle.length).concat(cycle).find((candidate) => candidate !== previous);
150
+ next.push({ ...state.levels[0], start: 1, style });
151
+ }
152
+ commit(state.path, perLevel.checked ? numberingValue(next) : numberingValue([state.levels[0]]), perLevel.checked ? "Numbering can now differ per level." : "Every level uses the first level's numbering.");
153
+ });
154
+ body.append(h(doc, "div", { class: "opf-numbering-toggle" }, perLevel, h(doc, "label", { for: perLevel.id, text: "Different numbering for each level" })));
155
+ }
156
+
157
+ if (enabled) {
158
+ const shown = state.markers.slice(0, 8).map((marker) => marker.text).join(" ");
159
+ body.append(h(doc, "p", { class: "opf-numbering-preview", "aria-label": "Markers", text: `${shown}${state.markers.length > 8 ? " …" : ""}` }));
160
+ if (state.markers.some((marker) => marker.adapted)) body.append(h(doc, "p", { class: "opf-numbering-note", text: "Roman numerals stop at 3999; larger numbers are drawn in arabic." }));
161
+ }
162
+
163
+ if (enabled && state.entry) {
164
+ const start = h(doc, "input", { id: `${id}-entry-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: state.entry.start ?? "", placeholder: state.entry.marker ?? "" });
165
+ const apply = () => {
166
+ const text = start.value.trim();
167
+ try {
168
+ setEntryStart(editor, `${state.path}.${state.entry.index}`, text === "" ? undefined : Number(text));
169
+ say(text === "" ? "Removed the restart." : `Entry ${state.entry.index + 1} now starts at ${text}.`);
170
+ } catch (error) {
171
+ say(error?.issues?.[0]?.message ?? error?.message ?? String(error), true);
172
+ draw(true);
173
+ }
174
+ };
175
+ start.addEventListener("change", apply);
176
+ body.append(h(doc, "div", { class: "opf-numbering-entry" },
177
+ h(doc, "label", { for: start.id, text: `Entry ${state.entry.index + 1} starts at` }), start,
178
+ h(doc, "span", { class: "opf-numbering-help", text: "Leave empty to continue the count. Later entries carry on from it." })));
179
+ }
180
+ }
181
+
182
+ const unsubscribe = editor.subscribe(() => draw());
183
+ draw(true);
184
+
185
+ return {
186
+ element: root,
187
+ setTarget(path) {
188
+ explicit = path;
189
+ draw(true);
190
+ },
191
+ refresh() {
192
+ draw(true);
193
+ },
194
+ destroy() {
195
+ destroyed = true;
196
+ unsubscribe();
197
+ root.remove();
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,62 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ export type NumberingStyleName = "arabic" | "roman-upper" | "roman-lower" | "alpha-upper" | "alpha-lower";
4
+ export type NumberingSuffixName = "period" | "paren" | "paren-both";
5
+ /** One list level's numbering with every default applied. */
6
+ export interface NumberingLevel { style: NumberingStyleName; start: number; suffix: NumberingSuffixName }
7
+ /** The `numbering` field: a style name, an object (every level) or an array with one entry per level. */
8
+ export type NumberingValue = NumberingStyleName | Partial<NumberingLevel> | readonly (NumberingStyleName | Partial<NumberingLevel>)[];
9
+
10
+ export declare const NUMBERING_STYLE_OPTIONS: readonly { value: NumberingStyleName; label: string; sample: string }[];
11
+ export declare const NUMBERING_SUFFIX_OPTIONS: readonly { value: NumberingSuffixName; label: string; sample: string }[];
12
+ export declare const MAX_NUMBERING_LEVELS: 9;
13
+ export declare const MAX_NUMBERING_START: 32767;
14
+
15
+ /** True when the core this editor runs on composes numbered lists. */
16
+ export declare function numberingAvailable(): boolean;
17
+
18
+ export interface ListPayloadLocation {
19
+ /** The payload that owns the list ("" for a document-level path is not possible; a slide, region or block). */
20
+ payloadPath: string;
21
+ field: "items" | "bullets";
22
+ /** Path of the list array itself. */
23
+ path: string;
24
+ payload: Record<string, unknown>;
25
+ /** The entry index when the path points at or into an entry. */
26
+ entry?: number;
27
+ }
28
+ /** Where a list lives for a path that points at it or into it (`slides.0.items`, `slides.0.items.2`, `slides.0.left.items.1.text`). */
29
+ export declare function listPayloadAt(document: unknown, path: string): ListPayloadLocation | undefined;
30
+
31
+ export interface NumberableList { path: string; payloadPath: string; field: "items" | "bullets"; label: string; count: number; numbered: boolean }
32
+ /** Every list of a slide: path, label, entry count and whether it is numbered. */
33
+ export declare function findNumberableLists(document: unknown, slideIndex: number): NumberableList[];
34
+
35
+ /** The shortest `numbering` value for per-level settings (a style name, an object of the non-default fields, or an array). */
36
+ export declare function numberingValue(levels: readonly Partial<NumberingLevel>[]): NumberingValue;
37
+
38
+ export interface NumberingMarker { index: number; level: number; text: string; value: number; adapted?: "roman-range" }
39
+ export interface NumberingState {
40
+ path: string;
41
+ payloadPath: string;
42
+ field: "items" | "bullets";
43
+ count: number;
44
+ numbered: boolean;
45
+ /** True when the levels differ (an array in the document). */
46
+ perLevel: boolean;
47
+ /** One entry when the numbering applies to every level, else one per authored level. */
48
+ levels: NumberingLevel[];
49
+ /** Levels the list uses (deepest entry level + 1, at most 9). */
50
+ depth: number;
51
+ value?: NumberingValue;
52
+ /** The markers the list draws, one per entry (empty when not numbered). */
53
+ markers: NumberingMarker[];
54
+ /** The entry the path selects, with its own `start` and the marker it draws. */
55
+ entry?: { index: number; start?: number; marker?: string };
56
+ }
57
+ export declare function numberingState(document: unknown, path: string): NumberingState | undefined;
58
+
59
+ /** Number a list, change its numbering, or (with `undefined` or `null`) turn it off, as one undoable validated edit. Turning it off also removes the entry `start` values. */
60
+ export declare function setNumbering(editor: EditorSession, path: string, value: NumberingValue | undefined | null, meta?: Record<string, unknown>): unknown;
61
+ /** Restart the numbering at an entry (`undefined` or `null` removes the restart), as one undoable validated edit. */
62
+ export declare function setEntryStart(editor: EditorSession, itemPath: string, start: number | undefined | null, meta?: Record<string, unknown>): unknown;