@openpresentation/opf-editor 0.10.6 → 0.11.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.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- 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
|
-
|
|
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
|
|
92
|
-
return
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
|
568
|
-
return
|
|
598
|
+
function clonePatchOperations(operations) {
|
|
599
|
+
return operations.map((operation) => clone(operation));
|
|
569
600
|
}
|
|
570
601
|
|
|
571
602
|
function parseJsonPointer(pointer) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 (!
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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;
|