@openpresentation/opf-editor 0.24.1 → 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 +64 -1
- package/dist/json-editor.d.ts +2 -0
- package/dist/json-editor.js +34 -5
- package/dist/json-options.d.ts +21 -0
- package/dist/json-options.js +127 -27
- package/dist/presenter.d.ts +102 -0
- package/dist/presenter.js +429 -0
- package/dist/read-deck-file.d.ts +59 -0
- package/dist/read-deck-file.js +142 -0
- package/dist/source-findings.js +27 -0
- package/dist/source-pane.d.ts +2 -0
- package/dist/source-pane.js +28 -23
- package/dist/styles.css +202 -1
- package/package.json +12 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Findings of the source pane (RR-98), kept apart from the DOM so they can be tested alone.
|
|
2
|
+
|
|
3
|
+
/** Normalize one finding: core `Finding` objects, `{ path, message, severity, line? }` objects and plain strings. */
|
|
4
|
+
export function normalizeFinding(input, kind) {
|
|
5
|
+
const finding = typeof input === "string" ? { message: input } : input ?? {};
|
|
6
|
+
const severity = finding.severity === "error" || finding.severity === "info" ? finding.severity : finding.severity === "warning" || finding.severity === "warn" ? "warning" : "error";
|
|
7
|
+
const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
|
|
8
|
+
const line = finding.line ?? finding.location?.line;
|
|
9
|
+
if (Number.isInteger(line) && line > 0) out.line = line;
|
|
10
|
+
const own = finding.kind ?? kind;
|
|
11
|
+
if (own) out.kind = own;
|
|
12
|
+
if (finding.ruleId) out.ruleId = finding.ruleId;
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The text of the first problem an error reports. */
|
|
17
|
+
export const errorText = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The findings for an edit the session refused: one per issue of the thrown error (its validator findings, each with its JSON pointer
|
|
21
|
+
* `path`), so the list has the validator's count. An error without issues becomes one finding with its message.
|
|
22
|
+
*/
|
|
23
|
+
export function rejectionFindings(error) {
|
|
24
|
+
const issues = Array.isArray(error?.issues) ? error.issues.filter((issue) => issue && (typeof issue === "string" || typeof issue === "object")) : [];
|
|
25
|
+
if (!issues.length) return [normalizeFinding({ message: errorText(error) }, "rejected")];
|
|
26
|
+
return issues.map((issue) => normalizeFinding(typeof issue === "string" ? issue : { ...issue, severity: issue.severity ?? "error", kind: undefined }, "rejected"));
|
|
27
|
+
}
|