@openpresentation/opf-editor 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/svelte.d.ts CHANGED
@@ -28,3 +28,19 @@ export declare function opfTextInput(node: any, params: OPFTextInputActionParams
28
28
  export declare function opfCatalogSelect(node: any, params: OPFCatalogSelectActionParams): SvelteAction;
29
29
 
30
30
  export declare function opfTrace(node: any, params: OPFTraceActionParams): SvelteAction;
31
+
32
+ /** The deck name store's editor: the handle of `createOpfEditor` (`@openpresentation/opf-editor/workspace`). */
33
+ export interface OpfDeckNameEditor {
34
+ getDeckName(): string;
35
+ setDeckName(name: string): boolean;
36
+ on(name: "deckName", listener: (detail: { name: string; previous: string }) => void): () => void;
37
+ }
38
+ /** CE-28: a Svelte store of the deck's name (`""` without one); `set` renames through `editor.setDeckName` (false when refused). */
39
+ export interface OpfDeckNameStore {
40
+ subscribe(run: (name: string) => void): () => void;
41
+ set(name: string): boolean;
42
+ update(updater: (name: string) => string): boolean;
43
+ }
44
+ export declare function opfDeckName(editor: OpfDeckNameEditor): OpfDeckNameStore;
45
+ /** CE-28 wave 2: a readable store of `editor.dirty`. */
46
+ export declare function opfDirty(editor: { readonly dirty: boolean; on(name: "dirty", listener: (detail: { dirty: boolean }) => void): () => void }): { subscribe(run: (dirty: boolean) => void): () => void };
package/dist/svelte.js CHANGED
@@ -110,3 +110,35 @@ function replaceOptions(node, options) {
110
110
  node.appendChild(optionElement);
111
111
  }
112
112
  }
