@openpresentation/opf-editor 0.29.0 → 0.30.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/workspace.js CHANGED
@@ -12,7 +12,10 @@ import { whenFontsReady } from "./font-gate.js";
12
12
  import { createSourcePane } from "./source-pane.js";
13
13
  import { createPersistence } from "./persistence.js";
14
14
  import { createPersistenceUi } from "./persistence-ui.js";
15
- import { buildWorkspaceShell } from "./workspace-shell.js";
15
+ import { ITEM_SLOTS, TOOLBAR_PRESETS, buildItemControls, buildToolbar, buildWorkspaceShell, viewLabel } from "./workspace-shell.js";
16
+ import { TOOLBAR_ITEMS, createToolbar, normalizeToolbarItems } from "./toolbar.js";
17
+ import { normalizeIcons } from "./icons.js";
18
+ import { deckNamePatch } from "./deck-title.js";
16
19
  import { fontSubstitutionNotes, layoutOptionsFor } from "./workspace-measure.js";
17
20
  import { SOURCE_CHECKS, copyText, downloadSourceFile, lf, reportSource } from "./workspace-source.js";
18
21
  import { createThemeController } from "./workspace-theme.js";
@@ -29,6 +32,7 @@ export { layoutOptionsFor } from "./workspace-measure.js";
29
32
  let toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu;
30
33
  let bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls;
31
34
  let prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio;
35
+ let createDeckTitle, createExpandController, createExpandButton, createDesignMenu, createReviewPill, createHelpButton, createToolbarActions, listSwitchOptions;
32
36
  let partsModule;
33
37
  let partsLoaded = false;
34
38
  function loadParts() {
@@ -36,6 +40,7 @@ function loadParts() {
36
40
  ({ toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu } = parts);
37
41
  ({ bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls } = parts);
38
42
  ({ prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio } = parts);
43
+ ({ createDeckTitle, createExpandController, createExpandButton, createDesignMenu, createReviewPill, createHelpButton, createToolbarActions, listSwitchOptions } = parts);
39
44
  partsLoaded = true;
40
45
  });
41
46
  // A chunk that failed to load is asked for again by the next editor.
@@ -49,7 +54,8 @@ function loadParts() {
49
54
  * mount in (the presenter mounts in the overlay).
50
55
  */
51
56
  const SOURCE_ONLY_PARTS = new Set(["source", "persistence", "history"]);
52
- const needsSlideParts = (parts, slots) => Object.entries(parts).some(([name, on]) => on && !SOURCE_ONLY_PARTS.has(name) && (name === "present" || Boolean(slots[name])));
57
+ const CHROME_PARTS = ["title", "counter", "expand", "designMenu", "reviewPill", "help", "actions"];
58
+ const needsSlideParts = (parts, slots) => CHROME_PARTS.some((name) => slots[name]) || Object.entries(parts).some(([name, on]) => on && !SOURCE_ONLY_PARTS.has(name) && (name === "present" || Boolean(slots[name])));
53
59
 
54
60
  const VIEWS = Object.freeze(["slide", "sorter", "outline", "layouts"]);
55
61
  const MUTATORS = new Set(["set", "setGroupComposition", "setCatalog", "setCatalogs", "applyPatch", "replaceDocument", "restoreState", "undo", "redo", "setComposition", "paginateSlide"]);
@@ -68,12 +74,48 @@ const PART_NAMES = ["source", "navigator", "thumbnails", "history", "find", "pre
68
74
  const LAYOUT_PARTS = {
69
75
  full: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, sorter: true, outline: true },
70
76
  canvas: { navigator: true, thumbnails: true, history: true },
77
+ // CE-28: the slide alone, for a page that shows a deck (the homepage hero): its toolbar has the title, the history, the counter and Present.
78
+ embed: { history: true, present: true },
79
+ // CE-28 wave 2: a deck to look at: read-only, the slide and its counter, Present (Export when the toolbar lists it).
80
+ viewer: { present: true },
71
81
  source: { source: true },
72
82
  // CE-20: the Studio and its Playground preset arrange the same parts; the Studio mounts its own Design panel.
73
83
  studio: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
74
84
  playground: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
75
85
  };
76
86
  const STUDIO_LAYOUTS = new Set(["studio", "playground"]);
87
+ const LAYOUTS = ["full", "canvas", "embed", "viewer", "source", "slots", "studio", "playground"];
88
+ // The layouts that live inside a page: their keys (arrows, F5, Ctrl/Cmd Z and S, "?") act only while the focus is inside the editor.
89
+ const SCOPED_LAYOUTS = new Set(["embed", "viewer"]);
90
+ // CE-28: the toolbar items a host can place in its own element (`slots.<item>`); `autosave`, `export` and `present` were slots already.
91
+ // The slots that hold only the toolbar or its items: given alone, the layout is still built.
92
+ const CHROME_SLOTS = new Set(["title", "history", "counter", "views", "expand", "designMenu", "reviewPill", "help", "actions", "toolbar"]);
93
+ // The slot that places an item in the host's own element: the item's name, but `designMenu` and `reviewPill` for `design` and `review`.
94
+ const itemSlot = (id) => ITEM_SLOTS[id]?.[0] ?? id;
95
+ const ITEM_SLOT_NAMES = new Set(["title", "history", "counter", "views", "expand", "autosave", "export", "present", "design", "review", "help", "actions"]);
96
+ // Reserved (accepted and left out): `zoom` is the Studio's own.
97
+ const RESERVED_ITEMS = new Set(["zoom"]);
98
+ // CE-29 styling hooks: the icon names of every part the editor mounts, for the one flat `icons` option (`close` is the help sheet's and the
99
+ // Design menu's both). Each part gets the names it has.
100
+ const PART_ICONS = Object.freeze({
101
+ toolbar: ["more"],
102
+ expand: ["expand", "collapse"],
103
+ reviewPill: ["valid", "error", "warning", "info", "checking", "unavailable"],
104
+ help: ["help", "close"],
105
+ designMenu: ["trigger", "close", "check"],
106
+ navigator: ["previous", "next"],
107
+ strip: ["add"],
108
+ present: ["present"],
109
+ });
110
+ const ICON_NAMES = [...new Set(Object.values(PART_ICONS).flat())];
111
+
112
+ /** The `toolbar` option as the list of items it asks for (the layout's preset when it is not given). */
113
+ function toolbarSpec(options, layout) {
114
+ if (STUDIO_LAYOUTS.has(layout)) return [];
115
+ const spec = options.toolbar === false ? [] : options.toolbar === undefined || options.toolbar === true ? (TOOLBAR_PRESETS[layout] ?? []) : options.toolbar;
116
+ if (!Array.isArray(spec)) throw new TypeError("toolbar must be false or an array of toolbar items.");
117
+ return normalizeToolbarItems(spec);
118
+ }
77
119
 
78
120
  const isThenable = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
79
121
 
@@ -111,7 +153,7 @@ function guardSession(session, isReadOnly) {
111
153
  });
112
154
  }
113
155
 
