@openpresentation/opf-editor 0.24.2 → 0.24.3

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 CHANGED
@@ -831,6 +831,67 @@ const dialog = createAgentHandoffDialog(document.body, { document: deck, target:
831
831
 
832
832
  Icons are inlined path data from [Lucide](https://lucide.dev) (ISC license), never loaded from a CDN.
833
833
 
834
+ ## Presenter and Present button (RR-98)
835
+
836
+ `@openpresentation/opf-editor/presenter` shows the deck full screen, one slide at a time. It is plain DOM like the other modules, with no network, no fonts or CSS fetched at runtime, and no second renderer: slides are drawn by `toSvg` with the same fonts handle, catalogs and render options the canvas and the thumbnails use. Its CSS is `src/styles/presenter.css` (classes `opf-presenter-*` and `opf-present-button`; `--opf-ui-*` tokens only, part of `styles.css`). The module needs `@openpresentation/opf-render`, an optional peer, like the thumbnail strip.
837
+
838
+ ```js
839
+ import { createPresenter, createPresentButton, bindPresentShortcut } from "@openpresentation/opf-editor/presenter";
840
+
841
+ const presenter = createPresenter(document.body, {
842
+ editor, // or presentation: deck (an object, or a function returning it)
843
+ fonts, // the renderer's fonts handle, the same one the canvas and the exports use
844
+ renderOptions: { catalogs }, // merged after the session's catalogs
845
+ slideIndex: current, // the slide start() opens on (default 0)
846
+ onSlideChange: (index) => {}, // the shown slide changed (deck index)
847
+ onExit: (index) => { current = index; redraw(); }, // by any route: the host returns to that slide
848
+ density: "comfortable", // "touch": the exit button is 40px
849
+ });
850
+ createPresentButton(document.querySelector("#present"), { onPresent: () => presenter.start(current) });
851
+ bindPresentShortcut(document, () => presenter.start(current)); // F5
852
+ ```
853
+
854
+ `createPresenter(container, options)` returns `{ element, start(slideIndex?), stop(), next(), previous(), goTo(index), getSlide(), isRunning(), destroy() }`. The presenter stays hidden until `start`. Call `start` from a user gesture (a click or a key press): the browser grants full screen only then. `start` resolves `"fullscreen"` or `"overlay"`.
855
+
856
+ - **Display.** The presenter's own element goes full screen through the Fullscreen API. When the browser refuses (no gesture, an iframe without `allow="fullscreen"`, iPhone Safari), the same element is a fixed overlay over the window, so everything below works the same. Slides fill the viewport and letterbox on black. The next and previous slides are drawn ahead, so advancing draws nothing. Slides draw with their `{{variables}}` resolved, as the exports do.
857
+ - **Navigation.** Right, Space, PageDown and Enter go forward; Left, PageUp and Backspace go back; Home and End jump; Esc exits. A click advances, and a click on the left third goes back. On touch, a swipe left goes forward and a swipe right goes back; a tap uses the same zones. Keys with Ctrl, Alt or Meta are left to the browser, and the keys the presenter handles do not reach the host page.
858
+ - **Hidden slides** are skipped, as the exports skip them (`slidesToConvert`; `includeHidden: true` presents them). `start(i)` and `goTo(i)` on a hidden slide show the next slide that is not hidden (`onSlideChange` says which). A deck with only hidden slides shows a message and `onExit(-1)`.
859
+ - **Counter.** A subtle "03 / 12" (mono, the navigator's format; hidden slides are not counted) and the exit button show on pointer move and hide after 2 seconds, with the cursor.
860
+ - **Exit.** Esc, the exit button, leaving full screen (the browser's Esc), `stop()` and `destroy()` all call `onExit(index)` with the deck index of the slide shown, then focus returns to the element that was focused at `start`.
861
+ - **Accessibility.** The element is `role="dialog"` with `aria-modal="true"` and an `aria-label` ("Presentation"; `labels.dialog`). Focus moves into it on start and stays in it (Tab cycles the dialog and the exit button). An `aria-live="polite"` region announces "Slide 3 of 12" (`labels.position`).
862
+ - **Fonts.** Like the canvas, the presenter waits for the faces the deck needs (`fonts.pending` and `fonts.ensure`) and shows "Loading fonts…" meanwhile.
863
+
864
+ `createPresentButton(container, { onPresent, disabled, density })` mounts an icon-only button (the Lucide Presentation icon, inlined) named "Present" (`aria-label`), titled "Present (F5)" and styled like the Export menu's trigger. `disabled` is `true` or a reason string (the title); the button stays focusable (`aria-disabled`) and does nothing. It returns `{ element, setDisabled(reason), setDensity(density), destroy() }`. `bindPresentShortcut(target, onPresent)` binds F5 (and prevents the browser's reload); it ignores F5 while typing in inputs, selects and editors (CodeMirror, contenteditable), in menus and dialogs, and with a modifier key, and returns a function that removes the listener. `examples/playground.js` wires all three next to the Export menu.
865
+
866
+ Tests: `npm run test:presenter-browser` and `npm run test:present-button-browser`.
867
+
868
+ ## Opening a deck file (RR-98)
869
+
870
+ `@openpresentation/opf-editor/read-deck-file` reads a deck file in any OPF source format, with no DOM:
871
+
872
+ ```js
873
+ import { readDeckFile, DECK_FILE_ACCEPT, DeckFileError } from "@openpresentation/opf-editor/read-deck-file";
874
+
875
+ input.accept = DECK_FILE_ACCEPT; // ".json,.opf,.yaml,.yml,.md"
876
+ input.onchange = async () => {
877
+ try {
878
+ const { document, format, name, findings } = await readDeckFile(input.files[0], { catalogs });
879
+ const editor = createEditorSession(document, { catalogs }); // the session validates it; findings say what to fix first
880
+ } catch (error) {
881
+ if (error instanceof DeckFileError) show(error.code, error.message, error.findings);
882
+ }
883
+ };
884
+ ```
885
+
886
+ `readDeckFile(file | { name, text }, { catalogs? })` takes a `File` (or anything with `name` and `text()`), or `{ name, text }` with the text as a string, and returns `{ document, format: "json" | "yaml" | "md", name, findings }`.
887
+
888
+ - **Format.** The extension picks it: `.yaml` and `.yml` are YAML, `.md` is Markdown, and `.json`, `.opf` or anything else is JSON. If JSON fails to parse, the text is tried as YAML, and as Markdown when it starts with front matter (`---`); `format` is the one that read it. YAML and Markdown go through core's browser-safe `fromYaml` and `fromMarkdown` (`@openpresentation/opf/yaml` and `/markdown`, loaded on first use). JSON that wraps a deck (`opfSnippet`, `opf`, `presentation`) is unwrapped as `parseOpfTransfer` does.
889
+ - **Name.** The file name without a directory and without `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension (`Q3 plan.opf.yaml` gives `Q3 plan`).
890
+ - **Findings.** The conversion's findings for YAML and Markdown, in core's `Finding` shape: `path` (a JSON Pointer such as `/slides/1/bogus`), `message`, `severity` and `location` (line and column in the file). A deck that reads but has OPF errors is returned with them, so a host can show them; JSON gives none (the editor session validates it).
891
+ - **Errors.** Text that cannot be read at all throws `DeckFileError` with a `code` and a clear message: `unreadable`, `file-too-large` (over 20 MB), `empty-file`, `unsupported-file` (a .pptx or other binary file), `invalid-json`, `invalid-yaml`, `invalid-markdown` (with the syntax `findings` and the line) and `not-a-deck` (JSON without a `slides` list, such as one slide or a block, which a host can send to its paste path as the playground does).
892
+
893
+ The source pane has no file-opening path of its own, so it keeps no second copy of this. The playground's Import dialog uses `readDeckFile` and `DECK_FILE_ACCEPT` for its file tab and for dropped files (`examples/transfer-controls.js`). Icons in the presenter button are inlined [Lucide](https://lucide.dev) paths (ISC license).
894
+
834
895
  ## Optional React Bindings
835
896
 
836
897
  React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
@@ -0,0 +1,102 @@
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
+ }
87
+
88
+ export interface PresentButton {
89
+ readonly element: HTMLButtonElement;
90
+ setDisabled(reason?: boolean | string): void;
91
+ setDensity(density: "comfortable" | "touch"): void;
92
+ destroy(): void;
93
+ }
94
+
95
+ /** The icon-only Present button: a Lucide Presentation icon, `aria-label="Present"`, title "Present (F5)", styled like the Export trigger. */
96
+ export declare function createPresentButton(container: HTMLElement, options?: PresentButtonOptions): PresentButton;
97
+
98
+ /**
99
+ * Bind F5 to `onPresent(event)` on `target` (default: the document); the browser's reload is suppressed when it fires. F5 is ignored while
100
+ * typing in inputs, selects and editors, in menus and dialogs, and with a modifier key held. Returns a function that removes the listener.
101
+ */
102
+ export declare function bindPresentShortcut(target: Document | HTMLElement | Window | undefined | null, onPresent: (event: KeyboardEvent) => void): () => void;
@@ -0,0 +1,429 @@
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
+ * and `label` (the accessible name, default "Present"). A disabled button stays focusable (`aria-disabled`) and does nothing.
376
+ *
377
+ * Returns `{ element, setDisabled(reason), setDensity(density), destroy() }`.
378
+ */
379
+ export function createPresentButton(container, options = {}) {
380
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
381
+ const doc = container.ownerDocument;
382
+ const label = options.label ?? "Present";
383
+ let disabledReason = options.disabled === true ? "Nothing to present." : typeof options.disabled === "string" && options.disabled ? options.disabled : undefined;
384
+ let destroyed = false;
385
+ const button = el(doc, "button", {
386
+ type: "button", class: "opf-present-button opf-ui", "aria-label": label, title: "Present (F5)", "aria-keyshortcuts": "F5",
387
+ "data-density": options.density === "touch" ? "touch" : "comfortable",
388
+ }, icon(doc, "Presentation", 16));
389
+ const paint = () => {
390
+ if (disabledReason) { button.setAttribute("aria-disabled", "true"); button.title = disabledReason; }
391
+ else { button.removeAttribute("aria-disabled"); button.title = "Present (F5)"; }
392
+ };
393
+ button.addEventListener("click", (event) => {
394
+ if (destroyed || disabledReason) return;
395
+ options.onPresent?.(event);
396
+ });
397
+ paint();
398
+ container.append(button);
399
+ return {
400
+ element: button,
401
+ /** `true` disables with a default reason, a string with your own (it becomes the title), `false` enables. */
402
+ setDisabled(reason) {
403
+ disabledReason = reason === true ? "Nothing to present." : typeof reason === "string" && reason ? reason : undefined;
404
+ paint();
405
+ },
406
+ setDensity(next) { button.dataset.density = next === "touch" ? "touch" : "comfortable"; },
407
+ destroy() { destroyed = true; button.remove(); },
408
+ };
409
+ }
410
+
411
+ /**
412
+ * F5 starts the presentation (`onPresent(event)`), and the browser's reload is suppressed when it does. `target` defaults to the document. F5 is
413
+ * ignored while typing in inputs, selects and editors (CodeMirror, contenteditable), in menus and in dialogs, and with Ctrl, Alt, Meta or Shift held.
414
+ * Returns a function that removes the listener.
415
+ */
416
+ export function bindPresentShortcut(target, onPresent) {
417
+ if (typeof onPresent !== "function") throw new TypeError("bindPresentShortcut needs onPresent().");
418
+ const doc = target?.ownerDocument ?? target?.document ?? (typeof document === "undefined" ? undefined : document);
419
+ const handler = (event) => {
420
+ if (event.key !== "F5" || event.defaultPrevented || event.repeat || event.isComposing) return;
421
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
422
+ if (isInteractiveTarget(event.target) || isInteractiveTarget(doc?.activeElement)) return;
423
+ event.preventDefault();
424
+ onPresent(event);
425
+ };
426
+ const listener = target ?? doc;
427
+ listener.addEventListener("keydown", handler);
428
+ return () => listener.removeEventListener("keydown", handler);
429
+ }
@@ -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>;
@@ -0,0 +1,142 @@
1
+ // RR-98: read a deck file of any OPF source format. `readDeckFile(file | { name, text })` detects JSON, YAML or Markdown (the extension decides
2
+ // first, then the content), converts YAML and Markdown with core's browser-safe `fromYaml` and `fromMarkdown` (`@openpresentation/opf/yaml`, `/markdown`,
3
+ // loaded on first use) and returns `{ document, format, name, findings }`. Plain functions, no DOM, no network. A host's Open button passes
4
+ // `DECK_FILE_ACCEPT` to `<input accept>` and the chosen file to `readDeckFile`.
5
+ import { MAX_OPF_BYTES, unwrapOpf } from "./transfer.js";
6
+
7
+ /** The `accept` value for a file input that opens a deck: JSON (`.json`, `.opf`), YAML and Markdown. */
8
+ export const DECK_FILE_ACCEPT = ".json,.opf,.yaml,.yml,.md";
9
+
10
+ /** Raised by `readDeckFile`. `code` says why; `format` is the format that was tried first; `findings` holds the parser's findings, if any. */
11
+ export class DeckFileError extends Error {
12
+ constructor(code, message, { format, name, findings = [], cause } = {}) {
13
+ super(message, cause ? { cause } : undefined);
14
+ this.name = "DeckFileError";
15
+ this.code = code;
16
+ this.format = format;
17
+ this.fileName = name;
18
+ this.findings = findings;
19
+ }
20
+ }
21
+
22
+ const YAML_EXTENSIONS = /\.(?:ya?ml)$/i;
23
+ const MARKDOWN_EXTENSIONS = /\.(?:md|markdown)$/i;
24
+ const COMPOUND = /\.opf\.(?:json|ya?ml|md|markdown)$/i;
25
+ const PLAIN = /\.(?:json|opf|ya?ml|md|markdown)$/i;
26
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
27
+ const stripBom = (text) => text.replace(/^/, "");
28
+
29
+ /** The file name without its directory and its `.opf.json`, `.opf.yaml`, `.opf.md` or plain extension. */
30
+ export function deckFileName(fileName) {
31
+ const base = String(fileName ?? "").split(/[\\/]/).pop().trim();
32
+ const stripped = base.replace(COMPOUND, "").replace(PLAIN, "");
33
+ const name = stripped !== base ? stripped : base.replace(/(?<=.)\.[A-Za-z0-9]{1,8}$/, "");
34
+ return name || "deck";
35
+ }
36
+
37
+ /** The format the extension suggests: `.yaml` and `.yml` are YAML, `.md` Markdown, and `.json`, `.opf` or anything else JSON. */
38
+ export function deckFileFormat(fileName) {
39
+ const name = String(fileName ?? "");
40
+ return YAML_EXTENSIONS.test(name) ? "yaml" : MARKDOWN_EXTENSIONS.test(name) ? "md" : "json";
41
+ }
42
+
43
+ const hasFrontMatter = (text) => /^---[ \t]*\r?\n/.test(stripBom(text).replace(/^\s*(?=---)/, ""));
44
+ const looksBinary = (text) => text.startsWith("PK\u0003\u0004") || text.includes("\u0000");
45
+
46
+ function failure(code, message, context, extra) {
47
+ return new DeckFileError(code, message, { ...context, ...extra });
48
+ }
49
+
50
+ async function readSource(input) {
51
+ if (input === null || typeof input !== "object") throw failure("unreadable", "Choose a deck file to open.", {});
52
+ const name = typeof input.name === "string" ? input.name : "";
53
+ const context = { name };
54
+ if (typeof input.size === "number" && input.size > MAX_OPF_BYTES) throw failure("file-too-large", "Choose a file smaller than 20 MB.", context);
55
+ let text = input.text;
56
+ try {
57
+ if (typeof text === "function") text = await input.text();
58
+ } catch (cause) {
59
+ throw failure("unreadable", `${name || "The file"} could not be read.${cause?.message ? ` ${cause.message}` : ""}`, context, { cause });
60
+ }
61
+ if (typeof text !== "string") throw failure("unreadable", "Pass a File, or { name, text } with the file's text.", context);
62
+ if (new TextEncoder().encode(text).length > MAX_OPF_BYTES) throw failure("file-too-large", "Choose a file smaller than 20 MB.", context);
63
+ return { name, text: stripBom(text), context };
64
+ }
65
+
66
+ function readJson(text, context) {
67
+ let value;
68
+ try {
69
+ value = JSON.parse(text);
70
+ } catch (cause) {
71
+ throw failure("invalid-json", `This is not valid JSON: ${cause.message}`, context, { format: "json", cause });
72
+ }
73
+ const document = unwrapOpf(value);
74
+ if (!isObject(document) || !Array.isArray(document.slides)) {
75
+ throw failure("not-a-deck", "This JSON is not an OPF deck: a deck has a \"slides\" list.", context, { format: "json" });
76
+ }
77
+ return { document, findings: [] };
78
+ }
79
+
80
+ // A conversion result as a deck, or a typed error when the text itself could not be read (a syntax or dialect error, not an OPF finding).
81
+ function fromConversion(result, format, context) {
82
+ const findings = result.findings ?? [];
83
+ const syntax = findings.filter((finding) => finding.severity === "error" && !String(finding.ruleId ?? "").startsWith("opf/"));
84
+ if (syntax.length) {
85
+ const first = syntax[0];
86
+ const place = first.location?.line ? ` (line ${first.location.line})` : "";
87
+ throw failure(format === "yaml" ? "invalid-yaml" : "invalid-markdown", `This is not valid ${format === "yaml" ? "YAML" : "Markdown"}${place}: ${first.message}`, context, { format, findings });
88
+ }
89
+ return { document: result.presentation, findings };
90
+ }
91
+
92
+ async function convert(format, text, options, context) {
93
+ if (format === "yaml") {
94
+ const { fromYaml } = await import("@openpresentation/opf/yaml");
95
+ return fromConversion(fromYaml(text, { catalogs: options.catalogs }), "yaml", context);
96
+ }
97
+ const { fromMarkdown } = await import("@openpresentation/opf/markdown");
98
+ return fromConversion(fromMarkdown(text, { catalogs: options.catalogs }), "md", context);
99
+ }
100
+
101
+ /**
102
+ * Read a deck file. `file` is a `File` (or anything with `name` and `text()`), or `{ name, text }` with the text as a string.
103
+ *
104
+ * The extension picks the format: `.yaml` and `.yml` are YAML, `.md` is Markdown, and `.json`, `.opf` or anything else is JSON. Text that is not
105
+ * valid JSON is then tried as YAML, and as Markdown when it starts with front matter (`---`). The result is
106
+ * `{ document, format: "json" | "yaml" | "md", name, findings }`: `format` is the one that read it, `name` is the file name without
107
+ * `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension, and `findings` are the conversion findings of YAML and Markdown (core's
108
+ * `Finding`, each with a JSON Pointer `path` and a `location` in the text; JSON gives none). A deck that reads but has OPF errors is returned,
109
+ * with those findings; text that cannot be read at all throws `DeckFileError`.
110
+ *
111
+ * `options.catalogs`: the host's catalogs, for the references check of the YAML and Markdown conversion.
112
+ */
113
+ export async function readDeckFile(file, options = {}) {
114
+ const { name: fileName, text, context } = await readSource(file);
115
+ const name = deckFileName(fileName);
116
+ const chosen = deckFileFormat(fileName);
117
+ const done = (format, read) => ({ document: read.document, format, name, findings: read.findings });
118
+ if (!text.trim()) throw failure("empty-file", `${fileName || "The file"} is empty.`, context, { format: chosen });
119
+ if (looksBinary(text)) throw failure("unsupported-file", "This looks like a PowerPoint or other binary file, not an OPF deck. Open a .json, .yaml or .md deck.", context, { format: chosen });
120
+
121
+ if (chosen === "yaml") return done("yaml", await convert("yaml", text, options, context));
122
+ if (chosen === "md") return done("md", await convert("md", text, options, context));
123
+
124
+ let jsonError;
125
+ try {
126
+ return done("json", readJson(text, context));
127
+ } catch (error) {
128
+ // A parsed value that is not a deck is a real answer. Only text that is not JSON falls through to the other formats.
129
+ if (error.code !== "invalid-json") throw error;
130
+ jsonError = error;
131
+ }
132
+ try {
133
+ const read = await convert("yaml", text, options, context);
134
+ if (isObject(read.document) && Array.isArray(read.document.slides)) return done("yaml", read);
135
+ } catch (error) {
136
+ if (!(error instanceof DeckFileError)) throw error;
137
+ }
138
+ if (hasFrontMatter(text)) {
139
+ try { return done("md", await convert("md", text, options, context)); } catch (error) { if (!(error instanceof DeckFileError)) throw error; }
140
+ }
141
+ throw failure("invalid-json", `${jsonError.message} It is not valid YAML${hasFrontMatter(text) ? " or Markdown" : ""} either.`, context, { format: "json", cause: jsonError.cause });
142
+ }
package/dist/styles.css CHANGED
@@ -2981,6 +2981,163 @@
2981
2981
  }
2982
2982
  }
2983
2983
 
2984
+ /* presenter.css */
2985
+ /* RR-98: the presenter and the Present button (src/presenter.js). Only --opf-ui-* tokens, no color literals (test/styles.mjs). */
2986
+
2987
+ /* ---- the presenter -------------------------------------------------------------------------------------------------------------------- */
2988
+ .opf-presenter,
2989
+ .opf-presenter *,
2990
+ .opf-presenter *::before,
2991
+ .opf-presenter *::after { box-sizing: border-box; }
2992
+ .opf-presenter[hidden] { display: none !important; }
2993
+
2994
+ .opf-presenter {
2995
+ position: fixed;
2996
+ inset: 0;
2997
+ z-index: 2000;
2998
+ display: flex;
2999
+ align-items: center;
3000
+ justify-content: center;
3001
+ overflow: hidden;
3002
+ /* Slides are letterboxed on black in every theme, so the bars never take a token's light or dark value. */
3003
+ background: black;
3004
+ font-family: var(--opf-ui-font);
3005
+ font-size: 13px;
3006
+ line-height: 1.4;
3007
+ color: var(--opf-ui-fg);
3008
+ user-select: none;
3009
+ -webkit-user-select: none;
3010
+ touch-action: none;
3011
+ outline: none;
3012
+ }
3013
+ .opf-presenter[data-chrome="hidden"] { cursor: none; }
3014
+
3015
+ .opf-presenter .opf-presenter-slide {
3016
+ width: 100%;
3017
+ height: 100%;
3018
+ display: flex;
3019
+ align-items: center;
3020
+ justify-content: center;
3021
+ }
3022
+ /* The slide's own width and height attributes give its shape (viewBox); this makes it as large as the screen allows. */
3023
+ .opf-presenter .opf-presenter-slide svg {
3024
+ display: block;
3025
+ width: 100%;
3026
+ height: 100%;
3027
+ max-width: 100%;
3028
+ max-height: 100%;
3029
+ }
3030
+ .opf-presenter .opf-presenter-message {
3031
+ position: absolute;
3032
+ margin: 0;
3033
+ max-width: 40ch;
3034
+ padding: 12px 16px;
3035
+ border: 1px solid var(--opf-ui-border);
3036
+ border-radius: var(--opf-ui-radius-md);
3037
+ background: var(--opf-ui-surface);
3038
+ color: var(--opf-ui-muted-fg);
3039
+ text-align: center;
3040
+ font: 400 13px/1.5 var(--opf-ui-font);
3041
+ }
3042
+ .opf-presenter .opf-presenter-message[hidden] { display: none; }
3043
+ .opf-presenter .opf-presenter-live {
3044
+ position: absolute;
3045
+ width: 1px;
3046
+ height: 1px;
3047
+ margin: -1px;
3048
+ padding: 0;
3049
+ overflow: hidden;
3050
+ clip-path: inset(50%);
3051
+ white-space: nowrap;
3052
+ border: 0;
3053
+ }
3054
+
3055
+ /* ---- the counter and the exit button (they appear on pointer move) ---------------------------------------------------------------------- */
3056
+ .opf-presenter .opf-presenter-counter,
3057
+ .opf-presenter .opf-presenter-close {
3058
+ position: absolute;
3059
+ background: var(--opf-ui-surface);
3060
+ color: var(--opf-ui-muted-fg);
3061
+ border: 1px solid var(--opf-ui-border);
3062
+ opacity: 0;
3063
+ transition: opacity var(--opf-ui-ease), color var(--opf-ui-ease), border-color var(--opf-ui-ease);
3064
+ }
3065
+ .opf-presenter[data-chrome="visible"] .opf-presenter-counter,
3066
+ .opf-presenter[data-chrome="visible"] .opf-presenter-close,
3067
+ .opf-presenter .opf-presenter-close:focus-visible { opacity: 1; }
3068
+
3069
+ .opf-presenter .opf-presenter-counter {
3070
+ bottom: 16px;
3071
+ left: 0;
3072
+ right: 0;
3073
+ width: max-content;
3074
+ margin: 0 auto;
3075
+ padding: 4px 10px;
3076
+ border-radius: var(--opf-ui-radius);
3077
+ font: 500 11.5px/1.4 var(--opf-ui-mono);
3078
+ letter-spacing: 0.04em;
3079
+ pointer-events: none;
3080
+ }
3081
+ .opf-presenter .opf-presenter-close {
3082
+ top: 16px;
3083
+ right: 16px;
3084
+ display: inline-flex;
3085
+ align-items: center;
3086
+ justify-content: center;
3087
+ width: var(--opf-ui-control-h);
3088
+ height: var(--opf-ui-control-h);
3089
+ padding: 0;
3090
+ margin: 0;
3091
+ border-radius: var(--opf-ui-radius);
3092
+ font: inherit;
3093
+ cursor: pointer;
3094
+ }
3095
+ .opf-presenter .opf-presenter-close:hover { color: var(--opf-ui-fg); background: var(--opf-ui-muted); }
3096
+ .opf-presenter .opf-presenter-close:focus-visible {
3097
+ outline: 2px solid var(--opf-ui-ring);
3098
+ outline-offset: 2px;
3099
+ }
3100
+ .opf-presenter .opf-icon { flex: none; }
3101
+
3102
+ /* ---- the Present button (styled like the Export trigger) ------------------------------------------------------------------------------- */
3103
+ .opf-present-button,
3104
+ .opf-present-button *,
3105
+ .opf-present-button *::before,
3106
+ .opf-present-button *::after { box-sizing: border-box; }
3107
+ .opf-present-button {
3108
+ display: inline-flex;
3109
+ align-items: center;
3110
+ justify-content: center;
3111
+ flex: none;
3112
+ width: var(--opf-ui-control-h);
3113
+ height: var(--opf-ui-control-h);
3114
+ margin: 0;
3115
+ padding: 0;
3116
+ border: 1px solid var(--opf-ui-accent);
3117
+ border-radius: var(--opf-ui-radius);
3118
+ background: var(--opf-ui-accent);
3119
+ color: var(--opf-ui-accent-fg);
3120
+ font: inherit;
3121
+ cursor: pointer;
3122
+ transition: opacity var(--opf-ui-ease);
3123
+ }
3124
+ .opf-present-button:hover { opacity: 0.92; }
3125
+ .opf-present-button:focus-visible {
3126
+ outline: 2px solid var(--opf-ui-ring);
3127
+ outline-offset: 2px;
3128
+ }
3129
+ .opf-present-button[aria-disabled="true"] {
3130
+ opacity: 0.5;
3131
+ cursor: not-allowed;
3132
+ }
3133
+ .opf-present-button .opf-icon { flex: none; }
3134
+
3135
+ @media (prefers-reduced-motion: reduce) {
3136
+ .opf-presenter .opf-presenter-counter,
3137
+ .opf-presenter .opf-presenter-close,
3138
+ .opf-present-button { transition: none; }
3139
+ }
3140
+
2984
3141
  /* source-pane.css */
2985
3142
  /* Source pane (RR-98). Only --opf-ui-* tokens (src/styles/tokens.css). */
2986
3143
  .opf-source {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.24.2",
3
+ "version": "0.24.3",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -230,6 +230,14 @@
230
230
  "types": "./dist/source-pane.d.ts",
231
231
  "default": "./dist/source-pane.js"
232
232
  },
233
+ "./presenter": {
234
+ "types": "./dist/presenter.d.ts",
235
+ "default": "./dist/presenter.js"
236
+ },
237
+ "./read-deck-file": {
238
+ "types": "./dist/read-deck-file.d.ts",
239
+ "default": "./dist/read-deck-file.js"
240
+ },
233
241
  "./export-menu": {
234
242
  "types": "./dist/export-menu.d.ts",
235
243
  "default": "./dist/export-menu.js"
@@ -270,7 +278,7 @@
270
278
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
271
279
  "check:font-hotlinks:built": "node scripts/check-font-hotlinks.mjs --built dist artifacts/playground",
272
280
  "build:playground": "node scripts/build-playground.mjs",
273
- "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/slide-navigator-browser.mjs && node test/layout-templates-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/source-pane-browser.mjs && node test/playground-download.mjs artifacts/playground-download && node test/export-menu-browser.mjs && node test/agent-handoff-browser.mjs",
281
+ "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/slide-navigator-browser.mjs && node test/layout-templates-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/source-pane-browser.mjs && node test/playground-download.mjs artifacts/playground-download && node test/export-menu-browser.mjs && node test/agent-handoff-browser.mjs && node test/presenter-browser.mjs && node test/present-button-browser.mjs",
274
282
  "test:code-browser": "node test/code-browser.mjs",
275
283
  "test:selection-browser": "node test/selection-browser.mjs",
276
284
  "test:click-entry-browser": "node test/click-entry-browser.mjs",
@@ -297,6 +305,8 @@
297
305
  "test:export-license-browser": "node test/export-license-browser.mjs",
298
306
  "test:chart-options-browser": "node test/chart-options-browser.mjs",
299
307
  "test:numbering-panel-browser": "node test/numbering-panel-browser.mjs",
308
+ "test:presenter-browser": "node test/presenter-browser.mjs",
309
+ "test:present-button-browser": "node test/present-button-browser.mjs",
300
310
  "test:export-menu-browser": "node test/export-menu-browser.mjs",
301
311
  "test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
302
312
  },