113
+
114
+ /** CE-28 wave 2: a readable Svelte store of `editor.dirty`, following its `dirty` event. */
115
+ export function opfDirty(editor) {
116
+ if (!editor || typeof editor.on !== "function") throw new TypeError("opfDirty needs an editor from createOpfEditor.");
117
+ return {
118
+ subscribe(run) {
119
+ run(Boolean(editor.dirty));
120
+ return editor.on("dirty", ({ dirty }) => run(dirty));
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * CE-28: the deck's name as a Svelte store (the store contract, so `$name` and `bind:value={$name}` work): it holds the document's `name` (`""`
127
+ * without one) and follows every `deckName` event of the editor; `set(name)` renames through `editor.setDeckName` (one undoable edit, an empty
128
+ * name removes it). No Svelte import: any object with `subscribe` is a store.
129
+ */
130
+ export function opfDeckName(editor) {
131
+ if (!editor || typeof editor.getDeckName !== "function" || typeof editor.on !== "function") throw new TypeError("opfDeckName needs an editor from createOpfEditor.");
132
+ return {
133
+ subscribe(run) {
134
+ run(editor.getDeckName());
135
+ return editor.on("deckName", ({ name }) => run(name));
136
+ },
137
+ set(name) {
138
+ return editor.setDeckName(name);
139
+ },
140
+ update(updater) {
141
+ return editor.setDeckName(updater(editor.getDeckName()));
142
+ },
143
+ };
144
+ }
@@ -1,3 +1,4 @@
1
+ import type { OpfIcons } from "./icons.js";
1
2
  import type { EditorSession } from "./index.js";
2
3
  import type { ThumbnailCache } from "./thumbnail-cache.js";
3
4
 
@@ -10,6 +11,43 @@ export interface ThumbnailStripLabels {
10
11
  hidden?: string;
11
12
  /** Shown in place of a thumbnail the renderer could not draw. Default: "Preview unavailable". */
12
13
  unavailable?: string;
14
+ /** The accessible name and tooltip of the "+" button (with `actions`). Default: "Add slide". */
15
+ add?: string;
16
+ /** The accessible name of the layout menu behind "+". Default: "Layouts". */
17
+ layouts?: string;
18
+ /** The accessible name of the thumbnail context menu. Default: "Slide actions". */
19
+ slideActions?: string;
20
+ /** Menu items. Defaults: "Duplicate slide", "Delete slide", "Hide in slide show", "Show in slide show". */
21
+ duplicate?: string;
22
+ delete?: string;
23
+ hideSlide?: string;
24
+ showSlide?: string;
25
+ /** Move items: left and right in a horizontal strip, up and down in a vertical one. Defaults: "Move left", "Move right", "Move up", "Move down". */
26
+ moveLeft?: string;
27
+ moveRight?: string;
28
+ moveUp?: string;
29
+ moveDown?: string;
30
+ /** Status lines (`onStatus` or the strip's live region). `{position}` and `{count}` are replaced. */
31
+ statusAdded?: string;
32
+ statusDuplicated?: string;
33
+ statusDeleted?: string;
34
+ statusHidden?: string;
35
+ statusShown?: string;
36
+ statusMoved?: string;
37
+ /** Status lines for a move that has nowhere to go and for deleting the last slide. */
38
+ atStart?: string;
39
+ atEnd?: string;
40
+ lastSlide?: string;
41
+ }
42
+ /** One layout the "+" menu offers: the shape `listSwitchOptions(presentation, "layouts")` returns. `id` is the layout reference written to the slide. */
43
+ export interface ThumbnailLayoutChoice {
44
+ id: string;
45
+ label?: string;
46
+ /** The catalog record; its first tag groups the menu (as in the slide manager's layout picker). */
47
+ record?: Record<string, any>;
48
+ group?: string;
49
+ origin?: string;
50
+ [key: string]: unknown;
13
51
  }
14
52
  export interface ThumbnailStripOptions {
15
53
  /** The editor session. Required. */
@@ -29,6 +67,8 @@ export interface ThumbnailStripOptions {
29
67
  /** `"horizontal"` (default) scrolls sideways; `"vertical"` scrolls down. */
30
68
  orientation?: "horizontal" | "vertical";
31
69
  labels?: ThumbnailStripLabels;
70
+ /** With `actions`, replace the icon of the add button: `add` (`.opf-thumbs-add-icon`). See `OpfIcon`. */
71
+ icons?: OpfIcons<"add">;
32
72
  /** A thumbnail cache to share with other views. Default: the strip's own. */
33
73
  cache?: ThumbnailCache;
34
74
  /** False to draw every thumbnail at once instead of those near the viewport. Default true; also off without IntersectionObserver. */
@@ -37,12 +77,31 @@ export interface ThumbnailStripOptions {
37
77
  autoRefresh?: boolean;
38
78
  /** The document's faces could not be loaded, so no thumbnail was drawn. */
39
79
  onError?: (error: unknown) => void;
80
+ /**
81
+ * Slide management (default false, so existing hosts are unchanged). True adds a trailing "+" button (accessible name "Add slide") and a
82
+ * context menu on every thumbnail (right-click, Shift+F10 or the ContextMenu key): Duplicate, Delete (disabled for the last slide), Hide or
83
+ * Show in slide show, Move left and Move right (Move up and Move down in a vertical strip; mirrored in a right-to-left one). On a focused
84
+ * thumbnail Alt+Arrow moves it and Delete deletes it. Each action is one undoable session change, the thumbnail follows the slide it
85
+ * leaves current (the copy, the moved or new slide, the neighbour of a deleted one) and `onSelect` reports it.
86
+ */
87
+ actions?: boolean;
88
+ /**
89
+ * Layout choices for "Add slide with layout", as the slide manager takes them. When given and non-empty, "+" opens a menu of them and adds
90
+ * the chosen layout after the current slide; without it "+" adds a plain slide there at once. Only used with `actions`.
91
+ */
92
+ layoutOptions?: (presentation: any) => ThumbnailLayoutChoice[];
93
+ /** True: no "+" button and every menu item disabled, the keys do nothing. Change it with `setReadOnly`. Only used with `actions`. */
94
+ readOnly?: boolean;
95
+ /** A line for a screen reader after each action. Without it the strip speaks through its own polite live region. */
96
+ onStatus?: (message: string) => void;
40
97
  }
41
98
  export interface ThumbnailStrip {
42
99
  readonly element: HTMLElement;
43
100
  getSlide(): number;
44
101
  /** Mark a slide as current (`aria-current`), scroll it into view and draw it. Does not call `onSelect`. */
45
102
  setSlide(index: number): void;
103
+ /** Turn slide management off (true) or on again: removes or restores the "+" button and disables the menu items and keys. Does nothing visible without `actions`. */
104
+ setReadOnly(readOnly: boolean): void;
46
105
  /** Redraw from the session now. Only slides whose content changed render again. `{ clear: true }` also drops the cache, for example after the host loaded fonts. */
47
106
  refresh(options?: { clear?: boolean }): void;
48
107
  destroy(): void;
@@ -2,10 +2,17 @@
2
2
  // accent ring. Thumbnails are drawn by the same renderer, catalogs and fonts handle the canvas uses, through the thumbnail cache: a slide whose
3
3
  // content did not change is never rendered again, and the strip is lazy (items near the viewport render first, through IntersectionObserver when
4
4
  // the browser has it). Plain DOM, no framework, no network.
5
+ //
6
+ // CE-29: with `actions: true` the strip also manages slides, for embeds that have neither the Sorter nor the Studio: a trailing "+" button
7
+ // adds a slide (through a layout menu when the host passes `layoutOptions`), and a context menu on each thumbnail (right-click, Shift+F10,
8
+ // the ContextMenu key) duplicates, deletes, hides or shows, and moves it (also Alt+Arrow and Delete on a focused thumbnail). Each action is
9
+ // one undoable session change from slides.js, the same functions the slide manager uses.
5
10
  import { mergeCatalogs } from "./index.js";
6
11
  import { toSvg } from "@openpresentation/opf-render/svg";
7
12
  import { whenFontsReady } from "./font-gate.js";
8
- import { slideTitle } from "./slides.js";
13
+ import { normalizeIcons, resolveIcon } from "./icons.js";
14
+ import { addSlide, duplicateSlides, moveSlidesBy, removeSlides, setHidden, slideTitle } from "./slides.js";
15
+ import { groupLayoutChoices } from "./layout-templates.js";
9
16
  import { createThumbnailCache } from "./thumbnail-cache.js";
10
17
 
11
18
  const DEFAULT_LABELS = {
@@ -13,7 +20,29 @@ const DEFAULT_LABELS = {
13
20
  untitled: "Untitled",
14
21
  hidden: "hidden",
15
22
  unavailable: "Preview unavailable",
23
+ add: "Add slide",
24
+ layouts: "Layouts",
25
+ slideActions: "Slide actions",
26
+ duplicate: "Duplicate slide",
27
+ delete: "Delete slide",
28
+ hideSlide: "Hide in slide show",
29
+ showSlide: "Show in slide show",
30
+ moveLeft: "Move left",
31
+ moveRight: "Move right",
32
+ moveUp: "Move up",
33
+ moveDown: "Move down",
34
+ statusAdded: "Slide added at position {position}",
35
+ statusDuplicated: "Slide duplicated",
36
+ statusDeleted: "Slide deleted. {count} left",
37
+ statusHidden: "Slide hidden from the slide show",
38
+ statusShown: "Slide shown in the slide show",
39
+ statusMoved: "Slide moved to position {position} of {count}",
40
+ atStart: "Already the first slide",
41
+ atEnd: "Already the last slide",
42
+ lastSlide: "A presentation needs at least one slide",
16
43
  };
44
+ const META = { source: "thumbnail-strip", rejectInvalid: true };
45
+ const fill = (text, values) => String(text).replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match));
17
46
  const pad = (value, count) => String(value).padStart(Math.max(2, String(count).length), "0");
18
47
 
19
48
  /**
@@ -25,7 +54,12 @@ const pad = (value, count) => String(value).padStart(Math.max(2, String(count).l
25
54
  * the default, or `"vertical"`), `labels` (`navigation`, `untitled`, `hidden`, `unavailable`), `cache` (a thumbnail cache to share; default:
26
55
  * its own), `lazy` (default true), `autoRefresh` (default true: follow the editor's changes on its own) and `onError(error)`.
27
56
  *
28
- * Returns `{ element, getSlide(), setSlide(index), refresh({ clear }), destroy() }`.
57
+ * Slide management (CE-29, off by default): `actions` (true adds the "+" button and the thumbnail context menu), `layoutOptions(presentation)`
58
+ * (layout choices `{ id, label, record? }` for a layout menu behind "+"; without it, or when it returns none, "+" adds a slide directly after
59
+ * the current one), `readOnly` (no "+", menu items disabled; change it with `setReadOnly`) and `onStatus(message)` (a screen-reader line
60
+ * after each action; without it the strip announces in its own live region). `labels` also names every control and status line.
61
+ *
62
+ * Returns `{ element, getSlide(), setSlide(index), setReadOnly(readOnly), refresh({ clear }), destroy() }`.
29
63
  */
30
64
  export function createThumbnailStrip(container, options = {}) {
31
65
  if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
@@ -34,10 +68,13 @@ export function createThumbnailStrip(container, options = {}) {
34
68
  const doc = container.ownerDocument;
35
69
  const win = doc.defaultView;
36
70
  const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
71
+ const icons = normalizeIcons(options.icons, ["add"], "createThumbnailStrip icons");
37
72
  const density = options.density === "touch" ? "touch" : "comfortable";
38
73
  const orientation = options.orientation === "vertical" ? "vertical" : "horizontal";
39
74
  const size = Number(options.size) > 0 ? Number(options.size) : density === "touch" ? 104 : 128;
40
75
  const lazy = options.lazy !== false && typeof win?.IntersectionObserver === "function";
76
+ const actions = options.actions === true;
77
+ let readOnly = options.readOnly === true;
41
78
 
42
79
  // The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list the canvas and core resolve with.
43
80
  const resolveOptions = () => ({ variables: false, ...(options.renderOptions ?? {}), catalogs: mergeCatalogs(editor.catalogs, options.renderOptions?.catalogs) });
@@ -53,6 +90,10 @@ export function createThumbnailStrip(container, options = {}) {
53
90
  let scheduled;
54
91
  let renderToken = 0;
55
92
  let observer;
93
+ let menu;
94
+ let addSlot;
95
+ let addButton;
96
+ let status;
56
97
  const visible = new Set();
57
98
  const items = [];
58
99
 
@@ -61,6 +102,8 @@ export function createThumbnailStrip(container, options = {}) {
61
102
  element.dataset.orientation = orientation;
62
103
  element.dataset.density = density;
63
104
  element.setAttribute("aria-label", labels.navigation);
105
+ if (actions) element.dataset.actions = "true";
106
+ if (actions && readOnly) element.dataset.readonly = "true";
64
107
  element.style.setProperty("--opf-thumbs-w", `${size}px`);
65
108
  const list = doc.createElement("ol");
66
109
  list.className = "opf-thumbs-list";
@@ -102,7 +145,7 @@ export function createThumbnailStrip(container, options = {}) {
102
145
  while (items.length < slides.length) {
103
146
  const item = makeItem(items.length);
104
147
  items.push(item);
105
- list.append(item.li);
148
+ list.insertBefore(item.li, addSlot?.parentNode === list ? addSlot : null);
106
149
  observer?.observe(item.button);
107
150
  }
108
151
  while (items.length > slides.length) {
@@ -218,10 +261,11 @@ export function createThumbnailStrip(container, options = {}) {
218
261
  });
219
262
  // Arrow keys move focus along the strip (Enter or Space chooses); Home and End jump to the ends.
220
263
  list.addEventListener("keydown", (event) => {
221
- if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing) return;
264
+ if (event.isComposing) return;
222
265
  const button = event.target.closest?.(".opf-thumbs-item");
223
- if (!button) return;
224
- const rtl = orientation === "horizontal" && win.getComputedStyle(element).direction === "rtl";
266
+ if (actions && button && handleActionKey(event, button)) return;
267
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || !button) return;
268
+ const rtl = isRtl();
225
269
  const keys = orientation === "horizontal" ? { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1 } : { ArrowUp: -1, ArrowDown: 1 };
226
270
  const from = Number(button.dataset.index);
227
271
  let target;
@@ -234,6 +278,340 @@ export function createThumbnailStrip(container, options = {}) {
234
278
  items[target]?.button.focus();
235
279
  });
236
280
 
281
+ // --- slide management (CE-29) ---------------------------------------------------------------------
282
+
283
+ function isRtl() {
284
+ return orientation === "horizontal" && win.getComputedStyle(element).direction === "rtl";
285
+ }
286
+ /** The step a move key asks for along the strip as it is drawn (left is forward in a right-to-left strip); 0 for any other key. */
287
+ function moveStep(key) {
288
+ if (orientation === "vertical") return key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
289
+ const rtl = isRtl();
290
+ return key === "ArrowLeft" ? (rtl ? 1 : -1) : key === "ArrowRight" ? (rtl ? -1 : 1) : 0;
291
+ }
292
+ function announce(message) {
293
+ if (!message) return;
294
+ if (options.onStatus) {
295
+ options.onStatus(message);
296
+ return;
297
+ }
298
+ if (!status) {
299
+ status = doc.createElement("div");
300
+ status.className = "opf-thumbs-status";
301
+ status.setAttribute("role", "status");
302
+ status.setAttribute("aria-live", "polite");
303
+ element.prepend(status);
304
+ }
305
+ // The same line twice in a row is read twice.
306
+ status.textContent = status.textContent === message ? `${message} ` : message;
307
+ }
308
+
309
+ /** After a change: show the slide it left current (the moved, copied, added or neighbouring one), keep focus on it and tell the host. */
310
+ function follow(target, message, notify = true) {
311
+ cancelScheduled();
312
+ deck = editor.presentation;
313
+ active = target;
314
+ focusIndex = target;
315
+ syncItems();
316
+ paintThumbnails();
317
+ scrollToActive();
318
+ items[active]?.button.focus();
319
+ if (notify) options.onSelect?.(active);
320
+ announce(message);
321
+ }
322
+ /** One undoable change for the slide at `index`. Every kind is a function of slides.js, the ones the slide manager calls. */
323
+ function perform(kind, index, extra = {}) {
324
+ if (disposed || !actions || readOnly) return;
325
+ const total = editor.presentation.slides.length;
326
+ try {
327
+ if (kind === "add") {
328
+ const at = total ? clamp(index, total) + 1 : 0;
329
+ const change = addSlide(editor, { at, ...(extra.layout ? { layout: extra.layout } : {}) }, META);
330
+ const target = change.index ?? at;
331
+ follow(target, fill(labels.statusAdded, { position: target + 1 }));
332
+ return;
333
+ }
334
+ if (!Number.isInteger(index) || index < 0 || index >= total) return;
335
+ if (kind === "duplicate") {
336
+ const change = duplicateSlides(editor, [index], {}, META);
337
+ follow(change.selection[0], labels.statusDuplicated);
338
+ } else if (kind === "delete") {
339
+ if (total <= 1) {
340
+ announce(labels.lastSlide);
341
+ return;
342
+ }
343
+ const change = removeSlides(editor, [index], META);
344
+ follow(change.selection[0], fill(labels.statusDeleted, { count: total - 1 }));
345
+ } else if (kind === "hide") {
346
+ const change = setHidden(editor, [index], undefined, META);
347
+ follow(index, change.hidden ? labels.statusHidden : labels.statusShown, false);
348
+ } else if (kind === "move") {
349
+ const change = moveSlidesBy(editor, [index], extra.step, {}, META);
350
+ if (!change.changed) {
351
+ announce(extra.step < 0 ? labels.atStart : labels.atEnd);
352
+ return;
353
+ }
354
+ follow(change.selection[0], fill(labels.statusMoved, { position: change.selection[0] + 1, count: total }));
355
+ }
356
+ } catch (error) {
357
+ announce(error?.message ?? String(error));
358
+ }
359
+ }
360
+
361
+ function handleActionKey(event, button) {
362
+ const index = Number(button.dataset.index);
363
+ const { key } = event;
364
+ if (event.ctrlKey || event.metaKey) return false;
365
+ if (key === "ContextMenu" || (key === "F10" && event.shiftKey && !event.altKey)) {
366
+ event.preventDefault();
367
+ openSlideMenu(index, button);
368
+ return true;
369
+ }
370
+ if (event.altKey && !event.shiftKey) {
371
+ const step = moveStep(key);
372
+ if (!step) return false;
373
+ event.preventDefault();
374
+ perform("move", index, { step });
375
+ return true;
376
+ }
377
+ if (!event.altKey && !event.shiftKey && (key === "Delete" || key === "Backspace")) {
378
+ event.preventDefault();
379
+ perform("delete", index);
380
+ return true;
381
+ }
382
+ return false;
383
+ }
384
+
385
+ // The "+" button: a trailing slot in the list, present while the strip can be edited.
386
+ function mountAdd() {
387
+ if (!actions || readOnly || disposed) {
388
+ addSlot?.remove();
389
+ return;
390
+ }
391
+ if (!addSlot) {
392
+ addSlot = doc.createElement("li");
393
+ addSlot.className = "opf-thumbs-add-slot";
394
+ addButton = doc.createElement("button");
395
+ addButton.type = "button";
396
+ addButton.className = "opf-thumbs-add";
397
+ addButton.setAttribute("aria-label", labels.add);
398
+ addButton.title = labels.add;
399
+ if (typeof options.layoutOptions === "function") {
400
+ addButton.setAttribute("aria-haspopup", "menu");
401
+ addButton.setAttribute("aria-expanded", "false");
402
+ }
403
+ addButton.append(resolveIcon(doc, icons.add, "M12 5v14M5 12h14", { name: "add", className: "opf-thumbs-add-icon" }));
404
+ addSlot.append(addButton);
405
+ addButton.addEventListener("click", onAddClick);
406
+ }
407
+ if (addSlot.parentNode !== list) list.append(addSlot);
408
+ }
409
+ function onAddClick() {
410
+ if (readOnly || disposed) return;
411
+ if (menu?.opener === addButton) {
412
+ closeMenu();
413
+ return;
414
+ }
415
+ let choices = [];
416
+ if (typeof options.layoutOptions === "function") {
417
+ try {
418
+ choices = options.layoutOptions(editor.presentation) ?? [];
419
+ } catch (error) {
420
+ announce(error?.message ?? String(error));
421
+ return;
422
+ }
423
+ }
424
+ if (choices.length) openLayoutMenu(choices);
425
+ else perform("add", active);
426
+ }
427
+
428
+ // --- menus ------------------------------------------------------------------------------------------
429
+
430
+ const SHORTCUT_KEYS = { "←": "ArrowLeft", "→": "ArrowRight", "↑": "ArrowUp", "↓": "ArrowDown" };
431
+ /**
432
+ * Open a menu. `entries` are `{ label, shortcut?, disabled?, run }`, `{ separator: true }` and `{ heading }` (the entries up to the next
433
+ * heading form a labelled group). It opens at `x`, `y`, or under (above, when there is no room) the `anchor` rectangle.
434
+ */
435
+ function openMenu({ label, opener, entries, x = 0, y = 0, anchor, toggle = false }) {
436
+ closeMenu(false);
437
+ const root = doc.createElement("div");
438
+ root.className = "opf-thumbs-menu";
439
+ root.setAttribute("role", "menu");
440
+ root.setAttribute("aria-label", label);
441
+ root.tabIndex = -1;
442
+ const buttons = [];
443
+ let target = root;
444
+ for (const entry of entries) {
445
+ if (entry.separator) {
446
+ const rule = doc.createElement("div");
447
+ rule.className = "opf-thumbs-menu-separator";
448
+ rule.setAttribute("role", "separator");
449
+ target.append(rule);
450
+ continue;
451
+ }
452
+ if (entry.heading !== undefined) {
453
+ target = doc.createElement("div");
454
+ target.className = "opf-thumbs-menu-group";
455
+ target.setAttribute("role", "group");
456
+ target.setAttribute("aria-label", entry.heading);
457
+ const heading = doc.createElement("div");
458
+ heading.className = "opf-thumbs-menu-heading";
459
+ heading.setAttribute("aria-hidden", "true");
460
+ heading.textContent = entry.heading;
461
+ target.append(heading);
462
+ root.append(target);
463
+ continue;
464
+ }
465
+ const item = doc.createElement("button");
466
+ item.type = "button";
467
+ item.className = "opf-thumbs-menu-item";
468
+ item.setAttribute("role", "menuitem");
469
+ item.tabIndex = -1;
470
+ item.disabled = entry.disabled === true;
471
+ const text = doc.createElement("span");
472
+ text.textContent = entry.label;
473
+ item.append(text);
474
+ if (entry.shortcut) {
475
+ const keys = doc.createElement("kbd");
476
+ keys.textContent = entry.shortcut;
477
+ keys.setAttribute("aria-hidden", "true");
478
+ item.append(keys);
479
+ item.setAttribute("aria-keyshortcuts", entry.shortcut.replace(/[←→↑↓]/, (arrow) => SHORTCUT_KEYS[arrow]));
480
+ }
481
+ item.addEventListener("click", () => {
482
+ // Focus goes back to where the menu came from first; a change then moves it to the slide it leaves current.
483
+ closeMenu(true);
484
+ entry.run();
485
+ });
486
+ target.append(item);
487
+ if (!item.disabled) buttons.push(item);
488
+ }
489
+ (element.closest(".opf-ui") ?? doc.body).append(root);
490
+ let box = root.getBoundingClientRect();
491
+ let left = x;
492
+ let top = y;
493
+ if (anchor) {
494
+ // Under the anchor, or above it when that side has more room; a menu taller than its side scrolls instead of covering the anchor.
495
+ const below = win.innerHeight - anchor.bottom - 8;
496
+ const above = anchor.top - 8;
497
+ const placeAbove = box.height > below && above > below;
498
+ const room = Math.max(96, placeAbove ? above : below);
499
+ if (box.height > room) {
500
+ root.style.maxHeight = `${room}px`;
501
+ box = root.getBoundingClientRect();
502
+ }
503
+ left = isRtl() ? anchor.right - box.width : anchor.left;
504
+ top = placeAbove ? anchor.top - box.height - 4 : anchor.bottom + 4;
505
+ }
506
+ root.style.left = `${Math.max(4, Math.min(left, win.innerWidth - box.width - 4))}px`;
507
+ root.style.top = `${Math.max(4, Math.min(top, win.innerHeight - box.height - 4))}px`;
508
+ // The key that opened the menu is released inside it: some platforms open their own menu on that keyup, which would take the focus.
509
+ root.addEventListener("keyup", (event) => {
510
+ if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) event.preventDefault();
511
+ });
512
+ root.addEventListener("keydown", (event) => {
513
+ const at = buttons.indexOf(doc.activeElement);
514
+ if (event.key === "Escape") {
515
+ event.preventDefault();
516
+ event.stopPropagation();
517
+ closeMenu();
518
+ } else if (event.key === "Tab") closeMenu(false);
519
+ else if (event.key === "ArrowDown") {
520
+ event.preventDefault();
521
+ buttons[(at + 1) % buttons.length]?.focus();
522
+ } else if (event.key === "ArrowUp") {
523
+ event.preventDefault();
524
+ buttons[(at - 1 + buttons.length) % buttons.length]?.focus();
525
+ } else if (event.key === "Home") {
526
+ event.preventDefault();
527
+ buttons[0]?.focus();
528
+ } else if (event.key === "End") {
529
+ event.preventDefault();
530
+ buttons.at(-1)?.focus();
531
+ }
532
+ });
533
+ // A press outside closes it (the "+" button toggles its own menu on click); so does scrolling anything, which would leave it adrift.
534
+ const onPointer = (event) => {
535
+ if (!root.contains(event.target) && !(toggle && opener.contains(event.target))) closeMenu(false);
536
+ };
537
+ const onScroll = (event) => {
538
+ if (!root.contains(event.target)) closeMenu(false);
539
+ };
540
+ doc.addEventListener("pointerdown", onPointer, true);
541
+ // Scrolling that the opening itself caused (focus scrolls its element into view) arrives a frame late: listen only after it.
542
+ const listenTimer = win.setTimeout(() => win.addEventListener("scroll", onScroll, true), 150);
543
+ menu = {
544
+ root,
545
+ opener,
546
+ off: () => {
547
+ win.clearTimeout(listenTimer);
548
+ doc.removeEventListener("pointerdown", onPointer, true);
549
+ win.removeEventListener("scroll", onScroll, true);
550
+ },
551
+ };
552
+ if (toggle) opener.setAttribute("aria-expanded", "true");
553
+ (buttons[0] ?? root).focus();
554
+ }
555
+ function closeMenu(restore = true) {
556
+ if (!menu) return;
557
+ const { root, opener, off } = menu;
558
+ menu = undefined;
559
+ off();
560
+ root.remove();
561
+ if (opener?.getAttribute?.("aria-haspopup")) opener.setAttribute("aria-expanded", "false");
562
+ if (restore && opener?.isConnected) opener.focus();
563
+ }
564
+ function openSlideMenu(index, opener, x, y) {
565
+ const total = editor.presentation.slides.length;
566
+ const slide = editor.presentation.slides[index];
567
+ if (!slide) return;
568
+ const off = readOnly;
569
+ const vertical = orientation === "vertical";
570
+ // "Left" steps back in a left-to-right strip and forward in a right-to-left one.
571
+ const left = moveStep(vertical ? "ArrowUp" : "ArrowLeft");
572
+ const canStep = (step) => index + step >= 0 && index + step < total;
573
+ const entries = [
574
+ { label: labels.duplicate, disabled: off, run: () => perform("duplicate", index) },
575
+ { label: labels.delete, shortcut: "Delete", disabled: off || total <= 1, run: () => perform("delete", index) },
576
+ { label: slide.hidden === true ? labels.showSlide : labels.hideSlide, disabled: off, run: () => perform("hide", index) },
577
+ { separator: true },
578
+ {
579
+ label: vertical ? labels.moveUp : labels.moveLeft,
580
+ shortcut: vertical ? "Alt+↑" : "Alt+←",
581
+ disabled: off || !canStep(left),
582
+ run: () => perform("move", index, { step: left }),
583
+ },
584
+ {
585
+ label: vertical ? labels.moveDown : labels.moveRight,
586
+ shortcut: vertical ? "Alt+↓" : "Alt+→",
587
+ disabled: off || !canStep(-left),
588
+ run: () => perform("move", index, { step: -left }),
589
+ },
590
+ ];
591
+ openMenu({ label: labels.slideActions, opener, entries, x, y, anchor: x === undefined ? opener.getBoundingClientRect() : undefined });
592
+ }
593
+ function openLayoutMenu(choices) {
594
+ const groups = groupLayoutChoices(choices).filter((group) => group.choices.length);
595
+ const entries = [];
596
+ for (const group of groups) {
597
+ if (groups.length > 1) entries.push({ heading: group.name });
598
+ for (const choice of group.choices) entries.push({ label: choice.label ?? choice.id, run: () => perform("add", active, { layout: choice.id }) });
599
+ }
600
+ openMenu({ label: labels.layouts, opener: addButton, entries, anchor: addButton.getBoundingClientRect(), toggle: true });
601
+ }
602
+ list.addEventListener("contextmenu", (event) => {
603
+ if (!actions) return;
604
+ const button = event.target.closest?.(".opf-thumbs-item");
605
+ if (!button || !list.contains(button)) return;
606
+ event.preventDefault();
607
+ const index = Number(button.dataset.index);
608
+ // A menu the keyboard just opened for this slide stays (some platforms also send a contextmenu event for the key).
609
+ if (menu?.opener === button) return;
610
+ if (index !== active) select(index);
611
+ const keyboard = event.clientX === 0 && event.clientY === 0;
612
+ openSlideMenu(index, button, keyboard ? undefined : event.clientX, keyboard ? undefined : event.clientY);
613
+ });
614
+
237
615
  if (lazy) {
238
616
  const margin = orientation === "horizontal" ? "0px 240px 0px 240px" : "240px 0px 240px 0px";
239
617
  observer = new win.IntersectionObserver((entries) => {
@@ -248,6 +626,7 @@ export function createThumbnailStrip(container, options = {}) {
248
626
  }
249
627
 
250
628
  syncItems();
629
+ mountAdd();
251
630
  paintThumbnails();
252
631
  scrollToActive();
253
632
 
@@ -296,11 +675,25 @@ export function createThumbnailStrip(container, options = {}) {
296
675
  scrollToActive();
297
676
  paintThumbnails();
298
677
  },
678
+ /** Switch slide management off (true) or on again: no "+" button, menu items disabled, keys do nothing. Without `actions` it only records the state. */
679
+ setReadOnly(value) {
680
+ if (disposed) return;
681
+ const next = Boolean(value);
682
+ if (next === readOnly) return;
683
+ readOnly = next;
684
+ if (actions) {
685
+ if (readOnly) element.dataset.readonly = "true";
686
+ else delete element.dataset.readonly;
687
+ }
688
+ closeMenu(false);
689
+ mountAdd();
690
+ },
299
691
  /** Redraw from the session now. `{ clear: true }` also drops the cache, for example after the host loaded fonts. */
300
692
  refresh,
301
693
  destroy() {
302
694
  if (disposed) return;
303
695
  disposed = true;
696
+ closeMenu(false);
304
697
  cancelScheduled();
305
698
  unsubscribe?.();
306
699
  observer?.disconnect();