@openpresentation/opf-editor 0.0.1 → 0.1.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/dist/canvas.js ADDED
@@ -0,0 +1,748 @@
1
+ import {
2
+ createEditorSession,
3
+ opfPathToJsonPointer,
4
+ applyJsonPatch,
5
+ validateOpfDocument,
6
+ getValueAtPath,
7
+ splitOpfPath,
8
+ } from "./index.js";
9
+ import {
10
+ renderSvg,
11
+ resolvePresentation,
12
+ } from "@openpresentation/opf-render/svg";
13
+ import {
14
+ getEditableFields,
15
+ parseCanvasValue,
16
+ createCanvasDraft,
17
+ } from "./canvas-fields.js";
18
+ import { createBlockControls } from "./block-controls.js";
19
+ import { createLayoutHandles } from "./layout-handles.js";
20
+ import { createRichTextToolbar } from "./rich-text-toolbar.js";
21
+ export { getEditableFields } from "./canvas-fields.js";
22
+
23
+ /** A framework-independent SVG canvas. Drafts render immediately; each edit commits once. */
24
+ export function createCanvasEditor(container, options = {}) {
25
+ if (!container?.ownerDocument)
26
+ throw new TypeError("A DOM container is required.");
27
+ const doc = container.ownerDocument,
28
+ win = doc.defaultView;
29
+ const editor =
30
+ options.editor ??
31
+ createEditorSession(options.document, { rejectInvalid: true });
32
+ let slideIndex = options.slideIndex ?? 0,
33
+ renderOptions = options.renderOptions ?? {},
34
+ active = null,
35
+ selectedPath = null,
36
+ disposed = false,
37
+ frame = 0,
38
+ committing = false;
39
+ const root = doc.createElement("div"),
40
+ preview = doc.createElement("div"),
41
+ overlay = doc.createElement("div"),
42
+ notice = doc.createElement("div");
43
+ root.className = "opf-canvas";
44
+ root.style.cssText = "position:relative;width:100%;isolation:isolate";
45
+ preview.className = "opf-canvas-preview";
46
+ overlay.className = "opf-canvas-overlay";
47
+ overlay.style.cssText = "position:absolute;inset:0;pointer-events:none";
48
+ notice.setAttribute("role", "status");
49
+ notice.style.cssText =
50
+ "position:absolute;left:8px;bottom:8px;max-width:calc(100% - 16px);font:12px/1.4 system-ui;background:#fff8ed;color:#875217;border:1px solid #ead5b3;border-radius:5px;padding:6px 10px;z-index:4";
51
+ notice.hidden = true;
52
+ const style = doc.createElement("style");
53
+ style.textContent =
54
+ ".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:transparent}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
55
+ root.append(style, preview, overlay, notice);
56
+ container.replaceChildren(root);
57
+ const report = (error) => {
58
+ notice.hidden = false;
59
+ notice.textContent = error.message;
60
+ options.onError?.(error);
61
+ };
62
+ const clearNotice = () => {
63
+ notice.hidden = true;
64
+ notice.textContent = "";
65
+ };
66
+ const richToolbar = createRichTextToolbar(root, { editor, report, beforeChange: commit,
67
+ onProperties(path) { if (commit()) openProperties(path, editor.get(path)); },
68
+ validateChange(path, value) {
69
+ renderSvg(createCanvasDraft(editor.document, path, value), {...renderOptions, slideIndex});
70
+ },
71
+ onChange(path) { clearNotice(); options.onCommit?.({path, editor}); }
72
+ });
73
+ const layoutHandles = createLayoutHandles(root, {
74
+ editor, enabled: options.layoutEditing, render, beforeEdit: commit,
75
+ isTextEditing: () => !!active,
76
+ clearError: clearNotice, onError: report,
77
+ onDraft: value => options.onDraft?.(value),
78
+ onCommit: value => options.onCommit?.(value),
79
+ onCancel: value => { clearNotice(); options.onCancel?.(value); },
80
+ });
81
+ const blockControls = createBlockControls(root, {
82
+ editor, render, beforeEdit: () => {if(!commit())return false;richToolbar.hide();return true;}, enabled: () => layoutHandles.enabled,
83
+ isEditing: () => !!active || !!layoutHandles.editingPath, slideIndex: () => slideIndex,
84
+ validate: document => renderSvg(document, {...renderOptions, slideIndex}),
85
+ clearError: clearNotice, onError: report,
86
+ onMove: path => choose(splitOpfPath(path).join(".")),
87
+ onCommit: value => options.onCommit?.(value),
88
+ });
89
+ function targets() {
90
+ return [...preview.querySelectorAll("[data-canvas-target]")];
91
+ }
92
+ function getTarget(path) {
93
+ return targets().find(
94
+ (node) => node.getAttribute("data-opf-path") === path,
95
+ );
96
+ }
97
+ function choose(path) {
98
+ selectedPath = path;
99
+ for (const node of targets())
100
+ node.toggleAttribute(
101
+ "data-canvas-selected",
102
+ node.getAttribute("data-opf-path") === path,
103
+ );
104
+ options.onSelect?.({
105
+ path,
106
+ value: editor.get(path),
107
+ element: getTarget(path),
108
+ editor,
109
+ });
110
+ }
111
+ function render(document = editor.document) {
112
+ if (disposed) return;
113
+ const slides = document.slides ?? [];
114
+ slideIndex = Math.max(0, Math.min(slideIndex, slides.length - 1));
115
+ const svgText = renderSvg(document, {
116
+ ...renderOptions,
117
+ slideIndex,
118
+ trace: true,
119
+ });
120
+ preview.innerHTML = svgText;
121
+ const svg = preview.querySelector("svg");
122
+ svg.setAttribute("role", "group");
123
+ svg.setAttribute("aria-label", "Editable slide");
124
+ svg.removeAttribute("aria-labelledby");
125
+ const geometry = resolvePresentation(document, renderOptions).slides[
126
+ slideIndex
127
+ ].geometry;
128
+ const candidates = [...svg.querySelectorAll("[data-opf-path]")];
129
+ const seen = new Set();
130
+ for (const node of candidates) {
131
+ const path = node.getAttribute("data-opf-path");
132
+ const item = geometry.items.find((item) => item.path === path);
133
+ const value = getValueAtPath(document, path);
134
+ if (
135
+ seen.has(path) ||
136
+ (!item && !node.matches("g") && !node.matches("image")) ||
137
+ value === undefined ||
138
+ path === `slides.${slideIndex}` ||
139
+ path.includes(".design.")
140
+ )
141
+ continue;
142
+ // Prefer the containing group over its duplicate text/shape trace attributes.
143
+ if (
144
+ !node.matches("g,image") ||
145
+ (node.matches("g") && !node.querySelector("text,image,rect,path"))
146
+ )
147
+ continue;
148
+ seen.add(path);
149
+ node.setAttribute("data-canvas-target", "");
150
+ node.setAttribute("role", "button");
151
+ node.setAttribute("tabindex", "0");
152
+ node.setAttribute(
153
+ "aria-label",
154
+ `Edit ${path.split(".").at(-1)}: ${["string", "number"].includes(typeof value) ? String(value).slice(0, 80) : "content properties"}`,
155
+ );
156
+ if (node.matches("g")) {
157
+ const bounds = node.getBBox(),
158
+ rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
159
+ for (const [key, value] of Object.entries({
160
+ x: bounds.x - 4,
161
+ y: bounds.y - 4,
162
+ width: Math.max(8, bounds.width + 8),
163
+ height: Math.max(8, bounds.height + 8),
164
+ fill: "transparent",
165
+ stroke: "transparent",
166
+ "stroke-width": 1.5,
167
+ "vector-effect": "non-scaling-stroke",
168
+ "pointer-events": "all",
169
+ }))
170
+ rect.setAttribute(key, value);
171
+ rect.classList.add("opf-selection");
172
+ node.prepend(rect);
173
+ }
174
+ node.addEventListener("click", (event) => {
175
+ event.stopPropagation();
176
+ if (event.target.closest("a")) event.preventDefault();
177
+ if (active && active.path !== path && !commit()) return;
178
+ choose(path);
179
+ });
180
+ node.addEventListener("dblclick", (event) => {
181
+ event.preventDefault();
182
+ event.stopPropagation();
183
+ beginEdit(path);
184
+ });
185
+ node.addEventListener("keydown", (event) => {
186
+ if (event.key === "Enter" || event.key === " ") {
187
+ event.preventDefault();
188
+ event.stopPropagation();
189
+ beginEdit(path);
190
+ }
191
+ });
192
+ if (path === selectedPath) node.setAttribute("data-canvas-selected", "");
193
+ }
194
+ layoutHandles.update(document, geometry);
195
+ blockControls.update(document, geometry);
196
+ if (active?.kind === "text") positionInput();
197
+ options.onRender?.({
198
+ document,
199
+ slideIndex,
200
+ svg,
201
+ geometry,
202
+ draft: !!active || !!layoutHandles.editingPath,
203
+ });
204
+ }
205
+ function queueDraft() {
206
+ if (frame) win.cancelAnimationFrame(frame);
207
+ frame = win.requestAnimationFrame(() => {
208
+ frame = 0;
209
+ if (!active || active.composing) return;
210
+ if (active.kind === "properties") {
211
+ try {
212
+ const draft = propertyDraft(active);
213
+ clearNotice();
214
+ render(draft);
215
+ options.onDraft?.({
216
+ document: draft,
217
+ path: active.path,
218
+ value: undefined,
219
+ });
220
+ } catch (error) {
221
+ report(error);
222
+ }
223
+ return;
224
+ }
225
+ try {
226
+ const value = parseCanvasValue(active.input.value, active.type);
227
+ const draft = createCanvasDraft(editor.document, active.path, value);
228
+ clearNotice();
229
+ active.valid = true;
230
+ active.draft = draft;
231
+ render(draft);
232
+ options.onDraft?.({ document: draft, path: active.path, value });
233
+ } catch (error) {
234
+ active.valid = false;
235
+ report(error);
236
+ }
237
+ });
238
+ }
239
+ function positionInput() {
240
+ if (!active || active.kind !== "text") return;
241
+ const target = getTarget(active.path),
242
+ text = target?.matches("text") ? target : target?.querySelector("text");
243
+ if (!text) return;
244
+ const svg = preview.querySelector("svg"),
245
+ svgRect = svg.getBoundingClientRect(),
246
+ rootRect = root.getBoundingClientRect();
247
+ const viewBox = svg.viewBox.baseVal,
248
+ scale = svgRect.width / viewBox.width;
249
+ const font = win.getComputedStyle(text),
250
+ fontSize = parseFloat(font.fontSize),
251
+ geometry = resolvePresentation(
252
+ active.draft ?? editor.document,
253
+ renderOptions,
254
+ ).slides[slideIndex].geometry;
255
+ const item = geometry.items.find((item) => item.path === active.path);
256
+ const allText = target.querySelectorAll("text");
257
+ const lineHeight =
258
+ allText.length > 1
259
+ ? Number(allText[1].getAttribute("y")) - Number(text.getAttribute("y"))
260
+ : fontSize * 1.22;
261
+ const canvas = doc.createElement("canvas"),
262
+ ctx = canvas.getContext("2d");
263
+ ctx.font = `${font.fontStyle} ${font.fontWeight} ${fontSize}px ${font.fontFamily}`;
264
+ const metrics = ctx.measureText("Mg"),
265
+ ascent = metrics.fontBoundingBoxAscent ?? fontSize * 0.9,
266
+ descent = metrics.fontBoundingBoxDescent ?? fontSize * 0.2;
267
+ const baseline = ascent + (lineHeight - ascent - descent) / 2;
268
+ const x = Number(text.getAttribute("x")),
269
+ y = Number(text.getAttribute("y")) - baseline;
270
+ const bounds = target.getBBox(),
271
+ anchor = text.getAttribute("text-anchor");
272
+ const tracedWidth = Number(target.getAttribute("data-opf-box-width"));
273
+ const width = item?.box.width ?? (tracedWidth > 0 ? tracedWidth : Math.max(80, bounds.width + 20));
274
+ const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
275
+ Object.assign(active.input.style, {
276
+ fontFamily: font.fontFamily,
277
+ fontSize: `${fontSize * scale}px`,
278
+ fontWeight: font.fontWeight,
279
+ fontStyle: font.fontStyle,
280
+ lineHeight: `${lineHeight * scale}px`,
281
+ color: active.composing ? font.fill : "transparent",
282
+ caretColor: font.fill,
283
+ textAlign: anchor === "middle" ? "center" : anchor === "end" ? "right" : "left",
284
+ width: `${width * scale}px`,
285
+ height: `${Math.max(lineHeight, allText.length * lineHeight) * scale + 2}px`,
286
+ minHeight: "0",
287
+ letterSpacing: font.letterSpacing,
288
+ });
289
+ active.input.style.left = `${svgRect.left - rootRect.left + (left - viewBox.x) * scale}px`;
290
+ active.input.style.top = `${svgRect.top - rootRect.top + (y - viewBox.y) * scale}px`;
291
+ // Keep the canonical SVG visible. The transparent input supplies native selection and caret.
292
+ target.style.opacity = active.composing ? "0" : "1";
293
+ }
294
+ function beginEdit(path) {
295
+ if (disposed) return;
296
+ if (active && active.path === path) return;
297
+ if (active && !commit()) return;
298
+ clearNotice();
299
+ layoutHandles.hide();
300
+ blockControls.hide();
301
+ richToolbar.hide();
302
+ choose(path);
303
+ const value = editor.get(path);
304
+ if (value === undefined) {
305
+ report(new Error("This content no longer exists."));
306
+ return;
307
+ }
308
+ const target = getTarget(path),
309
+ text = target?.querySelector("text");
310
+ if (
311
+ (typeof value === "string" || typeof value === "number") &&
312
+ text &&
313
+ !/\.(image|video)$/.test(path)
314
+ ) {
315
+ const input = doc.createElement("textarea");
316
+ input.className = "opf-inline-input";
317
+ input.value = String(value);
318
+ input.setAttribute("aria-label", `Edit ${path.split(".").at(-1)} inline`);
319
+ input.spellcheck = true;
320
+ input.style.cssText =
321
+ "position:absolute;pointer-events:auto;resize:none;border:0;outline:1px solid #8975d9;outline-offset:4px;margin:0;padding:0;background:transparent;overflow:hidden;min-height:0;box-shadow:none;border-radius:0;white-space:pre-wrap;overflow-wrap:break-word;box-sizing:border-box;z-index:2";
322
+ active = {
323
+ kind: "text",
324
+ path,
325
+ base: JSON.stringify(value),
326
+ type: typeof value,
327
+ input,
328
+ valid: true,
329
+ };
330
+ overlay.append(input);
331
+ // Only offer rich text where the canonical schema accepts TextRun[].
332
+ if (typeof value === "string") {
333
+ try {
334
+ createCanvasDraft(editor.document, path, [value]);
335
+ const format = doc.createElement("button");
336
+ format.type = "button";
337
+ format.textContent = "Format text";
338
+ format.style.cssText = "position:absolute;bottom:8px;left:8px;pointer-events:auto;z-index:4;background:#fff;color:#574774;border:1px solid #d5cce5;border-radius:5px;padding:6px 10px";
339
+ format.onmousedown = event => event.preventDefault();
340
+ format.onclick = () => {
341
+ if (!commit()) return;
342
+ try {
343
+ editor.set(path, [editor.get(path)], {source:"canvas-rich-text", rejectInvalid:true});
344
+ richToolbar.selectAll(path);
345
+ } catch (error) { report(error); }
346
+ };
347
+ overlay.append(format);
348
+ } catch { /* This scalar field does not accept rich text. */ }
349
+ }
350
+ positionInput();
351
+ input.addEventListener("input", queueDraft);
352
+ input.addEventListener("compositionstart", () => {
353
+ if (active) {
354
+ active.composing = true;
355
+ positionInput();
356
+ }
357
+ });
358
+ input.addEventListener("compositionend", () => {
359
+ active && (active.composing = false);
360
+ queueDraft();
361
+ });
362
+ input.addEventListener("keydown", (event) => {
363
+ if (event.isComposing) return;
364
+ if (event.key === "Escape") {
365
+ event.preventDefault();
366
+ event.stopPropagation();
367
+ cancel();
368
+ } else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
369
+ event.preventDefault();
370
+ commit();
371
+ }
372
+ });
373
+ input.addEventListener("blur", () => {
374
+ if (active?.input === input && !active.composing) commit();
375
+ });
376
+ input.focus();
377
+ input.select();
378
+ } else if (Array.isArray(value) && target?.hasAttribute("data-opf-rich-text")) {
379
+ richToolbar.selectAll(path);
380
+ } else openProperties(path, value);
381
+ }
382
+ function propertyPatches(edit) {
383
+ return edit.inputs.map(({ field, input }) => ({
384
+ op: "replace",
385
+ path: opfPathToJsonPointer(field.path),
386
+ value: parseCanvasValue(
387
+ field.type === "boolean" ? input.checked : input.value,
388
+ field.type,
389
+ ),
390
+ }));
391
+ }
392
+ function propertyDraft(edit) {
393
+ const draft = applyJsonPatch(editor.document, propertyPatches(edit)),
394
+ validation = validateOpfDocument(draft);
395
+ if (!validation.valid)
396
+ throw new Error(
397
+ validation.errors[0]?.message ?? "This change is not valid OPF.",
398
+ );
399
+ return draft;
400
+ }
401
+ function openProperties(path, value) {
402
+ const panel = doc.createElement("form");
403
+ panel.setAttribute("aria-label", "Content properties");
404
+ panel.style.cssText =
405
+ "position:absolute;right:10px;top:10px;width:min(330px,calc(100% - 20px));max-height:calc(100% - 20px);overflow:auto;pointer-events:auto;background:#fff;color:#332e40;border:1px solid #dcd5eb;border-radius:8px;padding:16px;box-shadow:0 8px 35px #28203922;z-index:3;font:12px/1.5 system-ui";
406
+ const heading = doc.createElement("strong");
407
+ heading.textContent = `Edit ${path.split(".").at(-1)}`;
408
+ panel.append(heading);
409
+ const { fields, arrays } = getEditableFields(value, path),
410
+ inputs = [];
411
+ for (const field of fields) {
412
+ const label = doc.createElement("label");
413
+ label.style.cssText =
414
+ "display:block;margin-top:10px;color:#756b85;font-size:11px";
415
+ label.textContent = field.label;
416
+ const input = doc.createElement(
417
+ field.type === "boolean" ? "input" : "textarea",
418
+ );
419
+ if (field.type === "boolean") {
420
+ input.type = "checkbox";
421
+ input.checked = field.value;
422
+ } else {
423
+ input.rows = field.type === "number" ? 1 : 2;
424
+ input.value = field.value === null ? "null" : String(field.value);
425
+ }
426
+ input.setAttribute("aria-label", label.textContent);
427
+ input.style.cssText =
428
+ "display:block;width:100%;font:12px/1.5 system-ui;padding:6px;min-height:0;box-sizing:border-box;border:1px solid #e0dae8;border-radius:4px;color:#332e40;background:white";
429
+ if (field.type === "boolean") input.style.width = "auto";
430
+ label.append(input);
431
+ panel.append(label);
432
+ inputs.push({ field, input });
433
+ input.addEventListener("input", queueDraft);
434
+ }
435
+ if (/(^|\.)image($|\.)/.test(path)) {
436
+ const upload = doc.createElement("input");
437
+ upload.type = "file";
438
+ upload.accept = "image/png,image/jpeg,image/gif,image/webp";
439
+ upload.setAttribute("aria-label", "Replace image");
440
+ upload.style.cssText = "display:block;max-width:100%;margin-top:12px";
441
+ upload.onchange = async () => {
442
+ const file = upload.files?.[0];
443
+ if (!file) return;
444
+ if (file.size > 20 * 1024 * 1024) {
445
+ report(new Error("Choose an image smaller than 20 MB."));
446
+ return;
447
+ }
448
+ const edit = active;
449
+ const reader = new win.FileReader();
450
+ reader.onload = () => {
451
+ if (active !== edit) return;
452
+ const source = inputs.find(
453
+ ({ field }) =>
454
+ field.path === opfPathToJsonPointer(path) ||
455
+ splitOpfPath(field.path).at(-1) === "src",
456
+ );
457
+ if (source) {
458
+ source.input.value = reader.result;
459
+ queueDraft();
460
+ } else
461
+ report(
462
+ new Error("Select an image source field to replace this image."),
463
+ );
464
+ };
465
+ reader.onerror = () => report(new Error("Could not read the image."));
466
+ reader.readAsDataURL(file);
467
+ };
468
+ panel.append(upload);
469
+ }
470
+ for (const array of arrays) {
471
+ const row = doc.createElement("div");
472
+ row.style.cssText =
473
+ "display:flex;justify-content:space-between;gap:8px;margin-top:10px";
474
+ const label = doc.createElement("span");
475
+ label.textContent = array.label || "Items";
476
+ row.append(label);
477
+ for (const [label, remove] of [
478
+ ["Add", false],
479
+ ["Remove last", true],
480
+ ]) {
481
+ const button = doc.createElement("button");
482
+ button.type = "button";
483
+ button.textContent = label;
484
+ button.setAttribute("aria-label", `${label} ${array.path}`);
485
+ button.disabled = remove && !array.length;
486
+ button.onclick = () => {
487
+ if (!commit()) return;
488
+ try {
489
+ const values = editor.get(array.path);
490
+ const empty = (value, key = "") =>
491
+ Array.isArray(value)
492
+ ? value.map((child) => empty(child))
493
+ : value && typeof value === "object"
494
+ ? Object.fromEntries(
495
+ Object.entries(value).map(([key, child]) => [
496
+ key,
497
+ empty(child, key),
498
+ ]),
499
+ )
500
+ : typeof value === "number"
501
+ ? 0
502
+ : typeof value === "boolean"
503
+ ? false
504
+ : ["type", "mode", "language"].includes(key)
505
+ ? value
506
+ : "";
507
+ const next = remove
508
+ ? values.slice(0, -1)
509
+ : [...values, empty(values.at(-1) ?? "")];
510
+ editor.set(array.path, next, {
511
+ source: "canvas-collection",
512
+ rejectInvalid: true,
513
+ });
514
+ beginEdit(path);
515
+ } catch (error) {
516
+ report(error);
517
+ }
518
+ };
519
+ row.append(button);
520
+ }
521
+ panel.append(row);
522
+ }
523
+ const controls = doc.createElement("div");
524
+ controls.style.cssText =
525
+ "display:flex;gap:8px;justify-content:flex-end;margin-top:14px";
526
+ for (const [title, action] of [
527
+ ["Cancel", () => cancel()],
528
+ ["Apply", () => commit()],
529
+ ]) {
530
+ const button = doc.createElement("button");
531
+ button.type = "button";
532
+ button.textContent = title;
533
+ button.style.cssText =
534
+ "border:1px solid #d5cce5;border-radius:5px;padding:5px 10px;background:#f5f2fb;color:#574774;cursor:pointer";
535
+ button.onclick = action;
536
+ controls.append(button);
537
+ }
538
+ panel.append(controls);
539
+ panel.onsubmit = (event) => {
540
+ event.preventDefault();
541
+ commit();
542
+ };
543
+ panel.onkeydown = (event) => {
544
+ if (event.key === "Escape") {
545
+ event.preventDefault();
546
+ cancel();
547
+ }
548
+ };
549
+ active = {
550
+ kind: "properties",
551
+ path,
552
+ base: JSON.stringify(value),
553
+ panel,
554
+ inputs,
555
+ valid: true,
556
+ };
557
+ if (options.propertiesContainer) {
558
+ panel.style.cssText =
559
+ "position:relative;width:100%;font:12px/1.5 system-ui;color:inherit";
560
+ options.propertiesContainer.replaceChildren(panel);
561
+ } else overlay.append(panel);
562
+ inputs[0]?.input.focus();
563
+ }
564
+ function commit() {
565
+ if (blockControls.editingPath) return false;
566
+ if (!committing && layoutHandles.editingPath) return layoutHandles.commit();
567
+ if (committing || !active) return true;
568
+ if (active.composing) return false;
569
+ if (JSON.stringify(editor.get(active.path)) !== active.base) {
570
+ report(
571
+ new Error(
572
+ "This content changed elsewhere. Your draft was cancelled to preserve the newer edit.",
573
+ ),
574
+ );
575
+ cancel(false);
576
+ return false;
577
+ }
578
+ const edit = active;
579
+ try {
580
+ let value;
581
+ if (edit.kind === "text")
582
+ value = parseCanvasValue(edit.input.value, edit.type);
583
+ else value = propertyDraft(edit);
584
+ if (edit.kind === "text")
585
+ createCanvasDraft(editor.document, edit.path, value);
586
+ committing = true;
587
+ if (edit.kind === "text" && JSON.stringify(value) !== edit.base)
588
+ editor.set(edit.path, value, { source: "canvas", rejectInvalid: true });
589
+ if (edit.kind === "properties") {
590
+ const patches = propertyPatches(edit).filter(
591
+ (patch) =>
592
+ JSON.stringify(editor.get(patch.path)) !==
593
+ JSON.stringify(patch.value),
594
+ );
595
+ if (patches.length)
596
+ editor.applyPatch(patches, {
597
+ source: "canvas-properties",
598
+ rejectInvalid: true,
599
+ });
600
+ }
601
+ committing = false;
602
+ active = null;
603
+ overlay.replaceChildren();
604
+ options.propertiesContainer?.replaceChildren();
605
+ clearNotice();
606
+ render();
607
+ getTarget(edit.path)?.focus();
608
+ options.onCommit?.({ path: edit.path, editor });
609
+ return true;
610
+ } catch (error) {
611
+ committing = false;
612
+ report(error);
613
+ return false;
614
+ }
615
+ }
616
+ function cancel(clear = true) {
617
+ if (blockControls.editingPath) { blockControls.cancel(); render(); return; }
618
+ blockControls.cancel();
619
+ if (layoutHandles.editingPath) { layoutHandles.cancel(clear); return; }
620
+ const path = active?.path;
621
+ active = null;
622
+ if (frame) {
623
+ win.cancelAnimationFrame(frame);
624
+ frame = 0;
625
+ }
626
+ overlay.replaceChildren();
627
+ richToolbar.hide();
628
+ options.propertiesContainer?.replaceChildren();
629
+ if (clear) clearNotice();
630
+ render();
631
+ getTarget(path)?.focus();
632
+ options.onCancel?.({ path });
633
+ }
634
+ const unsubscribe = editor.subscribe(() => {
635
+ if (committing || layoutHandles.editingPath) return;
636
+ if (active) {
637
+ if (JSON.stringify(editor.get(active.path)) !== active.base) {
638
+ report(
639
+ new Error(
640
+ "The selected content changed elsewhere; the draft was cancelled.",
641
+ ),
642
+ );
643
+ cancel(false);
644
+ return;
645
+ }
646
+ queueDraft();
647
+ return;
648
+ }
649
+ try {
650
+ render();
651
+ } catch (error) {
652
+ report(error);
653
+ }
654
+ });
655
+ const resize = new win.ResizeObserver(() => {
656
+ if (active?.kind === "text") positionInput();
657
+ });
658
+ resize.observe(root);
659
+ root.addEventListener("keydown", (event) => {
660
+ if (
661
+ (event.metaKey || event.ctrlKey) &&
662
+ event.key.toLowerCase() === "z" &&
663
+ !event.target.closest("textarea,input")
664
+ ) {
665
+ event.preventDefault();
666
+ event.stopPropagation();
667
+ event.shiftKey ? editor.redo() : editor.undo();
668
+ }
669
+ });
670
+ try {
671
+ render();
672
+ } catch (error) {
673
+ unsubscribe();
674
+ resize.disconnect();
675
+ richToolbar.destroy();
676
+ layoutHandles.destroy();
677
+ blockControls.destroy();
678
+ root.remove();
679
+ throw error;
680
+ }
681
+ const ready = Promise.resolve(doc.fonts?.ready).then(() => {
682
+ if (!disposed && !active) render();
683
+ });
684
+ return {
685
+ editor,
686
+ ready,
687
+ get slideIndex() {
688
+ return slideIndex;
689
+ },
690
+ get editingPath() {
691
+ return active?.path ?? layoutHandles.editingPath ?? blockControls.editingPath ?? null;
692
+ },
693
+ get layoutEditing() { return layoutHandles.enabled; },
694
+ setLayoutEditing(enabled) {
695
+ if (!commit()) return false;
696
+ return layoutHandles.setEnabled(enabled);
697
+ },
698
+ openBlockMenu: path => blockControls.open(path),
699
+ openInsertMenu: (containerPath,index) => blockControls.openInsert(containerPath,index),
700
+ select: choose,
701
+ beginEdit,
702
+ editProperties(path) {
703
+ if (active && !commit()) return false;
704
+ richToolbar.hide();
705
+ const value = editor.get(path);
706
+ if (value === undefined) return false;
707
+ choose(path);
708
+ openProperties(path, value);
709
+ return true;
710
+ },
711
+ commit,
712
+ cancel,
713
+ render,
714
+ setSlide(index) {
715
+ if (!Number.isInteger(index) || !editor.document.slides?.[index])
716
+ throw new RangeError("Slide index is out of range.");
717
+ if (index === slideIndex) {
718
+ if (!active) render();
719
+ return true;
720
+ }
721
+ if (!commit()) return false;
722
+ richToolbar.hide();
723
+ slideIndex = index;
724
+ selectedPath = null;
725
+ render();
726
+ return true;
727
+ },
728
+ setRenderOptions(next) {
729
+ if (!commit()) return false;
730
+ richToolbar.hide();
731
+ renderOptions = next;
732
+ render();
733
+ return true;
734
+ },
735
+ destroy() {
736
+ disposed = true;
737
+ richToolbar.destroy();
738
+ layoutHandles.destroy();
739
+ blockControls.destroy();
740
+ unsubscribe();
741
+ resize.disconnect();
742
+ if (frame) win.cancelAnimationFrame(frame);
743
+ root.remove();
744
+ options.propertiesContainer?.replaceChildren();
745
+ active = null;
746
+ },
747
+ };
748
+ }