114
- function resolveParts(options, layout, slots) {
156
+ function resolveParts(options, layout, slots, asked = []) {
115
157
  const panels = options.panels ?? {};
116
158
  const base = LAYOUT_PARTS[layout];
117
159
  const parts = {};
@@ -122,9 +164,48 @@ function resolveParts(options, layout, slots) {
122
164
  }
123
165
  // The slide is the editor; a source-only layout has none.
124
166
  parts.canvas = layout === "source" ? false : panels.canvas !== false;
167
+ // CE-28 wave 2: an item the toolbar lists brings the part it shows: Export in the viewer, and Review (run without its panel where the layout
168
+ // has none, so the pill has counts) wherever it is listed.
169
+ const listed = (id) => asked.some((item) => item.id === id) || Boolean(slots[itemSlot(id)]);
170
+ if (panels.export === undefined && listed("export")) parts.export = true;
171
+ if (panels.review === undefined && listed("review") && !parts.review) {
172
+ parts.review = true;
173
+ // A layout without a Review panel gets none: the review runs headless for the pill.
174
+ parts.reviewHeadless = true;
175
+ }
125
176
  return parts;
126
177
  }
127
178
 
179
+ /**
180
+ * CE-28: the toolbar items this editor builds: the preset of the layout, or the `toolbar` option, without the items it has nothing for (Export
181
+ * when there is no Export, the views with one view, a reserved name) and without the ones the host placed in its own element (`slots.<item>`).
182
+ */
183
+ function resolveToolbar(options, layout, parts, views, given, items) {
184
+ if (STUDIO_LAYOUTS.has(layout)) return [];
185
+ const preset = options.toolbar === undefined || options.toolbar === true;
186
+ const available = {
187
+ // The Design quick menu stands in for the Design side panel: a preset leaves it out where that panel is on (the embed has none).
188
+ design: !(preset && parts.design && layout !== "embed"),
189
+ review: parts.review,
190
+ help: true,
191
+ actions: true,
192
+ title: true,
193
+ // A host that arranges the page itself and asks for the history in its toolbar gets it (there is no other slot to name it by).
194
+ history: parts.history || (layout === "slots" && options.panels?.history !== false),
195
+ counter: parts.canvas,
196
+ views: views.length > 1,
197
+ find: parts.find,
198
+ autosave: parts.persistence,
199
+ export: parts.export,
200
+ present: parts.present,
201
+ expand: true,
202
+ more: true,
203
+ spacer: true,
204
+ "|": true,
205
+ };
206
+ return items.filter((item) => !RESERVED_ITEMS.has(item.id) && available[item.id] && !(ITEM_SLOT_NAMES.has(item.id) && given[itemSlot(item.id)]));
207
+ }
208
+
128
209
  /**
129
210
  * Mount the editor in `element`. Returns the handle at once; the parts mount once a `fonts` promise has resolved and, for a layout that shows
130
211
  * slides, once the slide chunk has loaded (`mounted` is emitted then, and `ready` settles after the first update). A source-only editor whose
@@ -139,8 +220,10 @@ export function createOpfEditor(element, options = {}) {
139
220
  const cleanups = [];
140
221
  let destroyed = false;
141
222
  let mounted = false;
142
- let readOnly = Boolean(options.readOnly);
223
+ // The viewer is read-only unless the host says otherwise.
224
+ let readOnly = Boolean(options.readOnly ?? options.layout === "viewer");
143
225
  let lastError;
226
+ const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
144
227
 
145
228
  // ---- events -----------------------------------------------------------------------------------------------------------------
146
229
  function off(name, listener) {
@@ -153,6 +236,11 @@ export function createOpfEditor(element, options = {}) {
153
236
  return () => off(name, listener);
154
237
  }
155
238
  function emit(name, detail = {}) {
239
+ // A copy or a download of the source (or a JSON, YAML or Markdown export) is the person's work kept somewhere: no longer dirty.
240
+ if (name === "kept") recordBaseline();
241
+ // The host's actions read state the editor does not change through the document: whether it is saved, expanded, the slide on show.
242
+ // `ready` too: a host's `enabled: () => editor.dirty` reads its own `editor` variable, assigned only once `createOpfEditor` has returned.
243
+ if (name === "dirty" || name === "expand" || name === "slide" || name === "ready") actionsPart?.refresh();
156
244
  for (const listener of [...(listeners.get(name) ?? [])]) {
157
245
  try {
158
246
  listener(detail);
@@ -183,11 +271,26 @@ export function createOpfEditor(element, options = {}) {
183
271
 
184
272
  // ---- layout, parts and slots ------------------------------------------------------------------------------------------------
185
273
  const given = options.slots ?? {};
186
- const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) ? "slots" : (options.layout ?? "full");
187
- if (!["full", "canvas", "source", "slots", "studio", "playground"].includes(layout)) throw new RangeError('layout must be "full", "canvas", "source", "studio" or "playground", or pass `slots`.');
274
+ // CE-28: slots for the toolbar's items only (mode B: the title in the host's own header) keep the built layout.
275
+ const chromeOnly = Boolean(options.slots) && Object.keys(options.slots).every((name) => CHROME_SLOTS.has(name));
276
+ const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) && !chromeOnly ? "slots" : (options.layout ?? "full");
277
+ if (!LAYOUTS.includes(layout)) throw new RangeError('layout must be "full", "canvas", "embed", "viewer", "source", "studio" or "playground", or pass `slots`.');
188
278
  const studioLayout = STUDIO_LAYOUTS.has(layout);
189
- const parts = resolveParts(options, layout, given);
279
+ const asked = toolbarSpec(options, layout);
280
+ const icons = normalizeIcons(options.icons, ICON_NAMES, "createOpfEditor icons");
281
+ // The icons one part takes, or nothing (the part keeps its own) when the host gave none of its names.
282
+ const iconsFor = (part) => {
283
+ const picked = Object.fromEntries(PART_ICONS[part].filter((name) => name in icons).map((name) => [name, icons[name]]));
284
+ return Object.keys(picked).length ? { icons: picked } : {};
285
+ };
286
+ const parts = resolveParts(options, layout, given, asked);
190
287
  const views = VIEWS.filter((view) => view === "slide" || parts[view]);
288
+ const toolbarItems = resolveToolbar(options, layout, parts, views, given, asked);
289
+ // CE-28: an embedded editor or a viewer keeps its keys (the arrows, F5, Ctrl/Cmd Z) to itself; the other layouts own the page.
290
+ const scopedKeys = SCOPED_LAYOUTS.has(layout);
291
+ // CE-28 wave 2: slide management on the thumbnail strip, in the layouts the editor arranges for editing (a host's own arrangement opts in).
292
+ const slideActions = options.slideActions ?? (layout === "full" || layout === "embed");
293
+ const keyTarget = scopedKeys ? element : doc;
191
294
  if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
192
295
  if (options.persistence && parts.persistence && (typeof options.persistence !== "object" || typeof options.persistence.key !== "string" || !options.persistence.key)) {
193
296
  throw new TypeError("persistence needs a `key` that names the document.");
@@ -197,11 +300,12 @@ export function createOpfEditor(element, options = {}) {
197
300
  root.className = "opf-workspace opf-ui";
198
301
  // 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
199
302
  // editor only adds one empty node for its overlays.
200
- if (options.slots) element.append(root);
303
+ if (layout === "slots") element.append(root);
201
304
  else element.replaceChildren(root);
202
305
  const themeController = createThemeController(element, options.theme);
203
306
  let slots = { ...given };
204
307
  let controls = {};
308
+ let toolbar;
205
309
  let studioShell = null;
206
310
  let overlay = root;
207
311
  // CE-20: the Studio builds its own shell; a host's slots add parts to it (`topbarEnd`, `overlay`). Its code is in the slide chunk, so the Studio
@@ -216,9 +320,22 @@ export function createOpfEditor(element, options = {}) {
216
320
  }
217
321
  if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
218
322
  else if (!studioLayout) {
219
- const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
323
+ const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels, toolbar: toolbarItems, createToolbar, density: densityFor(options.density), toolbarHost: given.toolbar, onError: (error) => report(error), onCollapse: (detail) => onToolbarCollapse(detail), ...iconsFor("toolbar") });
220
324
  slots = { ...built.slots, ...given };
221
325
  controls = built.controls;
326
+ toolbar = built.toolbar;
327
+ }
328
+ // CE-28: a host that arranges the page itself and gives `slots.toolbar` gets the toolbar there.
329
+ if (layout === "slots" && given.toolbar && toolbarItems.length) {
330
+ const built = buildToolbar(given.toolbar, { items: toolbarItems, views, labels: options.labels, density: densityFor(options.density), createToolbar, onError: (error) => report(error), onCollapse: (detail) => onToolbarCollapse(detail), ...iconsFor("toolbar") });
331
+ slots = { ...built.slots, ...given };
332
+ Object.assign(controls, built.controls);
333
+ toolbar = built.toolbar;
334
+ }
335
+ // Mode B: the history and the view switch in the host's own elements.
336
+ if (!studioLayout) {
337
+ if (given.history && parts.history) Object.assign(controls, buildItemControls("history", given.history, { labels: options.labels }));
338
+ if (given.views && views.length > 1) Object.assign(controls, buildItemControls("views", given.views, { views, labels: options.labels }));
222
339
  }
223
340
  overlay = slots.overlay ?? root;
224
341
  // The slide chunk is loaded before the parts mount, unless only the source pane (with its autosave) is mounted. The Studio always needs it.
@@ -229,6 +346,18 @@ export function createOpfEditor(element, options = {}) {
229
346
  let canvas;
230
347
  let pane;
231
348
  let slideNav;
349
+ let counterNav;
350
+ let deckTitle;
351
+ let expandCtl;
352
+ let expandButton;
353
+ let deckName;
354
+ let designMenu;
355
+ let reviewPill;
356
+ let helpButton;
357
+ let actionsPart;
358
+ let hostActions = Array.isArray(options.actions) ? [...options.actions] : [];
359
+ let reviewCounts = null;
360
+ let exportCollapsed = false;
232
361
  let strip;
233
362
  let slideManager;
234
363
  let sorter;
@@ -266,6 +395,31 @@ export function createOpfEditor(element, options = {}) {
266
395
  let statusText = "";
267
396
  let density = "comfortable";
268
397
  let exportMode = "menu";
398
+ // The parts are bound once the slide chunk has arrived and the fonts are at hand; `partsReady` is true from then on. A call on the handle made
399
+ // before that (a React host's effects run right after `createOpfEditor` returns) must neither reach a factory that is not bound yet nor be lost:
400
+ // a setting is remembered in `requested` and read when the parts mount; a call that acts on a mounted part waits in `queued` and runs, in order,
401
+ // once the editor is mounted (a later call with the same key replaces the earlier one).
402
+ let partsReady = false;
403
+ const requested = {};
404
+ const queued = [];
405
+ const waiting = () => !mounted && !destroyed;
406
+ const queue = (key, task) => {
407
+ const at = queued.findIndex((entry) => entry.key === key);
408
+ if (at >= 0) queued.splice(at, 1);
409
+ const entry = { key, task };
410
+ queued.push(entry);
411
+ return entry;
412
+ };
413
+ function flushQueued() {
414
+ for (const { task } of queued.splice(0)) {
415
+ if (destroyed) return;
416
+ try {
417
+ task();
418
+ } catch (error) {
419
+ report(error);
420
+ }
421
+ }
422
+ }
269
423
  let frameSize = { width: 0, height: 0 };
270
424
  const draftMemory = createSourceMemory();
271
425
  const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
@@ -276,7 +430,6 @@ export function createOpfEditor(element, options = {}) {
276
430
  if (slots.status) slots.status.textContent = message;
277
431
  emit("status", { message });
278
432
  };
279
- const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
280
433
  const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
281
434
 
282
435
  // The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
@@ -288,6 +441,12 @@ export function createOpfEditor(element, options = {}) {
288
441
  }
289
442
  const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
290
443
 
444
+ // The one slide index on every part that shows it: the slide row's navigator, the toolbar's counter and the strip.
445
+ function showIndex(index) {
446
+ slideNav?.setSlide(index);
447
+ counterNav?.setSlide(index);
448
+ strip?.setSlide(index);
449
+ }
291
450
  // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
292
451
  function renderViews(deck) {
293
452
  // The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
@@ -323,8 +482,7 @@ export function createOpfEditor(element, options = {}) {
323
482
  const deck = session.snapshot().presentation;
324
483
  slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
325
484
  renderViews(deck);
326
- slideNav?.setSlide(slideIndex);
327
- strip?.setSlide(slideIndex);
485
+ showIndex(slideIndex);
328
486
  fonts?.registry?.clearSubstitutions?.();
329
487
  if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
330
488
  emit("sync", syncDetail(deck));
@@ -378,8 +536,7 @@ export function createOpfEditor(element, options = {}) {
378
536
  if (canvas.setSlide(index)) {
379
537
  const changed = index !== slideIndex;
380
538
  slideIndex = index;
381
- slideNav?.setSlide(index);
382
- strip?.setSlide(index);
539
+ showIndex(index);
383
540
  if (changed) emit("slide", { index, count: slides.length });
384
541
  refresh();
385
542
  return true;
@@ -387,11 +544,15 @@ export function createOpfEditor(element, options = {}) {
387
544
  } catch (error) {
388
545
  report(error);
389
546
  }
390
- slideNav?.setSlide(canvas.slideIndex);
391
- strip?.setSlide(canvas.slideIndex);
547
+ showIndex(canvas.slideIndex);
392
548
  return false;
393
549
  }
394
550
  function goTo(index, path) {
551
+ if (waiting()) {
552
+ go(index);
553
+ queue("goTo", () => goTo(index, path));
554
+ return true;
555
+ }
395
556
  if (index !== slideIndex && !go(index)) return false;
396
557
  if (studioCtl) studioCtl.revealPath(path ?? `slides.${index}`);
397
558
  else if (path) canvas?.reveal(path);
@@ -405,6 +566,11 @@ export function createOpfEditor(element, options = {}) {
405
566
  presentButton?.setDisabled(!inSync || slides.length === 0);
406
567
  if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
407
568
  if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
569
+ // The deck name is not renamed over text in the source pane that is not a valid deck (that would replace the person's draft).
570
+ deckTitle?.setDisabled(!inSync);
571
+ designMenu?.setDisabled(!inSync);
572
+ actionsPart?.setDisabled(!inSync);
573
+ actionsPart?.refresh();
408
574
  // 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.
409
575
  const frame = slots.canvas && (slots.slide ?? slots.canvas);
410
576
  // The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
@@ -454,6 +620,7 @@ export function createOpfEditor(element, options = {}) {
454
620
  if (!views.includes(view)) return false;
455
621
  if (!mounted) {
456
622
  activeView = view;
623
+ requested.view = view;
457
624
  return true;
458
625
  }
459
626
  if (view !== "slide" && canvas && !canvas.commit()) return false;
@@ -496,6 +663,15 @@ export function createOpfEditor(element, options = {}) {
496
663
  return true;
497
664
  }
498
665
  function showPanel(name, focus = false) {
666
+ if (!mounted) {
667
+ // The tabs of the shell are known now, the Studio's when it mounts; the panel is shown after the default one.
668
+ if (destroyed || (!studioLayout && !controls.tabs?.[name])) return false;
669
+ queue("panel", () => showPanel(name, focus));
670
+ return true;
671
+ }
672
+ return showPanelNow(name, focus);
673
+ }
674
+ function showPanelNow(name, focus = false) {
499
675
  if (studioCtl) return studioCtl.showTab(name, { focus });
500
676
  const tabs = controls.tabs;
501
677
  if (!tabs?.[name]) return false;
@@ -513,10 +689,15 @@ export function createOpfEditor(element, options = {}) {
513
689
 
514
690
  // ---- the parts --------------------------------------------------------------------------------------------------------------
515
691
  function mountSlideRow() {
692
+ // The row is built from the slide chunk's factories: before they are bound (and after destroy) there is nothing to rebuild; the first
693
+ // mount reads the density and the layout as they are by then.
694
+ if (!partsReady || destroyed) return;
516
695
  unbindKeys?.();
517
696
  slideNav?.destroy();
697
+ counterNav?.destroy();
518
698
  strip?.destroy();
519
699
  slideNav = undefined;
700
+ counterNav = undefined;
520
701
  strip = undefined;
521
702
  // The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
522
703
  if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
@@ -525,12 +706,27 @@ export function createOpfEditor(element, options = {}) {
525
706
  editor: ui,
526
707
  slideIndex,
527
708
  density,
709
+ ...iconsFor("navigator"),
528
710
  ...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
529
711
  onChange: (index) => {
530
712
  go(index);
531
713
  },
532
714
  });
533
- unbindKeys = slideNav.bindKeys(doc);
715
+ unbindKeys = slideNav.bindKeys(keyTarget);
716
+ }
717
+ // CE-28: the toolbar's counter is a second navigator on the same slide index; it binds the arrow keys only when there is no slide row.
718
+ if (parts.canvas && slots.counter) {
719
+ counterNav = createSlideNavigator(slots.counter, {
720
+ editor: ui,
721
+ slideIndex,
722
+ density,
723
+ ...iconsFor("navigator"),
724
+ ...(options.labels?.counter ? { labels: { navigation: options.labels.counter } } : {}),
725
+ onChange: (index) => {
726
+ go(index);
727
+ },
728
+ });
729
+ if (!slideNav) unbindKeys = counterNav.bindKeys(keyTarget);
534
730
  }
535
731
  if (parts.thumbnails && slots.thumbnails) {
536
732
  strip = createThumbnailStrip(slots.thumbnails, {
@@ -538,12 +734,17 @@ export function createOpfEditor(element, options = {}) {
538
734
  fonts,
539
735
  slideIndex,
540
736
  density,
737
+ ...iconsFor("strip"),
541
738
  ...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
542
739
  renderOptions: renderOptions(),
543
740
  onSelect: (index) => {
544
741
  if (index !== slideIndex) go(index);
545
742
  },
546
743
  onError: (error) => report(error instanceof Error ? error : new Error("Thumbnails unavailable")),
744
+ // CE-28 wave 2: "+" and the thumbnail menu (duplicate, delete, hide, move), each one undoable change; "+" offers the deck's layouts.
745
+ ...(slideActions
746
+ ? { actions: true, readOnly, onStatus: say, ...(listSwitchOptions ? { layoutOptions: (presentation) => listSwitchOptions(presentation, "layouts", { catalogs: catalogs() }) } : {}) }
747
+ : {}),
547
748
  });
548
749
  }
549
750
  }
@@ -590,7 +791,7 @@ export function createOpfEditor(element, options = {}) {
590
791
  ...(configured.cursorDelay !== undefined ? { cursorDelay: configured.cursorDelay } : {}),
591
792
  ...(configured.completions !== undefined ? { completions: configured.completions } : {}),
592
793
  ...(configured.catalogs !== undefined ? { catalogs: configured.catalogs } : {}),
593
- ...(configured.name !== undefined ? { name: configured.name } : {}),
794
+ ...((requested.name ?? configured.name) !== undefined ? { name: requested.name ?? configured.name } : {}),
594
795
  ...(text !== undefined ? { source: text } : {}),
595
796
  editor: ui,
596
797
  format: configured.format ?? "json",
@@ -616,6 +817,7 @@ export function createOpfEditor(element, options = {}) {
616
817
  sourceState = { status: made.status, format, text: shown };
617
818
  syncChrome();
618
819
  emit("source", sourceState);
820
+ updateDirty();
619
821
  return made.findings;
620
822
  },
621
823
  onCopy: async (file) => {
@@ -695,6 +897,7 @@ export function createOpfEditor(element, options = {}) {
695
897
  },
696
898
  onSelect: (value) => {
697
899
  selection = { path: value.path, value: value.value };
900
+ actionsPart?.refresh();
698
901
  // The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
699
902
  if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
700
903
  design?.refresh();
@@ -729,8 +932,7 @@ export function createOpfEditor(element, options = {}) {
729
932
  if (event.slideIndex !== slideIndex) {
730
933
  // The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
731
934
  slideIndex = event.slideIndex;
732
- slideNav?.setSlide(slideIndex);
733
- strip?.setSlide(slideIndex);
935
+ showIndex(slideIndex);
734
936
  emit("slide", { index: slideIndex, count: slides.length });
735
937
  refresh();
736
938
  }
@@ -841,12 +1043,18 @@ export function createOpfEditor(element, options = {}) {
841
1043
  tab.textContent = found ? `${name} (${counts.total})` : name;
842
1044
  tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
843
1045
  }
1046
+ reviewCounts = counts;
1047
+ reviewPill?.update(counts);
844
1048
  emit("review", counts);
845
1049
  },
846
1050
  });
847
1051
  }
848
1052
 
849
1053
  function present(from) {
1054
+ if (waiting() && parts.present) {
1055
+ queue("present", () => present(from));
1056
+ return true;
1057
+ }
850
1058
  if (destroyed || !mounted || !presenter || presenter.isRunning() || !inSync || slides.length === 0) return false;
851
1059
  // 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.
852
1060
  if (canvas?.commit() === false) return false;
@@ -890,15 +1098,27 @@ export function createOpfEditor(element, options = {}) {
890
1098
  present();
891
1099
  },
892
1100
  density,
1101
+ ...iconsFor("present"),
893
1102
  });
894
1103
  }
895
1104
  cleanups.push(
896
- bindPresentShortcut(doc, () => {
1105
+ bindPresentShortcut(keyTarget, () => {
897
1106
  present();
898
1107
  }),
899
1108
  );
900
1109
  }
901
1110
  if (parts.find && slots.find) mountFind();
1111
+ // CE-28 wave 2: a `review` item without a Review panel (the embed) runs the review in a hidden element, so the pill has its counts.
1112
+ if (parts.review && !slots.review && slots.reviewPill) {
1113
+ // The panel owns its container's attributes; the hidden wrapper keeps it out of sight.
1114
+ const hidden = doc.createElement("div");
1115
+ hidden.hidden = true;
1116
+ hidden.setAttribute("data-opf-headless", "review");
1117
+ slots.review = doc.createElement("div");
1118
+ slots.review.setAttribute("data-opf-slot", "review");
1119
+ hidden.append(slots.review);
1120
+ root.append(hidden);
1121
+ }
902
1122
  if (parts.review && slots.review) mountReview();
903
1123
  if (parts.design && slots.design) {
904
1124
  const configured = typeof options.design === "object" ? options.design : {};
@@ -916,6 +1136,7 @@ export function createOpfEditor(element, options = {}) {
916
1136
  });
917
1137
  }
918
1138
  if (parts.persistence) mountPersistence();
1139
+ mountChrome();
919
1140
  mountControls();
920
1141
  mountEnvironment();
921
1142
  if (studioShell && canvas) mountStudio();
@@ -932,6 +1153,14 @@ export function createOpfEditor(element, options = {}) {
932
1153
  if (event.type !== "catalogs") {
933
1154
  syncChrome();
934
1155
  emit("change", { document: event.snapshot.presentation, canUndo: event.snapshot.canUndo, canRedo: event.snapshot.canRedo, event });
1156
+ // CE-28: the deck's name changed, from whatever changed it (the title, the source, undo, the host).
1157
+ const name = currentDeckName();
1158
+ if (name !== deckName) {
1159
+ const previous = deckName;
1160
+ deckName = name;
1161
+ emit("deckName", { name, previous });
1162
+ }
1163
+ updateDirty();
935
1164
  }
936
1165
  refresh();
937
1166
  }),
@@ -998,33 +1227,199 @@ export function createOpfEditor(element, options = {}) {
998
1227
  getName: () => pane?.getName(),
999
1228
  setName: (name) => pane?.setName(name),
1000
1229
  layoutStudio: () => studio,
1230
+ // CE-28 wave 2: the rows the Studio's shortcut sheet adds to its commands, the same the help item lists.
1231
+ extraShortcuts: () => chromeShortcuts(),
1001
1232
  mountSlideRow: () => mountSlideRow(),
1002
1233
  });
1003
1234
  }
1004
1235
 
1236
+ // ---- CE-28: the toolbar's parts --------------------------------------------------------------------------------------------------
1237
+ // What an open edit needs before the document changes from outside it: the canvas commits it, text typed in the source is applied.
1238
+ function commitPending() {
1239
+ if (canvas?.commit() === false) return false;
1240
+ pane?.getText();
1241
+ return true;
1242
+ }
1243
+ const chromeLabels = () => options.labels ?? {};
1244
+ function mountChrome() {
1245
+ deckName = currentDeckName();
1246
+ if (slots.title && createDeckTitle) {
1247
+ deckTitle = createDeckTitle(slots.title, {
1248
+ editor: ui,
1249
+ ...(chromeLabels().untitled ? { placeholder: chromeLabels().untitled } : {}),
1250
+ ...(chromeLabels().title ? { label: chromeLabels().title } : {}),
1251
+ readOnly,
1252
+ density,
1253
+ beforeCommit: () => inSync && commitPending(),
1254
+ onError: (error) => report(error),
1255
+ });
1256
+ }
1257
+ // The editor fills the window (or, with `expand.fullscreen`, the screen): the mount element becomes a fixed overlay, in place.
1258
+ if (createExpandController && options.expand !== false) {
1259
+ const labels = { ...(chromeLabels().expand ? { expand: chromeLabels().expand } : {}), ...(chromeLabels().collapse ? { collapse: chromeLabels().collapse } : {}) };
1260
+ const configured = typeof options.expand === "object" && options.expand ? options.expand : {};
1261
+ const target = configured.target ?? element;
1262
+ // The host's own elements for the bar and its items that sit outside what expands stay usable over the overlay.
1263
+ const keep = ["toolbar", ...CHROME_SLOTS].map((name) => given[name]).filter((node, at, all) => node && all.indexOf(node) === at && !target.contains(node));
1264
+ expandCtl = createExpandController(target, {
1265
+ labels,
1266
+ onStatus: say,
1267
+ ...(keep.length ? { keep } : {}),
1268
+ ...(configured.fullscreen !== undefined ? { fullscreen: configured.fullscreen } : {}),
1269
+ onChange: ({ expanded }) => {
1270
+ if (destroyed) return;
1271
+ root.toggleAttribute("data-expanded", expanded);
1272
+ emit("expand", { expanded });
1273
+ },
1274
+ });
1275
+ if (slots.expand && createExpandButton) expandButton = createExpandButton(slots.expand, { controller: expandCtl, density, labels, ...iconsFor("expand") });
1276
+ }
1277
+ mountChromeParts();
1278
+ if (!toolbar) return;
1279
+ // What each item offers in the more menu once it has collapsed into it, read when the menu opens (so always the current state).
1280
+ const text = { undo: "Undo", redo: "Redo", find: "Find", ...chromeLabels() };
1281
+ const register = (name, part) => cleanups.push(toolbar.mount(name, part));
1282
+ register("history", {
1283
+ menuItems: () => [
1284
+ { id: "undo", label: text.undo, disabled: Boolean(controls.undo?.disabled), run: () => undo() },
1285
+ { id: "redo", label: text.redo, disabled: Boolean(controls.redo?.disabled), run: () => redo() },
1286
+ ],
1287
+ });
1288
+ register("find", { menuItems: () => (find ? [{ id: "find", label: `${text.find}…`, run: () => find.openPanel({}) }] : []) });
1289
+ register("views", { menuItems: () => views.map((view) => ({ id: `view:${view}`, label: viewLabel(view), pressed: activeView === view, run: () => setView(view, { focus: true }) })) });
1290
+ register("counter", {
1291
+ menuItems: () => [
1292
+ { id: "previous", label: "Previous slide", disabled: slideIndex <= 0, run: () => go(slideIndex - 1) },
1293
+ { id: "next", label: "Next slide", disabled: slideIndex >= slides.length - 1, run: () => go(slideIndex + 1) },
1294
+ ],
1295
+ });
1296
+ register("present", { menuItems: () => (presenter ? [{ id: "present", label: "Present", disabled: !inSync || slides.length === 0, run: () => present() }] : []) });
1297
+ // Export: one entry that opens the Export menu as its sheet (while Export is in ⋯ the menu is a sheet, so its progress and result stay on screen).
1298
+ register("export", { menuItems: () => (exportMenu ? [{ id: "export", label: text.exportItem ?? "Export…", run: () => openExportFromMore() }] : []) });
1299
+ if (deckTitle) register("title", deckTitle);
1300
+ if (expandButton) register("expand", expandButton);
1301
+ if (designMenu) register("design", designMenu);
1302
+ if (reviewPill) register("review", reviewPill);
1303
+ if (helpButton) register("help", helpButton);
1304
+ if (actionsPart) register("actions", actionsPart);
1305
+ }
1306
+ // The Export sheet opened from ⋯ gives the focus back to ⋯ when it closes (its own trigger is out of sight there), or, when ⋯ is gone (the
1307
+ // bar widened meanwhile), to the Export button back in the bar, else to the first control of the bar: never to the body.
1308
+ function openExportFromMore() {
1309
+ if (!exportMenu) return;
1310
+ const shown = (node) => Boolean(node?.isConnected && node.getClientRects().length && win.getComputedStyle(node).visibility !== "hidden");
1311
+ const back = () => {
1312
+ const more = toolbar?.element.querySelector(".opf-tb-more-button");
1313
+ const candidates = [more, exportMenu?.trigger, ...(toolbar ? toolbar.element.querySelectorAll("button, input") : [])];
1314
+ candidates.find((node) => shown(node) && !node.disabled)?.focus();
1315
+ };
1316
+ const watch = new win.MutationObserver(() => {
1317
+ if (exportMenu?.trigger.getAttribute("aria-expanded") === "true") return;
1318
+ watch.disconnect();
1319
+ if (destroyed) return;
1320
+ const active = doc.activeElement;
1321
+ if (!active || active === doc.body || !shown(active)) back();
1322
+ });
1323
+ watch.observe(exportMenu.trigger, { attributes: true, attributeFilter: ["aria-expanded"] });
1324
+ cleanups.push(() => watch.disconnect());
1325
+ exportMenu.open("first");
1326
+ }
1327
+ // While Export is collapsed into ⋯ its menu opens as the bottom sheet (fixed on screen); back in the bar it is the editor's mode again.
1328
+ function onToolbarCollapse({ collapsed }) {
1329
+ exportCollapsed = collapsed.includes("export");
1330
+ exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
1331
+ }
1332
+
1333
+ // CE-28 wave 2: the Design quick menu, the review pill, the help sheet and the host's actions.
1334
+ function mountChromeParts() {
1335
+ const labels = chromeLabels();
1336
+ const designPanel = () => Boolean(design || controls.tabs?.design || studioShell?.tabs?.design);
1337
+ if (slots.designMenu && createDesignMenu) {
1338
+ designMenu = createDesignMenu(slots.designMenu, {
1339
+ editor: ui,
1340
+ catalogs,
1341
+ getSlideIndex: () => slideIndex,
1342
+ density,
1343
+ readOnly,
1344
+ ...iconsFor("designMenu"),
1345
+ ...(labels.designMenu ? { labels: labels.designMenu } : {}),
1346
+ // "More design options…": the workspace's Design panel when there is one, else the host's (`designMenu.onOpenPanel`), else no row.
1347
+ ...(designPanel()
1348
+ ? { onOpenPanel: () => showPanel("design", true) }
1349
+ : typeof options.designMenu?.onOpenPanel === "function"
1350
+ ? { onOpenPanel: () => options.designMenu.onOpenPanel() }
1351
+ : {}),
1352
+ onStatus: say,
1353
+ });
1354
+ }
1355
+ if (slots.reviewPill && createReviewPill) {
1356
+ reviewPill = createReviewPill(slots.reviewPill, {
1357
+ density,
1358
+ ...iconsFor("reviewPill"),
1359
+ ...(labels.reviewPill ? { labels: labels.reviewPill } : {}),
1360
+ // The Review panel when there is one; without it (the embed) the host hears `review-open` and shows the findings its own way.
1361
+ onOpen: () => {
1362
+ if (controls.tabs?.review || studioShell?.tabs?.review) showPanel("review", true);
1363
+ else emit("review-open", { counts: reviewCounts, findings: review?.findings ?? [] });
1364
+ },
1365
+ });
1366
+ reviewPill.update(reviewCounts);
1367
+ }
1368
+ if (slots.help && createHelpButton) {
1369
+ helpButton = createHelpButton(slots.help, {
1370
+ overlay,
1371
+ getShortcuts: () => shortcuts(),
1372
+ density,
1373
+ ...iconsFor("help"),
1374
+ ...(labels.help ? { labels: labels.help } : {}),
1375
+ // "?" opens it from anywhere in a page the editor owns; an embedded editor or a viewer only from inside it (the button still works).
1376
+ bindKey: !scopedKeys,
1377
+ });
1378
+ }
1379
+ if (slots.actions && createToolbarActions) {
1380
+ actionsPart = createToolbarActions(slots.actions, { actions: hostActions, density, ...(labels.actions ? { labels: labels.actions } : {}) });
1381
+ slots.actions.hidden = hostActions.length === 0;
1382
+ syncActionsPriority();
1383
+ }
1384
+ }
1385
+
1005
1386
  // The shell's own controls: history, views, tabs.
1006
1387
  function mountControls() {
1007
1388
  controls.undo?.addEventListener("click", () => undo());
1008
1389
  controls.redo?.addEventListener("click", () => redo());
1009
1390
  for (const [name, button] of Object.entries(controls.views ?? {})) button.addEventListener("click", () => setView(name, { focus: true }));
1010
1391
  for (const [name, tab] of Object.entries(controls.tabs ?? {})) {
1011
- tab.addEventListener("click", () => showPanel(name));
1392
+ tab.addEventListener("click", () => showPanelNow(name));
1012
1393
  tab.addEventListener("keydown", (event) => {
1013
1394
  if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
1014
1395
  event.preventDefault();
1015
1396
  const names = Object.keys(controls.tabs);
1016
1397
  const at = names.indexOf(name);
1017
1398
  const next = event.key === "Home" ? 0 : event.key === "End" ? names.length - 1 : (at + (event.key === "ArrowRight" ? 1 : -1) + names.length) % names.length;
1018
- showPanel(names[next], true);
1399
+ showPanelNow(names[next], true);
1019
1400
  });
1020
1401
  }
1021
- if (controls.tabs) showPanel(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
1022
- if (options.shortcuts !== false) {
1402
+ if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
1403
+ // CE-28 wave 2: Ctrl/Cmd+S inside the editor asks the host to save (`save`); the browser's own Save page is kept unless a host listens.
1404
+ if (options.shortcuts !== false && layout !== "viewer") {
1405
+ const onSave = (event) => {
1406
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s" || event.defaultPrevented) return;
1407
+ // As for Undo: the editor's own element, or the page's body (nothing focused) where the editor owns the page; embed and viewer stay scoped.
1408
+ if (!listeners.get("save")?.size) return;
1409
+ if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
1410
+ event.preventDefault();
1411
+ requestSave();
1412
+ };
1413
+ doc.addEventListener("keydown", onSave);
1414
+ cleanups.push(() => doc.removeEventListener("keydown", onSave));
1415
+ }
1416
+ // The viewer edits nothing, so it has no Undo keys.
1417
+ if (options.shortcuts !== false && layout !== "viewer") {
1023
1418
  // 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).
1024
1419
  const onKey = (event) => {
1025
1420
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
1026
1421
  if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
1027
- if (!element.contains(event.target) && event.target !== doc.body) return;
1422
+ if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
1028
1423
  event.preventDefault();
1029
1424
  if (event.shiftKey) redo();
1030
1425
  else undo();
@@ -1055,8 +1450,8 @@ export function createOpfEditor(element, options = {}) {
1055
1450
  media.addEventListener("change", change);
1056
1451
  cleanups.push(() => media.removeEventListener("change", change));
1057
1452
  };
1058
- follow(options.density, "(pointer: coarse)", setDensity);
1059
- follow(options.exportMode, "(max-width: 900px)", setExportMode);
1453
+ follow("density" in requested ? requested.density : options.density, "(pointer: coarse)", setDensity);
1454
+ follow("exportMode" in requested ? requested.exportMode : options.exportMode, "(max-width: 900px)", setExportMode);
1060
1455
  }
1061
1456
 
1062
1457
  // ---- the handle -------------------------------------------------------------------------------------------------------------
@@ -1087,11 +1482,23 @@ export function createOpfEditor(element, options = {}) {
1087
1482
  function applyReadOnly() {
1088
1483
  pane?.setReadOnly(readOnly);
1089
1484
  studio?.setReadOnly(readOnly);
1485
+ deckTitle?.setReadOnly(readOnly);
1486
+ designMenu?.setReadOnly(readOnly);
1487
+ strip?.setReadOnly?.(readOnly);
1090
1488
  root.toggleAttribute("data-readonly", readOnly);
1091
1489
  syncChrome();
1092
1490
  studioCtl?.refresh();
1093
1491
  }
1094
1492
  function setDensity(setting) {
1493
+ if (destroyed) return;
1494
+ // Before the parts mount the setting is kept; the mount reads it instead of the `density` option.
1495
+ if (!partsReady) {
1496
+ requested.density = setting;
1497
+ density = densityFor(setting);
1498
+ root.setAttribute("data-density", density);
1499
+ toolbar?.setDensity(density);
1500
+ return;
1501
+ }
1095
1502
  const next = densityFor(setting);
1096
1503
  if (next === density) return;
1097
1504
  density = next;
@@ -1100,11 +1507,20 @@ export function createOpfEditor(element, options = {}) {
1100
1507
  exportMenu?.setDensity(density);
1101
1508
  presentButton?.setDensity(density);
1102
1509
  studio?.setDensity(density);
1510
+ deckTitle?.setDensity(density);
1511
+ expandButton?.setDensity(density);
1512
+ designMenu?.setDensity(density);
1513
+ reviewPill?.setDensity(density);
1514
+ helpButton?.setDensity(density);
1515
+ actionsPart?.setDensity(density);
1516
+ toolbar?.setDensity(density);
1103
1517
  mountSlideRow();
1104
1518
  }
1105
1519
  function setExportMode(setting) {
1520
+ if (destroyed) return;
1521
+ if (!partsReady) requested.exportMode = setting;
1106
1522
  exportMode = exportModeFor(setting);
1107
- exportMenu?.setMode(exportMode);
1523
+ exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
1108
1524
  }
1109
1525
  function setDocument(document, openOptions = {}) {
1110
1526
  if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
@@ -1122,7 +1538,10 @@ export function createOpfEditor(element, options = {}) {
1122
1538
  openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
1123
1539
  } else session.replaceDocument(document, { source: "load" });
1124
1540
  // A document the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1125
- if (openOptions.rebase !== false) persistence?.rebase();
1541
+ if (openOptions.rebase !== false) {
1542
+ persistence?.rebase();
1543
+ recordBaseline();
1544
+ }
1126
1545
  return true;
1127
1546
  } catch (error) {
1128
1547
  if (error?.code !== "invalid-opf-edit") throw error;
@@ -1147,10 +1566,160 @@ export function createOpfEditor(element, options = {}) {
1147
1566
  ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
1148
1567
  });
1149
1568
  // Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1150
- if (applied && openOptions.rebase !== false) persistence?.rebase();
1569
+ if (applied && openOptions.rebase !== false) {
1570
+ persistence?.rebase();
1571
+ recordBaseline();
1572
+ }
1151
1573
  return applied;
1152
1574
  }
1153
1575
 
1576
+ // ---- CE-28: the deck name and the expanded editor ------------------------------------------------------------------------------
1577
+ function currentDeckName() {
1578
+ const name = session.snapshot().presentation?.name;
1579
+ return typeof name === "string" ? name : "";
1580
+ }
1581
+ // ---- CE-28 wave 2: unsaved changes, save, the host's actions and the shortcuts ----------------------------------------------------
1582
+ // The baseline is what was last loaded or saved: the document (as JSON) and the text of the source pane. Dirty is the document differing from
1583
+ // it, or the pane holding a draft that is not a valid deck (it never reaches the document) whose text differs from the baseline's.
1584
+ let baseline = null;
1585
+ let dirty = false;
1586
+ const documentKey = () => JSON.stringify(session.snapshot().presentation);
1587
+ const paneText = () => pane?.editor?.api?.getValue?.() ?? "";
1588
+ function recordBaseline() {
1589
+ if (!partsReady || destroyed) return;
1590
+ baseline = { document: documentKey(), text: paneText() };
1591
+ updateDirty();
1592
+ }
1593
+ function updateDirty() {
1594
+ if (!baseline || destroyed) return;
1595
+ const next = documentKey() !== baseline.document || Boolean(pane && !inSync && paneText() !== baseline.text);
1596
+ if (next === dirty) return;
1597
+ dirty = next;
1598
+ root.toggleAttribute("data-dirty", dirty);
1599
+ emit("dirty", { dirty });
1600
+ }
1601
+ function markSaved() {
1602
+ if (destroyed) return false;
1603
+ if (!mounted) return true;
1604
+ commitPending();
1605
+ recordBaseline();
1606
+ persistence?.markSaved();
1607
+ return true;
1608
+ }
1609
+ /** What a save writes: the document, and the source pane's text as it is (a draft too, so a host can keep it as text). */
1610
+ function saveDetail() {
1611
+ commitPending();
1612
+ const source = handle.readSource();
1613
+ return { document: session.snapshot().presentation, text: source.text, format: source.format, draft: Boolean(source.draft) };
1614
+ }
1615
+ function requestSave() {
1616
+ if (!mounted || destroyed) return false;
1617
+ emit("save", saveDetail());
1618
+ return true;
1619
+ }
1620
+ function setActions(list) {
1621
+ if (!Array.isArray(list)) throw new TypeError("setActions needs an array of actions.");
1622
+ hostActions = [...list];
1623
+ if (actionsPart) {
1624
+ actionsPart.setActions(hostActions);
1625
+ slots.actions.hidden = hostActions.length === 0;
1626
+ syncActionsPriority();
1627
+ toolbar?.update();
1628
+ }
1629
+ }
1630
+ // A primary action (Save) stays in the bar: while one is listed the item's priority is 90, above everything but the title and the counter
1631
+ // (85 < 90 < 95), unless the host gave the item a priority of its own in `toolbar`.
1632
+ const explicitActionsPriority = Array.isArray(options.toolbar) && options.toolbar.some((item) => item && typeof item === "object" && item.id === "actions" && item.priority !== undefined);
1633
+ function syncActionsPriority() {
1634
+ if (!toolbar || explicitActionsPriority) return;
1635
+ toolbar.setItem("actions", { priority: hostActions.some((action) => action?.primary) ? 90 : TOOLBAR_ITEMS.actions.priority });
1636
+ }
1637
+ /** The keyboard shortcuts this editor binds now, for the help sheet (and, in the Studio, beside its command registry). */
1638
+ function shortcuts() {
1639
+ const list = [];
1640
+ const add = (id, label, keys, group) => list.push({ id, label, keys, group });
1641
+ if (studioCtl) {
1642
+ // The Studio's registry: every command with a key, as its own sheet lists them; then what the registry does not hold.
1643
+ for (const entry of studioCtl.commands.list()) if (entry.keys.length) add(entry.id, entry.label, entry.keys, entry.group ?? "Studio");
1644
+ } else {
1645
+ if (parts.history && layout !== "viewer" && options.shortcuts !== false) {
1646
+ add("undo", "Undo", ["Mod-z"], "Edit");
1647
+ add("redo", "Redo", ["Shift-Mod-z"], "Edit");
1648
+ }
1649
+ if (find) {
1650
+ add("find", "Find", ["Mod-f"], "Edit");
1651
+ add("replace", "Find and replace", ["Mod-h"], "Edit");
1652
+ }
1653
+ if (slideNav || counterNav) {
1654
+ add("previous", "Previous slide", ["ArrowLeft"], "Slides");
1655
+ add("next", "Next slide", ["ArrowRight"], "Slides");
1656
+ }
1657
+ if (presenter) add("present", "Present from this slide", ["F5"], "Slides");
1658
+ if (helpButton && !scopedKeys) add("help", "Keyboard shortcuts", ["?"], "Window");
1659
+ }
1660
+ return [...list, ...chromeShortcuts()];
1661
+ }
1662
+ /** The shortcuts of the chrome itself (save, the title, expand): the help sheet lists them, and so does the Studio's own sheet. */
1663
+ function chromeShortcuts() {
1664
+ const list = [];
1665
+ const add = (id, label, keys, group) => list.push({ id, label, keys, group });
1666
+ if (listeners.get("save")?.size && layout !== "viewer" && options.shortcuts !== false) add("save", "Save", ["Mod-s"], "Edit");
1667
+ if (deckTitle && !readOnly) {
1668
+ add("title-commit", "Rename: keep the name typed in the title", ["Enter"], "Presentation name");
1669
+ add("title-cancel", "Rename: put the name back", ["Escape"], "Presentation name");
1670
+ }
1671
+ if (expandCtl) add("expand-exit", "Leave the expanded editor", ["Escape"], "Window");
1672
+ return list;
1673
+ }
1674
+
1675
+ function setDeckName(name) {
1676
+ if (destroyed) return false;
1677
+ if (typeof name !== "string") throw new TypeError("setDeckName needs the name as a string.");
1678
+ if (waiting()) {
1679
+ queue("deckName", () => setDeckName(name));
1680
+ return true;
1681
+ }
1682
+ // Not over text in the source pane that is not a valid deck (that would replace the draft), and an open canvas edit is committed first.
1683
+ if (readOnly || !inSync || !commitPending()) return false;
1684
+ const patch = deckNamePatch(session.snapshot().presentation?.name, name);
1685
+ if (!patch) return true;
1686
+ try {
1687
+ ui.applyPatch(patch, { source: "deck-name", rejectInvalid: true });
1688
+ return true;
1689
+ } catch (error) {
1690
+ report(error);
1691
+ return false;
1692
+ }
1693
+ }
1694
+ function expand(on = true) {
1695
+ if (destroyed || options.expand === false) return false;
1696
+ if (waiting()) {
1697
+ queue("expand", () => expand(on));
1698
+ return true;
1699
+ }
1700
+ return expandCtl ? expandCtl.expand(on) : false;
1701
+ }
1702
+
1703
+ // The Studio's command registry is not there before mount: `register` keeps the command and adds it when the Studio mounts (the function it
1704
+ // returns removes it either way); `list` is empty and `run` finds nothing yet.
1705
+ const preMountCommands = {
1706
+ list: () => [],
1707
+ run: () => false,
1708
+ register(command) {
1709
+ if (!waiting() || !studioLayout) return () => {};
1710
+ let stop;
1711
+ let withdrawn = false;
1712
+ const entry = queue(Symbol("command"), () => {
1713
+ if (!withdrawn) stop = studioCtl?.commands.register(command);
1714
+ });
1715
+ return () => {
1716
+ withdrawn = true;
1717
+ const at = queued.indexOf(entry);
1718
+ if (at >= 0) queued.splice(at, 1);
1719
+ stop?.();
1720
+ };
1721
+ },
1722
+ };
1154
1723
  let fontsRef = { value: undefined };
1155
1724
  let releaseFonts;
1156
1725
  let readyPromise;
@@ -1220,24 +1789,33 @@ export function createOpfEditor(element, options = {}) {
1220
1789
  },
1221
1790
  /** CE-20: switch the Studio between "studio" and "playground" in place (same session, history, slide and selection). */
1222
1791
  setLayout(next) {
1223
- if (!studioCtl) return false;
1224
- return studioCtl.setLayout(next);
1792
+ if (studioCtl) return studioCtl.setLayout(next);
1793
+ // Before mount the switch is queued (true), as the Studio's own check of the name would refuse it later.
1794
+ if (!waiting() || !studioLayout) return false;
1795
+ if (next !== "studio" && next !== "playground") throw new RangeError('setLayout takes "studio" or "playground".');
1796
+ queue("layout", () => studioCtl?.setLayout(next));
1797
+ return true;
1225
1798
  },
1226
1799
  get zoom() {
1227
1800
  return studioCtl?.zoom ?? 1;
1228
1801
  },
1229
1802
  setZoom(value) {
1230
- studioCtl?.setZoom(value);
1803
+ if (studioCtl) studioCtl.setZoom(value);
1804
+ else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.setZoom(value));
1231
1805
  },
