@openpresentation/opf-editor 0.29.1 → 0.31.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,51 @@ 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, Present (CE-30: also where the host arranges the
168
+ // parts with `slots`), and Review (run without its panel where the layout 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.present === undefined && listed("present")) parts.present = true;
172
+ if (panels.review === undefined && listed("review") && !parts.review) {
173
+ parts.review = true;
174
+ // A layout without a Review panel gets none: the review runs headless for the pill.
175
+ parts.reviewHeadless = true;
176
+ }
125
177
  return parts;
126
178
  }
127
179
 
180
+ /**
181
+ * 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
182
+ * 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>`).
183
+ */
184
+ function resolveToolbar(options, layout, parts, views, given, items) {
185
+ if (STUDIO_LAYOUTS.has(layout)) return [];
186
+ const preset = options.toolbar === undefined || options.toolbar === true;
187
+ const available = {
188
+ // 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).
189
+ design: !(preset && parts.design && layout !== "embed"),
190
+ review: parts.review,
191
+ help: true,
192
+ actions: true,
193
+ title: true,
194
+ // 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).
195
+ history: parts.history || (layout === "slots" && options.panels?.history !== false),
196
+ counter: parts.canvas,
197
+ views: views.length > 1,
198
+ find: parts.find,
199
+ autosave: parts.persistence,
200
+ export: parts.export,
201
+ present: parts.present,
202
+ expand: true,
203
+ more: true,
204
+ spacer: true,
205
+ "|": true,
206
+ };
207
+ // A host's own item (`{ id, priority }` with a name of its own) is always built: its slot is the host's to fill (`editor.toolbar.slot(id)`).
208
+ const host = (id) => !Object.hasOwn(TOOLBAR_ITEMS, id);
209
+ return items.filter((item) => !RESERVED_ITEMS.has(item.id) && (host(item.id) || available[item.id]) && !(ITEM_SLOT_NAMES.has(item.id) && given[itemSlot(item.id)]));
210
+ }
211
+
128
212
  /**
129
213
  * 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
214
  * 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 +223,10 @@ export function createOpfEditor(element, options = {}) {
139
223
  const cleanups = [];
140
224
  let destroyed = false;
141
225
  let mounted = false;
142
- let readOnly = Boolean(options.readOnly);
226
+ // The viewer is read-only unless the host says otherwise.
227
+ let readOnly = Boolean(options.readOnly ?? options.layout === "viewer");
143
228
  let lastError;
229
+ const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
144
230
 
145
231
  // ---- events -----------------------------------------------------------------------------------------------------------------
146
232
  function off(name, listener) {
@@ -153,6 +239,11 @@ export function createOpfEditor(element, options = {}) {
153
239
  return () => off(name, listener);
154
240
  }
155
241
  function emit(name, detail = {}) {
242
+ // 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.
243
+ if (name === "kept") recordBaseline();
244
+ // The host's actions read state the editor does not change through the document: whether it is saved, expanded, the slide on show.
245
+ // `ready` too: a host's `enabled: () => editor.dirty` reads its own `editor` variable, assigned only once `createOpfEditor` has returned.
246
+ if (name === "dirty" || name === "expand" || name === "slide" || name === "ready") actionsPart?.refresh();
156
247
  for (const listener of [...(listeners.get(name) ?? [])]) {
157
248
  try {
158
249
  listener(detail);
@@ -183,11 +274,26 @@ export function createOpfEditor(element, options = {}) {
183
274
 
184
275
  // ---- layout, parts and slots ------------------------------------------------------------------------------------------------
185
276
  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`.');
277
+ // CE-28: slots for the toolbar's items only (mode B: the title in the host's own header) keep the built layout.
278
+ const chromeOnly = Boolean(options.slots) && Object.keys(options.slots).every((name) => CHROME_SLOTS.has(name));
279
+ const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) && !chromeOnly ? "slots" : (options.layout ?? "full");
280
+ if (!LAYOUTS.includes(layout)) throw new RangeError('layout must be "full", "canvas", "embed", "viewer", "source", "studio" or "playground", or pass `slots`.');
188
281
  const studioLayout = STUDIO_LAYOUTS.has(layout);
189
- const parts = resolveParts(options, layout, given);
282
+ const asked = toolbarSpec(options, layout);
283
+ const icons = normalizeIcons(options.icons, ICON_NAMES, "createOpfEditor icons");
284
+ // The icons one part takes, or nothing (the part keeps its own) when the host gave none of its names.
285
+ const iconsFor = (part) => {
286
+ const picked = Object.fromEntries(PART_ICONS[part].filter((name) => name in icons).map((name) => [name, icons[name]]));
287
+ return Object.keys(picked).length ? { icons: picked } : {};
288
+ };
289
+ const parts = resolveParts(options, layout, given, asked);
190
290
  const views = VIEWS.filter((view) => view === "slide" || parts[view]);
291
+ const toolbarItems = resolveToolbar(options, layout, parts, views, given, asked);
292
+ // 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.
293
+ const scopedKeys = SCOPED_LAYOUTS.has(layout);
294
+ // 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).
295
+ const slideActions = options.slideActions ?? (layout === "full" || layout === "embed");
296
+ const keyTarget = scopedKeys ? element : doc;
191
297
  if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
192
298
  if (options.persistence && parts.persistence && (typeof options.persistence !== "object" || typeof options.persistence.key !== "string" || !options.persistence.key)) {
193
299
  throw new TypeError("persistence needs a `key` that names the document.");
@@ -197,11 +303,12 @@ export function createOpfEditor(element, options = {}) {
197
303
  root.className = "opf-workspace opf-ui";
198
304
  // 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
305
  // editor only adds one empty node for its overlays.
200
- if (options.slots) element.append(root);
306
+ if (layout === "slots") element.append(root);
201
307
  else element.replaceChildren(root);
202
308
  const themeController = createThemeController(element, options.theme);
203
309
  let slots = { ...given };
204
310
  let controls = {};
311
+ let toolbar;
205
312
  let studioShell = null;
206
313
  let overlay = root;
207
314
  // 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 +323,22 @@ export function createOpfEditor(element, options = {}) {
216
323
  }
217
324
  if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
218
325
  else if (!studioLayout) {
219
- const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
326
+ 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
327
  slots = { ...built.slots, ...given };
221
328
  controls = built.controls;
329
+ toolbar = built.toolbar;
330
+ }
331
+ // CE-28: a host that arranges the page itself and gives `slots.toolbar` gets the toolbar there.
332
+ if (layout === "slots" && given.toolbar && toolbarItems.length) {
333
+ 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") });
334
+ slots = { ...built.slots, ...given };
335
+ Object.assign(controls, built.controls);
336
+ toolbar = built.toolbar;
337
+ }
338
+ // Mode B: the history and the view switch in the host's own elements.
339
+ if (!studioLayout) {
340
+ if (given.history && parts.history) Object.assign(controls, buildItemControls("history", given.history, { labels: options.labels }));
341
+ if (given.views && views.length > 1) Object.assign(controls, buildItemControls("views", given.views, { views, labels: options.labels }));
222
342
  }
223
343
  overlay = slots.overlay ?? root;
224
344
  // 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 +349,18 @@ export function createOpfEditor(element, options = {}) {
229
349
  let canvas;
230
350
  let pane;
231
351
  let slideNav;
352
+ let counterNav;
353
+ let deckTitle;
354
+ let expandCtl;
355
+ let expandButton;
356
+ let deckName;
357
+ let designMenu;
358
+ let reviewPill;
359
+ let helpButton;
360
+ let actionsPart;
361
+ let hostActions = Array.isArray(options.actions) ? [...options.actions] : [];
362
+ let reviewCounts = null;
363
+ let exportCollapsed = false;
232
364
  let strip;
233
365
  let slideManager;
234
366
  let sorter;
@@ -293,7 +425,10 @@ export function createOpfEditor(element, options = {}) {
293
425
  }
294
426
  let frameSize = { width: 0, height: 0 };
295
427
  const draftMemory = createSourceMemory();
296
- const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
428
+ // CE-30: `variables: { focusSource }` is "resolved" with the host's own code editor: a press on text without a field of its own focuses that.
429
+ const variablesObject = options.variables !== null && typeof options.variables === "object" ? options.variables : undefined;
430
+ const variablesMode = options.variables === "resolved" || variablesObject ? "resolved" : "authored";
431
+ if (variablesObject && variablesObject.focusSource !== undefined && typeof variablesObject.focusSource !== "function") throw new TypeError("variables.focusSource must be a function.");
297
432
  const fitMode = studioLayout ? false : options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
298
433
 
299
434
  const say = (message) => {
@@ -301,7 +436,6 @@ export function createOpfEditor(element, options = {}) {
301
436
  if (slots.status) slots.status.textContent = message;
302
437
  emit("status", { message });
303
438
  };
304
- const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
305
439
  const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
306
440
 
307
441
  // The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
@@ -313,6 +447,12 @@ export function createOpfEditor(element, options = {}) {
313
447
  }
314
448
  const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
315
449
 
450
+ // The one slide index on every part that shows it: the slide row's navigator, the toolbar's counter and the strip.
451
+ function showIndex(index) {
452
+ slideNav?.setSlide(index);
453
+ counterNav?.setSlide(index);
454
+ strip?.setSlide(index);
455
+ }
316
456
  // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
317
457
  function renderViews(deck) {
318
458
  // The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
@@ -348,8 +488,7 @@ export function createOpfEditor(element, options = {}) {
348
488
  const deck = session.snapshot().presentation;
349
489
  slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
350
490
  renderViews(deck);
351
- slideNav?.setSlide(slideIndex);
352
- strip?.setSlide(slideIndex);
491
+ showIndex(slideIndex);
353
492
  fonts?.registry?.clearSubstitutions?.();
354
493
  if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
355
494
  emit("sync", syncDetail(deck));
@@ -403,8 +542,7 @@ export function createOpfEditor(element, options = {}) {
403
542
  if (canvas.setSlide(index)) {
404
543
  const changed = index !== slideIndex;
405
544
  slideIndex = index;
406
- slideNav?.setSlide(index);
407
- strip?.setSlide(index);
545
+ showIndex(index);
408
546
  if (changed) emit("slide", { index, count: slides.length });
409
547
  refresh();
410
548
  return true;
@@ -412,8 +550,7 @@ export function createOpfEditor(element, options = {}) {
412
550
  } catch (error) {
413
551
  report(error);
414
552
  }
415
- slideNav?.setSlide(canvas.slideIndex);
416
- strip?.setSlide(canvas.slideIndex);
553
+ showIndex(canvas.slideIndex);
417
554
  return false;
418
555
  }
419
556
  function goTo(index, path) {
@@ -435,6 +572,11 @@ export function createOpfEditor(element, options = {}) {
435
572
  presentButton?.setDisabled(!inSync || slides.length === 0);
436
573
  if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
437
574
  if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
575
+ // 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).
576
+ deckTitle?.setDisabled(!inSync);
577
+ designMenu?.setDisabled(!inSync);
578
+ actionsPart?.setDisabled(!inSync);
579
+ actionsPart?.refresh();
438
580
  // 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
581
  const frame = slots.canvas && (slots.slide ?? slots.canvas);
440
582
  // 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 +700,10 @@ export function createOpfEditor(element, options = {}) {
558
700
  if (!partsReady || destroyed) return;
559
701
  unbindKeys?.();
560
702
  slideNav?.destroy();
703
+ counterNav?.destroy();
561
704
  strip?.destroy();
562
705
  slideNav = undefined;
706
+ counterNav = undefined;
563
707
  strip = undefined;
564
708
  // The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
565
709
  if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
@@ -568,12 +712,27 @@ export function createOpfEditor(element, options = {}) {
568
712
  editor: ui,
569
713
  slideIndex,
570
714
  density,
715
+ ...iconsFor("navigator"),
571
716
  ...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
572
717
  onChange: (index) => {
573
718
  go(index);
574
719
  },
575
720
  });
576
- unbindKeys = slideNav.bindKeys(doc);
721
+ unbindKeys = slideNav.bindKeys(keyTarget);
722
+ }
723
+ // 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.
724
+ if (parts.canvas && slots.counter) {
725
+ counterNav = createSlideNavigator(slots.counter, {
726
+ editor: ui,
727
+ slideIndex,
728
+ density,
729
+ ...iconsFor("navigator"),
730
+ ...(options.labels?.counter ? { labels: { navigation: options.labels.counter } } : {}),
731
+ onChange: (index) => {
732
+ go(index);
733
+ },
734
+ });
735
+ if (!slideNav) unbindKeys = counterNav.bindKeys(keyTarget);
577
736
  }
578
737
  if (parts.thumbnails && slots.thumbnails) {
579
738
  strip = createThumbnailStrip(slots.thumbnails, {
@@ -581,12 +740,17 @@ export function createOpfEditor(element, options = {}) {
581
740
  fonts,
582
741
  slideIndex,
583
742
  density,
743
+ ...iconsFor("strip"),
584
744
  ...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
585
745
  renderOptions: renderOptions(),
586
746
  onSelect: (index) => {
587
747
  if (index !== slideIndex) go(index);
588
748
  },
589
749
  onError: (error) => report(error instanceof Error ? error : new Error("Thumbnails unavailable")),
750
+ // CE-28 wave 2: "+" and the thumbnail menu (duplicate, delete, hide, move), each one undoable change; "+" offers the deck's layouts.
751
+ ...(slideActions
752
+ ? { actions: true, readOnly, onStatus: say, ...(listSwitchOptions ? { layoutOptions: (presentation) => listSwitchOptions(presentation, "layouts", { catalogs: catalogs() }) } : {}) }
753
+ : {}),
590
754
  });
591
755
  }
592
756
  }
@@ -659,6 +823,7 @@ export function createOpfEditor(element, options = {}) {
659
823
  sourceState = { status: made.status, format, text: shown };
660
824
  syncChrome();
661
825
  emit("source", sourceState);
826
+ updateDirty();
662
827
  return made.findings;
663
828
  },
664
829
  onCopy: async (file) => {
@@ -738,6 +903,7 @@ export function createOpfEditor(element, options = {}) {
738
903
  },
739
904
  onSelect: (value) => {
740
905
  selection = { path: value.path, value: value.value };
906
+ actionsPart?.refresh();
741
907
  // The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
742
908
  if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
743
909
  design?.refresh();
@@ -772,8 +938,7 @@ export function createOpfEditor(element, options = {}) {
772
938
  if (event.slideIndex !== slideIndex) {
773
939
  // The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
774
940
  slideIndex = event.slideIndex;
775
- slideNav?.setSlide(slideIndex);
776
- strip?.setSlide(slideIndex);
941
+ showIndex(slideIndex);
777
942
  emit("slide", { index: slideIndex, count: slides.length });
778
943
  refresh();
779
944
  }
@@ -785,7 +950,14 @@ export function createOpfEditor(element, options = {}) {
785
950
  cleanups.push(
786
951
  installCanvasVariableEditing(slots.canvas, ui, canvas, {
787
952
  resolved: () => !authoredTemplate,
788
- focusSource: () => pane?.editor?.api?.focus?.(),
953
+ focusSource: () => {
954
+ if (!variablesObject?.focusSource) return pane?.editor?.api?.focus?.();
955
+ try {
956
+ variablesObject.focusSource();
957
+ } catch (error) {
958
+ report(error);
959
+ }
960
+ },
789
961
  }),
790
962
  );
791
963
  }
@@ -884,6 +1056,8 @@ export function createOpfEditor(element, options = {}) {
884
1056
  tab.textContent = found ? `${name} (${counts.total})` : name;
885
1057
  tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
886
1058
  }
1059
+ reviewCounts = counts;
1060
+ reviewPill?.update(counts);
887
1061
  emit("review", counts);
888
1062
  },
889
1063
  });
@@ -937,15 +1111,27 @@ export function createOpfEditor(element, options = {}) {
937
1111
  present();
938
1112
  },
939
1113
  density,
1114
+ ...iconsFor("present"),
940
1115
  });
941
1116
  }
942
1117
  cleanups.push(
943
- bindPresentShortcut(doc, () => {
1118
+ bindPresentShortcut(keyTarget, () => {
944
1119
  present();
945
1120
  }),
946
1121
  );
947
1122
  }
948
1123
  if (parts.find && slots.find) mountFind();
1124
+ // 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.
1125
+ if (parts.review && !slots.review && slots.reviewPill) {
1126
+ // The panel owns its container's attributes; the hidden wrapper keeps it out of sight.
1127
+ const hidden = doc.createElement("div");
1128
+ hidden.hidden = true;
1129
+ hidden.setAttribute("data-opf-headless", "review");
1130
+ slots.review = doc.createElement("div");
1131
+ slots.review.setAttribute("data-opf-slot", "review");
1132
+ hidden.append(slots.review);
1133
+ root.append(hidden);
1134
+ }
949
1135
  if (parts.review && slots.review) mountReview();
950
1136
  if (parts.design && slots.design) {
951
1137
  const configured = typeof options.design === "object" ? options.design : {};
@@ -963,6 +1149,7 @@ export function createOpfEditor(element, options = {}) {
963
1149
  });
964
1150
  }
965
1151
  if (parts.persistence) mountPersistence();
1152
+ mountChrome();
966
1153
  mountControls();
967
1154
  mountEnvironment();
968
1155
  if (studioShell && canvas) mountStudio();
@@ -979,6 +1166,14 @@ export function createOpfEditor(element, options = {}) {
979
1166
  if (event.type !== "catalogs") {
980
1167
  syncChrome();
981
1168
  emit("change", { document: event.snapshot.presentation, canUndo: event.snapshot.canUndo, canRedo: event.snapshot.canRedo, event });
1169
+ // CE-28: the deck's name changed, from whatever changed it (the title, the source, undo, the host).
1170
+ const name = currentDeckName();
1171
+ if (name !== deckName) {
1172
+ const previous = deckName;
1173
+ deckName = name;
1174
+ emit("deckName", { name, previous });
1175
+ }
1176
+ updateDirty();
982
1177
  }
983
1178
  refresh();
984
1179
  }),
@@ -1045,10 +1240,162 @@ export function createOpfEditor(element, options = {}) {
1045
1240
  getName: () => pane?.getName(),
1046
1241
  setName: (name) => pane?.setName(name),
1047
1242
  layoutStudio: () => studio,
1243
+ // CE-28 wave 2: the rows the Studio's shortcut sheet adds to its commands, the same the help item lists.
1244
+ extraShortcuts: () => chromeShortcuts(),
1048
1245
  mountSlideRow: () => mountSlideRow(),
1049
1246
  });
1050
1247
  }
1051
1248
 
1249
+ // ---- CE-28: the toolbar's parts --------------------------------------------------------------------------------------------------
1250
+ // What an open edit needs before the document changes from outside it: the canvas commits it, text typed in the source is applied.
1251
+ function commitPending() {
1252
+ if (canvas?.commit() === false) return false;
1253
+ pane?.getText();
1254
+ return true;
1255
+ }
1256
+ const chromeLabels = () => options.labels ?? {};
1257
+ function mountChrome() {
1258
+ deckName = currentDeckName();
1259
+ if (slots.title && createDeckTitle) {
1260
+ deckTitle = createDeckTitle(slots.title, {
1261
+ editor: ui,
1262
+ ...(chromeLabels().untitled ? { placeholder: chromeLabels().untitled } : {}),
1263
+ ...(chromeLabels().title ? { label: chromeLabels().title } : {}),
1264
+ readOnly,
1265
+ density,
1266
+ beforeCommit: () => inSync && commitPending(),
1267
+ onError: (error) => report(error),
1268
+ });
1269
+ }
1270
+ // The editor fills the window (or, with `expand.fullscreen`, the screen): the mount element becomes a fixed overlay, in place.
1271
+ if (createExpandController && options.expand !== false) {
1272
+ const labels = { ...(chromeLabels().expand ? { expand: chromeLabels().expand } : {}), ...(chromeLabels().collapse ? { collapse: chromeLabels().collapse } : {}) };
1273
+ const configured = typeof options.expand === "object" && options.expand ? options.expand : {};
1274
+ const target = configured.target ?? element;
1275
+ // The host's own elements for the bar and its items that sit outside what expands stay usable over the overlay.
1276
+ const keep = ["toolbar", ...CHROME_SLOTS].map((name) => given[name]).filter((node, at, all) => node && all.indexOf(node) === at && !target.contains(node));
1277
+ expandCtl = createExpandController(target, {
1278
+ labels,
1279
+ onStatus: say,
1280
+ ...(keep.length ? { keep } : {}),
1281
+ ...(configured.fullscreen !== undefined ? { fullscreen: configured.fullscreen } : {}),
1282
+ onChange: ({ expanded }) => {
1283
+ if (destroyed) return;
1284
+ root.toggleAttribute("data-expanded", expanded);
1285
+ emit("expand", { expanded });
1286
+ },
1287
+ });
1288
+ if (slots.expand && createExpandButton) expandButton = createExpandButton(slots.expand, { controller: expandCtl, density, labels, ...iconsFor("expand") });
1289
+ }
1290
+ mountChromeParts();
1291
+ if (!toolbar) return;
1292
+ // What each item offers in the more menu once it has collapsed into it, read when the menu opens (so always the current state).
1293
+ const text = { undo: "Undo", redo: "Redo", find: "Find", ...chromeLabels() };
1294
+ const register = (name, part) => cleanups.push(toolbar.mount(name, part));
1295
+ register("history", {
1296
+ menuItems: () => [
1297
+ { id: "undo", label: text.undo, disabled: Boolean(controls.undo?.disabled), run: () => undo() },
1298
+ { id: "redo", label: text.redo, disabled: Boolean(controls.redo?.disabled), run: () => redo() },
1299
+ ],
1300
+ });
1301
+ register("find", { menuItems: () => (find ? [{ id: "find", label: `${text.find}…`, run: () => find.openPanel({}) }] : []) });
1302
+ register("views", { menuItems: () => views.map((view) => ({ id: `view:${view}`, label: viewLabel(view), pressed: activeView === view, run: () => setView(view, { focus: true }) })) });
1303
+ register("counter", {
1304
+ menuItems: () => [
1305
+ { id: "previous", label: "Previous slide", disabled: slideIndex <= 0, run: () => go(slideIndex - 1) },
1306
+ { id: "next", label: "Next slide", disabled: slideIndex >= slides.length - 1, run: () => go(slideIndex + 1) },
1307
+ ],
1308
+ });
1309
+ register("present", { menuItems: () => (presenter ? [{ id: "present", label: "Present", disabled: !inSync || slides.length === 0, run: () => present() }] : []) });
1310
+ // 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).
1311
+ register("export", { menuItems: () => (exportMenu ? [{ id: "export", label: text.exportItem ?? "Export…", run: () => openExportFromMore() }] : []) });
1312
+ if (deckTitle) register("title", deckTitle);
1313
+ if (expandButton) register("expand", expandButton);
1314
+ if (designMenu) register("design", designMenu);
1315
+ if (reviewPill) register("review", reviewPill);
1316
+ if (helpButton) register("help", helpButton);
1317
+ if (actionsPart) register("actions", actionsPart);
1318
+ }
1319
+ // 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
1320
+ // bar widened meanwhile), to the Export button back in the bar, else to the first control of the bar: never to the body.
1321
+ function openExportFromMore() {
1322
+ if (!exportMenu) return;
1323
+ const shown = (node) => Boolean(node?.isConnected && node.getClientRects().length && win.getComputedStyle(node).visibility !== "hidden");
1324
+ const back = () => {
1325
+ const more = toolbar?.element.querySelector(".opf-tb-more-button");
1326
+ const candidates = [more, exportMenu?.trigger, ...(toolbar ? toolbar.element.querySelectorAll("button, input") : [])];
1327
+ candidates.find((node) => shown(node) && !node.disabled)?.focus();
1328
+ };
1329
+ const watch = new win.MutationObserver(() => {
1330
+ if (exportMenu?.trigger.getAttribute("aria-expanded") === "true") return;
1331
+ watch.disconnect();
1332
+ if (destroyed) return;
1333
+ const active = doc.activeElement;
1334
+ if (!active || active === doc.body || !shown(active)) back();
1335
+ });
1336
+ watch.observe(exportMenu.trigger, { attributes: true, attributeFilter: ["aria-expanded"] });
1337
+ cleanups.push(() => watch.disconnect());
1338
+ exportMenu.open("first");
1339
+ }
1340
+ // 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.
1341
+ function onToolbarCollapse({ collapsed }) {
1342
+ exportCollapsed = collapsed.includes("export");
1343
+ exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
1344
+ }
1345
+
1346
+ // CE-28 wave 2: the Design quick menu, the review pill, the help sheet and the host's actions.
1347
+ function mountChromeParts() {
1348
+ const labels = chromeLabels();
1349
+ const designPanel = () => Boolean(design || controls.tabs?.design || studioShell?.tabs?.design);
1350
+ if (slots.designMenu && createDesignMenu) {
1351
+ designMenu = createDesignMenu(slots.designMenu, {
1352
+ editor: ui,
1353
+ catalogs,
1354
+ getSlideIndex: () => slideIndex,
1355
+ density,
1356
+ readOnly,
1357
+ ...iconsFor("designMenu"),
1358
+ ...(labels.designMenu ? { labels: labels.designMenu } : {}),
1359
+ // "More design options…": the workspace's Design panel when there is one, else the host's (`designMenu.onOpenPanel`), else no row.
1360
+ ...(designPanel()
1361
+ ? { onOpenPanel: () => showPanel("design", true) }
1362
+ : typeof options.designMenu?.onOpenPanel === "function"
1363
+ ? { onOpenPanel: () => options.designMenu.onOpenPanel() }
1364
+ : {}),
1365
+ onStatus: say,
1366
+ });
1367
+ }
1368
+ if (slots.reviewPill && createReviewPill) {
1369
+ reviewPill = createReviewPill(slots.reviewPill, {
1370
+ density,
1371
+ ...iconsFor("reviewPill"),
1372
+ ...(labels.reviewPill ? { labels: labels.reviewPill } : {}),
1373
+ // The Review panel when there is one; without it (the embed) the host hears `review-open` and shows the findings its own way.
1374
+ onOpen: () => {
1375
+ if (controls.tabs?.review || studioShell?.tabs?.review) showPanel("review", true);
1376
+ else emit("review-open", { counts: reviewCounts, findings: review?.findings ?? [] });
1377
+ },
1378
+ });
1379
+ reviewPill.update(reviewCounts);
1380
+ }
1381
+ if (slots.help && createHelpButton) {
1382
+ helpButton = createHelpButton(slots.help, {
1383
+ overlay,
1384
+ getShortcuts: () => shortcuts(),
1385
+ density,
1386
+ ...iconsFor("help"),
1387
+ ...(labels.help ? { labels: labels.help } : {}),
1388
+ // "?" opens it from anywhere in a page the editor owns; an embedded editor or a viewer only from inside it (the button still works).
1389
+ bindKey: !scopedKeys,
1390
+ });
1391
+ }
1392
+ if (slots.actions && createToolbarActions) {
1393
+ actionsPart = createToolbarActions(slots.actions, { actions: hostActions, density, ...(labels.actions ? { labels: labels.actions } : {}) });
1394
+ slots.actions.hidden = hostActions.length === 0;
1395
+ syncActionsPriority();
1396
+ }
1397
+ }
1398
+
1052
1399
  // The shell's own controls: history, views, tabs.
1053
1400
  function mountControls() {
1054
1401
  controls.undo?.addEventListener("click", () => undo());
@@ -1066,12 +1413,26 @@ export function createOpfEditor(element, options = {}) {
1066
1413
  });
1067
1414
  }
1068
1415
  if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
1069
- if (options.shortcuts !== false) {
1416
+ // 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.
1417
+ if (options.shortcuts !== false && layout !== "viewer") {
1418
+ const onSave = (event) => {
1419
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s" || event.defaultPrevented) return;
1420
+ // 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.
1421
+ if (!listeners.get("save")?.size) return;
1422
+ if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
1423
+ event.preventDefault();
1424
+ requestSave();
1425
+ };
1426
+ doc.addEventListener("keydown", onSave);
1427
+ cleanups.push(() => doc.removeEventListener("keydown", onSave));
1428
+ }
1429
+ // The viewer edits nothing, so it has no Undo keys.
1430
+ if (options.shortcuts !== false && layout !== "viewer") {
1070
1431
  // 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
1432
  const onKey = (event) => {
1072
1433
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
1073
1434
  if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
1074
- if (!element.contains(event.target) && event.target !== doc.body) return;
1435
+ if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
1075
1436
  event.preventDefault();
1076
1437
  if (event.shiftKey) redo();
1077
1438
  else undo();
@@ -1134,6 +1495,9 @@ export function createOpfEditor(element, options = {}) {
1134
1495
  function applyReadOnly() {
1135
1496
  pane?.setReadOnly(readOnly);
1136
1497
  studio?.setReadOnly(readOnly);
1498
+ deckTitle?.setReadOnly(readOnly);
1499
+ designMenu?.setReadOnly(readOnly);
1500
+ strip?.setReadOnly?.(readOnly);
1137
1501
  root.toggleAttribute("data-readonly", readOnly);
1138
1502
  syncChrome();
1139
1503
  studioCtl?.refresh();
@@ -1145,6 +1509,7 @@ export function createOpfEditor(element, options = {}) {
1145
1509
  requested.density = setting;
1146
1510
  density = densityFor(setting);
1147
1511
  root.setAttribute("data-density", density);
1512
+ toolbar?.setDensity(density);
1148
1513
  return;
1149
1514
  }
1150
1515
  const next = densityFor(setting);
@@ -1155,13 +1520,20 @@ export function createOpfEditor(element, options = {}) {
1155
1520
  exportMenu?.setDensity(density);
1156
1521
  presentButton?.setDensity(density);
1157
1522
  studio?.setDensity(density);
1523
+ deckTitle?.setDensity(density);
1524
+ expandButton?.setDensity(density);
1525
+ designMenu?.setDensity(density);
1526
+ reviewPill?.setDensity(density);
1527
+ helpButton?.setDensity(density);
1528
+ actionsPart?.setDensity(density);
1529
+ toolbar?.setDensity(density);
1158
1530
  mountSlideRow();
1159
1531
  }
1160
1532
  function setExportMode(setting) {
1161
1533
  if (destroyed) return;
1162
1534
  if (!partsReady) requested.exportMode = setting;
1163
1535
  exportMode = exportModeFor(setting);
1164
- exportMenu?.setMode(exportMode);
1536
+ exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
1165
1537
  }
1166
1538
  function setDocument(document, openOptions = {}) {
1167
1539
  if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
@@ -1179,7 +1551,10 @@ export function createOpfEditor(element, options = {}) {
1179
1551
  openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
1180
1552
  } else session.replaceDocument(document, { source: "load" });
1181
1553
  // 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();
1554
+ if (openOptions.rebase !== false) {
1555
+ persistence?.rebase();
1556
+ recordBaseline();
1557
+ }
1183
1558
  return true;
1184
1559
  } catch (error) {
1185
1560
  if (error?.code !== "invalid-opf-edit") throw error;
@@ -1204,10 +1579,140 @@ export function createOpfEditor(element, options = {}) {
1204
1579
  ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
1205
1580
  });
1206
1581
  // 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();
1582
+ if (applied && openOptions.rebase !== false) {
1583
+ persistence?.rebase();
1584
+ recordBaseline();
1585
+ }
1208
1586
  return applied;
1209
1587
  }
1210
1588
 
1589
+ // ---- CE-28: the deck name and the expanded editor ------------------------------------------------------------------------------
1590
+ function currentDeckName() {
1591
+ const name = session.snapshot().presentation?.name;
1592
+ return typeof name === "string" ? name : "";
1593
+ }
1594
+ // ---- CE-28 wave 2: unsaved changes, save, the host's actions and the shortcuts ----------------------------------------------------
1595
+ // 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
1596
+ // 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.
1597
+ let baseline = null;
1598
+ let dirty = false;
1599
+ const documentKey = () => JSON.stringify(session.snapshot().presentation);
1600
+ const paneText = () => pane?.editor?.api?.getValue?.() ?? "";
1601
+ function recordBaseline() {
1602
+ if (!partsReady || destroyed) return;
1603
+ baseline = { document: documentKey(), text: paneText() };
1604
+ updateDirty();
1605
+ }
1606
+ function updateDirty() {
1607
+ if (!baseline || destroyed) return;
1608
+ const next = documentKey() !== baseline.document || Boolean(pane && !inSync && paneText() !== baseline.text);
1609
+ if (next === dirty) return;
1610
+ dirty = next;
1611
+ root.toggleAttribute("data-dirty", dirty);
1612
+ emit("dirty", { dirty });
1613
+ }
1614
+ function markSaved() {
1615
+ if (destroyed) return false;
1616
+ if (!mounted) return true;
1617
+ commitPending();
1618
+ recordBaseline();
1619
+ persistence?.markSaved();
1620
+ return true;
1621
+ }
1622
+ /** 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). */
1623
+ function saveDetail() {
1624
+ commitPending();
1625
+ const source = handle.readSource();
1626
+ return { document: session.snapshot().presentation, text: source.text, format: source.format, draft: Boolean(source.draft) };
1627
+ }
1628
+ function requestSave() {
1629
+ if (!mounted || destroyed) return false;
1630
+ emit("save", saveDetail());
1631
+ return true;
1632
+ }
1633
+ function setActions(list) {
1634
+ if (!Array.isArray(list)) throw new TypeError("setActions needs an array of actions.");
1635
+ hostActions = [...list];
1636
+ if (actionsPart) {
1637
+ actionsPart.setActions(hostActions);
1638
+ slots.actions.hidden = hostActions.length === 0;
1639
+ syncActionsPriority();
1640
+ toolbar?.update();
1641
+ }
1642
+ }
1643
+ // 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
1644
+ // (85 < 90 < 95), unless the host gave the item a priority of its own in `toolbar`.
1645
+ const explicitActionsPriority = Array.isArray(options.toolbar) && options.toolbar.some((item) => item && typeof item === "object" && item.id === "actions" && item.priority !== undefined);
1646
+ function syncActionsPriority() {
1647
+ if (!toolbar || explicitActionsPriority) return;
1648
+ toolbar.setItem("actions", { priority: hostActions.some((action) => action?.primary) ? 90 : TOOLBAR_ITEMS.actions.priority });
1649
+ }
1650
+ /** The keyboard shortcuts this editor binds now, for the help sheet (and, in the Studio, beside its command registry). */
1651
+ function shortcuts() {
1652
+ const list = [];
1653
+ const add = (id, label, keys, group) => list.push({ id, label, keys, group });
1654
+ if (studioCtl) {
1655
+ // The Studio's registry: every command with a key, as its own sheet lists them; then what the registry does not hold.
1656
+ for (const entry of studioCtl.commands.list()) if (entry.keys.length) add(entry.id, entry.label, entry.keys, entry.group ?? "Studio");
1657
+ } else {
1658
+ if (parts.history && layout !== "viewer" && options.shortcuts !== false) {
1659
+ add("undo", "Undo", ["Mod-z"], "Edit");
1660
+ add("redo", "Redo", ["Shift-Mod-z"], "Edit");
1661
+ }
1662
+ if (find) {
1663
+ add("find", "Find", ["Mod-f"], "Edit");
1664
+ add("replace", "Find and replace", ["Mod-h"], "Edit");
1665
+ }
1666
+ if (slideNav || counterNav) {
1667
+ add("previous", "Previous slide", ["ArrowLeft"], "Slides");
1668
+ add("next", "Next slide", ["ArrowRight"], "Slides");
1669
+ }
1670
+ if (presenter) add("present", "Present from this slide", ["F5"], "Slides");
1671
+ if (helpButton && !scopedKeys) add("help", "Keyboard shortcuts", ["?"], "Window");
1672
+ }
1673
+ return [...list, ...chromeShortcuts()];
1674
+ }
1675
+ /** The shortcuts of the chrome itself (save, the title, expand): the help sheet lists them, and so does the Studio's own sheet. */
1676
+ function chromeShortcuts() {
1677
+ const list = [];
1678
+ const add = (id, label, keys, group) => list.push({ id, label, keys, group });
1679
+ if (listeners.get("save")?.size && layout !== "viewer" && options.shortcuts !== false) add("save", "Save", ["Mod-s"], "Edit");
1680
+ if (deckTitle && !readOnly) {
1681
+ add("title-commit", "Rename: keep the name typed in the title", ["Enter"], "Presentation name");
1682
+ add("title-cancel", "Rename: put the name back", ["Escape"], "Presentation name");
1683
+ }
1684
+ if (expandCtl) add("expand-exit", "Leave the expanded editor", ["Escape"], "Window");
1685
+ return list;
1686
+ }
1687
+
1688
+ function setDeckName(name) {
1689
+ if (destroyed) return false;
1690
+ if (typeof name !== "string") throw new TypeError("setDeckName needs the name as a string.");
1691
+ if (waiting()) {
1692
+ queue("deckName", () => setDeckName(name));
1693
+ return true;
1694
+ }
1695
+ // 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.
1696
+ if (readOnly || !inSync || !commitPending()) return false;
1697
+ const patch = deckNamePatch(session.snapshot().presentation?.name, name);
1698
+ if (!patch) return true;
1699
+ try {
1700
+ ui.applyPatch(patch, { source: "deck-name", rejectInvalid: true });
1701
+ return true;
1702
+ } catch (error) {
1703
+ report(error);
1704
+ return false;
1705
+ }
1706
+ }
1707
+ function expand(on = true) {
1708
+ if (destroyed || options.expand === false) return false;
1709
+ if (waiting()) {
1710
+ queue("expand", () => expand(on));
1711
+ return true;
1712
+ }
1713
+ return expandCtl ? expandCtl.expand(on) : false;
1714
+ }
1715
+
1211
1716
  // 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
