@openpresentation/opf-editor 0.29.1 → 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;
@@ -301,7 +430,6 @@ export function createOpfEditor(element, options = {}) {
301
430
  if (slots.status) slots.status.textContent = message;
302
431
  emit("status", { message });
303
432
  };
304
- const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
305
433
  const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
306
434
 
307
435
  // The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
@@ -313,6 +441,12 @@ export function createOpfEditor(element, options = {}) {
313
441
  }
314
442
  const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
315
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
+ }
316
450
  // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
317
451
  function renderViews(deck) {
318
452
  // The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
@@ -348,8 +482,7 @@ export function createOpfEditor(element, options = {}) {
348
482
  const deck = session.snapshot().presentation;
349
483
  slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
350
484
  renderViews(deck);
351
- slideNav?.setSlide(slideIndex);
352
- strip?.setSlide(slideIndex);
485
+ showIndex(slideIndex);
353
486
  fonts?.registry?.clearSubstitutions?.();
354
487
  if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
355
488
  emit("sync", syncDetail(deck));
@@ -403,8 +536,7 @@ export function createOpfEditor(element, options = {}) {
403
536
  if (canvas.setSlide(index)) {
404
537
  const changed = index !== slideIndex;
405
538
  slideIndex = index;
406
- slideNav?.setSlide(index);
407
- strip?.setSlide(index);
539
+ showIndex(index);
408
540
  if (changed) emit("slide", { index, count: slides.length });
409
541
  refresh();
410
542
  return true;
@@ -412,8 +544,7 @@ export function createOpfEditor(element, options = {}) {
412
544
  } catch (error) {
413
545
  report(error);
414
546
  }
415
- slideNav?.setSlide(canvas.slideIndex);
416
- strip?.setSlide(canvas.slideIndex);
547
+ showIndex(canvas.slideIndex);
417
548
  return false;
418
549
  }
419
550
  function goTo(index, path) {
@@ -435,6 +566,11 @@ export function createOpfEditor(element, options = {}) {
435
566
  presentButton?.setDisabled(!inSync || slides.length === 0);
436
567
  if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
437
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();
438
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.
439
575
  const frame = slots.canvas && (slots.slide ?? slots.canvas);
440
576
  // The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
@@ -558,8 +694,10 @@ export function createOpfEditor(element, options = {}) {
558
694
  if (!partsReady || destroyed) return;
559
695
  unbindKeys?.();
560
696
  slideNav?.destroy();
697
+ counterNav?.destroy();
561
698
  strip?.destroy();
562
699
  slideNav = undefined;
700
+ counterNav = undefined;
563
701
  strip = undefined;
564
702
  // The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
565
703
  if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
@@ -568,12 +706,27 @@ export function createOpfEditor(element, options = {}) {
568
706
  editor: ui,
569
707
  slideIndex,
570
708
  density,
709
+ ...iconsFor("navigator"),
571
710
  ...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
572
711
  onChange: (index) => {
573
712
  go(index);
574
713
  },
575
714
  });
576
- 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);
577
730
  }
578
731
  if (parts.thumbnails && slots.thumbnails) {
579
732
  strip = createThumbnailStrip(slots.thumbnails, {
@@ -581,12 +734,17 @@ export function createOpfEditor(element, options = {}) {
581
734
  fonts,
582
735
  slideIndex,
583
736
  density,
737
+ ...iconsFor("strip"),
584
738
  ...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
585
739
  renderOptions: renderOptions(),
586
740
  onSelect: (index) => {
587
741
  if (index !== slideIndex) go(index);
588
742
  },
589
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
+ : {}),
590
748
  });
591
749
  }
592
750
  }
@@ -659,6 +817,7 @@ export function createOpfEditor(element, options = {}) {
659
817
  sourceState = { status: made.status, format, text: shown };
660
818
  syncChrome();
661
819
  emit("source", sourceState);
820
+ updateDirty();
662
821
  return made.findings;
663
822
  },
664
823
  onCopy: async (file) => {
@@ -738,6 +897,7 @@ export function createOpfEditor(element, options = {}) {
738
897
  },
739
898
  onSelect: (value) => {
740
899
  selection = { path: value.path, value: value.value };
900
+ actionsPart?.refresh();
741
901
  // The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
742
902
  if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
743
903
  design?.refresh();
@@ -772,8 +932,7 @@ export function createOpfEditor(element, options = {}) {
772
932
  if (event.slideIndex !== slideIndex) {
773
933
  // The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
774
934
  slideIndex = event.slideIndex;
775
- slideNav?.setSlide(slideIndex);
776
- strip?.setSlide(slideIndex);
935
+ showIndex(slideIndex);
777
936
  emit("slide", { index: slideIndex, count: slides.length });
778
937
  refresh();
779
938
  }
@@ -884,6 +1043,8 @@ export function createOpfEditor(element, options = {}) {
884
1043
  tab.textContent = found ? `${name} (${counts.total})` : name;
885
1044
  tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
886
1045
  }
1046
+ reviewCounts = counts;
1047
+ reviewPill?.update(counts);
887
1048
  emit("review", counts);
888
1049
  },
889
1050
  });
@@ -937,15 +1098,27 @@ export function createOpfEditor(element, options = {}) {
937
1098
  present();
938
1099
  },
939
1100
  density,
1101
+ ...iconsFor("present"),
940
1102
  });
941
1103
  }
942
1104
  cleanups.push(
943
- bindPresentShortcut(doc, () => {
1105
+ bindPresentShortcut(keyTarget, () => {
944
1106
  present();
945
1107
  }),
946
1108
  );
947
1109
  }
948
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
+ }
949
1122
  if (parts.review && slots.review) mountReview();
