@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 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 every write and before the page unloads
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
@@ -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, debounced or lifecycle flush, even before any session edit. Commit the active draft; returning false or throwing refuses the write and reports a draft error. */
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;
@@ -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 flush(); }, wait);
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
- // Commit synchronously, including a draft in an otherwise untouched session. The lifecycle
234
- // warning below must see that edit before it decides whether leaving would lose work.
235
- // A commit emits a change; clear its debounce again and avoid recursive host flush hooks.
236
- if (preparingFlush) return chain;
237
- preparingFlush = true;
238
- try {
239
- if (options.beforeFlush?.() === false) throw new Error("Commit or cancel the active draft first.");
240
- flushRefused = false;
241
- } catch (error) {
242
- flushRefused = true;
243
- 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.` });
244
- return Promise.resolve(false);
245
- } finally {
246
- preparingFlush = false;
247
- clearTimeout(timer);
248
- firstDirtyAt = 0;
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: the browser ends whatever is full screen when an exit it was asked for runs, and tells
23
- // every listener. A presenter that closed early asks for its exit late (when its pending full screen request resolves), possibly after a newer
24
- // presenter took full screen. So the exits the presenters asked for are counted per document, and any presenter reads a `fullscreenchange` that
25
- // answers one of them as ours, not as the user leaving full screen.
26
- const exitRequests = new WeakMap(); // Document -> Set of exits asked for and not yet reported
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 noteExit(doc, win) {
29
- let requests = exitRequests.get(doc);
30
- if (!requests) exitRequests.set(doc, requests = new Set());
31
- const request = {};
32
- requests.add(request);
33
- // The browser reports an exit with the event; when it does not (the exit was refused, or two exits became one event), stop counting it soon.
34
- return { forget: () => requests.delete(request), forgetSoon: () => win.setTimeout(() => requests.delete(request), 500) };
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 requests = exitRequests.get(doc);
39
- const ours = Boolean(requests?.size) && !doc.fullscreenElement;
40
- if (ours) requests.delete(requests.values().next().value);
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
- if (doc.fullscreenElement !== root) return "overlay";
224
- // 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
225
- // page stays full screen behind a hidden presenter.
226
- if (!active) { leaveFullscreen(); return "overlay"; }
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
- () => "overlay",
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, because the page
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, win);
239
- Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.forgetSoon(); restoreFocus(); }, exit.forget);
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: calc(var(--opf-lx-h) - 6px);
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: calc(var(--opf-lx-h) - 4px);
1085
- height: calc(var(--opf-lx-h) - 4px);
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: calc(var(--opf-lx-h) - 2px);
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.8",
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.8"
290
+ "@openpresentation/opf": "^0.26.10"
291
291
  },
292
292
  "peerDependencies": {
293
- "@openpresentation/opf-render": "^0.26.8"
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/gallery": "0.26.8",
353
- "@openpresentation/opf-pptx": "0.26.8",
354
- "@openpresentation/opf-render": "0.26.8"
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": "bb6faa40bebc1654f607bf6a434c43b5e95d0b1e"
405
+ "gitHead": "da3f869f6f3a152a0d7d5cb42eeb2843d302a46a"
406
406
  }