@openpresentation/opf-editor 0.26.8 → 0.26.10
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 +1 -1
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +26 -17
- package/dist/presenter.js +63 -26
- package/dist/styles.css +32 -4
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -707,7 +707,7 @@ const persistence = createPersistence(editor, {
|
|
|
707
707
|
storage: "indexeddb", // default; or "localstorage", "memory", false, or your own { get, set, delete } adapter
|
|
708
708
|
onRestorePrompt: ui.prompt, // or return "restore" | "discard" | "later" (or a promise) yourself
|
|
709
709
|
onStatus: ui.status, // "Saved on this device at 2:03 PM", or why autosave is off
|
|
710
|
-
beforeFlush: () => canvas.commit(), // commit a draft before
|
|
710
|
+
beforeFlush: () => canvas.commit(), // commit a draft before an explicit flush and before the page unloads (the debounced write leaves a draft alone)
|
|
711
711
|
});
|
|
712
712
|
await persistence.ready; // { available, offered }: storage opened and read (it does not wait for the user's decision)
|
|
713
713
|
await persistence.markSaved(); // after the host saved the document somewhere of its own: not unsaved, and the stored copy says so
|
package/dist/persistence.d.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface PersistenceOptions {
|
|
|
63
63
|
onRestorePrompt?: (offer: RestoreOffer, actions: { restore(): Promise<boolean>; discard(): Promise<boolean> }) => RestoreChoice | Promise<RestoreChoice>;
|
|
64
64
|
/** Called on every status change, for an indicator. */
|
|
65
65
|
onStatus?: (status: AutosaveStatus) => void;
|
|
66
|
-
/** Called synchronously before each explicit
|
|
66
|
+
/** Called synchronously before each explicit or lifecycle flush (`flush()`, `markSaved()`, `beforeunload`, `pagehide`), even before any session edit. The debounced autosave does not call it: it writes the committed document and leaves an edit in progress alone. Commit the active draft; returning false or throwing refuses the write and reports a draft error. */
|
|
67
67
|
beforeFlush?: () => boolean | void;
|
|
68
68
|
/** Warn before the page closes while the document is `dirty` (default true). */
|
|
69
69
|
warnOnUnload?: boolean;
|
package/dist/persistence.js
CHANGED
|
@@ -223,29 +223,38 @@ export function createPersistence(editor, options = {}) {
|
|
|
223
223
|
if (!firstDirtyAt) firstDirtyAt = at;
|
|
224
224
|
clearTimeout(timer);
|
|
225
225
|
const wait = Math.max(0, Math.min(debounceMs, firstDirtyAt + maxWaitMs - at));
|
|
226
|
-
timer = setTimeout(() => { void
|
|
226
|
+
timer = setTimeout(() => { void flushNow(false); }, wait);
|
|
227
227
|
}
|
|
228
228
|
/** Write the document now (cancelling the debounce) and resolve when it is stored. Resolves false when nothing could be written. */
|
|
229
229
|
function flush() {
|
|
230
|
+
return flushNow(true);
|
|
231
|
+
}
|
|
232
|
+
// The debounce timer writes what is committed and leaves an edit in progress alone: committing it would close the inline editor
|
|
233
|
+
// under the user's hands, a moment after an earlier change (the timer starts at that change) and long before they stop typing.
|
|
234
|
+
// The draft is not lost: the page is still open, and every flush that precedes leaving (unload, page hide, `flush()`, `markSaved()`)
|
|
235
|
+
// commits it first.
|
|
236
|
+
function flushNow(commitDraft) {
|
|
230
237
|
clearTimeout(timer);
|
|
231
238
|
firstDirtyAt = 0;
|
|
232
239
|
if (destroyed) return Promise.resolve(false);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
if (commitDraft) {
|
|
241
|
+
// Commit synchronously, including a draft in an otherwise untouched session. The lifecycle
|
|
242
|
+
// warning below must see that edit before it decides whether leaving would lose work.
|
|
243
|
+
// A commit emits a change; clear its debounce again and avoid recursive host flush hooks.
|
|
244
|
+
if (preparingFlush) return chain;
|
|
245
|
+
preparingFlush = true;
|
|
246
|
+
try {
|
|
247
|
+
if (options.beforeFlush?.() === false) throw new Error("Commit or cancel the active draft first.");
|
|
248
|
+
flushRefused = false;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
flushRefused = true;
|
|
251
|
+
setStatus({ state: "error", error: "draft", message: `Autosave could not commit the active draft (${String(error?.message ?? error)}). Keep this page open to recover your work.` });
|
|
252
|
+
return Promise.resolve(false);
|
|
253
|
+
} finally {
|
|
254
|
+
preparingFlush = false;
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
firstDirtyAt = 0;
|
|
257
|
+
}
|
|
249
258
|
}
|
|
250
259
|
if (!touched) return chain.then(() => true);
|
|
251
260
|
chain = chain.then(writeRecord, writeRecord);
|
package/dist/presenter.js
CHANGED
|
@@ -19,25 +19,40 @@ const CLICK_GUARD_MS = 450;
|
|
|
19
19
|
const NEXT_KEYS = new Set(["ArrowRight", " ", "PageDown", "Enter"]);
|
|
20
20
|
const PREVIOUS_KEYS = new Set(["ArrowLeft", "PageUp", "Backspace"]);
|
|
21
21
|
|
|
22
|
-
// Full screen belongs to the document, not to one presenter
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
22
|
+
// Full screen belongs to the document, not to one presenter, and the browser answers a request later than it was made. Two things follow, and
|
|
23
|
+
// both are kept per document so that presenters (any number can be mounted) see the same facts.
|
|
24
|
+
// 1. An exit a presenter asked for can run after a newer presenter took full screen, and the browser then ends that one's full screen too.
|
|
25
|
+
// A presenter whose request resolved after it was closed therefore leaves full screen only when no newer request is in flight (that request
|
|
26
|
+
// replaces it); otherwise it marks its element abandoned, and the newer presenter gives the screen back if its own request does not get it.
|
|
27
|
+
// 2. The `fullscreenchange` an exit answers must not read as the user leaving. The exits presenters asked for are listed until their event is
|
|
28
|
+
// seen and the exit has settled, in whichever order those two happen. An event that finds the screen taken by an element drops the exits that
|
|
29
|
+
// already settled, since events arrive in the order of the changes: a later exit event is the user's, however soon it follows.
|
|
30
|
+
const documentStates = new WeakMap(); // Document -> { requests (counted), inFlight (their numbers, until answered), exits, abandoned (elements) }
|
|
27
31
|
const attributedEvents = new WeakMap(); // fullscreenchange Event -> whether it answered an exit a presenter asked for
|
|
28
|
-
function
|
|
29
|
-
let
|
|
30
|
-
if (!
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
function stateOf(doc) {
|
|
33
|
+
let state = documentStates.get(doc);
|
|
34
|
+
if (!state) documentStates.set(doc, state = { requests: 0, inFlight: new Set(), exits: new Set(), abandoned: new WeakSet() });
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
function noteExit(doc) {
|
|
38
|
+
const { exits } = stateOf(doc);
|
|
39
|
+
const exit = { settled: false, seen: false };
|
|
40
|
+
exits.add(exit);
|
|
41
|
+
return {
|
|
42
|
+
// The browser reports an exit with an event; when none came (the exit was refused, or two exits became one event) it is not waited for.
|
|
43
|
+
settle: (done) => { if (!done || exit.seen) exits.delete(exit); else exit.settled = true; },
|
|
44
|
+
};
|
|
35
45
|
}
|
|
36
46
|
function answersAnExit(doc, event) {
|
|
37
47
|
if (attributedEvents.has(event)) return attributedEvents.get(event);
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (
|
|
48
|
+
const { exits } = stateOf(doc);
|
|
49
|
+
let ours = false;
|
|
50
|
+
if (doc.fullscreenElement) {
|
|
51
|
+
for (const exit of exits) if (exit.settled) exits.delete(exit);
|
|
52
|
+
} else {
|
|
53
|
+
const exit = [...exits].find((candidate) => !candidate.seen);
|
|
54
|
+
if (exit) { ours = true; if (exit.settled) exits.delete(exit); else exit.seen = true; }
|
|
55
|
+
}
|
|
41
56
|
attributedEvents.set(event, ours);
|
|
42
57
|
return ours;
|
|
43
58
|
}
|
|
@@ -92,7 +107,7 @@ export function createPresenter(container, options = {}) {
|
|
|
92
107
|
// Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
|
|
93
108
|
let drawn = new Map(), drawnFor;
|
|
94
109
|
|
|
95
|
-
let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null;
|
|
110
|
+
let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null, pendingRequests = 0;
|
|
96
111
|
let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
|
|
97
112
|
|
|
98
113
|
// ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
|
|
@@ -216,36 +231,57 @@ export function createPresenter(container, options = {}) {
|
|
|
216
231
|
function enterFullscreen() {
|
|
217
232
|
const request = root.requestFullscreen ?? root.webkitRequestFullscreen;
|
|
218
233
|
if (typeof request !== "function" || doc.fullscreenEnabled === false) return Promise.resolve("overlay");
|
|
234
|
+
const state = stateOf(doc);
|
|
235
|
+
const ticket = ++state.requests;
|
|
236
|
+
state.inFlight.add(ticket);
|
|
237
|
+
pendingRequests++;
|
|
238
|
+
state.abandoned.delete(root);
|
|
239
|
+
const answered = () => { state.inFlight.delete(ticket); pendingRequests--; };
|
|
219
240
|
let attempt;
|
|
220
241
|
try { attempt = Promise.resolve(request.call(root, { navigationUI: "hide" })); } catch (error) { attempt = Promise.reject(error); }
|
|
221
242
|
return attempt.then(
|
|
222
243
|
() => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
answered();
|
|
245
|
+
if (doc.fullscreenElement !== root) return reclaimAbandoned();
|
|
246
|
+
if (!active) {
|
|
247
|
+
// stop() ran while the request was pending (a quick Esc or close), so it saw no full screen to leave: give the screen back now, or the
|
|
248
|
+
// page stays full screen behind a hidden presenter. Not when a newer request is still in flight: it replaces this element, and an exit asked
|
|
249
|
+
// for now would run after it and end the newer presenter's full screen.
|
|
250
|
+
if ([...state.inFlight].some((newer) => newer > ticket)) state.abandoned.add(root); else leaveFullscreen();
|
|
251
|
+
return "overlay";
|
|
252
|
+
}
|
|
227
253
|
enteredFullscreen = true;
|
|
228
254
|
root.dataset.mode = "fullscreen";
|
|
229
255
|
return "fullscreen";
|
|
230
256
|
},
|
|
231
|
-
() =>
|
|
257
|
+
() => { answered(); return reclaimAbandoned(); },
|
|
232
258
|
);
|
|
233
259
|
}
|
|
260
|
+
// This presenter did not get the screen (refused, or taken by another element): if an earlier, closed presenter's element still holds it
|
|
261
|
+
// because that presenter left the exit to this request, give it back.
|
|
262
|
+
function reclaimAbandoned() {
|
|
263
|
+
const holder = doc.fullscreenElement, { abandoned } = stateOf(doc);
|
|
264
|
+
if (holder && holder !== root && abandoned.has(holder)) { abandoned.delete(holder); leaveFullscreen(); } else if (!active) restoreFocus();
|
|
265
|
+
return "overlay";
|
|
266
|
+
}
|
|
234
267
|
// Ask the browser to leave full screen; onFullscreenChange then sees that the event answers an exit asked for (by this presenter or an earlier
|
|
235
|
-
// one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done
|
|
236
|
-
// behind a full screen element takes no focus until then.
|
|
268
|
+
// one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done (or refused: the
|
|
269
|
+
// screen was already gone), because the page behind a full screen element takes no focus until then.
|
|
237
270
|
function leaveFullscreen() {
|
|
238
|
-
const exit = noteExit(doc
|
|
239
|
-
Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.
|
|
271
|
+
const exit = noteExit(doc);
|
|
272
|
+
Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.settle(true); restoreFocus(); }, () => { exit.settle(false); restoreFocus(); });
|
|
240
273
|
}
|
|
241
|
-
// Only when nobody took focus since: the host may move it in `onExit`.
|
|
274
|
+
// Only when nobody took focus since: the host may move it in `onExit`. While the screen is still full screen the page behind takes no focus, and
|
|
275
|
+
// a browser reports the end of an exit's promise before the end of full screen; so the element is kept until the `fullscreenchange` that ends it (and while a request of this presenter is unanswered: it may still take the screen).
|
|
242
276
|
function restoreFocus() {
|
|
243
277
|
const now = doc.activeElement;
|
|
244
278
|
if (returnTo?.isConnected && typeof returnTo.focus === "function" && (!now || now === doc.body || now === root)) returnTo.focus({ preventScroll: true });
|
|
279
|
+
if (!doc.fullscreenElement && !pendingRequests) returnTo = null;
|
|
245
280
|
}
|
|
246
281
|
// The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
|
|
247
282
|
function onFullscreenChange(event) {
|
|
248
283
|
if (doc.fullscreenElement === root) return;
|
|
284
|
+
if (!active && !doc.fullscreenElement) restoreFocus();
|
|
249
285
|
if (answersAnExit(doc, event)) {
|
|
250
286
|
// An exit that a presenter asked for finished: ours from stop(), or an earlier presenter's that ran after this one took full screen. A
|
|
251
287
|
// presentation still running carries on as the overlay.
|
|
@@ -321,6 +357,7 @@ export function createPresenter(container, options = {}) {
|
|
|
321
357
|
const wanted = slideIndex ?? options.slideIndex ?? 0;
|
|
322
358
|
if (active) { show(wanted); return Promise.resolve(root.dataset.mode); }
|
|
323
359
|
active = true;
|
|
360
|
+
returnTo = null;
|
|
324
361
|
opener = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : null;
|
|
325
362
|
current = -1;
|
|
326
363
|
drawn = new Map();
|
package/dist/styles.css
CHANGED
|
@@ -596,6 +596,10 @@
|
|
|
596
596
|
--opf-lx-slot: 20px;
|
|
597
597
|
--opf-lx-pad: 16px;
|
|
598
598
|
--opf-lx-chip: calc(var(--opf-ui-control-h) - 4px);
|
|
599
|
+
/* Compact controls sit a little under --opf-lx-h so they sit quietly beside it: the zoom buttons, the validity pill, the segments. */
|
|
600
|
+
--opf-lx-zoom-btn: calc(var(--opf-lx-h) - 4px);
|
|
601
|
+
--opf-lx-pill: calc(var(--opf-lx-h) - 6px);
|
|
602
|
+
--opf-lx-seg: calc(var(--opf-lx-h) - 2px);
|
|
599
603
|
font-family: var(--opf-ui-font);
|
|
600
604
|
font-size: 13px;
|
|
601
605
|
color: var(--opf-ui-fg);
|
|
@@ -667,7 +671,7 @@
|
|
|
667
671
|
display: inline-flex;
|
|
668
672
|
align-items: center;
|
|
669
673
|
gap: 6px;
|
|
670
|
-
height:
|
|
674
|
+
height: var(--opf-lx-pill);
|
|
671
675
|
padding: 0 10px;
|
|
672
676
|
background: transparent;
|
|
673
677
|
border: 1px solid var(--opf-ui-border);
|
|
@@ -1081,8 +1085,8 @@
|
|
|
1081
1085
|
display: inline-flex;
|
|
1082
1086
|
align-items: center;
|
|
1083
1087
|
justify-content: center;
|
|
1084
|
-
width:
|
|
1085
|
-
height:
|
|
1088
|
+
width: var(--opf-lx-zoom-btn);
|
|
1089
|
+
height: var(--opf-lx-zoom-btn);
|
|
1086
1090
|
padding: 0;
|
|
1087
1091
|
background: transparent;
|
|
1088
1092
|
border: 0;
|
|
@@ -1271,7 +1275,7 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
|
|
|
1271
1275
|
display: inline-flex;
|
|
1272
1276
|
align-items: center;
|
|
1273
1277
|
gap: 6px;
|
|
1274
|
-
height:
|
|
1278
|
+
height: var(--opf-lx-seg);
|
|
1275
1279
|
padding: 0 12px;
|
|
1276
1280
|
background: transparent;
|
|
1277
1281
|
border: 0;
|
|
@@ -1354,6 +1358,30 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
|
|
|
1354
1358
|
.opf-lx-canvas[data-density="touch"] .opf-lx-label { --opf-lx-chip-h: 19px; font-size: 12px; line-height: 18px; }
|
|
1355
1359
|
.opf-lx[data-density="touch"] .opf-lx-actions { grid-template-columns: minmax(0, 1fr); }
|
|
1356
1360
|
|
|
1361
|
+
/* A coarse pointer (a finger): every control sized from these tokens is a 44px target, at any width. --opf-lx-h already follows
|
|
1362
|
+
--opf-ui-control-h (44px under a coarse pointer, tokens.css); the compact controls that sit a few pixels under it, and the rows and
|
|
1363
|
+
chips, take its full height. A fine pointer keeps the compact look. Fields and the other controls that size themselves: touch.css. */
|
|
1364
|
+
@media (pointer: coarse) {
|
|
1365
|
+
.opf-lx,
|
|
1366
|
+
.opf-lx-palette,
|
|
1367
|
+
.opf-lx-canvas,
|
|
1368
|
+
.opf-lx-layers,
|
|
1369
|
+
.opf-lx-inspector {
|
|
1370
|
+
--opf-lx-row: var(--opf-lx-h);
|
|
1371
|
+
--opf-lx-chip: var(--opf-lx-h);
|
|
1372
|
+
--opf-lx-zoom-btn: var(--opf-lx-h);
|
|
1373
|
+
--opf-lx-pill: var(--opf-lx-h);
|
|
1374
|
+
--opf-lx-seg: var(--opf-lx-h);
|
|
1375
|
+
}
|
|
1376
|
+
/* A checkbox is hit through its label: the label is the 44px target and the box a little larger (as .opf-dc-check in touch.css). */
|
|
1377
|
+
.opf-lx-field-check .opf-lx-label-text {
|
|
1378
|
+
display: flex;
|
|
1379
|
+
align-items: center;
|
|
1380
|
+
min-height: var(--opf-lx-h);
|
|
1381
|
+
}
|
|
1382
|
+
.opf-lx-check { width: 22px; height: 22px; }
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1357
1385
|
@media (prefers-reduced-motion: reduce) {
|
|
1358
1386
|
.opf-lx *,
|
|
1359
1387
|
.opf-lx-palette *,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.10",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -287,10 +287,10 @@
|
|
|
287
287
|
"@lezer/highlight": "1.2.5",
|
|
288
288
|
"jsonc-parser": "3.3.1",
|
|
289
289
|
"yaml": "^2.9.1",
|
|
290
|
-
"@openpresentation/opf": "^0.26.
|
|
290
|
+
"@openpresentation/opf": "^0.26.10"
|
|
291
291
|
},
|
|
292
292
|
"peerDependencies": {
|
|
293
|
-
"@openpresentation/opf-render": "^0.26.
|
|
293
|
+
"@openpresentation/opf-render": "^0.26.10"
|
|
294
294
|
},
|
|
295
295
|
"peerDependenciesMeta": {
|
|
296
296
|
"@openpresentation/opf-render": {
|
|
@@ -349,9 +349,9 @@
|
|
|
349
349
|
"pdf-lib": "1.17.1",
|
|
350
350
|
"pdfjs-dist": "6.4.299",
|
|
351
351
|
"playwright": "1.64.0",
|
|
352
|
-
"@openpresentation/
|
|
353
|
-
"@openpresentation/
|
|
354
|
-
"@openpresentation/opf-render": "0.26.
|
|
352
|
+
"@openpresentation/opf-pptx": "0.26.10",
|
|
353
|
+
"@openpresentation/gallery": "0.26.10",
|
|
354
|
+
"@openpresentation/opf-render": "0.26.10"
|
|
355
355
|
},
|
|
356
356
|
"scripts": {
|
|
357
357
|
"build": "node scripts/build.mjs",
|
|
@@ -402,5 +402,5 @@
|
|
|
402
402
|
"test:export-menu-browser": "node test/export-menu-browser.mjs",
|
|
403
403
|
"test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
|
|
404
404
|
},
|
|
405
|
-
"gitHead": "
|
|
405
|
+
"gitHead": "da3f869f6f3a152a0d7d5cb42eeb2843d302a46a"
|
|
406
406
|
}
|