@openpresentation/opf-editor 0.26.10 → 0.27.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.
@@ -0,0 +1,1261 @@
1
+ // CE-06: the embeddable editor. `createOpfEditor(element, options)` mounts the editor's components (the source pane, the canvas, the slide row,
2
+ // the presenter, the export menu, the sorter, the outline, Layout Studio, the Review and Design panels, Find and the autosave) over ONE editor
3
+ // session and the ONE fonts handle, wires them to each other, and hands back a small handle. It is the glue the playground and the sites wrote
4
+ // by hand, promoted: the font gate before every draw, one slide index, commit-before-navigate, the source verdict, the lifecycle of the fonts
5
+ // lease and of every listener, and the order in which the parts hear a change.
6
+ //
7
+ // A host picks a layout: `"full"` (a bar, the source, the slide, the slide row and a side panel are built for it), `"canvas"`, `"source"`,
8
+ // or `slots`, an object of its own elements, one per part, for a host that arranges the page itself. Each part is optional (`panels`).
9
+ // Nothing here fetches anything, stores anything on its own or talks to a service; the host supplies fonts, catalogs and storage.
10
+ import { toSvg } from "@openpresentation/opf-render/svg";
11
+ import { OPFEditorError, createEditorSession, mergeCatalogs } from "./index.js";
12
+ import { createCanvasEditor } from "./canvas.js";
13
+ import { whenFontsReady } from "./font-gate.js";
14
+ import { createSourcePane } from "./source-pane.js";
15
+ import { createSlideNavigator } from "./slide-navigator.js";
16
+ import { createThumbnailStrip } from "./thumbnail-strip.js";
17
+ import { createThumbnailCache } from "./thumbnail-cache.js";
18
+ import { createSlideManager } from "./slide-manager.js";
19
+ import { createOutlineView } from "./outline-view.js";
20
+ import { createExportMenu } from "./export-menu.js";
21
+ import { bindPresentShortcut, createPresentButton, createPresenter } from "./presenter.js";
22
+ import { createReviewPanel } from "./review-panel.js";
23
+ import { createFindPanel, installFindShortcuts } from "./find-panel.js";
24
+ import { createDesignControls } from "./design-controls.js";
25
+ import { createPersistence } from "./persistence.js";
26
+ import { createPersistenceUi } from "./persistence-ui.js";
27
+ import { prepareDimensionSwitch } from "./switches.js";
28
+ import { mergeSlides, splitSlideByBlocks } from "./content-actions.js";
29
+ import { installCanvasVariableEditing } from "./canvas-variables.js";
30
+ import { createLayoutStudio } from "./workspace-layouts.js";
31
+ import { fontSubstitutionNotes, layoutOptionsFor } from "./workspace-measure.js";
32
+ import { buildWorkspaceShell } from "./workspace-shell.js";
33
+ import { SOURCE_CHECKS, copyText, downloadSourceFile, lf, reportSource } from "./workspace-source.js";
34
+ import { createThemeController } from "./workspace-theme.js";
35
+ import { retainedSlideIndex } from "./slide-retention.js";
36
+ import { createSourceMemory, updateJsonSource } from "./exact-source.js";
37
+
38
+ export { THEME_TOKENS, createThemeController, normalizeTheme, normalizeThemeTokens } from "./workspace-theme.js";
39
+
40
+ const VIEWS = Object.freeze(["slide", "sorter", "outline", "layouts"]);
41
+ const MUTATORS = new Set(["set", "setGroupComposition", "setCatalog", "setCatalogs", "applyPatch", "replaceDocument", "restoreState", "undo", "redo", "setComposition", "paginateSlide"]);
42
+ const STARTER = Object.freeze({ name: "Untitled presentation", slides: [{ id: "slide-1", title: "Untitled slide" }] });
43
+ const MESSAGES = Object.freeze({
44
+ ready: "Ready to edit",
45
+ loadingFonts: "Loading fonts…",
46
+ views: {
47
+ slide: "Slide view",
48
+ sorter: "Slide sorter: drag slides, or use Alt and the arrow keys, to reorder",
49
+ outline: "Outline: edit titles and text as an outline",
50
+ layouts: "Layouts: build a layout from regions and splits, then save it to this presentation",
51
+ },
52
+ });
53
+ const PART_NAMES = ["source", "navigator", "thumbnails", "history", "find", "present", "export", "review", "design", "layouts", "sorter", "outline", "slides", "persistence"];
54
+ const LAYOUT_PARTS = {
55
+ full: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, sorter: true, outline: true },
56
+ canvas: { navigator: true, thumbnails: true, history: true },
57
+ source: { source: true },
58
+ };
59
+
60
+ const isThenable = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
61
+
62
+ /**
63
+ * Normalize the `fonts` option: a fonts handle, a lease `{ fonts, release() }` (what a host's own acquire function returns), a promise of either, a
64
+ * function that returns any of those, or `{ lease(), release(handle) }`. `use(resolved)` turns what arrived into the handle to draw with and the
65
+ * function that gives it back.
66
+ */
67
+ function fontsSource(spec) {
68
+ const unwrap = (resolved, release) => {
69
+ if (resolved && typeof resolved === "object" && resolved.fonts && typeof resolved.release === "function") return { fonts: resolved.fonts, release: () => resolved.release() };
70
+ return { fonts: resolved, release: release ? () => release(resolved) : undefined };
71
+ };
72
+ if (spec === undefined || spec === null) return { value: undefined, use: (resolved) => unwrap(resolved) };
73
+ if (typeof spec === "function") return { value: spec(), use: (resolved) => unwrap(resolved) };
74
+ if (typeof spec === "object" && typeof spec.lease === "function") {
75
+ const release = typeof spec.release === "function" ? (handle) => spec.release(handle) : undefined;
76
+ return { value: spec.lease(), use: (resolved) => unwrap(resolved, release) };
77
+ }
78
+ return { value: spec, use: (resolved) => unwrap(resolved) };
79
+ }
80
+
81
+ /** The session with its writes refused while the editor is read-only; everything else is the session itself. */
82
+ function guardSession(session, isReadOnly) {
83
+ return new Proxy(session, {
84
+ get(target, property) {
85
+ const value = Reflect.get(target, property, target);
86
+ if (typeof value !== "function") return value;
87
+ if (!MUTATORS.has(property)) return value.bind(target);
88
+ return (...args) => {
89
+ if (isReadOnly()) throw new OPFEditorError("read-only", "This editor is read-only.");
90
+ return value.apply(target, args);
91
+ };
92
+ },
93
+ });
94
+ }
95
+
96
+ function resolveParts(options, layout, slots) {
97
+ const panels = options.panels ?? {};
98
+ const base = LAYOUT_PARTS[layout];
99
+ const parts = {};
100
+ for (const name of PART_NAMES) {
101
+ if (name === "persistence") parts[name] = Boolean(options.persistence) && panels[name] !== false;
102
+ else if (panels[name] !== undefined) parts[name] = Boolean(panels[name]);
103
+ else parts[name] = base ? Boolean(base[name]) : Boolean(slots[name]);
104
+ }
105
+ // The slide is the editor; a source-only layout has none.
106
+ parts.canvas = layout === "source" ? false : panels.canvas !== false;
107
+ return parts;
108
+ }
109
+
110
+ /**
111
+ * Mount the editor in `element`. Returns the handle at once; with a `fonts` promise the parts mount when it resolves (`ready` settles then).
112
+ * See `workspace.d.ts` for the options and the handle.
113
+ */
114
+ export function createOpfEditor(element, options = {}) {
115
+ if (!element || element.nodeType !== 1 || !element.ownerDocument) throw new TypeError("createOpfEditor needs a DOM element to mount in.");
116
+ const doc = element.ownerDocument;
117
+ const win = doc.defaultView;
118
+ const listeners = new Map();
119
+ const cleanups = [];
120
+ let destroyed = false;
121
+ let mounted = false;
122
+ let readOnly = Boolean(options.readOnly);
123
+ let lastError;
124
+
125
+ // ---- events -----------------------------------------------------------------------------------------------------------------
126
+ function off(name, listener) {
127
+ listeners.get(name)?.delete(listener);
128
+ }
129
+ function on(name, listener) {
130
+ if (typeof listener !== "function") throw new TypeError("An editor listener must be a function.");
131
+ if (!listeners.has(name)) listeners.set(name, new Set());
132
+ listeners.get(name).add(listener);
133
+ return () => off(name, listener);
134
+ }
135
+ function emit(name, detail = {}) {
136
+ for (const listener of [...(listeners.get(name) ?? [])]) {
137
+ try {
138
+ listener(detail);
139
+ } catch (error) {
140
+ if (name === "error") globalThis.console?.error?.(error);
141
+ else report(error);
142
+ }
143
+ }
144
+ }
145
+ function report(error, detail = {}) {
146
+ const value = error instanceof Error ? error : new Error(String(error));
147
+ lastError = value;
148
+ emit("error", { error: value, message: value.message, ...detail });
149
+ }
150
+ for (const [name, listener] of Object.entries(options.on ?? {})) on(name, listener);
151
+ if (options.onChange) on("change", options.onChange);
152
+ if (options.onSelection) on("selection", options.onSelection);
153
+ if (options.onError) on("error", ({ error, ...detail }) => options.onError(error, detail));
154
+ if (options.onReady) on("ready", options.onReady);
155
+
156
+ // ---- the session ------------------------------------------------------------------------------------------------------------
157
+ const startText = typeof options.document === "string" ? options.document : undefined;
158
+ const session = options.session ?? createEditorSession(options.document ?? STARTER, { rejectInvalid: true, catalogs: options.catalogs });
159
+ const ui = guardSession(session, () => readOnly);
160
+ const hostCatalogs = options.catalogs;
161
+ const catalogs = () => mergeCatalogs(session.catalogs, hostCatalogs);
162
+
163
+ // ---- layout, parts and slots ------------------------------------------------------------------------------------------------
164
+ const given = options.slots ?? {};
165
+ const layout = options.slots ? "slots" : (options.layout ?? "full");
166
+ if (!["full", "canvas", "source", "slots"].includes(layout)) throw new RangeError('layout must be "full", "canvas" or "source", or pass `slots`.');
167
+ const parts = resolveParts(options, layout, given);
168
+ const views = VIEWS.filter((view) => view === "slide" || parts[view]);
169
+ if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
170
+ if (options.persistence && parts.persistence && (typeof options.persistence !== "object" || typeof options.persistence.key !== "string" || !options.persistence.key)) {
171
+ throw new TypeError("persistence needs a `key` that names the document.");
172
+ }
173
+
174
+ const root = doc.createElement("div");
175
+ root.className = "opf-workspace opf-ui";
176
+ // The editor owns the content of the element it builds a layout in. With `slots` the element is the host's own page (it holds the slots), so the
177
+ // editor only adds one empty node for its overlays.
178
+ if (options.slots) element.append(root);
179
+ else element.replaceChildren(root);
180
+ const themeController = createThemeController(element, options.theme);
181
+ let slots = { ...given };
182
+ let controls = {};
183
+ if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
184
+ else {
185
+ const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
186
+ slots = { ...built.slots, ...given };
187
+ controls = built.controls;
188
+ }
189
+ const overlay = slots.overlay ?? root;
190
+
191
+ // ---- state ------------------------------------------------------------------------------------------------------------------
192
+ let fonts;
193
+ let canvas;
194
+ let pane;
195
+ let slideNav;
196
+ let strip;
197
+ let slideManager;
198
+ let sorter;
199
+ let outline;
200
+ let studio;
201
+ let presenter;
202
+ let presentButton;
203
+ let exportMenu;
204
+ let review;
205
+ let find;
206
+ let design;
207
+ let persistence;
208
+ let persistenceUi;
209
+ let thumbnailCache;
210
+ let unbindKeys;
211
+ let checking;
212
+ let slideIndex = Math.max(0, Number(options.slideIndex) || 0);
213
+ let activeView = "slide";
214
+ let selection = null;
215
+ let slides = [];
216
+ let authoredTemplate = false;
217
+ let draftInvalid = false;
218
+ let inSync = true;
219
+ let reportedText = "";
220
+ let drafting = false;
221
+ let openedText;
222
+ let sourceState = null;
223
+ let fontsFailure;
224
+ let refreshToken = 0;
225
+ let lastRefresh = Promise.resolve();
226
+ let geometrySize = null;
227
+ let statusText = "";
228
+ let density = "comfortable";
229
+ let exportMode = "menu";
230
+ let frameSize = { width: 0, height: 0 };
231
+ const draftMemory = createSourceMemory();
232
+ const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
233
+ const fitMode = options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
234
+
235
+ const say = (message) => {
236
+ statusText = message;
237
+ if (slots.status) slots.status.textContent = message;
238
+ emit("status", { message });
239
+ };
240
+ const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
241
+ const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
242
+
243
+ // The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
244
+ function renderOptions() {
245
+ const out = { ...(options.renderOptions ?? {}), catalogs: catalogs() };
246
+ if (options.imageResolver) out.imageResolver = options.imageResolver;
247
+ if (variablesMode === "resolved") out.variables = authoredTemplate ? false : {};
248
+ return out;
249
+ }
250
+ const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
251
+
252
+ // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
253
+ function renderViews(deck) {
254
+ thumbnailCache?.prepare(deck);
255
+ slideManager?.render();
256
+ if (activeView === "sorter") sorter?.render();
257
+ if (activeView === "outline") outline?.render();
258
+ }
259
+ // The detail of a `sync` event. Geometry and diagnostics compose the slide, so they are worked out only when a listener asks.
260
+ function syncDetail(deck) {
261
+ let geometry;
262
+ let diagnostics;
263
+ const compute = () => {
264
+ geometry ??= session.composeSlide(slideIndex, layoutFor(deck, slideIndex));
265
+ return geometry;
266
+ };
267
+ return {
268
+ presentation: deck,
269
+ slideIndex,
270
+ count: deck.slides.length,
271
+ get geometry() {
272
+ return compute();
273
+ },
274
+ get diagnostics() {
275
+ diagnostics ??= [...compute().diagnostics, ...fontSubstitutionNotes(fonts)];
276
+ return diagnostics;
277
+ },
278
+ };
279
+ }
280
+ function sync() {
281
+ fonts?.registry?.clearSubstitutions?.();
282
+ const deck = session.presentation;
283
+ slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
284
+ renderViews(deck);
285
+ slideNav?.setSlide(slideIndex);
286
+ strip?.setSlide(slideIndex);
287
+ fonts?.registry?.clearSubstitutions?.();
288
+ if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
289
+ emit("sync", syncDetail(deck));
290
+ review?.schedule();
291
+ design?.refresh();
292
+ }
293
+ // The one "ensure fonts, then update" path: every document change and every slide change goes through whenFontsReady, so nothing outside
294
+ // the canvas (which gates its own draws with the same handle) measures a document whose faces are still loading.
295
+ function refresh() {
296
+ if (!mounted || destroyed) return Promise.resolve();
297
+ const token = ++refreshToken;
298
+ let loaded = false;
299
+ lastRefresh = whenFontsReady(fonts, session.presentation, {
300
+ renderOptions: renderOptions(),
301
+ isCurrent: () => token === refreshToken && !destroyed,
302
+ loading: () => {
303
+ loaded = true;
304
+ emit("fonts", { state: "loading" });
305
+ say(MESSAGES.loadingFonts);
306
+ },
307
+ ready: () => {
308
+ fontsFailure = undefined;
309
+ if (loaded) {
310
+ thumbnailCache?.clear();
311
+ emit("fonts", { state: "ready" });
312
+ if (/Loading fonts/.test(statusText)) say(MESSAGES.ready);
313
+ }
314
+ try {
315
+ sync();
316
+ } catch (error) {
317
+ report(error);
318
+ }
319
+ },
320
+ failed: (error) => {
321
+ fontsFailure = error;
322
+ emit("fonts", { state: "error", error });
323
+ say(error.message);
324
+ report(error);
325
+ },
326
+ });
327
+ return lastRefresh;
328
+ }
329
+ function go(index) {
330
+ if (!mounted || !canvas) {
331
+ slideIndex = Math.max(0, index);
332
+ return false;
333
+ }
334
+ try {
335
+ if (canvas.setSlide(index)) {
336
+ const changed = index !== slideIndex;
337
+ slideIndex = index;
338
+ slideNav?.setSlide(index);
339
+ strip?.setSlide(index);
340
+ if (changed) emit("slide", { index, count: slides.length });
341
+ refresh();
342
+ return true;
343
+ }
344
+ } catch (error) {
345
+ report(error);
346
+ }
347
+ slideNav?.setSlide(canvas.slideIndex);
348
+ strip?.setSlide(canvas.slideIndex);
349
+ return false;
350
+ }
351
+ function goTo(index, path) {
352
+ if (index !== slideIndex && !go(index)) return false;
353
+ if (path) canvas?.reveal(path);
354
+ return true;
355
+ }
356
+
357
+ // ---- chrome that follows the source verdict, the history and read-only ------------------------------------------------------
358
+ const slideFrame = () => (slots.slide ?? slots.canvas?.closest?.(".opf-ws-frame") ?? slots.canvas);
359
+ function syncChrome() {
360
+ exportMenu?.setDisabled(!inSync);
361
+ presentButton?.setDisabled(!inSync || slides.length === 0);
362
+ if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
363
+ if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
364
+ // Text in the source pane that is not a valid deck: the slide shows the last valid one and is not editable until the text is fixed.
365
+ const frame = slots.canvas && (slots.slide ?? slots.canvas);
366
+ if (frame && (!inSync || readOnly)) frame.setAttribute("inert", "");
367
+ else frame?.removeAttribute("inert");
368
+ }
369
+ function applyFit() {
370
+ if (!fitMode || !slots.canvas) return;
371
+ const { width, height } = frameSize;
372
+ if (geometrySize && width > 0 && height > 0) {
373
+ const scale = Math.min(width / geometrySize.width, height / geometrySize.height);
374
+ slots.canvas.style.width = `${geometrySize.width * scale}px`;
375
+ slots.canvas.style.height = `${geometrySize.height * scale}px`;
376
+ slots.canvas.style.visibility = "";
377
+ } else {
378
+ slots.canvas.style.width = "0px";
379
+ slots.canvas.style.height = "0px";
380
+ slots.canvas.style.visibility = "hidden";
381
+ }
382
+ }
383
+
384
+ // ---- views ------------------------------------------------------------------------------------------------------------------
385
+ function slideManagerOptions() {
386
+ return {
387
+ editor: ui,
388
+ getSlideIndex: () => slideIndex,
389
+ setSlideIndex: (index) => {
390
+ go(index);
391
+ },
392
+ renderThumbnail: (deck, index) => thumbnailCache.html(deck, index),
393
+ renderLayoutPreview: (deck, index, choice) => {
394
+ const candidate = prepareDimensionSwitch(deck, "layouts", choice.id, { slideIndex: index, catalogs: catalogs() }).presentation;
395
+ return toSvg(candidate, index + 1, { ...renderOptions(), fonts, text: "system", trace: false });
396
+ },
397
+ onStatus: say,
398
+ onError: (error) => report(error),
399
+ autoRender: false,
400
+ contentActions: { splitSlideByBlocks, mergeSlides },
401
+ };
402
+ }
403
+ function markView(view) {
404
+ root.setAttribute("data-view", view);
405
+ for (const [name, button] of Object.entries(controls.views ?? {})) button.setAttribute("aria-pressed", String(name === view));
406
+ }
407
+ function setView(view, { focus = false } = {}) {
408
+ if (!views.includes(view)) return false;
409
+ if (!mounted) {
410
+ activeView = view;
411
+ return true;
412
+ }
413
+ if (view !== "slide" && canvas && !canvas.commit()) return false;
414
+ const previous = activeView;
415
+ activeView = view;
416
+ if (view === "sorter" && !sorter && slots.sorter) sorter = createSlideManager(slots.sorter, { ...slideManagerOptions(), variant: "sorter" });
417
+ if (view === "outline" && !outline && slots.outline) {
418
+ outline = createOutlineView(slots.outline, {
419
+ editor: ui,
420
+ getSlideIndex: () => slideIndex,
421
+ onSelectSlide: (index) => {
422
+ if (index !== slideIndex) go(index);
423
+ },
424
+ onStatus: say,
425
+ });
426
+ }
427
+ if (view === "layouts" && !studio && slots.layouts) {
428
+ studio = createLayoutStudio(slots.layouts, { session: ui, toSvg, fonts, renderOptions, getSlideIndex: () => slideIndex, onStatus: say, density, readOnly });
429
+ }
430
+ if (view === "layouts") studio?.refresh();
431
+ const frame = slideFrame();
432
+ if (frame) frame.hidden = view !== "slide";
433
+ // `slots.sorterView` (and the like) name the element that is hidden when the view is not shown, when it is not the element the view mounts in.
434
+ for (const name of ["sorter", "outline", "layouts"]) {
435
+ const panel = slots[`${name}View`] ?? slots[name];
436
+ if (panel) panel.hidden = view !== name;
437
+ }
438
+ markView(view);
439
+ if (view === "sorter") {
440
+ sorter?.render();
441
+ if (focus) sorter?.focus(slideIndex);
442
+ }
443
+ if (view === "outline") {
444
+ outline?.render();
445
+ if (focus) outline?.focusSlide(slideIndex);
446
+ }
447
+ if (view === "slide" && canvas) canvas.setSlide(slideIndex);
448
+ say(MESSAGES.views[view]);
449
+ if (previous !== view) emit("view", { view, previous });
450
+ return true;
451
+ }
452
+ function showPanel(name, focus = false) {
453
+ const tabs = controls.tabs;
454
+ if (!tabs?.[name]) return false;
455
+ for (const [tabName, tab] of Object.entries(tabs)) {
456
+ const selected = tabName === name;
457
+ tab.setAttribute("aria-selected", String(selected));
458
+ tab.tabIndex = selected ? 0 : -1;
459
+ const panel = doc.getElementById(tab.getAttribute("aria-controls"));
460
+ if (panel) panel.hidden = !selected;
461
+ }
462
+ if (focus) tabs[name].focus();
463
+ emit("panel", { panel: name });
464
+ return true;
465
+ }
466
+
467
+ // ---- the parts --------------------------------------------------------------------------------------------------------------
468
+ function mountSlideRow() {
469
+ unbindKeys?.();
470
+ slideNav?.destroy();
471
+ strip?.destroy();
472
+ slideNav = undefined;
473
+ strip = undefined;
474
+ if (parts.navigator && slots.navigator) {
475
+ slideNav = createSlideNavigator(slots.navigator, {
476
+ editor: ui,
477
+ slideIndex,
478
+ density,
479
+ ...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
480
+ onChange: (index) => {
481
+ go(index);
482
+ },
483
+ });
484
+ unbindKeys = slideNav.bindKeys(doc);
485
+ }
486
+ if (parts.thumbnails && slots.thumbnails) {
487
+ strip = createThumbnailStrip(slots.thumbnails, {
488
+ editor: ui,
489
+ fonts,
490
+ slideIndex,
491
+ density,
492
+ ...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
493
+ renderOptions: renderOptions(),
494
+ onSelect: (index) => {
495
+ if (index !== slideIndex) go(index);
496
+ },
497
+ onError: (error) => report(error instanceof Error ? error : new Error("Thumbnails unavailable")),
498
+ });
499
+ }
500
+ }
501
+
502
+ function exportOptions() {
503
+ const configured = options.export ?? {};
504
+ return {
505
+ ...configured,
506
+ getDeck: () => session.presentation,
507
+ // An open canvas edit is committed, and text typed a moment ago is applied, before the document is read; slide exports of a
508
+ // document that turns out not to be valid wait for the fix.
509
+ beforeExport: (kind) => {
510
+ if (canvas?.commit() === false) return false;
511
+ pane?.getText();
512
+ return !(kind === "slides" && draftInvalid);
513
+ },
514
+ fonts,
515
+ catalogs,
516
+ renderOptions: { ...(configured.renderOptions ?? options.renderOptions ?? {}), ...(options.imageResolver ? { imageResolver: options.imageResolver } : {}) },
517
+ onExport: (done) => {
518
+ configured.onExport?.(done);
519
+ emit("export", done);
520
+ if (["json", "yaml", "markdown"].includes(done.format)) {
521
+ persistence?.markSaved();
522
+ emit("kept", { kind: "download" });
523
+ }
524
+ },
525
+ onStatus: (value) => {
526
+ configured.onStatus?.(value);
527
+ if (value.state === "error") say(value.message);
528
+ },
529
+ handoffContainer: configured.handoffContainer ?? overlay,
530
+ mode: exportMode,
531
+ density,
532
+ };
533
+ }
534
+
535
+ function paneOptions() {
536
+ const configured = options.source ?? {};
537
+ const text = configured.text ?? startText;
538
+ return {
539
+ ...(configured.outline !== undefined ? { outline: configured.outline } : {}),
540
+ ...(configured.applyDelay !== undefined ? { applyDelay: configured.applyDelay } : {}),
541
+ ...(configured.completions !== undefined ? { completions: configured.completions } : {}),
542
+ ...(configured.catalogs !== undefined ? { catalogs: configured.catalogs } : {}),
543
+ ...(configured.name !== undefined ? { name: configured.name } : {}),
544
+ ...(text !== undefined ? { source: text } : {}),
545
+ editor: ui,
546
+ format: configured.format ?? "json",
547
+ density,
548
+ label: configured.label ?? "OPF source",
549
+ readOnly,
550
+ worker: configured.worker ?? false,
551
+ resolvePreview: configured.resolvePreview,
552
+ onRename: (name) => {
553
+ configured.onRename?.(name);
554
+ emit("rename", { name });
555
+ },
556
+ getFindings: ({ format, text: shown }) => {
557
+ reportedText = shown;
558
+ const made = reportSource(format, shown, { catalogs: catalogs(), checks: configured.checks ?? SOURCE_CHECKS });
559
+ draftInvalid = !made.valid;
560
+ inSync = made.status.kind === "valid";
561
+ sourceState = { status: made.status, format, text: shown };
562
+ syncChrome();
563
+ emit("source", sourceState);
564
+ return made.findings;
565
+ },
566
+ onCopy: async (file) => {
567
+ await (configured.onCopy ?? ((value) => copyText(value.text)))(file);
568
+ emit("kept", { kind: "copy" });
569
+ },
570
+ onDownload: async (file) => {
571
+ // A file the person opened and has not touched is written with its own bytes (its line endings included; the code editor holds LF).
572
+ const original = file.format === "json" && openedText !== undefined && file.text === lf(openedText) ? openedText : undefined;
573
+ await (configured.onDownload ?? ((value) => downloadSourceFile(value, original)))(original === undefined ? file : { ...file, text: original });
574
+ emit("kept", { kind: "download" });
575
+ },
576
+ onError: (error) => report(error),
577
+ };
578
+ }
579
+
580
+ // While the canvas holds an inline edit, the source shows the draft as it is typed, without touching the session.
581
+ function showDraft(presentation) {
582
+ if (!pane || pane.getFormat() !== "json") return;
583
+ // Text typed in the source a moment ago is applied first, so its typing delay cannot fire while the draft is on show.
584
+ if (!drafting) {
585
+ pane.getText();
586
+ drafting = true;
587
+ }
588
+ try {
589
+ pane.editor.update(updateJsonSource(pane.editor.api.getValue(), presentation, draftMemory));
590
+ } catch {
591
+ /* the document stays as it was */
592
+ }
593
+ }
594
+
595
+ function mountCanvas() {
596
+ const configured = options.canvas ?? {};
597
+ // Registered before the canvas, so it runs first: a source edit that reorders the slides keeps the slide the person was on (stable ids)
598
+ // before the canvas draws the new document.
599
+ cleanups.push(
600
+ session.subscribe((event) => {
601
+ const next = event.snapshot.presentation.slides ?? [];
602
+ const before = slides;
603
+ slides = next;
604
+ // `slideRetention: "position"` keeps the position (the update clamps it to the deck) instead of following the slide's id.
605
+ if (!canvas || event.type === "catalogs" || options.slideRetention === "position") return;
606
+ const keep = retainedSlideIndex(before, next, canvas.slideIndex);
607
+ if (keep !== canvas.slideIndex) {
608
+ try {
609
+ canvas.setSlide(keep);
610
+ } catch {
611
+ /* the canvas redraws its own slide */
612
+ }
613
+ }
614
+ }),
615
+ );
616
+ canvas = createCanvasEditor(slots.canvas, {
617
+ ...configured,
618
+ editor: ui,
619
+ fonts,
620
+ renderOptions: renderOptions(),
621
+ slideIndex,
622
+ ...(slots.properties ? { propertiesContainer: slots.properties } : {}),
623
+ onFonts: (event) => {
624
+ if (destroyed) return;
625
+ if (event.state === "error") {
626
+ fontsFailure = event.error;
627
+ say(event.error.message);
628
+ } else if (event.state === "loading") say(MESSAGES.loadingFonts);
629
+ else if (fontsFailure) {
630
+ fontsFailure = undefined;
631
+ refresh();
632
+ }
633
+ emit("fonts", event);
634
+ configured.onFonts?.(event);
635
+ },
636
+ onSelect: (value) => {
637
+ selection = { path: value.path, value: value.value };
638
+ emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
639
+ design?.refresh();
640
+ configured.onSelect?.(value);
641
+ },
642
+ onDraft: (value) => {
643
+ if (destroyed) return;
644
+ showDraft(value.presentation);
645
+ emit("draft", value);
646
+ configured.onDraft?.(value);
647
+ },
648
+ onCommit: (value) => {
649
+ emit("commit", value);
650
+ configured.onCommit?.(value);
651
+ },
652
+ onCancel: (value) => {
653
+ if (destroyed) return;
654
+ drafting = false;
655
+ pane?.refresh();
656
+ emit("cancel", value);
657
+ configured.onCancel?.(value);
658
+ },
659
+ onError: (error) => {
660
+ if (!destroyed) report(error);
661
+ configured.onError?.(error);
662
+ },
663
+ onRender: (event) => {
664
+ if (destroyed) return;
665
+ geometrySize = { width: event.geometry.width, height: event.geometry.height };
666
+ applyFit();
667
+ emit("geometry", geometrySize);
668
+ if (event.slideIndex !== slideIndex) {
669
+ // The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
670
+ slideIndex = event.slideIndex;
671
+ slideNav?.setSlide(slideIndex);
672
+ strip?.setSlide(slideIndex);
673
+ emit("slide", { index: slideIndex, count: slides.length });
674
+ refresh();
675
+ }
676
+ emit("render", event);
677
+ configured.onRender?.(event);
678
+ },
679
+ });
680
+ if (variablesMode === "resolved") {
681
+ cleanups.push(
682
+ installCanvasVariableEditing(slots.canvas, ui, canvas, {
683
+ resolved: () => !authoredTemplate,
684
+ focusSource: () => pane?.editor?.api?.focus?.(),
685
+ }),
686
+ );
687
+ }
688
+ }
689
+
690
+ function mountPersistence() {
691
+ const configured = options.persistence;
692
+ persistenceUi = createPersistenceUi({
693
+ banner: slots.banner,
694
+ indicator: slots.autosave,
695
+ restoreEarlier: () => persistence.restoreEarlier(),
696
+ suffix: configured.suffix ?? "Save an OPF file to keep a copy.",
697
+ fallback: configured.fallback ?? "Changes stay in this session.\nSave an OPF file to keep your work.",
698
+ });
699
+ const { suffix, fallback, ...rest } = configured;
700
+ persistence = createPersistence(ui, {
701
+ ...rest,
702
+ onRestorePrompt: configured.onRestorePrompt ?? persistenceUi.prompt,
703
+ onStatus: (value) => {
704
+ persistenceUi.status(value);
705
+ configured.onStatus?.(value);
706
+ emit("persistence", value);
707
+ },
708
+ beforeFlush: () => canvas?.commit(),
709
+ });
710
+ }
711
+
712
+ function mountFind() {
713
+ const configured = typeof options.find === "object" ? options.find : {};
714
+ find = createFindPanel(slots.find, {
715
+ editor: ui,
716
+ canvas: () => canvas,
717
+ getSlideIndex: () => slideIndex,
718
+ goToSlide: (index) => {
719
+ if (index === slideIndex) return undefined;
720
+ go(index);
721
+ return lastRefresh;
722
+ },
723
+ getSelectionText: () => {
724
+ const active = doc.activeElement;
725
+ if (active && /^(TEXTAREA|INPUT)$/.test(active.tagName) && active.selectionStart !== active.selectionEnd && !slots.find.contains(active)) {
726
+ return active.value.slice(active.selectionStart, active.selectionEnd);
727
+ }
728
+ return win?.getSelection?.().toString() || undefined;
729
+ },
730
+ onGoTo: configured.onGoTo,
731
+ onStatus: (message) => say(message),
732
+ onClose() {
733
+ controls.find?.setAttribute("aria-expanded", "false");
734
+ slots.find.hidden = true;
735
+ },
736
+ });
737
+ slots.find.hidden = true;
738
+ const open = (openOptions) => {
739
+ slots.find.hidden = false;
740
+ controls.find?.setAttribute("aria-expanded", "true");
741
+ find.open(openOptions);
742
+ };
743
+ find.openPanel = open;
744
+ controls.find?.addEventListener("click", () => (find.isOpen ? find.close() : open({})));
745
+ cleanups.push(
746
+ installFindShortcuts({
747
+ open,
748
+ element: find.element,
749
+ get isOpen() {
750
+ return find.isOpen;
751
+ },
752
+ }),
753
+ );
754
+ }
755
+
756
+ function mountReview() {
757
+ const configured = typeof options.review === "object" ? options.review : {};
758
+ const { hook, ...panelOptions } = configured;
759
+ review = createReviewPanel(slots.review, {
760
+ ...panelOptions,
761
+ editor: ui,
762
+ getSlideIndex: () => slideIndex,
763
+ // The layout checks measure text with the fonts the canvas draws with, script-aware per slide.
764
+ getValidateOptions: (deck) => (fonts?.textMeasurement ? { fonts: { textMeasurement: (index) => layoutFor(deck, index).fonts.textMeasurement } } : {}),
765
+ // The workspace re-checks after it has redrawn, so the faces are loaded and the checks measure what the canvas shows.
766
+ autoRefresh: false,
767
+ ...(hook ? { review: hook } : {}),
768
+ onGoTo: ({ finding, target }) => {
769
+ if (target.slide === null) emit("review-goto", { finding, target });
770
+ else goTo(target.slide, target.path);
771
+ say(finding.message);
772
+ },
773
+ onFocusField: ({ fix, target }) => emit("review-focus", { fix, target }),
774
+ onStatus: say,
775
+ onChange: (counts) => {
776
+ const tab = controls.tabs?.review;
777
+ if (tab) {
778
+ const name = options.labels?.review ?? "Review";
779
+ const found = !counts.unavailable && counts.total;
780
+ tab.textContent = found ? `${name} (${counts.total})` : name;
781
+ tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
782
+ }
783
+ emit("review", counts);
784
+ },
785
+ });
786
+ }
787
+
788
+ function present() {
789
+ if (destroyed || !mounted || !presenter || presenter.isRunning() || !inSync || slides.length === 0) return false;
790
+ // An open canvas edit is committed, and text typed a moment ago is applied, before the document is shown; a source that is not a valid deck waits.
791
+ if (canvas?.commit() === false) return false;
792
+ pane?.getText();
793
+ if (draftInvalid) return false;
794
+ void presenter.start(canvas?.slideIndex ?? slideIndex);
795
+ return true;
796
+ }
797
+
798
+ function mountParts() {
799
+ slides = session.presentation.slides ?? [];
800
+ authoredTemplate = session.presentation.template === true;
801
+ if (parts.canvas && slots.canvas) mountCanvas();
802
+ thumbnailCache = createThumbnailCache({ toSvg, getOptions: () => ({ ...renderOptions(), fonts, text: "system", trace: false }) });
803
+ mountSlideRow();
804
+ if (parts.slides && slots.slides) slideManager = createSlideManager(slots.slides, { ...slideManagerOptions(), ...(slots.slideToolbar ? { toolbar: slots.slideToolbar } : {}) });
805
+ if (parts.source && slots.source) {
806
+ pane = createSourcePane(slots.source, paneOptions());
807
+ checking = new MutationObserver(() => {
808
+ if (!destroyed) emit("checking", { checking: pane.editor.api.getValue() !== reportedText });
809
+ });
810
+ checking.observe(pane.editor.view.dom, { subtree: true, childList: true, characterData: true });
811
+ }
812
+ if (parts.export && slots.export) exportMenu = createExportMenu(slots.export, exportOptions());
813
+ if (parts.present) {
814
+ presenter = createPresenter(overlay, {
815
+ editor: ui,
816
+ fonts,
817
+ get renderOptions() {
818
+ return renderOptions();
819
+ },
820
+ density,
821
+ onExit: (index) => {
822
+ if (!destroyed && index >= 0) go(index);
823
+ },
824
+ });
825
+ if (slots.present) {
826
+ presentButton = createPresentButton(slots.present, {
827
+ variant: "quiet",
828
+ onPresent: () => {
829
+ present();
830
+ },
831
+ density,
832
+ });
833
+ }
834
+ cleanups.push(
835
+ bindPresentShortcut(doc, () => {
836
+ present();
837
+ }),
838
+ );
839
+ }
840
+ if (parts.find && slots.find) mountFind();
841
+ if (parts.review && slots.review) mountReview();
842
+ if (parts.design && slots.design) {
843
+ const configured = typeof options.design === "object" ? options.design : {};
844
+ design = createDesignControls(slots.design, {
845
+ ...configured,
846
+ editor: ui,
847
+ getSlideIndex: () => slideIndex,
848
+ getSelectedPath: () => selection?.path,
849
+ catalogs: hostCatalogs,
850
+ ...(options.onAddAsset ? { onAddAsset: options.onAddAsset } : {}),
851
+ onSelectPath: (path) => {
852
+ canvas?.reveal(path);
853
+ },
854
+ onStatus: say,
855
+ });
856
+ }
857
+ if (parts.persistence) mountPersistence();
858
+ mountControls();
859
+ mountEnvironment();
860
+ // These come last, so a host hears a change after every part has. The refresh runs for every event, the catalogs' included.
861
+ cleanups.push(
862
+ session.subscribe((event) => {
863
+ if (event.type !== "catalogs") drafting = false;
864
+ const template = event.snapshot.presentation.template === true;
865
+ if (template !== authoredTemplate) {
866
+ authoredTemplate = template;
867
+ if (variablesMode === "resolved") canvas?.setRenderOptions(renderOptions());
868
+ mountSlideRow();
869
+ }
870
+ if (event.type !== "catalogs") {
871
+ syncChrome();
872
+ emit("change", { document: event.snapshot.presentation, canUndo: event.snapshot.canUndo, canRedo: event.snapshot.canRedo, event });
873
+ }
874
+ refresh();
875
+ }),
876
+ // An edit of a catalog's record forks it into this deck's own records; tell the person.
877
+ session.subscribe((event) => {
878
+ if (event.meta?.notice) setTimeout(() => !destroyed && say(event.meta.notice));
879
+ }),
880
+ );
881
+ }
882
+
883
+ // The shell's own controls: history, views, tabs.
884
+ function mountControls() {
885
+ controls.undo?.addEventListener("click", () => undo());
886
+ controls.redo?.addEventListener("click", () => redo());
887
+ for (const [name, button] of Object.entries(controls.views ?? {})) button.addEventListener("click", () => setView(name, { focus: true }));
888
+ for (const [name, tab] of Object.entries(controls.tabs ?? {})) {
889
+ tab.addEventListener("click", () => showPanel(name));
890
+ tab.addEventListener("keydown", (event) => {
891
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
892
+ event.preventDefault();
893
+ const names = Object.keys(controls.tabs);
894
+ const at = names.indexOf(name);
895
+ const next = event.key === "Home" ? 0 : event.key === "End" ? names.length - 1 : (at + (event.key === "ArrowRight" ? 1 : -1) + names.length) % names.length;
896
+ showPanel(names[next], true);
897
+ });
898
+ }
899
+ if (controls.tabs) showPanel(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
900
+ if (options.shortcuts !== false) {
901
+ // Ctrl/Cmd+Z and +Shift+Z undo and redo when nothing that edits text has focus (the code editor and the canvas's inputs keep their own).
902
+ const onKey = (event) => {
903
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
904
+ if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
905
+ if (!element.contains(event.target) && event.target !== doc.body) return;
906
+ event.preventDefault();
907
+ if (event.shiftKey) redo();
908
+ else undo();
909
+ };
910
+ doc.addEventListener("keydown", onKey);
911
+ cleanups.push(() => doc.removeEventListener("keydown", onKey));
912
+ }
913
+ }
914
+
915
+ // The slide fits its frame; the density and the export mode follow the device when they were left to "auto".
916
+ function mountEnvironment() {
917
+ if (fitMode && slots.canvas && typeof ResizeObserver === "function" && slots.canvas.parentElement) {
918
+ const observer = new ResizeObserver(([entry]) => {
919
+ const { width, height } = entry.contentRect;
920
+ if (width !== frameSize.width || height !== frameSize.height) {
921
+ frameSize = { width, height };
922
+ applyFit();
923
+ }
924
+ });
925
+ observer.observe(slots.canvas.parentElement);
926
+ cleanups.push(() => observer.disconnect());
927
+ }
928
+ const follow = (setting, query, apply) => {
929
+ if (setting !== undefined && setting !== "auto") return;
930
+ const media = win?.matchMedia?.(query);
931
+ if (!media?.addEventListener) return;
932
+ const change = () => apply("auto");
933
+ media.addEventListener("change", change);
934
+ cleanups.push(() => media.removeEventListener("change", change));
935
+ };
936
+ follow(options.density, "(pointer: coarse)", setDensity);
937
+ follow(options.exportMode, "(max-width: 900px)", setExportMode);
938
+ }
939
+
940
+ // ---- the handle -------------------------------------------------------------------------------------------------------------
941
+ function undo() {
942
+ if (!mounted || readOnly) return false;
943
+ if (canvas?.commit() === false) return false;
944
+ pane?.getText();
945
+ try {
946
+ session.undo();
947
+ return true;
948
+ } catch (error) {
949
+ report(error);
950
+ return false;
951
+ }
952
+ }
953
+ function redo() {
954
+ if (!mounted || readOnly) return false;
955
+ if (canvas?.commit() === false) return false;
956
+ pane?.getText();
957
+ try {
958
+ session.redo();
959
+ return true;
960
+ } catch (error) {
961
+ report(error);
962
+ return false;
963
+ }
964
+ }
965
+ function applyReadOnly() {
966
+ pane?.setReadOnly(readOnly);
967
+ studio?.setReadOnly(readOnly);
968
+ root.toggleAttribute("data-readonly", readOnly);
969
+ syncChrome();
970
+ }
971
+ function setDensity(setting) {
972
+ const next = densityFor(setting);
973
+ if (next === density) return;
974
+ density = next;
975
+ root.setAttribute("data-density", density);
976
+ pane?.setDensity(density);
977
+ exportMenu?.setDensity(density);
978
+ presentButton?.setDensity(density);
979
+ studio?.setDensity(density);
980
+ mountSlideRow();
981
+ }
982
+ function setExportMode(setting) {
983
+ exportMode = exportModeFor(setting);
984
+ exportMenu?.setMode(exportMode);
985
+ }
986
+ function setDocument(document, openOptions = {}) {
987
+ if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
988
+ const text = typeof openOptions.text === "string" ? lf(openOptions.text).replace(/^/, "") : undefined;
989
+ try {
990
+ if (pane) {
991
+ if (openOptions.format && pane.getFormat() !== openOptions.format && !pane.setFormat(openOptions.format)) throw new Error("The editor could not switch the source format.");
992
+ // The pane shows the text as written (JSON, or a YAML or Markdown file with its comments and prose layout) and brings it back on Undo and Redo.
993
+ pane.replaceDocument(document, {
994
+ ...(text !== undefined ? { source: text } : {}),
995
+ ...(openOptions.format ? { format: openOptions.format } : {}),
996
+ ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
997
+ });
998
+ pane.getText();
999
+ openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
1000
+ } else session.replaceDocument(document, { source: "load" });
1001
+ // A document the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1002
+ if (openOptions.rebase !== false) persistence?.rebase();
1003
+ return true;
1004
+ } catch (error) {
1005
+ if (error?.code !== "invalid-opf-edit") throw error;
1006
+ // A document the session refuses is no document to open: show its text with its findings, as a draft the person can fix.
1007
+ if (pane && text !== undefined) {
1008
+ if (openOptions.format && pane.getFormat() !== openOptions.format) pane.setFormat(openOptions.format);
1009
+ const api = pane.editor.api;
1010
+ api.replace(0, api.getValue().length, text);
1011
+ pane.getText();
1012
+ if (openOptions.name !== undefined) pane.setName(openOptions.name);
1013
+ }
1014
+ return false;
1015
+ }
1016
+ }
1017
+
1018
+ let fontsRef = { value: undefined };
1019
+ let releaseFonts;
1020
+ let readyPromise;
1021
+ const handle = {
1022
+ get element() {
1023
+ return element;
1024
+ },
1025
+ get root() {
1026
+ return root;
1027
+ },
1028
+ get ready() {
1029
+ return readyPromise;
1030
+ },
1031
+ get destroyed() {
1032
+ return destroyed;
1033
+ },
1034
+ get mounted() {
1035
+ return mounted;
1036
+ },
1037
+ /** The editor session: every part reads and writes it. The host may write to it directly, also while the editor is read-only. */
1038
+ get session() {
1039
+ return session;
1040
+ },
1041
+ get canvas() {
1042
+ return canvas;
1043
+ },
1044
+ get sourcePane() {
1045
+ return pane;
1046
+ },
1047
+ get presenter() {
1048
+ return presenter;
1049
+ },
1050
+ get exportMenu() {
1051
+ return exportMenu;
1052
+ },
1053
+ get review() {
1054
+ return review;
1055
+ },
1056
+ get find() {
1057
+ return find;
1058
+ },
1059
+ get design() {
1060
+ return design;
1061
+ },
1062
+ get persistence() {
1063
+ return persistence;
1064
+ },
1065
+ get slideManager() {
1066
+ return slideManager;
1067
+ },
1068
+ get layoutStudio() {
1069
+ return studio;
1070
+ },
1071
+ get fonts() {
1072
+ return fonts;
1073
+ },
1074
+ get slideIndex() {
1075
+ return slideIndex;
1076
+ },
1077
+ get selection() {
1078
+ return selection;
1079
+ },
1080
+ get view() {
1081
+ return activeView;
1082
+ },
1083
+ get views() {
1084
+ return [...views];
1085
+ },
1086
+ get readOnly() {
1087
+ return readOnly;
1088
+ },
1089
+ get inSync() {
1090
+ return inSync;
1091
+ },
1092
+ /** The latest verdict on the text of the source pane (what the last `source` event carried), or `null` before the first. */
1093
+ get sourceState() {
1094
+ return sourceState;
1095
+ },
1096
+ get lastError() {
1097
+ return lastError;
1098
+ },
1099
+ get slots() {
1100
+ return slots;
1101
+ },
1102
+ getDocument() {
1103
+ return session.snapshot().presentation;
1104
+ },
1105
+ setDocument,
1106
+ setView,
1107
+ setSlide: go,
1108
+ goTo,
1109
+ select(path, { focus = false } = {}) {
1110
+ return canvas?.reveal(path, { focus }) ?? null;
1111
+ },
1112
+ showPanel,
1113
+ present,
1114
+ undo,
1115
+ redo,
1116
+ /** Run the update again now (the parts redraw, `sync` is emitted once the fonts are ready). Resolves when it is done. */
1117
+ refresh,
1118
+ /** Resolves when the latest update has finished (fonts loaded, parts redrawn). */
1119
+ whenIdle() {
1120
+ return lastRefresh;
1121
+ },
1122
+ commit() {
1123
+ return canvas?.commit() !== false;
1124
+ },
1125
+ focus(target) {
1126
+ if ((target ?? (pane ? "source" : "canvas")) === "source") {
1127
+ pane?.editor?.api?.focus?.();
1128
+ return Boolean(pane);
1129
+ }
1130
+ const node = slots.canvas?.querySelector?.("[data-canvas-target], [role='button']");
1131
+ node?.focus();
1132
+ return Boolean(node);
1133
+ },
1134
+ openFind(openOptions) {
1135
+ if (!find) return false;
1136
+ find.openPanel(openOptions ?? {});
1137
+ return true;
1138
+ },
1139
+ readSource() {
1140
+ if (!pane) return { text: JSON.stringify(session.presentation, null, 2), format: "json", draft: false };
1141
+ // While the canvas holds an inline edit the pane shows that draft; reading it must not commit it to the document.
1142
+ if (canvas?.editingPath != null) return { text: pane.editor.api.getValue(), format: pane.getFormat(), draft: true };
1143
+ const text = pane.getText();
1144
+ return { text, format: pane.getFormat(), draft: draftInvalid };
1145
+ },
1146
+ setName(name) {
1147
+ pane?.setName(name);
1148
+ },
1149
+ getName() {
1150
+ return pane?.getName();
1151
+ },
1152
+ setReadOnly(value) {
1153
+ readOnly = Boolean(value);
1154
+ if (mounted) applyReadOnly();
1155
+ },
1156
+ setTheme(theme) {
1157
+ return themeController.update(theme);
1158
+ },
1159
+ setDensity,
1160
+ setExportMode,
1161
+ status: say,
1162
+ on,
1163
+ off,
1164
+ destroy() {
1165
+ if (destroyed) return;
1166
+ destroyed = true;
1167
+ mounted = false;
1168
+ refreshToken++;
1169
+ emit("destroy", {});
1170
+ for (const stop of cleanups.splice(0).reverse()) {
1171
+ try {
1172
+ stop();
1173
+ } catch {
1174
+ /* already gone */
1175
+ }
1176
+ }
1177
+ unbindKeys?.();
1178
+ checking?.disconnect();
1179
+ persistence?.destroy();
1180
+ persistenceUi?.destroy();
1181
+ find?.destroy();
1182
+ review?.destroy();
1183
+ design?.destroy();
1184
+ studio?.destroy();
1185
+ outline?.destroy();
1186
+ sorter?.destroy();
1187
+ slideManager?.destroy();
1188
+ presentButton?.destroy();
1189
+ presenter?.destroy();
1190
+ exportMenu?.destroy();
1191
+ strip?.destroy();
1192
+ slideNav?.destroy();
1193
+ pane?.destroy();
1194
+ canvas?.destroy();
1195
+ themeController.destroy();
1196
+ root.remove();
1197
+ releaseFonts?.();
1198
+ listeners.clear();
1199
+ },
1200
+ };
1201
+
1202
+ // ---- start ------------------------------------------------------------------------------------------------------------------
1203
+ function mount(resolved) {
1204
+ fonts = resolved;
1205
+ density = densityFor(options.density);
1206
+ exportMode = exportModeFor(options.exportMode);
1207
+ root.setAttribute("data-density", density);
1208
+ mountParts();
1209
+ mounted = true;
1210
+ if (readOnly) applyReadOnly();
1211
+ say(MESSAGES.ready);
1212
+ if (options.view && options.view !== "slide") setView(options.view);
1213
+ else markView("slide");
1214
+ syncChrome();
1215
+ }
1216
+ readyPromise = new Promise((resolve, reject) => {
1217
+ const start = (arrived) => {
1218
+ const lease = fontsRef.use(arrived);
1219
+ if (destroyed) {
1220
+ lease.release?.();
1221
+ resolve(handle);
1222
+ return;
1223
+ }
1224
+ releaseFonts = lease.release;
1225
+ mount(lease.fonts);
1226
+ emit("mounted", { editor: handle });
1227
+ // The first update runs a tick later, so a host that listens after `createOpfEditor` returns hears it. Ready once it is done: the document has
1228
+ // been checked against the fonts it needs.
1229
+ Promise.resolve()
1230
+ .then(() => refresh())
1231
+ .then(() => {
1232
+ if (!destroyed) emit("ready", { editor: handle });
1233
+ resolve(handle);
1234
+ })
1235
+ .catch(reject);
1236
+ };
1237
+ const fail = (error) => {
1238
+ report(error);
1239
+ if (!destroyed) say(error.message);
1240
+ reject(error);
1241
+ };
1242
+ try {
1243
+ fontsRef = fontsSource(options.fonts);
1244
+ if (isThenable(fontsRef.value)) {
1245
+ say(MESSAGES.loadingFonts);
1246
+ Promise.resolve(fontsRef.value).then((resolved) => {
1247
+ try {
1248
+ start(resolved);
1249
+ } catch (error) {
1250
+ fail(error);
1251
+ }
1252
+ }, fail);
1253
+ } else start(fontsRef.value);
1254
+ } catch (error) {
1255
+ fail(error);
1256
+ }
1257
+ });
1258
+ // A host that never awaits `ready` must not get an unhandled rejection for a failure it can read from `error`.
1259
+ readyPromise.catch(() => {});
1260
+ return handle;
1261
+ }