@michaelyagi/shoji 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
@@ -30,6 +30,8 @@ export declare class Gallery {
30
30
  private autoHidden;
31
31
  private hoveredControlCount;
32
32
  private isClosing;
33
+ /** True while a vertical drag has hidden controls past its own distance threshold (`setControlsHiddenForDrag`) — `onActivity()` defers to it, since the drag's own continuous pointermove stream would otherwise immediately re-reveal what it just hid on every single move. */
34
+ private controlsHiddenByDrag;
33
35
  private itemList;
34
36
  private scannedElements;
35
37
  private activeIndex;
@@ -48,7 +50,18 @@ export declare class Gallery {
48
50
  private pluginCleanups;
49
51
  private readonly onContainerClick;
50
52
  private readonly onOuterClick;
51
- /** DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle clock. `autoHideDelay: 0` = "never show controls" — a no-op here. */
53
+ /**
54
+ * DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle
55
+ * clock. `autoHideDelay: 0` = "never show controls" — a no-op here.
56
+ * `autoHideDelay: false` = "always visible" — also a no-op: nothing to
57
+ * reveal (already shown) or reschedule (no timer ever runs). Also a no-op
58
+ * once `isClosing` — a real bug, reported from real usage: moving the
59
+ * mouse during close()'s own controls-fade-then-zoom-out sequence (§2.6a)
60
+ * re-showed the just-hidden controls mid-animation, since these listeners
61
+ * stay wired for the whole close sequence. Same reasoning for
62
+ * `controlsHiddenByDrag` (§2.4/§2.8): a vertical drag's own `pointermove`
63
+ * stream would otherwise re-reveal what it just hid, every single frame.
64
+ */
52
65
  private readonly onActivity;
53
66
  /** x */
54
67
  private controlsHiddenAtGestureStart;
@@ -105,7 +118,18 @@ export declare class Gallery {
105
118
  */
106
119
  getOriginElement(index: number): HTMLElement | null;
107
120
  private scheduleAutoHide;
108
- /** Forces the same fade §2.8's idle timer would eventually trigger — public so a plugin can hide controls on its own trigger. Same `isControlActive()` guard as the timer. */
121
+ /**
122
+ * Forces the same fade §2.8's idle timer would eventually trigger —
123
+ * public so a plugin can hide controls on its own trigger. Same
124
+ * `isControlActive()` guard as the timer. Also a no-op under
125
+ * `autoHideDelay: false` — a real bug, reported from real usage: Autoplay's
126
+ * tap-to-toggle-chrome behavior called this directly and ignored `false`
127
+ * entirely, since only the idle timer checked it. `false` has to hold for
128
+ * every caller, not just the timer — `mobileSettings.controls: false` is
129
+ * affected the same way, by design. `forceHideControls()`/
130
+ * `setControlsHiddenForDrag()` (close, drag-to-close) deliberately don't
131
+ * check this — direct user actions with their own feedback, not auto-hide.
132
+ */
109
133
  hideControls(): void;
110
134
  private showControls;
111
135
  /**
@@ -177,6 +201,40 @@ export declare class Gallery {
177
201
  */
178
202
  private applyMobileControlsSetting;
179
203
  close(): void;
204
+ /**
205
+ * DESIGN.md §2.4/§2.6a — same effect as `close()`, from a completed
206
+ * vertical swipe. `frozenDrag` is threaded through to `zoomOut()`'s own
207
+ * `dragStart`, so the whole close continues as one motion from exactly
208
+ * where the drag left off (see `GestureController.
209
+ * takeFrozenDragTransform`).
210
+ */
211
+ private closeFromSwipe;
212
+ /**
213
+ * `frozenDrag` — see `closeFromSwipe()`'s doc comment; absent for a
214
+ * button-close, which has no drag to continue from. Controls fade and the
215
+ * zoom-out run concurrently, both starting the instant `forceHideControls()`
216
+ * runs — not sequenced one after the other. (A previous version waited for
217
+ * the controls' own fade to fully finish before starting the zoom-out, for
218
+ * a button-close specifically — requested directly, to avoid stationary
219
+ * chrome hovering over an already-shrinking photo. Reversed on later,
220
+ * explicit feedback: waiting read as two distinct steps rather than one
221
+ * motion; starting both together still avoids stationary chrome, since
222
+ * the chrome is disappearing too, just without the pause.)
223
+ */
224
+ private beginClose;
225
+ /** Forces the same fade §2.8's idle timer would eventually trigger, bypassing `hideControls()`'s own `isControlActive()` hover guard — the most common close path (clicking close) is hovering a control at this exact instant, and a deliberate close should hide regardless. No-op if already hidden. */
226
+ private forceHideControls;
227
+ /**
228
+ * DESIGN.md §2.4/§2.8 — `GestureController`'s live vertical-drag cue: hide
229
+ * past the same distance a release would close, reveal again on retreat.
230
+ * `hidden: false` only reveals if visible when *this* gesture started
231
+ * (`controlsHiddenAtGestureStart`) — shouldn't resurrect controls already
232
+ * separately hidden (idle, or `autoHideDelay: 0`) before the drag began.
233
+ * `dragCloseThreshold` (bus event) fires on every crossing regardless of
234
+ * that guard — Autoplay (§4-autoplay) needs to know about the crossing
235
+ * itself, not just whether controls visibly moved.
236
+ */
237
+ private setControlsHiddenForDrag;
180
238
  /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
181
239
  private resolveAspectRatio;
182
240
  /**
@@ -1,12 +1,15 @@
1
1
  import { GestureEngineOptions } from '../gestures/GestureEngine';
2
2
  import { SlideManager } from './SlideManager';
3
+ import { FrozenDragTransform } from './zoomTransition';
3
4
  /**
4
- * A real interactive control. Also used by `Gallery.ts`'s `isBackdropClick`,
5
- * which used to have its own narrower list (missing select/input/textarea/
6
- * a[href]) — a plugin-mounted non-button control got misread as a backdrop
7
- * click and closed the gallery. One shared selector so that can't recur.
5
+ * A real interactive control, plus `.shoji-caption` — a click/drag starting
6
+ * there should select/scroll its text natively, not get captured as a
7
+ * gesture. Also used by `Gallery.ts`'s `isBackdropClick`, which used to have
8
+ * its own narrower list (missing select/input/textarea/a[href]) — a
9
+ * plugin-mounted non-button control got misread as a backdrop click and
10
+ * closed the gallery. One shared selector so that can't recur.
8
11
  */
9
- export declare const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag]";
12
+ export declare const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag], .shoji-caption";
10
13
  /** What `GestureController` needs from `Gallery` — narrow on purpose, so this module never reaches into Gallery internals beyond this contract. */
11
14
  export interface GestureControllerHost {
12
15
  dialog: HTMLElement;
@@ -16,6 +19,10 @@ export interface GestureControllerHost {
16
19
  next(): void;
17
20
  prev(): void;
18
21
  close(): void;
22
+ /** Same effect as `close()`, but for a completed vertical swipe — skips the separate controls-fade pause in front of the zoom-out (see `finishVerticalDrag`). */
23
+ closeFromSwipe(frozenDrag: FrozenDragTransform): void;
24
+ /** Live, reversible — crossing the close threshold mid-drag hides the toolbar/nav/counter/caption (a "let go now and this closes" cue); dragging back under it before release reveals them again. See `applyVerticalDragFeedback`. */
25
+ setControlsHiddenForDrag(hidden: boolean): void;
19
26
  /** `closable: false` (GalleryOptions) — false suspends vertical drag-to-close entirely: no live feedback, release never calls `close()`. */
20
27
  canClose(): boolean;
21
28
  onActivity(): void;
@@ -42,6 +49,10 @@ export interface GestureRelayCallbacks {
42
49
  export declare class GestureController {
43
50
  private readonly host;
44
51
  private readonly engine;
52
+ /** Same distance `GestureEngine` itself uses to decide a release would complete the close — reused here so the live controls-hide cue in `applyVerticalDragFeedback` actually means what it visually claims. */
53
+ private readonly controlsHideThreshold;
54
+ private controlsHiddenForDrag;
55
+ private lastDragDelta;
45
56
  constructor(host: GestureControllerHost, relay: GestureRelayCallbacks, options?: Partial<GestureEngineOptions>);
46
57
  destroy(): void;
47
58
  /** Horizontal follows the finger 1:1; vertical drives close-feedback instead. Axis-locked, one branch per drag. */
@@ -50,8 +61,33 @@ export declare class GestureController {
50
61
  /** `completed` decides intent; `canGoNext`/`canGoPrev` guard whether that's actually possible (`loop: false` at an end item). */
51
62
  private finishHorizontalDrag;
52
63
  private settleDragOffset;
53
- /** Purely presentational drag feedback — scales/fades the dialog toward `close()`'s target state as the viewer drags, without closing until release decides the outcome. */
64
+ /**
65
+ * Purely presentational — scales/fades the *image* as it's dragged away,
66
+ * without closing until release decides. Applied to `host.slides.element`,
67
+ * not `host.dialog` — requested directly: toolbar/nav/counter/caption are
68
+ * siblings, not descendants, so they stay anchored, not moving with it.
69
+ */
54
70
  private applyVerticalDragFeedback;
55
71
  private clearVerticalDragFeedback;
56
72
  private finishVerticalDrag;
73
+ /**
74
+ * Reads the drag's last live appearance, instantly resets `.shoji-slides`
75
+ * back to neutral (nothing left on it to visibly snap — `Gallery` bakes
76
+ * these same values onto the photo itself in the same synchronous tick,
77
+ * so the rendered result is unchanged, just re-homed), and returns them
78
+ * for that hand-off. Translate is the raw, unclamped drag distance —
79
+ * requested directly: the close animation must continue from exactly
80
+ * where the drag left off, full stop, not recenter first. (A previous
81
+ * version clamped this to the same 160px the dim/scale feedback ramps
82
+ * over, to bound how far away the close animation could start — but
83
+ * clamping is itself an instant correction: for any drag past that
84
+ * distance, release visibly snapped the photo from wherever it actually
85
+ * was back to the clamped point, before the real shrink-to-thumbnail
86
+ * motion continued from there. That snap — not the shrink itself — is
87
+ * what reads as "jumps to a small image in the middle of the screen."
88
+ * Reported from real usage, confirmed on video: released past the clamp,
89
+ * the photo visibly jumped from off-screen back to near-center in a
90
+ * single frame.)
91
+ */
92
+ private takeFrozenDragTransform;
57
93
  }
@@ -7,6 +7,7 @@ export interface LightboxLabels {
7
7
  }
8
8
  export interface LightboxDom {
9
9
  outer: HTMLElement;
10
+ backdrop: HTMLElement;
10
11
  dialog: HTMLElement;
11
12
  counter: HTMLElement;
12
13
  caption: HTMLElement;
@@ -1,14 +1,18 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
- import { w as waitForTransitionEnd, c as containedBox, z as zoomIn, a as zoomOut } from "../zoomTransition-2_HG7tyJ.js";
4
+ import { w as waitForTransitionEnd, c as containedBox, z as zoomIn, a as zoomOut } from "../zoomTransition-BeIimqDT.js";
5
5
  let lockCount = 0;
6
- let savedOverflow = "";
7
6
  let savedHtmlOverflow = "";
7
+ let savedHtmlPaddingRight = "";
8
8
  function lockBodyScroll() {
9
9
  if (lockCount === 0) {
10
- savedOverflow = document.body.style.overflow;
11
- document.body.style.overflow = "hidden";
10
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
11
+ savedHtmlPaddingRight = document.documentElement.style.paddingRight;
12
+ if (scrollbarWidth > 0) {
13
+ const currentPaddingRight = parseFloat(getComputedStyle(document.documentElement).paddingRight) || 0;
14
+ document.documentElement.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
15
+ }
12
16
  savedHtmlOverflow = document.documentElement.style.overflow;
13
17
  document.documentElement.style.overflow = "hidden";
14
18
  }
@@ -17,8 +21,8 @@ function lockBodyScroll() {
17
21
  function unlockBodyScroll() {
18
22
  lockCount = Math.max(0, lockCount - 1);
19
23
  if (lockCount === 0) {
20
- document.body.style.overflow = savedOverflow;
21
24
  document.documentElement.style.overflow = savedHtmlOverflow;
25
+ document.documentElement.style.paddingRight = savedHtmlPaddingRight;
22
26
  }
23
27
  }
24
28
  class EventBus {
@@ -108,6 +112,7 @@ function buildLightboxDom(slides, labels) {
108
112
  outer.appendChild(dialog);
109
113
  return {
110
114
  outer,
115
+ backdrop,
111
116
  dialog,
112
117
  counter,
113
118
  caption,
@@ -181,6 +186,8 @@ class GestureEngine {
181
186
  // along the locked axis, at the moment direction locked — subtracted so onDragStart's delta is 0
182
187
  __publicField(this, "pinching", false);
183
188
  __publicField(this, "pinchStartDistance", 0);
189
+ /** True once `setPointerCapture` has actually been called for the gesture currently in progress — see `suppressRetargetedClick`'s own doc comment for why this needs tracking separately from `direction`. */
190
+ __publicField(this, "capturedThisGesture", false);
184
191
  // -Infinity, not 0: `event.timeStamp` is time-since-navigation-start, so a
185
192
  // real first tap is always some large positive number and 0 would seem
186
193
  // like a safe "no previous tap" sentinel in practice — but not always: a
@@ -206,6 +213,7 @@ class GestureEngine {
206
213
  if (this.pointers.size === 1) {
207
214
  this.primaryPointerId = event.pointerId;
208
215
  this.direction = null;
216
+ this.capturedThisGesture = false;
209
217
  } else if (this.pointers.size === 2) {
210
218
  this.direction = null;
211
219
  this.pinching = true;
@@ -240,7 +248,10 @@ class GestureEngine {
240
248
  if (Math.max(absX, absY) < this.options.lockThreshold) return;
241
249
  this.direction = absX > absY ? "horizontal" : "vertical";
242
250
  this.dragStartDistance = this.direction === "horizontal" ? dx : dy;
243
- if (((_d = (_c = this.callbacks).shouldCapture) == null ? void 0 : _d.call(_c)) ?? true) this.target.setPointerCapture(event.pointerId);
251
+ if (((_d = (_c = this.callbacks).shouldCapture) == null ? void 0 : _d.call(_c)) ?? true) {
252
+ this.target.setPointerCapture(event.pointerId);
253
+ this.capturedThisGesture = true;
254
+ }
244
255
  (_f = (_e = this.callbacks).onDragStart) == null ? void 0 : _f.call(_e, this.direction, event);
245
256
  }
246
257
  if (this.direction === "horizontal") event.preventDefault();
@@ -307,9 +318,30 @@ class GestureEngine {
307
318
  const velocity = totalDelta / elapsed;
308
319
  const completed = !cancelled && (Math.abs(totalDelta) >= this.options.swipeThreshold || Math.abs(velocity) >= this.options.swipeVelocity);
309
320
  (_d = (_c = this.callbacks).onDragEnd) == null ? void 0 : _d.call(_c, this.direction, totalDelta, velocity, completed, cancelled);
321
+ if (this.capturedThisGesture) this.suppressRetargetedClick();
310
322
  }
311
323
  this.primaryPointerId = null;
312
324
  this.direction = null;
325
+ this.capturedThisGesture = false;
326
+ }
327
+ /**
328
+ * A captured pointer's release still fires a real `click`, retargeted to
329
+ * `target` regardless of where the pointer visually ends up — misread by
330
+ * Gallery's click-outside-to-close as landing nowhere recognizable.
331
+ * Consumes exactly one `click` on `target` in the capture phase, then
332
+ * removes itself; a fallback timeout also removes it in case no `click`
333
+ * ever comes, so it can't swallow a later, unrelated one.
334
+ */
335
+ suppressRetargetedClick() {
336
+ let done = false;
337
+ const cleanup = (event) => {
338
+ if (done) return;
339
+ done = true;
340
+ event == null ? void 0 : event.stopPropagation();
341
+ this.target.removeEventListener("click", cleanup, { capture: true });
342
+ };
343
+ this.target.addEventListener("click", cleanup, { capture: true });
344
+ setTimeout(cleanup, 500);
313
345
  }
314
346
  registerTap(x, y, event) {
315
347
  var _a, _b, _c, _d;
@@ -335,13 +367,17 @@ function centerOf(a, b) {
335
367
  const DRAG_SETTLE_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing)";
336
368
  const DRAG_FEEDBACK_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing), opacity var(--shoji-duration) var(--shoji-momentum-easing)";
337
369
  const VERTICAL_FEEDBACK_DISTANCE = 160;
338
- const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag]";
370
+ const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag], .shoji-caption";
339
371
  function shouldIgnoreGesture(event) {
340
372
  return event.composedPath().some((node) => node instanceof Element && node.matches(INTERACTIVE_CONTROL_SELECTOR));
341
373
  }
342
374
  class GestureController {
343
375
  constructor(host, relay, options) {
344
376
  __publicField(this, "engine");
377
+ /** Same distance `GestureEngine` itself uses to decide a release would complete the close — reused here so the live controls-hide cue in `applyVerticalDragFeedback` actually means what it visually claims. */
378
+ __publicField(this, "controlsHideThreshold");
379
+ __publicField(this, "controlsHiddenForDrag", false);
380
+ __publicField(this, "lastDragDelta", 0);
345
381
  /** Horizontal follows the finger 1:1; vertical drives close-feedback instead. Axis-locked, one branch per drag. */
346
382
  __publicField(this, "onDragMove", (direction, delta) => {
347
383
  if (this.host.isZoomed()) return;
@@ -360,6 +396,7 @@ class GestureController {
360
396
  }
361
397
  });
362
398
  this.host = host;
399
+ this.controlsHideThreshold = (options == null ? void 0 : options.swipeThreshold) ?? 50;
363
400
  this.engine = new GestureEngine(
364
401
  host.dialog,
365
402
  {
@@ -407,28 +444,70 @@ class GestureController {
407
444
  }
408
445
  waitForTransitionEnd(settleEl, onSettled);
409
446
  }
410
- /** Purely presentational drag feedback — scales/fades the dialog toward `close()`'s target state as the viewer drags, without closing until release decides the outcome. */
447
+ /**
448
+ * Purely presentational — scales/fades the *image* as it's dragged away,
449
+ * without closing until release decides. Applied to `host.slides.element`,
450
+ * not `host.dialog` — requested directly: toolbar/nav/counter/caption are
451
+ * siblings, not descendants, so they stay anchored, not moving with it.
452
+ */
411
453
  applyVerticalDragFeedback(delta) {
412
454
  const progress = Math.min(Math.abs(delta) / VERTICAL_FEEDBACK_DISTANCE, 1);
413
- const dialog = this.host.dialog;
414
- dialog.style.transition = "";
415
- dialog.style.transform = `translateY(${delta}px) scale(${1 - progress * 0.15})`;
416
- dialog.style.opacity = String(1 - progress * 0.6);
455
+ const slides = this.host.slides.element;
456
+ this.lastDragDelta = delta;
457
+ slides.style.transition = "";
458
+ slides.style.transform = `translateY(${delta}px) scale(${1 - progress * 0.15})`;
459
+ slides.style.opacity = String(1 - progress * 0.6);
460
+ const pastThreshold = Math.abs(delta) >= this.controlsHideThreshold;
461
+ if (pastThreshold !== this.controlsHiddenForDrag) {
462
+ this.controlsHiddenForDrag = pastThreshold;
463
+ this.host.setControlsHiddenForDrag(pastThreshold);
464
+ }
417
465
  }
418
466
  clearVerticalDragFeedback(animate) {
419
- const dialog = this.host.dialog;
420
- dialog.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
421
- dialog.style.transform = "";
422
- dialog.style.opacity = "";
467
+ const slides = this.host.slides.element;
468
+ slides.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
469
+ slides.style.transform = "";
470
+ slides.style.opacity = "";
423
471
  }
424
472
  finishVerticalDrag(completed) {
473
+ const wasHiddenForDrag = this.controlsHiddenForDrag;
474
+ this.controlsHiddenForDrag = false;
425
475
  if (completed && this.host.canClose()) {
426
- this.clearVerticalDragFeedback(false);
427
- this.host.close();
476
+ this.host.closeFromSwipe(this.takeFrozenDragTransform());
428
477
  } else {
429
478
  this.clearVerticalDragFeedback(true);
479
+ if (wasHiddenForDrag) this.host.setControlsHiddenForDrag(false);
430
480
  }
431
481
  }
482
+ /**
483
+ * Reads the drag's last live appearance, instantly resets `.shoji-slides`
484
+ * back to neutral (nothing left on it to visibly snap — `Gallery` bakes
485
+ * these same values onto the photo itself in the same synchronous tick,
486
+ * so the rendered result is unchanged, just re-homed), and returns them
487
+ * for that hand-off. Translate is the raw, unclamped drag distance —
488
+ * requested directly: the close animation must continue from exactly
489
+ * where the drag left off, full stop, not recenter first. (A previous
490
+ * version clamped this to the same 160px the dim/scale feedback ramps
491
+ * over, to bound how far away the close animation could start — but
492
+ * clamping is itself an instant correction: for any drag past that
493
+ * distance, release visibly snapped the photo from wherever it actually
494
+ * was back to the clamped point, before the real shrink-to-thumbnail
495
+ * motion continued from there. That snap — not the shrink itself — is
496
+ * what reads as "jumps to a small image in the middle of the screen."
497
+ * Reported from real usage, confirmed on video: released past the clamp,
498
+ * the photo visibly jumped from off-screen back to near-center in a
499
+ * single frame.)
500
+ */
501
+ takeFrozenDragTransform() {
502
+ const progress = Math.min(Math.abs(this.lastDragDelta) / VERTICAL_FEEDBACK_DISTANCE, 1);
503
+ const frozen = {
504
+ translateY: this.lastDragDelta,
505
+ scale: 1 - progress * 0.15,
506
+ opacity: 1 - progress * 0.6
507
+ };
508
+ this.clearVerticalDragFeedback(false);
509
+ return frozen;
510
+ }
432
511
  }
433
512
  class LiveRegion {
434
513
  constructor() {
@@ -1344,7 +1423,7 @@ const DEFAULT_LOCALE = {
1344
1423
  function isBackdropClick(event) {
1345
1424
  return !event.composedPath().some(
1346
1425
  (node) => node instanceof Element && node.matches(
1347
- `.shoji-slide-img, .shoji-slide-provider-video, .shoji-counter, .shoji-caption, ${INTERACTIVE_CONTROL_SELECTOR}`
1426
+ `.shoji-slide-img, .shoji-slide-provider-video, .shoji-counter, ${INTERACTIVE_CONTROL_SELECTOR}`
1348
1427
  )
1349
1428
  );
1350
1429
  }
@@ -1373,6 +1452,8 @@ class Gallery {
1373
1452
  __publicField(this, "autoHidden", false);
1374
1453
  __publicField(this, "hoveredControlCount", 0);
1375
1454
  __publicField(this, "isClosing", false);
1455
+ /** True while a vertical drag has hidden controls past its own distance threshold (`setControlsHiddenForDrag`) — `onActivity()` defers to it, since the drag's own continuous pointermove stream would otherwise immediately re-reveal what it just hid on every single move. */
1456
+ __publicField(this, "controlsHiddenByDrag", false);
1376
1457
  __publicField(this, "itemList", []);
1377
1458
  __publicField(this, "scannedElements", []);
1378
1459
  __publicField(this, "activeIndex", 0);
@@ -1406,9 +1487,21 @@ class Gallery {
1406
1487
  __publicField(this, "onOuterClick", (event) => {
1407
1488
  if (this.closable && isBackdropClick(event)) this.close();
1408
1489
  });
1409
- /** DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle clock. `autoHideDelay: 0` = "never show controls" — a no-op here. */
1490
+ /**
1491
+ * DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle
1492
+ * clock. `autoHideDelay: 0` = "never show controls" — a no-op here.
1493
+ * `autoHideDelay: false` = "always visible" — also a no-op: nothing to
1494
+ * reveal (already shown) or reschedule (no timer ever runs). Also a no-op
1495
+ * once `isClosing` — a real bug, reported from real usage: moving the
1496
+ * mouse during close()'s own controls-fade-then-zoom-out sequence (§2.6a)
1497
+ * re-showed the just-hidden controls mid-animation, since these listeners
1498
+ * stay wired for the whole close sequence. Same reasoning for
1499
+ * `controlsHiddenByDrag` (§2.4/§2.8): a vertical drag's own `pointermove`
1500
+ * stream would otherwise re-reveal what it just hid, every single frame.
1501
+ */
1410
1502
  __publicField(this, "onActivity", () => {
1411
- if (this.autoHideDelay === 0) return;
1503
+ if (this.autoHideDelay === 0 || this.autoHideDelay === false || this.isClosing || this.controlsHiddenByDrag)
1504
+ return;
1412
1505
  this.showControls();
1413
1506
  this.scheduleAutoHide();
1414
1507
  });
@@ -1538,6 +1631,11 @@ class Gallery {
1538
1631
  this.transition = new SlideTransition(this.slides);
1539
1632
  const dom = buildLightboxDom(this.slides.element, this.locale);
1540
1633
  this.dom = dom;
1634
+ if (this.options.backdropOpacity != null) {
1635
+ const clamped = Math.min(Math.max(this.options.backdropOpacity, 0), 1);
1636
+ dom.outer.style.setProperty("--shoji-backdrop-opacity", String(clamped));
1637
+ }
1638
+ if (this.autoHideDelay === 0) dom.dialog.classList.add("shoji-cursor-visible");
1541
1639
  dom.outer.appendChild(this.liveRegion.element);
1542
1640
  dom.closeButton.addEventListener("click", () => this.close());
1543
1641
  dom.prevButton.addEventListener("click", () => this.prev());
@@ -1580,6 +1678,8 @@ class Gallery {
1580
1678
  next: () => this.navigate(this.nextIndex(), 1, false),
1581
1679
  prev: () => this.navigate(this.prevIndex(), -1, false),
1582
1680
  close: () => this.close(),
1681
+ closeFromSwipe: (frozenDrag) => this.closeFromSwipe(frozenDrag),
1682
+ setControlsHiddenForDrag: (hidden) => this.setControlsHiddenForDrag(hidden),
1583
1683
  canClose: () => this.closable,
1584
1684
  onActivity: () => this.onActivity(),
1585
1685
  isZoomed: () => {
@@ -1761,15 +1861,28 @@ class Gallery {
1761
1861
  this.autoHideTimer = null;
1762
1862
  }
1763
1863
  if (!this.opened) return;
1864
+ if (this.autoHideDelay === false) return;
1764
1865
  if (this.autoHideDelay === 0) {
1765
1866
  this.hideControls();
1766
1867
  return;
1767
1868
  }
1768
1869
  this.autoHideTimer = setTimeout(() => this.hideControls(), this.autoHideDelay);
1769
1870
  }
1770
- /** Forces the same fade §2.8's idle timer would eventually trigger — public so a plugin can hide controls on its own trigger. Same `isControlActive()` guard as the timer. */
1871
+ /**
1872
+ * Forces the same fade §2.8's idle timer would eventually trigger —
1873
+ * public so a plugin can hide controls on its own trigger. Same
1874
+ * `isControlActive()` guard as the timer. Also a no-op under
1875
+ * `autoHideDelay: false` — a real bug, reported from real usage: Autoplay's
1876
+ * tap-to-toggle-chrome behavior called this directly and ignored `false`
1877
+ * entirely, since only the idle timer checked it. `false` has to hold for
1878
+ * every caller, not just the timer — `mobileSettings.controls: false` is
1879
+ * affected the same way, by design. `forceHideControls()`/
1880
+ * `setControlsHiddenForDrag()` (close, drag-to-close) deliberately don't
1881
+ * check this — direct user actions with their own feedback, not auto-hide.
1882
+ */
1771
1883
  hideControls() {
1772
- if (!this.dom || this.autoHidden || this.isControlActive()) return;
1884
+ if (!this.dom || this.autoHidden || this.isControlActive() || this.autoHideDelay === false)
1885
+ return;
1773
1886
  this.autoHidden = true;
1774
1887
  this.dom.dialog.classList.add("shoji-controls-hidden");
1775
1888
  this.bus.emit("controls:hide", {});
@@ -1996,6 +2109,31 @@ class Gallery {
1996
2109
  }
1997
2110
  }
1998
2111
  close() {
2112
+ this.beginClose();
2113
+ }
2114
+ /**
2115
+ * DESIGN.md §2.4/§2.6a — same effect as `close()`, from a completed
2116
+ * vertical swipe. `frozenDrag` is threaded through to `zoomOut()`'s own
2117
+ * `dragStart`, so the whole close continues as one motion from exactly
2118
+ * where the drag left off (see `GestureController.
2119
+ * takeFrozenDragTransform`).
2120
+ */
2121
+ closeFromSwipe(frozenDrag) {
2122
+ this.beginClose(frozenDrag);
2123
+ }
2124
+ /**
2125
+ * `frozenDrag` — see `closeFromSwipe()`'s doc comment; absent for a
2126
+ * button-close, which has no drag to continue from. Controls fade and the
2127
+ * zoom-out run concurrently, both starting the instant `forceHideControls()`
2128
+ * runs — not sequenced one after the other. (A previous version waited for
2129
+ * the controls' own fade to fully finish before starting the zoom-out, for
2130
+ * a button-close specifically — requested directly, to avoid stationary
2131
+ * chrome hovering over an already-shrinking photo. Reversed on later,
2132
+ * explicit feedback: waiting read as two distinct steps rather than one
2133
+ * motion; starting both together still avoids stationary chrome, since
2134
+ * the chrome is disappearing too, just without the pause.)
2135
+ */
2136
+ beginClose(frozenDrag) {
1999
2137
  var _a, _b;
2000
2138
  if (this.destroyed || !this.opened || this.isClosing) return;
2001
2139
  this.bus.emit("beforeClose", {});
@@ -2006,11 +2144,41 @@ class Gallery {
2006
2144
  const naturalSize = this.resolveNaturalSize(this.activeIndex);
2007
2145
  if (media && origin && (((_b = this.slides) == null ? void 0 : _b.isActiveReady()) || naturalSize)) {
2008
2146
  const aspectRatio = this.resolveAspectRatio(this.activeIndex, origin);
2009
- zoomOut({ origin, target: media, aspectRatio, naturalSize }, () => this.finishClose());
2147
+ this.forceHideControls();
2148
+ if (this.dom) this.dom.backdrop.style.opacity = "0";
2149
+ zoomOut(
2150
+ { origin, target: media, aspectRatio, naturalSize, dragStart: frozenDrag },
2151
+ () => this.finishClose()
2152
+ );
2010
2153
  } else {
2011
2154
  this.finishClose();
2012
2155
  }
2013
2156
  }
2157
+ /** Forces the same fade §2.8's idle timer would eventually trigger, bypassing `hideControls()`'s own `isControlActive()` hover guard — the most common close path (clicking close) is hovering a control at this exact instant, and a deliberate close should hide regardless. No-op if already hidden. */
2158
+ forceHideControls() {
2159
+ if (!this.dom || this.autoHidden) return;
2160
+ this.autoHidden = true;
2161
+ this.dom.dialog.classList.add("shoji-controls-hidden");
2162
+ this.bus.emit("controls:hide", {});
2163
+ }
2164
+ /**
2165
+ * DESIGN.md §2.4/§2.8 — `GestureController`'s live vertical-drag cue: hide
2166
+ * past the same distance a release would close, reveal again on retreat.
2167
+ * `hidden: false` only reveals if visible when *this* gesture started
2168
+ * (`controlsHiddenAtGestureStart`) — shouldn't resurrect controls already
2169
+ * separately hidden (idle, or `autoHideDelay: 0`) before the drag began.
2170
+ * `dragCloseThreshold` (bus event) fires on every crossing regardless of
2171
+ * that guard — Autoplay (§4-autoplay) needs to know about the crossing
2172
+ * itself, not just whether controls visibly moved.
2173
+ */
2174
+ setControlsHiddenForDrag(hidden) {
2175
+ var _a;
2176
+ this.controlsHiddenByDrag = hidden;
2177
+ if (hidden) this.forceHideControls();
2178
+ else if (!this.controlsHiddenAtGestureStart) this.showControls();
2179
+ (_a = this.dom) == null ? void 0 : _a.dialog.classList.toggle("shoji-controls-hidden-for-drag", hidden);
2180
+ this.bus.emit("dragCloseThreshold", { hidden });
2181
+ }
2014
2182
  /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
2015
2183
  resolveAspectRatio(index, origin) {
2016
2184
  const item = this.itemList[index];
@@ -2041,7 +2209,7 @@ class Gallery {
2041
2209
  * instead of a second `close`/`afterClose` emission on a torn-down gallery.
2042
2210
  */
2043
2211
  finishClose() {
2044
- var _a, _b;
2212
+ var _a, _b, _c;
2045
2213
  if (!this.opened) return;
2046
2214
  this.isClosing = false;
2047
2215
  this.opened = false;
@@ -2049,13 +2217,16 @@ class Gallery {
2049
2217
  document.removeEventListener("keydown", this.onKeydown);
2050
2218
  this.focusTrap.deactivate();
2051
2219
  (_a = this.dom) == null ? void 0 : _a.outer.classList.remove("shoji-open");
2220
+ if (this.dom) this.dom.backdrop.style.opacity = "";
2052
2221
  if (this.autoHideTimer !== null) {
2053
2222
  clearTimeout(this.autoHideTimer);
2054
2223
  this.autoHideTimer = null;
2055
2224
  }
2056
2225
  this.autoHidden = false;
2057
2226
  this.hoveredControlCount = 0;
2227
+ this.controlsHiddenByDrag = false;
2058
2228
  (_b = this.dom) == null ? void 0 : _b.dialog.classList.remove("shoji-controls-hidden");
2229
+ (_c = this.dom) == null ? void 0 : _c.dialog.classList.remove("shoji-controls-hidden-for-drag");
2059
2230
  this.bus.emit("close", {});
2060
2231
  this.bus.emit("afterClose", {});
2061
2232
  }