950
1123
  if (parts.design && slots.design) {
951
1124
  const configured = typeof options.design === "object" ? options.design : {};
@@ -963,6 +1136,7 @@ export function createOpfEditor(element, options = {}) {
963
1136
  });
964
1137
  }
965
1138
  if (parts.persistence) mountPersistence();
1139
+ mountChrome();
966
1140
  mountControls();
967
1141
  mountEnvironment();
968
1142
  if (studioShell && canvas) mountStudio();
@@ -979,6 +1153,14 @@ export function createOpfEditor(element, options = {}) {
979
1153
  if (event.type !== "catalogs") {
980
1154
  syncChrome();
981
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();
982
1164
  }
983
1165
  refresh();
984
1166
  }),
@@ -1045,10 +1227,162 @@ export function createOpfEditor(element, options = {}) {
1045
1227
  getName: () => pane?.getName(),
1046
1228
  setName: (name) => pane?.setName(name),
1047
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(),
1048
1232
  mountSlideRow: () => mountSlideRow(),
1049
1233
  });
1050
1234
  }
1051
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
+
1052
1386
  // The shell's own controls: history, views, tabs.
1053
1387
  function mountControls() {
1054
1388
  controls.undo?.addEventListener("click", () => undo());
@@ -1066,12 +1400,26 @@ export function createOpfEditor(element, options = {}) {
1066
1400
  });
1067
1401
  }
1068
1402
  if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
1069
- if (options.shortcuts !== false) {
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") {
1070
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).
1071
1419
  const onKey = (event) => {
1072
1420
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
1073
1421
  if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
1074
- if (!element.contains(event.target) && event.target !== doc.body) return;
1422
+ if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
1075
1423
  event.preventDefault();
1076
1424
  if (event.shiftKey) redo();
1077
1425
  else undo();
@@ -1134,6 +1482,9 @@ export function createOpfEditor(element, options = {}) {
1134
1482
  function applyReadOnly() {
1135
1483
  pane?.setReadOnly(readOnly);
1136
1484
  studio?.setReadOnly(readOnly);
1485
+ deckTitle?.setReadOnly(readOnly);
1486
+ designMenu?.setReadOnly(readOnly);
1487
+ strip?.setReadOnly?.(readOnly);
1137
1488
  root.toggleAttribute("data-readonly", readOnly);
1138
1489
  syncChrome();
1139
1490
  studioCtl?.refresh();
@@ -1145,6 +1496,7 @@ export function createOpfEditor(element, options = {}) {
1145
1496
  requested.density = setting;
1146
1497
  density = densityFor(setting);
1147
1498
  root.setAttribute("data-density", density);
1499
+ toolbar?.setDensity(density);
1148
1500
  return;
1149
1501
  }
1150
1502
  const next = densityFor(setting);
@@ -1155,13 +1507,20 @@ export function createOpfEditor(element, options = {}) {
1155
1507
  exportMenu?.setDensity(density);
1156
1508
  presentButton?.setDensity(density);
1157
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);
1158
1517
  mountSlideRow();
1159
1518
  }
1160
1519
  function setExportMode(setting) {
1161
1520
  if (destroyed) return;
1162
1521
  if (!partsReady) requested.exportMode = setting;
1163
1522
  exportMode = exportModeFor(setting);
1164
- exportMenu?.setMode(exportMode);
1523
+ exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
1165
1524
  }
1166
1525
  function setDocument(document, openOptions = {}) {
1167
1526
  if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
@@ -1179,7 +1538,10 @@ export function createOpfEditor(element, options = {}) {
1179
1538
  openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
1180
1539
  } else session.replaceDocument(document, { source: "load" });
1181
1540
  // A document the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1182
- if (openOptions.rebase !== false) persistence?.rebase();
1541
+ if (openOptions.rebase !== false) {
1542
+ persistence?.rebase();
1543
+ recordBaseline();
1544
+ }
1183
1545
  return true;
1184
1546
  } catch (error) {
1185
1547
  if (error?.code !== "invalid-opf-edit") throw error;
@@ -1204,10 +1566,140 @@ export function createOpfEditor(element, options = {}) {
1204
1566
  ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
1205
1567
  });
1206
1568
  // Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1207
- if (applied && openOptions.rebase !== false) persistence?.rebase();
1569
+ if (applied && openOptions.rebase !== false) {
1570
+ persistence?.rebase();
1571
+ recordBaseline();
1572
+ }
1208
1573
  return applied;
1209
1574
  }
1210
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
+
1211
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
1212
1704
  // returns removes it either way); `list` is empty and `run` finds nothing yet.
1213
1705
  const preMountCommands = {
@@ -1350,6 +1842,40 @@ export function createOpfEditor(element, options = {}) {
1350
1842
  get slots() {
1351
1843
  return slots;
1352
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
+ },
1353
1879
  getDocument() {
1354
1880
  return session.snapshot().presentation;
1355
1881
  },
@@ -1442,6 +1968,15 @@ export function createOpfEditor(element, options = {}) {
1442
1968
  }
1443
1969
  }
1444
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();
1445
1980
  unbindKeys?.();
1446
1981
  checking?.disconnect();
1447
1982
  persistence?.destroy();
@@ -1485,7 +2020,35 @@ export function createOpfEditor(element, options = {}) {
1485
2020
  if (wantedView && wantedView !== "slide") setView(wantedView);
1486
2021
  else markView("slide");
1487
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();
1488
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));
1489
2052
  }
1490
2053
  readyPromise = new Promise((resolve, reject) => {
1491
2054
  const start = (arrived) => {