@openpresentation/opf-editor 0.24.2 → 0.25.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/README.md +124 -2
- package/dist/galleries.js +2 -2
- package/dist/layout-canvas.js +748 -0
- package/dist/layout-diagram.js +25 -0
- package/dist/layout-editor-ui.js +232 -0
- package/dist/layout-editor.d.ts +212 -0
- package/dist/layout-editor.js +197 -0
- package/dist/layout-inspector.js +339 -0
- package/dist/layout-layers.js +331 -0
- package/dist/layout-model.js +299 -0
- package/dist/layout-palette.js +77 -0
- package/dist/layout-samples.js +84 -0
- package/dist/layout-templates.d.ts +32 -8
- package/dist/layout-templates.js +89 -37
- package/dist/layout-tree.d.ts +87 -0
- package/dist/layout-tree.js +704 -0
- package/dist/presenter.d.ts +105 -0
- package/dist/presenter.js +431 -0
- package/dist/read-deck-file.d.ts +59 -0
- package/dist/read-deck-file.js +142 -0
- package/dist/slide-manager.js +3 -7
- package/dist/styles.css +1003 -25
- package/package.json +25 -10
- package/dist/custom-layout-editor.d.ts +0 -7
- package/dist/custom-layout-editor.js +0 -105
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { ToSvgOptions } from "@openpresentation/opf-render";
|
|
3
|
+
import type { EditorFonts } from "./canvas.js";
|
|
4
|
+
|
|
5
|
+
export interface PresenterLabels {
|
|
6
|
+
/** The accessible name of the presenter dialog. Default: "Presentation". */
|
|
7
|
+
dialog?: string;
|
|
8
|
+
/** The accessible name and title of the exit button. Default: "Exit presentation". */
|
|
9
|
+
exit?: string;
|
|
10
|
+
/** Default: "Exit (Esc)". */
|
|
11
|
+
exitTitle?: string;
|
|
12
|
+
/** What the live region announces. Default: "Slide 3 of 12" (both numbers count the slides that are shown, not the hidden ones). */
|
|
13
|
+
position?: (position: number, count: number) => string;
|
|
14
|
+
/** Shown while the faces the deck needs load. Default: "Loading fonts…". */
|
|
15
|
+
loading?: string;
|
|
16
|
+
/** Shown when every slide is hidden. */
|
|
17
|
+
empty?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PresenterOptions {
|
|
21
|
+
/** The editor session. The presenter reads its current document and catalogs. */
|
|
22
|
+
editor?: EditorSession;
|
|
23
|
+
/** Without an editor: an OPF presentation, or a function returning the current one. Keep the object stable while presenting: slides are drawn once per object. */
|
|
24
|
+
presentation?: { slides?: unknown[] } | (() => { slides?: unknown[] });
|
|
25
|
+
/**
|
|
26
|
+
* The renderer's fonts handle (`loadFonts()`), the one the canvas, the thumbnails and the exports use. With its `pending` and `ensure` the
|
|
27
|
+
* presenter loads the faces a deck needs before it draws ("Loading fonts…" meanwhile).
|
|
28
|
+
*/
|
|
29
|
+
fonts?: EditorFonts;
|
|
30
|
+
/** Extra `toSvg` options, such as `catalogs` (merged after the session's). Slides draw with `text: "system"`, `trace: false` and their `{{variables}}` resolved. */
|
|
31
|
+
renderOptions?: ToSvgOptions;
|
|
32
|
+
/** The slide `start()` opens on (0-based deck index; default 0). A hidden slide opens the next one that is not hidden. */
|
|
33
|
+
slideIndex?: number;
|
|
34
|
+
/** The presentation closed, by any route (Esc, the exit button, leaving full screen, `stop()`, `destroy()`). `index` is the deck index of the slide it showed, so the host can return to it. */
|
|
35
|
+
onExit?(index: number): void;
|
|
36
|
+
/** The shown slide changed (deck index): by key, click, swipe or `next`, `previous` and `goTo`. Also once at start when a hidden slide was requested. */
|
|
37
|
+
onSlideChange?(index: number): void;
|
|
38
|
+
/** `"touch"` makes the exit button 40px. Default `"comfortable"` (32px). */
|
|
39
|
+
density?: "comfortable" | "touch";
|
|
40
|
+
/** Show hidden slides too (default false: they are skipped, as the exports skip them). */
|
|
41
|
+
includeHidden?: boolean;
|
|
42
|
+
labels?: PresenterLabels;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Presenter {
|
|
46
|
+
/** The root element, mounted in the container: `role="dialog"`, `aria-modal`, hidden until `start`. `data-mode` is `fullscreen` or `overlay`. */
|
|
47
|
+
readonly element: HTMLElement;
|
|
48
|
+
/**
|
|
49
|
+
* Open the presentation on `slideIndex` (default: the `slideIndex` option) and ask the browser for full screen. Call it from a user gesture
|
|
50
|
+
* (a click, a key press): browsers refuse full screen otherwise, and the presentation then fills the window as a fixed overlay. Resolves
|
|
51
|
+
* with the mode once the browser answered. Calling it while open goes to that slide.
|
|
52
|
+
*/
|
|
53
|
+
start(slideIndex?: number): Promise<"fullscreen" | "overlay">;
|
|
54
|
+
/** Close the presentation, leave full screen, return focus to the element that was focused at `start`, and call `onExit(index)`. */
|
|
55
|
+
stop(): void;
|
|
56
|
+
/** Show the next slide that is not hidden. Returns false at the end. */
|
|
57
|
+
next(): boolean;
|
|
58
|
+
/** Show the previous slide that is not hidden. Returns false at the start. */
|
|
59
|
+
previous(): boolean;
|
|
60
|
+
/** Show a slide by its deck index (a hidden slide shows the next one that is not hidden). Returns false when the shown slide did not change. */
|
|
61
|
+
goTo(index: number): boolean;
|
|
62
|
+
/** The deck index of the shown slide; -1 when stopped or when every slide is hidden. */
|
|
63
|
+
getSlide(): number;
|
|
64
|
+
isRunning(): boolean;
|
|
65
|
+
/** `stop()`, then remove the element. */
|
|
66
|
+
destroy(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The presenter: full screen (or a fixed overlay), one slide at a time on black, drawn by the renderer, catalogs and fonts handle the canvas
|
|
71
|
+
* uses. Right, Space, PageDown and Enter go forward; Left, PageUp and Backspace go back; Home and End jump; Esc exits. A click goes forward,
|
|
72
|
+
* a click on the left third goes back, and a swipe on touch moves. A "03 / 12" counter shows on pointer move for 2 seconds.
|
|
73
|
+
* CSS: `src/styles/presenter.css`, classes `opf-presenter-*`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createPresenter(container: HTMLElement, options: PresenterOptions): Presenter;
|
|
76
|
+
|
|
77
|
+
export interface PresentButtonOptions {
|
|
78
|
+
/** The button was activated (not called while disabled). */
|
|
79
|
+
onPresent?(event: MouseEvent): void;
|
|
80
|
+
/** `true` disables with a default reason, a string with your own (it is the title). The button stays focusable (`aria-disabled`) and does nothing. */
|
|
81
|
+
disabled?: boolean | string;
|
|
82
|
+
/** `"touch"` makes the button 40px. Default `"comfortable"` (32px). */
|
|
83
|
+
density?: "comfortable" | "touch";
|
|
84
|
+
/** The accessible name (default "Present"). */
|
|
85
|
+
label?: string;
|
|
86
|
+
/** `"quiet"` (default): a transparent icon button like the toolbar's undo and redo. `"primary"`: the filled accent look of the Export trigger. */
|
|
87
|
+
variant?: "quiet" | "primary";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PresentButton {
|
|
91
|
+
readonly element: HTMLButtonElement;
|
|
92
|
+
setDisabled(reason?: boolean | string): void;
|
|
93
|
+
setDensity(density: "comfortable" | "touch"): void;
|
|
94
|
+
setVariant(variant: "quiet" | "primary"): void;
|
|
95
|
+
destroy(): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The icon-only Present button: a Lucide Presentation icon, `aria-label="Present"`, title "Present (F5)". Quiet by default (a transparent icon button like undo and redo); `variant: "primary"` styles it like the Export trigger. */
|
|
99
|
+
export declare function createPresentButton(container: HTMLElement, options?: PresentButtonOptions): PresentButton;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Bind F5 to `onPresent(event)` on `target` (default: the document); the browser's reload is suppressed when it fires. F5 is ignored while
|
|
103
|
+
* typing in inputs, selects and editors, in menus and dialogs, and with a modifier key held. Returns a function that removes the listener.
|
|
104
|
+
*/
|
|
105
|
+
export declare function bindPresentShortcut(target: Document | HTMLElement | Window | undefined | null, onPresent: (event: KeyboardEvent) => void): () => void;
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// RR-98: the presenter and the Present button. `createPresenter` shows the deck full screen (the Fullscreen API on its own element, with a fixed
|
|
2
|
+
// overlay when the browser refuses), one slide at a time, letterboxed on black. Slides are drawn by the renderer the canvas and the thumbnails
|
|
3
|
+
// use (`toSvg` with the host's one fonts handle and catalogs); the next and previous slides are drawn ahead, so advancing is instant. Hidden
|
|
4
|
+
// slides are skipped, as the exports skip them (`slidesToConvert`). `createPresentButton` is the icon-only trigger and `bindPresentShortcut`
|
|
5
|
+
// binds F5. Plain DOM, no framework, no network; styles in src/styles/presenter.css (classes `opf-presenter-*` and `opf-present-button`).
|
|
6
|
+
import { toSvg } from "@openpresentation/opf-render/svg";
|
|
7
|
+
import { mergeCatalogs } from "./catalogs.js";
|
|
8
|
+
import { el, icon } from "./export-dom.js";
|
|
9
|
+
import { whenFontsReady } from "./font-gate.js";
|
|
10
|
+
import { formatSlideCounter } from "./slide-navigator.js";
|
|
11
|
+
|
|
12
|
+
const COUNTER_MS = 2000;
|
|
13
|
+
const PRELOAD_DELAY_MS = 20;
|
|
14
|
+
const SWIPE_MIN = 48;
|
|
15
|
+
const SWIPE_MAX_MS = 800;
|
|
16
|
+
const TAP_SLOP = 10;
|
|
17
|
+
const TAP_MAX_MS = 600;
|
|
18
|
+
const CLICK_GUARD_MS = 450;
|
|
19
|
+
const NEXT_KEYS = new Set(["ArrowRight", " ", "PageDown", "Enter"]);
|
|
20
|
+
const PREVIOUS_KEYS = new Set(["ArrowLeft", "PageUp", "Backspace"]);
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LABELS = {
|
|
23
|
+
/** The accessible name of the presenter dialog. */
|
|
24
|
+
dialog: "Presentation",
|
|
25
|
+
exit: "Exit presentation",
|
|
26
|
+
exitTitle: "Exit (Esc)",
|
|
27
|
+
/** What the live region announces, for example "Slide 3 of 12". */
|
|
28
|
+
position: (position, count) => `Slide ${position} of ${count}`,
|
|
29
|
+
loading: "Loading fonts…",
|
|
30
|
+
empty: "No visible slides to present. Unhide a slide first.",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The slides a presentation shows: the deck's indexes without the hidden ones, as the exports select them (`slidesToConvert`). */
|
|
34
|
+
function visibleIndices(deck, includeHidden) {
|
|
35
|
+
const list = Array.isArray(deck?.slides) ? deck.slides : [];
|
|
36
|
+
const found = [];
|
|
37
|
+
list.forEach((slide, index) => { if (includeHidden || slide?.hidden !== true) found.push(index); });
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const clampIndex = (deck, wanted) => Math.max(0, Math.min((Array.isArray(deck?.slides) ? deck.slides.length : 1) - 1, Math.trunc(Number(wanted) || 0)));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mount the presenter in `container` (it stays hidden until `start`).
|
|
45
|
+
*
|
|
46
|
+
* Options: `editor` (an editor session) or `presentation` (an OPF object, or a function returning the current one), `fonts` (the renderer's fonts
|
|
47
|
+
* handle the canvas uses), `renderOptions` (extra `toSvg` options, such as `catalogs`; slides are drawn with `text: "system"`, `trace: false` and
|
|
48
|
+
* their `{{variables}}` resolved), `slideIndex` (the slide `start()` shows, 0-based), `onExit(index)` (the presenter closed, on the slide it showed),
|
|
49
|
+
* `onSlideChange(index)` (the shown slide changed), `density` (`"comfortable"` or `"touch"`), `includeHidden` (default false) and `labels`. Indexes are always positions in the deck, hidden slides included.
|
|
50
|
+
*
|
|
51
|
+
* Returns `{ element, start(slideIndex?), stop(), next(), previous(), goTo(index), getSlide(), isRunning(), destroy() }`.
|
|
52
|
+
*/
|
|
53
|
+
export function createPresenter(container, options = {}) {
|
|
54
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
55
|
+
const { editor } = options;
|
|
56
|
+
const source = options.presentation;
|
|
57
|
+
if (!editor && source === undefined) throw new TypeError("createPresenter needs an editor session or a presentation.");
|
|
58
|
+
if (editor && typeof editor.subscribe !== "function") throw new TypeError("createPresenter needs an editor session.");
|
|
59
|
+
const doc = container.ownerDocument;
|
|
60
|
+
const win = doc.defaultView;
|
|
61
|
+
const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
|
|
62
|
+
const density = options.density === "touch" ? "touch" : "comfortable";
|
|
63
|
+
const includeHidden = options.includeHidden === true;
|
|
64
|
+
|
|
65
|
+
// The snapshot is cached between edits, so reading it on every move is cheap.
|
|
66
|
+
const deckNow = () => (editor ? (editor.snapshot?.().presentation ?? editor.presentation) : typeof source === "function" ? source() : source);
|
|
67
|
+
const resolveOptions = () => ({ ...(options.renderOptions ?? {}), catalogs: mergeCatalogs(editor?.catalogs, options.renderOptions?.catalogs) });
|
|
68
|
+
const drawOptions = () => ({ ...resolveOptions(), fonts: options.fonts, text: "system", trace: false });
|
|
69
|
+
// Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
|
|
70
|
+
let drawn = new Map(), drawnFor;
|
|
71
|
+
|
|
72
|
+
let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, leaving = false;
|
|
73
|
+
let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
|
|
74
|
+
|
|
75
|
+
// ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
|
|
76
|
+
const slide = el(doc, "div", { class: "opf-presenter-slide" });
|
|
77
|
+
const message = el(doc, "p", { class: "opf-presenter-message", hidden: true });
|
|
78
|
+
const counter = el(doc, "span", { class: "opf-presenter-counter", "aria-hidden": "true" });
|
|
79
|
+
const closeButton = el(doc, "button", { type: "button", class: "opf-presenter-close", "aria-label": labels.exit, title: labels.exitTitle }, icon(doc, "X", 18));
|
|
80
|
+
const live = el(doc, "div", { class: "opf-presenter-live", role: "status", "aria-live": "polite", "aria-atomic": "true" });
|
|
81
|
+
const root = el(doc, "div", {
|
|
82
|
+
class: "opf-presenter opf-ui", role: "dialog", "aria-modal": "true", "aria-label": labels.dialog, tabindex: "-1", hidden: true,
|
|
83
|
+
"data-density": density, "data-mode": "overlay", "data-chrome": "hidden",
|
|
84
|
+
}, slide, message, counter, closeButton, live);
|
|
85
|
+
container.append(root);
|
|
86
|
+
|
|
87
|
+
// ---- slides -----------------------------------------------------------------------------------------------------------------------------
|
|
88
|
+
/** The slide to show for a requested index: that slide, else the next one that is not hidden, else the last one before it. -1: none. */
|
|
89
|
+
function resolve(deck, wanted) {
|
|
90
|
+
const shown = visibleIndices(deck, includeHidden);
|
|
91
|
+
if (!shown.length) return -1;
|
|
92
|
+
const target = clampIndex(deck, wanted);
|
|
93
|
+
return shown.find((index) => index >= target) ?? shown[shown.length - 1];
|
|
94
|
+
}
|
|
95
|
+
function showMessage(text) {
|
|
96
|
+
slide.replaceChildren();
|
|
97
|
+
message.textContent = text;
|
|
98
|
+
message.hidden = false;
|
|
99
|
+
}
|
|
100
|
+
function paint(html) {
|
|
101
|
+
if (typeof html === "string" && /^\s*<svg[\s>]/.test(html)) {
|
|
102
|
+
message.hidden = true;
|
|
103
|
+
slide.innerHTML = html;
|
|
104
|
+
} else showMessage(typeof html === "string" ? html : "Preview unavailable");
|
|
105
|
+
}
|
|
106
|
+
// Draw the listed slides that are not drawn yet, in one renderer call (it composes the deck once). A slide that cannot be drawn keeps the renderer's message.
|
|
107
|
+
function draw(deck, indices) {
|
|
108
|
+
if (deck !== drawnFor) { drawn = new Map(); drawnFor = deck; }
|
|
109
|
+
const missing = indices.filter((index) => !drawn.has(index));
|
|
110
|
+
if (!missing.length) return;
|
|
111
|
+
const options = drawOptions();
|
|
112
|
+
try {
|
|
113
|
+
const images = toSvg(deck, missing.map((index) => index + 1), options);
|
|
114
|
+
missing.forEach((index, position) => drawn.set(index, images[position]));
|
|
115
|
+
} catch {
|
|
116
|
+
// One bad slide must not hide the others.
|
|
117
|
+
for (const index of missing) {
|
|
118
|
+
try { drawn.set(index, toSvg(deck, index + 1, options)); } catch (error) { drawn.set(index, error?.message ?? "Preview unavailable"); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function preload(deck, shown, index) {
|
|
123
|
+
win.clearTimeout(preloadTimer);
|
|
124
|
+
const at = shown.indexOf(index);
|
|
125
|
+
const wanted = [shown[at - 1], shown[at + 1]].filter((value) => value !== undefined);
|
|
126
|
+
if (!wanted.length) return;
|
|
127
|
+
preloadTimer = win.setTimeout(() => {
|
|
128
|
+
if (active) draw(deck, wanted);
|
|
129
|
+
}, PRELOAD_DELAY_MS);
|
|
130
|
+
}
|
|
131
|
+
// Draw the slide once the faces its deck needs are loaded (a deck in loaded faces draws synchronously).
|
|
132
|
+
function render(index) {
|
|
133
|
+
const token = ++renderToken;
|
|
134
|
+
const deck = deckNow();
|
|
135
|
+
const renderOptions = resolveOptions();
|
|
136
|
+
return whenFontsReady(options.fonts, deck, {
|
|
137
|
+
renderOptions,
|
|
138
|
+
isCurrent: () => active && token === renderToken,
|
|
139
|
+
loading: () => showMessage(labels.loading),
|
|
140
|
+
ready: () => {
|
|
141
|
+
draw(deck, [index]);
|
|
142
|
+
paint(drawn.get(index));
|
|
143
|
+
preload(deck, visibleIndices(deck, includeHidden), index);
|
|
144
|
+
},
|
|
145
|
+
failed: (error) => showMessage(error?.message ?? "Preview unavailable"),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function announce(deck, index) {
|
|
149
|
+
const shown = visibleIndices(deck, includeHidden);
|
|
150
|
+
const position = shown.indexOf(index);
|
|
151
|
+
counter.textContent = formatSlideCounter(Math.max(0, position), shown.length);
|
|
152
|
+
live.textContent = position < 0 ? "" : labels.position(position + 1, shown.length);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function show(wanted, { notify = true } = {}) {
|
|
156
|
+
const deck = deckNow();
|
|
157
|
+
const index = resolve(deck, wanted);
|
|
158
|
+
if (index < 0) {
|
|
159
|
+
current = -1;
|
|
160
|
+
renderToken++;
|
|
161
|
+
showMessage(labels.empty);
|
|
162
|
+
counter.textContent = formatSlideCounter(0, 0);
|
|
163
|
+
live.textContent = labels.empty;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const changed = index !== current;
|
|
167
|
+
current = index;
|
|
168
|
+
announce(deck, index);
|
|
169
|
+
render(index);
|
|
170
|
+
if (changed && notify) options.onSlideChange?.(index);
|
|
171
|
+
return changed;
|
|
172
|
+
}
|
|
173
|
+
function move(step) {
|
|
174
|
+
if (!active || current < 0) return false;
|
|
175
|
+
const shown = visibleIndices(deckNow(), includeHidden);
|
|
176
|
+
const at = shown.indexOf(current);
|
|
177
|
+
// The shown slide can be gone (the deck changed under it): step from where it was.
|
|
178
|
+
const target = at >= 0 ? shown[at + step] : step > 0 ? shown.find((index) => index > current) : [...shown].reverse().find((index) => index < current);
|
|
179
|
+
return target === undefined ? false : show(target);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- the counter ------------------------------------------------------------------------------------------------------------------------
|
|
183
|
+
function showCounter() {
|
|
184
|
+
if (!active) return;
|
|
185
|
+
root.dataset.chrome = "visible";
|
|
186
|
+
win.clearTimeout(counterTimer);
|
|
187
|
+
counterTimer = win.setTimeout(() => { root.dataset.chrome = "hidden"; }, COUNTER_MS);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- full screen ------------------------------------------------------------------------------------------------------------------------
|
|
191
|
+
// The element is always a fixed overlay; the Fullscreen API only makes the browser give the screen to it. When the browser refuses (no
|
|
192
|
+
// user gesture, an iframe without `allow="fullscreen"`, iPhone Safari), the overlay is the presentation.
|
|
193
|
+
function enterFullscreen() {
|
|
194
|
+
const request = root.requestFullscreen ?? root.webkitRequestFullscreen;
|
|
195
|
+
if (typeof request !== "function" || doc.fullscreenEnabled === false) return Promise.resolve("overlay");
|
|
196
|
+
let attempt;
|
|
197
|
+
try { attempt = Promise.resolve(request.call(root, { navigationUI: "hide" })); } catch (error) { attempt = Promise.reject(error); }
|
|
198
|
+
return attempt.then(
|
|
199
|
+
() => {
|
|
200
|
+
if (!active || doc.fullscreenElement !== root) return "overlay";
|
|
201
|
+
enteredFullscreen = true;
|
|
202
|
+
root.dataset.mode = "fullscreen";
|
|
203
|
+
return "fullscreen";
|
|
204
|
+
},
|
|
205
|
+
() => "overlay",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
|
|
209
|
+
function onFullscreenChange() {
|
|
210
|
+
if (doc.fullscreenElement === root) return;
|
|
211
|
+
if (leaving) {
|
|
212
|
+
// The exit that stop() asked for finished. If the host started again meanwhile, full screen is gone and the overlay carries on.
|
|
213
|
+
leaving = false;
|
|
214
|
+
if (active && enteredFullscreen) { enteredFullscreen = false; root.dataset.mode = "overlay"; }
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (active && enteredFullscreen) stop();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- input ------------------------------------------------------------------------------------------------------------------------------
|
|
221
|
+
function onKeydown(event) {
|
|
222
|
+
if (!active || event.altKey || event.ctrlKey || event.metaKey || event.isComposing) return;
|
|
223
|
+
const key = event.key;
|
|
224
|
+
const onButton = event.target === closeButton;
|
|
225
|
+
let handled = true;
|
|
226
|
+
if (key === "Escape") stop();
|
|
227
|
+
else if (key === "Tab") (doc.activeElement === closeButton ? root : closeButton).focus();
|
|
228
|
+
else if (onButton && (key === "Enter" || key === " ")) handled = false; // the button's own click exits
|
|
229
|
+
else if (NEXT_KEYS.has(key)) move(1);
|
|
230
|
+
else if (PREVIOUS_KEYS.has(key)) move(-1);
|
|
231
|
+
else if (key === "Home") show(0);
|
|
232
|
+
else if (key === "End") show(Number.MAX_SAFE_INTEGER);
|
|
233
|
+
else handled = false;
|
|
234
|
+
if (handled) { event.preventDefault(); event.stopPropagation(); }
|
|
235
|
+
}
|
|
236
|
+
// A press on the left third goes back, anywhere else forward.
|
|
237
|
+
function byZone(clientX) {
|
|
238
|
+
const box = root.getBoundingClientRect();
|
|
239
|
+
return move(clientX < box.left + box.width / 3 ? -1 : 1);
|
|
240
|
+
}
|
|
241
|
+
function onClick(event) {
|
|
242
|
+
if (!active || closeButton.contains(event.target)) return;
|
|
243
|
+
// A touch is handled on pointerup (below); the click the browser adds after it must not move a second time.
|
|
244
|
+
if (win.performance.now() < clickGuardUntil) return;
|
|
245
|
+
byZone(event.clientX);
|
|
246
|
+
}
|
|
247
|
+
function onPointerdown(event) {
|
|
248
|
+
showCounter();
|
|
249
|
+
swipe = event.pointerType === "mouse" ? null : { id: event.pointerId, x: event.clientX, y: event.clientY, at: win.performance.now() };
|
|
250
|
+
}
|
|
251
|
+
// Touch and pen: a swipe moves, and a tap uses the click zones. Both are read here, so they do not depend on the browser's click for touch.
|
|
252
|
+
function onPointerup(event) {
|
|
253
|
+
const start = swipe;
|
|
254
|
+
swipe = null;
|
|
255
|
+
if (!active || !start || start.id !== event.pointerId) return;
|
|
256
|
+
const dx = event.clientX - start.x, dy = event.clientY - start.y, now = win.performance.now(), elapsed = now - start.at;
|
|
257
|
+
if (Math.abs(dx) >= SWIPE_MIN && Math.abs(dx) >= Math.abs(dy) * 1.5 && elapsed <= SWIPE_MAX_MS) {
|
|
258
|
+
clickGuardUntil = now + CLICK_GUARD_MS;
|
|
259
|
+
move(dx < 0 ? 1 : -1);
|
|
260
|
+
} else if (Math.abs(dx) <= TAP_SLOP && Math.abs(dy) <= TAP_SLOP && elapsed <= TAP_MAX_MS && !closeButton.contains(event.target)) {
|
|
261
|
+
clickGuardUntil = now + CLICK_GUARD_MS;
|
|
262
|
+
byZone(event.clientX);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Focus stays inside the dialog: the page behind it is not reachable while it is open.
|
|
266
|
+
function onFocusin(event) {
|
|
267
|
+
if (active && !root.contains(event.target)) root.focus({ preventScroll: true });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
root.addEventListener("click", onClick);
|
|
271
|
+
root.addEventListener("pointermove", showCounter);
|
|
272
|
+
root.addEventListener("pointerdown", onPointerdown);
|
|
273
|
+
root.addEventListener("pointerup", onPointerup);
|
|
274
|
+
root.addEventListener("pointercancel", () => { swipe = null; });
|
|
275
|
+
closeButton.addEventListener("click", () => stop());
|
|
276
|
+
// Always listening: the exit that stop() asks for arrives after stop() returned.
|
|
277
|
+
doc.addEventListener("fullscreenchange", onFullscreenChange);
|
|
278
|
+
doc.addEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
279
|
+
|
|
280
|
+
// ---- start and stop ---------------------------------------------------------------------------------------------------------------------
|
|
281
|
+
function start(slideIndex) {
|
|
282
|
+
if (destroyed) return Promise.resolve("overlay");
|
|
283
|
+
const wanted = slideIndex ?? options.slideIndex ?? 0;
|
|
284
|
+
if (active) { show(wanted); return Promise.resolve(root.dataset.mode); }
|
|
285
|
+
active = true;
|
|
286
|
+
opener = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : null;
|
|
287
|
+
current = -1;
|
|
288
|
+
drawn = new Map();
|
|
289
|
+
drawnFor = undefined;
|
|
290
|
+
root.hidden = false;
|
|
291
|
+
root.dataset.mode = "overlay";
|
|
292
|
+
root.dataset.chrome = "hidden";
|
|
293
|
+
doc.addEventListener("keydown", onKeydown, true);
|
|
294
|
+
doc.addEventListener("focusin", onFocusin, true);
|
|
295
|
+
// Full screen must be requested inside the user's gesture, so before anything is drawn.
|
|
296
|
+
const entering = enterFullscreen();
|
|
297
|
+
root.focus({ preventScroll: true });
|
|
298
|
+
show(wanted, { notify: false });
|
|
299
|
+
// The host asked for a slide that is hidden: tell it which slide the presentation really opened on.
|
|
300
|
+
if (current >= 0 && current !== clampIndex(deckNow(), wanted)) options.onSlideChange?.(current);
|
|
301
|
+
return entering;
|
|
302
|
+
}
|
|
303
|
+
function stop() {
|
|
304
|
+
if (!active) return;
|
|
305
|
+
const index = current;
|
|
306
|
+
active = false;
|
|
307
|
+
enteredFullscreen = false;
|
|
308
|
+
renderToken++;
|
|
309
|
+
win.clearTimeout(counterTimer);
|
|
310
|
+
win.clearTimeout(preloadTimer);
|
|
311
|
+
doc.removeEventListener("keydown", onKeydown, true);
|
|
312
|
+
doc.removeEventListener("focusin", onFocusin, true);
|
|
313
|
+
const fullscreen = doc.fullscreenElement === root;
|
|
314
|
+
root.hidden = true;
|
|
315
|
+
slide.replaceChildren();
|
|
316
|
+
swipe = null;
|
|
317
|
+
const back = opener;
|
|
318
|
+
opener = null;
|
|
319
|
+
const restoreFocus = () => {
|
|
320
|
+
// Only when nobody took focus since: the host may move it in `onExit`.
|
|
321
|
+
const now = doc.activeElement;
|
|
322
|
+
if (back?.isConnected && typeof back.focus === "function" && (!now || now === doc.body || now === root)) back.focus({ preventScroll: true });
|
|
323
|
+
};
|
|
324
|
+
restoreFocus();
|
|
325
|
+
if (fullscreen) {
|
|
326
|
+
leaving = true;
|
|
327
|
+
// The page behind a full screen element takes no focus until the exit is done.
|
|
328
|
+
Promise.resolve(doc.exitFullscreen?.()).then(restoreFocus, () => { leaving = false; });
|
|
329
|
+
}
|
|
330
|
+
// The deck index the presentation ended on (-1 when no slide was shown: there was nothing to present).
|
|
331
|
+
options.onExit?.(index);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
element: root,
|
|
336
|
+
/** Open the presentation on `slideIndex` (default: the `slideIndex` option). Resolves `"fullscreen"` or `"overlay"` once the browser answered. */
|
|
337
|
+
start,
|
|
338
|
+
/** Close the presentation. `onExit(index)` is called with the slide it showed. */
|
|
339
|
+
stop,
|
|
340
|
+
/** Show the next slide that is not hidden. Returns false at the end. */
|
|
341
|
+
next: () => move(1),
|
|
342
|
+
/** Show the previous slide that is not hidden. Returns false at the start. */
|
|
343
|
+
previous: () => move(-1),
|
|
344
|
+
/** Show a slide by its deck index; a hidden slide shows the next one that is not hidden. Returns false when the slide did not change. */
|
|
345
|
+
goTo: (index) => (active ? show(index) : false),
|
|
346
|
+
/** The deck index of the shown slide (-1 when stopped or empty). */
|
|
347
|
+
getSlide: () => (active ? current : -1),
|
|
348
|
+
isRunning: () => active,
|
|
349
|
+
destroy() {
|
|
350
|
+
if (destroyed) return;
|
|
351
|
+
stop();
|
|
352
|
+
destroyed = true;
|
|
353
|
+
doc.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
354
|
+
doc.removeEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
355
|
+
root.remove();
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---- the Present button ---------------------------------------------------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/** True when a key press belongs to something else: typing, an editor, a menu or a dialog. */
|
|
363
|
+
function isInteractiveTarget(target) {
|
|
364
|
+
if (!target || typeof target.closest !== "function") return false;
|
|
365
|
+
if (target.isContentEditable) return true;
|
|
366
|
+
const tag = target.tagName;
|
|
367
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
368
|
+
return Boolean(target.closest('[contenteditable=""],[contenteditable="true"],.cm-editor,[role="textbox"],[role="combobox"],[role="listbox"],[role="menu"],[role="slider"],[role="spinbutton"],dialog[open],[role="dialog"]'));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Mount the Present button in `container`: an icon-only button (Lucide Presentation) named "Present", titled "Present (F5)".
|
|
373
|
+
*
|
|
374
|
+
* Options: `onPresent(event)`, `disabled` (`true`, or a string that says why and is the title), `density` (`"comfortable"` 32px, or `"touch"` 40px)
|
|
375
|
+
* `label` (the accessible name, default "Present") and `variant` (`"quiet"`, the default: a transparent icon button like the toolbar's undo and redo,
|
|
376
|
+
* or `"primary"`: the filled accent look of the Export trigger). A disabled button stays focusable (`aria-disabled`) and does nothing.
|
|
377
|
+
*
|
|
378
|
+
* Returns `{ element, setDisabled(reason), setDensity(density), setVariant(variant), destroy() }`.
|
|
379
|
+
*/
|
|
380
|
+
export function createPresentButton(container, options = {}) {
|
|
381
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
382
|
+
const doc = container.ownerDocument;
|
|
383
|
+
const label = options.label ?? "Present";
|
|
384
|
+
let disabledReason = options.disabled === true ? "Nothing to present." : typeof options.disabled === "string" && options.disabled ? options.disabled : undefined;
|
|
385
|
+
let destroyed = false;
|
|
386
|
+
const button = el(doc, "button", {
|
|
387
|
+
type: "button", class: "opf-present-button opf-ui", "aria-label": label, title: "Present (F5)", "aria-keyshortcuts": "F5",
|
|
388
|
+
"data-density": options.density === "touch" ? "touch" : "comfortable", "data-variant": options.variant === "primary" ? "primary" : "quiet",
|
|
389
|
+
}, icon(doc, "Presentation", 16));
|
|
390
|
+
const paint = () => {
|
|
391
|
+
if (disabledReason) { button.setAttribute("aria-disabled", "true"); button.title = disabledReason; }
|
|
392
|
+
else { button.removeAttribute("aria-disabled"); button.title = "Present (F5)"; }
|
|
393
|
+
};
|
|
394
|
+
button.addEventListener("click", (event) => {
|
|
395
|
+
if (destroyed || disabledReason) return;
|
|
396
|
+
options.onPresent?.(event);
|
|
397
|
+
});
|
|
398
|
+
paint();
|
|
399
|
+
container.append(button);
|
|
400
|
+
return {
|
|
401
|
+
element: button,
|
|
402
|
+
/** `true` disables with a default reason, a string with your own (it becomes the title), `false` enables. */
|
|
403
|
+
setDisabled(reason) {
|
|
404
|
+
disabledReason = reason === true ? "Nothing to present." : typeof reason === "string" && reason ? reason : undefined;
|
|
405
|
+
paint();
|
|
406
|
+
},
|
|
407
|
+
setDensity(next) { button.dataset.density = next === "touch" ? "touch" : "comfortable"; },
|
|
408
|
+
setVariant(next) { button.dataset.variant = next === "primary" ? "primary" : "quiet"; },
|
|
409
|
+
destroy() { destroyed = true; button.remove(); },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* F5 starts the presentation (`onPresent(event)`), and the browser's reload is suppressed when it does. `target` defaults to the document. F5 is
|
|
415
|
+
* ignored while typing in inputs, selects and editors (CodeMirror, contenteditable), in menus and in dialogs, and with Ctrl, Alt, Meta or Shift held.
|
|
416
|
+
* Returns a function that removes the listener.
|
|
417
|
+
*/
|
|
418
|
+
export function bindPresentShortcut(target, onPresent) {
|
|
419
|
+
if (typeof onPresent !== "function") throw new TypeError("bindPresentShortcut needs onPresent().");
|
|
420
|
+
const doc = target?.ownerDocument ?? target?.document ?? (typeof document === "undefined" ? undefined : document);
|
|
421
|
+
const handler = (event) => {
|
|
422
|
+
if (event.key !== "F5" || event.defaultPrevented || event.repeat || event.isComposing) return;
|
|
423
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
424
|
+
if (isInteractiveTarget(event.target) || isInteractiveTarget(doc?.activeElement)) return;
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
onPresent(event);
|
|
427
|
+
};
|
|
428
|
+
const listener = target ?? doc;
|
|
429
|
+
listener.addEventListener("keydown", handler);
|
|
430
|
+
return () => listener.removeEventListener("keydown", handler);
|
|
431
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Catalog, Finding } from "@openpresentation/opf";
|
|
2
|
+
|
|
3
|
+
/** The `accept` value for a file input that opens a deck: `".json,.opf,.yaml,.yml,.md"`. */
|
|
4
|
+
export declare const DECK_FILE_ACCEPT: string;
|
|
5
|
+
|
|
6
|
+
/** The format a deck file was read as: `json` (canonical), `yaml` (`.opf.yaml`) or `md` (`.opf.md`). */
|
|
7
|
+
export type DeckFileFormat = "json" | "yaml" | "md";
|
|
8
|
+
|
|
9
|
+
/** A file, or a name and its text. A `File` or any object with `name` and `text()` works; `size` is checked against the 20 MB limit when present. */
|
|
10
|
+
export type DeckFileInput = { name: string; size?: number; text(): Promise<string> } | { name: string; text: string };
|
|
11
|
+
|
|
12
|
+
export interface ReadDeckFileOptions {
|
|
13
|
+
/** The host's catalogs, for the references check of the YAML and Markdown conversion. Nothing is fetched. */
|
|
14
|
+
catalogs?: readonly Catalog[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A conversion finding. Core's `Finding`: `path` is a JSON Pointer into the deck when there is one, `location` the line and column in the text. */
|
|
18
|
+
export type DeckFileFinding = Finding & { location?: { offset: number; length: number; line: number; column: number } };
|
|
19
|
+
|
|
20
|
+
export interface DeckFile {
|
|
21
|
+
/** The deck. Not validated for JSON; for YAML and Markdown the OPF errors are in `findings`. */
|
|
22
|
+
document: any;
|
|
23
|
+
/** The format that read it, which can differ from the one the extension suggests (see `readDeckFile`). */
|
|
24
|
+
format: DeckFileFormat;
|
|
25
|
+
/** The file name without `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension (`deck` when nothing is left). */
|
|
26
|
+
name: string;
|
|
27
|
+
/** The conversion findings of YAML and Markdown, in source order. Empty for JSON. */
|
|
28
|
+
findings: DeckFileFinding[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why a file could not be read:
|
|
33
|
+
* `unreadable` (not a file, or reading it failed), `file-too-large` (over 20 MB), `empty-file`, `unsupported-file` (a binary file such as .pptx),
|
|
34
|
+
* `invalid-json` (not JSON, YAML or Markdown with front matter), `invalid-yaml`, `invalid-markdown` (syntax errors, in `findings`) and
|
|
35
|
+
* `not-a-deck` (JSON that has no `slides` list).
|
|
36
|
+
*/
|
|
37
|
+
export type DeckFileErrorCode = "unreadable" | "file-too-large" | "empty-file" | "unsupported-file" | "invalid-json" | "invalid-yaml" | "invalid-markdown" | "not-a-deck";
|
|
38
|
+
|
|
39
|
+
export declare class DeckFileError extends Error {
|
|
40
|
+
readonly name: "DeckFileError";
|
|
41
|
+
readonly code: DeckFileErrorCode;
|
|
42
|
+
/** The format that was tried first (from the extension). */
|
|
43
|
+
readonly format?: DeckFileFormat;
|
|
44
|
+
readonly fileName?: string;
|
|
45
|
+
/** The parser's findings for `invalid-yaml` and `invalid-markdown`. */
|
|
46
|
+
readonly findings: DeckFileFinding[];
|
|
47
|
+
constructor(code: DeckFileErrorCode, message: string, context?: { format?: DeckFileFormat; name?: string; findings?: DeckFileFinding[]; cause?: unknown });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The file name without its directory and its `.opf.json`, `.opf.yaml`, `.opf.md` or plain extension. */
|
|
51
|
+
export declare function deckFileName(fileName: string): string;
|
|
52
|
+
/** The format the extension suggests: `.yaml`/`.yml` YAML, `.md` Markdown, anything else JSON. */
|
|
53
|
+
export declare function deckFileFormat(fileName: string): DeckFileFormat;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a deck file in any OPF source format. The extension picks the format; text that is not JSON falls back to YAML, and to Markdown when it
|
|
57
|
+
* starts with front matter. YAML and Markdown go through core's `fromYaml` and `fromMarkdown` (loaded on first use). Throws `DeckFileError`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readDeckFile(file: DeckFileInput | File, options?: ReadDeckFileOptions): Promise<DeckFile>;
|