1232
1806
  fit(mode) {
1233
- studioCtl?.fit(mode);
1807
+ if (studioCtl) studioCtl.fit(mode);
1808
+ else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.fit(mode));
1234
1809
  },
1235
1810
  openLayout(layoutId, openOptions = {}) {
1236
- return studioCtl ? studioCtl.openLayout(layoutId, openOptions) : false;
1811
+ if (studioCtl) return studioCtl.openLayout(layoutId, openOptions);
1812
+ if (!waiting() || !studioLayout) return false;
1813
+ queue("openLayout", () => studioCtl?.openLayout(layoutId, openOptions));
1814
+ return true;
1237
1815
  },
1238
- /** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. */
1816
+ /** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. Commands registered before mount are added when it mounts. */
1239
1817
  get commands() {
1240
- return studioCtl?.commands ?? { list: () => [], run: () => false, register: () => () => {} };
1818
+ return studioCtl?.commands ?? preMountCommands;
1241
1819
  },
1242
1820
  get view() {
1243
1821
  return activeView;
@@ -1264,6 +1842,40 @@ export function createOpfEditor(element, options = {}) {
1264
1842
  get slots() {
1265
1843
  return slots;
1266
1844
  },
1845
+ /** CE-28: the toolbar the editor built (undefined without one) and the deck title part. */
1846
+ get toolbar() {
1847
+ return toolbar;
1848
+ },
1849
+ get deckTitle() {
1850
+ return deckTitle;
1851
+ },
1852
+ /** CE-28 wave 2: the toolbar's other parts, once mounted (a host that runs its own Review feeds `reviewPill.update(counts)`). */
1853
+ get designMenu() {
1854
+ return designMenu;
1855
+ },
1856
+ get reviewPill() {
1857
+ return reviewPill;
1858
+ },
1859
+ get helpButton() {
1860
+ return helpButton;
1861
+ },
1862
+ get toolbarActions() {
1863
+ return actionsPart;
1864
+ },
1865
+ getDeckName: currentDeckName,
1866
+ /** CE-28 wave 2: whether the document (or a source draft) differs from what was last loaded or saved. */
1867
+ get dirty() {
1868
+ return dirty;
1869
+ },
1870
+ markSaved,
1871
+ save: requestSave,
1872
+ setActions,
1873
+ shortcuts,
1874
+ setDeckName,
1875
+ expand,
1876
+ get expanded() {
1877
+ return Boolean(expandCtl?.expanded);
1878
+ },
1267
1879
  getDocument() {
1268
1880
  return session.snapshot().presentation;
1269
1881
  },
@@ -1273,6 +1885,10 @@ export function createOpfEditor(element, options = {}) {
1273
1885
  setSlide: go,
1274
1886
  goTo,
1275
1887
  select(path, selectOptions = {}) {
1888
+ if (waiting()) {
1889
+ queue("select", () => handle.select(path, selectOptions));
1890
+ return studioLayout ? true : null;
1891
+ }
1276
1892
  if (studioCtl) return studioCtl.selectTargets(path, selectOptions);
1277
1893
  return canvas?.reveal(path, { focus: Boolean(selectOptions.focus) }) ?? null;
1278
1894
  },
@@ -1290,6 +1906,10 @@ export function createOpfEditor(element, options = {}) {
1290
1906
  return canvas?.commit() !== false;
1291
1907
  },
1292
1908
  focus(target) {
1909
+ if (waiting()) {
1910
+ queue("focus", () => handle.focus(target));
1911
+ return true;
1912
+ }
1293
1913
  if ((target ?? (pane ? "source" : "canvas")) === "source") {
1294
1914
  pane?.editor?.api?.focus?.();
1295
1915
  return Boolean(pane);
@@ -1299,6 +1919,10 @@ export function createOpfEditor(element, options = {}) {
1299
1919
  return Boolean(node);
1300
1920
  },
1301
1921
  openFind(openOptions) {
1922
+ if (waiting() && parts.find) {
1923
+ queue("find", () => handle.openFind(openOptions));
1924
+ return true;
1925
+ }
1302
1926
  if (!find) return false;
1303
1927
  find.openPanel(openOptions ?? {});
1304
1928
  return true;
@@ -1311,10 +1935,12 @@ export function createOpfEditor(element, options = {}) {
1311
1935
  return { text, format: pane.getFormat(), draft: draftInvalid };
1312
1936
  },
1313
1937
  setName(name) {
1938
+ if (waiting()) requested.name = name;
1314
1939
  pane?.setName(name);
1315
1940
  },
1316
1941
  getName() {
1317
- return pane?.getName();
1942
+ if (pane) return pane.getName();
1943
+ return waiting() ? (requested.name ?? options.source?.name) : undefined;
1318
1944
  },
1319
1945
  setReadOnly(value) {
1320
1946
  readOnly = Boolean(value);
@@ -1342,6 +1968,15 @@ export function createOpfEditor(element, options = {}) {
1342
1968
  }
1343
1969
  }
1344
1970
  studioCtl?.destroy();
1971
+ expandButton?.destroy();
1972
+ expandCtl?.destroy();
1973
+ deckTitle?.destroy();
1974
+ designMenu?.destroy();
1975
+ reviewPill?.destroy();
1976
+ helpButton?.destroy();
1977
+ actionsPart?.destroy();
1978
+ counterNav?.destroy();
1979
+ toolbar?.destroy();
1345
1980
  unbindKeys?.();
1346
1981
  checking?.disconnect();
1347
1982
  persistence?.destroy();
@@ -1371,16 +2006,49 @@ export function createOpfEditor(element, options = {}) {
1371
2006
  function mount(resolved) {
1372
2007
  if (studioLayout && !studioShell) buildStudio();
1373
2008
  fonts = resolved;
1374
- density = densityFor(options.density);
1375
- exportMode = exportModeFor(options.exportMode);
2009
+ // A density, an export mode or a view the host chose before the parts mounted wins over the option.
2010
+ density = densityFor("density" in requested ? requested.density : options.density);
2011
+ exportMode = exportModeFor("exportMode" in requested ? requested.exportMode : options.exportMode);
1376
2012
  root.setAttribute("data-density", density);
2013
+ partsReady = true;
1377
2014
  mountParts();
1378
2015
  mounted = true;
1379
2016
  if (readOnly) applyReadOnly();
1380
2017
  say(MESSAGES.ready);
1381
- if (options.view && options.view !== "slide") setView(options.view);
2018
+ const wantedView = "view" in requested ? requested.view : options.view;
2019
+ activeView = "slide";
2020
+ if (wantedView && wantedView !== "slide") setView(wantedView);
1382
2021
  else markView("slide");
1383
2022
  syncChrome();
2023
+ // The document as it was opened is the baseline of `dirty`; calls queued before mount (a rename) are changes after it.
2024
+ recordBaseline();
2025
+ if (options.guardUnload) mountUnloadGuard();
2026
+ flushQueued();
2027
+ // The bar's parts are in their slots now: fit the row at once rather than on the next frame.
2028
+ toolbar?.update();
2029
+ }
2030
+ // CE-28 wave 2 (promoted from openpresentation.org's source unload guard): the browser asks before the page closes while there is unsaved work.
2031
+ // `guardUnload: true` asks while `dirty`; a function decides itself (a host with state of its own), given the editor.
2032
+ function mountUnloadGuard() {
2033
+ if (!win?.addEventListener) return;
2034
+ const decide = typeof options.guardUnload === "function" ? () => Boolean(options.guardUnload(handle)) : () => dirty;
2035
+ const onBeforeUnload = (event) => {
2036
+ if (destroyed) return;
2037
+ // Text typed a moment ago is applied first, so its verdict counts.
2038
+ pane?.getText();
2039
+ updateDirty();
2040
+ let warn = false;
2041
+ try {
2042
+ warn = decide();
2043
+ } catch (error) {
2044
+ report(error);
2045
+ }
2046
+ if (!warn) return;
2047
+ event.preventDefault();
2048
+ event.returnValue = "";
2049
+ };
2050
+ win.addEventListener("beforeunload", onBeforeUnload);
2051
+ cleanups.push(() => win.removeEventListener("beforeunload", onBeforeUnload));
1384
2052
  }
1385
2053
  readyPromise = new Promise((resolve, reject) => {
1386
2054
  const start = (arrived) => {