1717
  // returns removes it either way); `list` is empty and `run` finds nothing yet.
1213
1718
  const preMountCommands = {
@@ -1350,6 +1855,40 @@ export function createOpfEditor(element, options = {}) {
1350
1855
  get slots() {
1351
1856
  return slots;
1352
1857
  },
1858
+ /** CE-28: the toolbar the editor built (undefined without one) and the deck title part. */
1859
+ get toolbar() {
1860
+ return toolbar;
1861
+ },
1862
+ get deckTitle() {
1863
+ return deckTitle;
1864
+ },
1865
+ /** CE-28 wave 2: the toolbar's other parts, once mounted (a host that runs its own Review feeds `reviewPill.update(counts)`). */
1866
+ get designMenu() {
1867
+ return designMenu;
1868
+ },
1869
+ get reviewPill() {
1870
+ return reviewPill;
1871
+ },
1872
+ get helpButton() {
1873
+ return helpButton;
1874
+ },
1875
+ get toolbarActions() {
1876
+ return actionsPart;
1877
+ },
1878
+ getDeckName: currentDeckName,
1879
+ /** CE-28 wave 2: whether the document (or a source draft) differs from what was last loaded or saved. */
1880
+ get dirty() {
1881
+ return dirty;
1882
+ },
1883
+ markSaved,
1884
+ save: requestSave,
1885
+ setActions,
1886
+ shortcuts,
1887
+ setDeckName,
1888
+ expand,
1889
+ get expanded() {
1890
+ return Boolean(expandCtl?.expanded);
1891
+ },
1353
1892
  getDocument() {
1354
1893
  return session.snapshot().presentation;
1355
1894
  },
@@ -1442,6 +1981,15 @@ export function createOpfEditor(element, options = {}) {
1442
1981
  }
1443
1982
  }
1444
1983
  studioCtl?.destroy();
1984
+ expandButton?.destroy();
1985
+ expandCtl?.destroy();
1986
+ deckTitle?.destroy();
1987
+ designMenu?.destroy();
1988
+ reviewPill?.destroy();
1989
+ helpButton?.destroy();
1990
+ actionsPart?.destroy();
1991
+ counterNav?.destroy();
1992
+ toolbar?.destroy();
1445
1993
  unbindKeys?.();
1446
1994
  checking?.disconnect();
1447
1995
  persistence?.destroy();
@@ -1485,7 +2033,35 @@ export function createOpfEditor(element, options = {}) {
1485
2033
  if (wantedView && wantedView !== "slide") setView(wantedView);
1486
2034
  else markView("slide");
1487
2035
  syncChrome();
2036
+ // The document as it was opened is the baseline of `dirty`; calls queued before mount (a rename) are changes after it.
2037
+ recordBaseline();
2038
+ if (options.guardUnload) mountUnloadGuard();
1488
2039
  flushQueued();
2040
+ // The bar's parts are in their slots now: fit the row at once rather than on the next frame.
2041
+ toolbar?.update();
2042
+ }
2043
+ // CE-28 wave 2 (promoted from openpresentation.org's source unload guard): the browser asks before the page closes while there is unsaved work.
2044
+ // `guardUnload: true` asks while `dirty`; a function decides itself (a host with state of its own), given the editor.
2045
+ function mountUnloadGuard() {
2046
+ if (!win?.addEventListener) return;
2047
+ const decide = typeof options.guardUnload === "function" ? () => Boolean(options.guardUnload(handle)) : () => dirty;
2048
+ const onBeforeUnload = (event) => {
2049
+ if (destroyed) return;
2050
+ // Text typed a moment ago is applied first, so its verdict counts.
2051
+ pane?.getText();
2052
+ updateDirty();
2053
+ let warn = false;
2054
+ try {
2055
+ warn = decide();
2056
+ } catch (error) {
2057
+ report(error);
2058
+ }
2059
+ if (!warn) return;
2060
+ event.preventDefault();
2061
+ event.returnValue = "";
2062
+ };
2063
+ win.addEventListener("beforeunload", onBeforeUnload);
2064
+ cleanups.push(() => win.removeEventListener("beforeunload", onBeforeUnload));
1489
2065
  }
1490
2066
  readyPromise = new Promise((resolve, reject) => {
1491
2067
  const start = (arrived) => {