@openpresentation/opf-editor 0.26.9 → 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/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.9",
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.9"
290
+ "@openpresentation/opf": "^0.26.10"
291
291
  },
292
292
  "peerDependencies": {
293
- "@openpresentation/opf-render": "^0.26.9"
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/opf-pptx": "0.26.9",
353
- "@openpresentation/gallery": "0.26.9",
354
- "@openpresentation/opf-render": "0.26.9"
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": "7563d8dea2ba24a36b032614a50f665876d145e5"
405
+ "gitHead": "da3f869f6f3a152a0d7d5cb42eeb2843d302a46a"
406
406
  }