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

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,24 +1,69 @@
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
+ let savedHtmlPaddingLeft = "";
9
+ let savedScrollX = 0;
10
+ let savedScrollY = 0;
11
+ let styleObserver = null;
12
+ const LIGHTBOX_SELECTOR = ".shoji-outer";
13
+ function isRtl() {
14
+ return getComputedStyle(document.documentElement).direction === "rtl";
15
+ }
16
+ function onTouchMove(event) {
17
+ const target = event.target;
18
+ const insideLightbox = target instanceof Element && target.closest(LIGHTBOX_SELECTOR) !== null;
19
+ if (!insideLightbox) event.preventDefault();
20
+ }
21
+ function onStyleMutation() {
22
+ if (lockCount === 0) return;
23
+ const html = document.documentElement;
24
+ if (getComputedStyle(html).overflow !== "hidden") {
25
+ html.style.overflow = "hidden";
26
+ styleObserver == null ? void 0 : styleObserver.takeRecords();
27
+ }
28
+ }
8
29
  function lockBodyScroll() {
9
30
  if (lockCount === 0) {
10
- savedOverflow = document.body.style.overflow;
11
- document.body.style.overflow = "hidden";
31
+ savedScrollX = window.scrollX;
32
+ savedScrollY = window.scrollY;
33
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
34
+ const rtl = isRtl();
35
+ savedHtmlPaddingRight = document.documentElement.style.paddingRight;
36
+ savedHtmlPaddingLeft = document.documentElement.style.paddingLeft;
37
+ if (scrollbarWidth > 0) {
38
+ if (rtl) {
39
+ const currentPaddingLeft = parseFloat(getComputedStyle(document.documentElement).paddingLeft) || 0;
40
+ document.documentElement.style.paddingLeft = `${currentPaddingLeft + scrollbarWidth}px`;
41
+ } else {
42
+ const currentPaddingRight = parseFloat(getComputedStyle(document.documentElement).paddingRight) || 0;
43
+ document.documentElement.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
44
+ }
45
+ }
12
46
  savedHtmlOverflow = document.documentElement.style.overflow;
13
47
  document.documentElement.style.overflow = "hidden";
48
+ document.addEventListener("touchmove", onTouchMove, { passive: false });
49
+ styleObserver = new MutationObserver(onStyleMutation);
50
+ styleObserver.observe(document.documentElement, {
51
+ attributes: true,
52
+ attributeFilter: ["style"]
53
+ });
14
54
  }
15
55
  lockCount++;
16
56
  }
17
57
  function unlockBodyScroll() {
18
58
  lockCount = Math.max(0, lockCount - 1);
19
59
  if (lockCount === 0) {
20
- document.body.style.overflow = savedOverflow;
60
+ styleObserver == null ? void 0 : styleObserver.disconnect();
61
+ styleObserver = null;
62
+ document.removeEventListener("touchmove", onTouchMove);
21
63
  document.documentElement.style.overflow = savedHtmlOverflow;
64
+ document.documentElement.style.paddingRight = savedHtmlPaddingRight;
65
+ document.documentElement.style.paddingLeft = savedHtmlPaddingLeft;
66
+ window.scrollTo({ left: savedScrollX, top: savedScrollY, behavior: "instant" });
22
67
  }
23
68
  }
24
69
  class EventBus {
@@ -108,6 +153,7 @@ function buildLightboxDom(slides, labels) {
108
153
  outer.appendChild(dialog);
109
154
  return {
110
155
  outer,
156
+ backdrop,
111
157
  dialog,
112
158
  counter,
113
159
  caption,
@@ -181,6 +227,8 @@ class GestureEngine {
181
227
  // along the locked axis, at the moment direction locked — subtracted so onDragStart's delta is 0
182
228
  __publicField(this, "pinching", false);
183
229
  __publicField(this, "pinchStartDistance", 0);
230
+ /** 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`. */
231
+ __publicField(this, "capturedThisGesture", false);
184
232
  // -Infinity, not 0: `event.timeStamp` is time-since-navigation-start, so a
185
233
  // real first tap is always some large positive number and 0 would seem
186
234
  // like a safe "no previous tap" sentinel in practice — but not always: a
@@ -206,6 +254,7 @@ class GestureEngine {
206
254
  if (this.pointers.size === 1) {
207
255
  this.primaryPointerId = event.pointerId;
208
256
  this.direction = null;
257
+ this.capturedThisGesture = false;
209
258
  } else if (this.pointers.size === 2) {
210
259
  this.direction = null;
211
260
  this.pinching = true;
@@ -240,7 +289,10 @@ class GestureEngine {
240
289
  if (Math.max(absX, absY) < this.options.lockThreshold) return;
241
290
  this.direction = absX > absY ? "horizontal" : "vertical";
242
291
  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);
292
+ if (((_d = (_c = this.callbacks).shouldCapture) == null ? void 0 : _d.call(_c)) ?? true) {
293
+ this.target.setPointerCapture(event.pointerId);
294
+ this.capturedThisGesture = true;
295
+ }
244
296
  (_f = (_e = this.callbacks).onDragStart) == null ? void 0 : _f.call(_e, this.direction, event);
245
297
  }
246
298
  if (this.direction === "horizontal") event.preventDefault();
@@ -307,9 +359,30 @@ class GestureEngine {
307
359
  const velocity = totalDelta / elapsed;
308
360
  const completed = !cancelled && (Math.abs(totalDelta) >= this.options.swipeThreshold || Math.abs(velocity) >= this.options.swipeVelocity);
309
361
  (_d = (_c = this.callbacks).onDragEnd) == null ? void 0 : _d.call(_c, this.direction, totalDelta, velocity, completed, cancelled);
362
+ if (this.capturedThisGesture) this.suppressRetargetedClick();
310
363
  }
311
364
  this.primaryPointerId = null;
312
365
  this.direction = null;
366
+ this.capturedThisGesture = false;
367
+ }
368
+ /**
369
+ * A captured pointer's release still fires a real `click`, retargeted to
370
+ * `target` regardless of where the pointer visually ends up — misread by
371
+ * Gallery's click-outside-to-close as landing nowhere recognizable.
372
+ * Consumes exactly one `click` on `target` in the capture phase, then
373
+ * removes itself; a fallback timeout also removes it in case no `click`
374
+ * ever comes, so it can't swallow a later, unrelated one.
375
+ */
376
+ suppressRetargetedClick() {
377
+ let done = false;
378
+ const cleanup = (event) => {
379
+ if (done) return;
380
+ done = true;
381
+ event == null ? void 0 : event.stopPropagation();
382
+ this.target.removeEventListener("click", cleanup, { capture: true });
383
+ };
384
+ this.target.addEventListener("click", cleanup, { capture: true });
385
+ setTimeout(cleanup, 500);
313
386
  }
314
387
  registerTap(x, y, event) {
315
388
  var _a, _b, _c, _d;
@@ -335,13 +408,17 @@ function centerOf(a, b) {
335
408
  const DRAG_SETTLE_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing)";
336
409
  const DRAG_FEEDBACK_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing), opacity var(--shoji-duration) var(--shoji-momentum-easing)";
337
410
  const VERTICAL_FEEDBACK_DISTANCE = 160;
338
- const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag]";
411
+ const INTERACTIVE_CONTROL_SELECTOR = "button, video, input, select, textarea, a[href], [data-shoji-no-drag], .shoji-caption";
339
412
  function shouldIgnoreGesture(event) {
340
413
  return event.composedPath().some((node) => node instanceof Element && node.matches(INTERACTIVE_CONTROL_SELECTOR));
341
414
  }
342
415
  class GestureController {
343
416
  constructor(host, relay, options) {
344
417
  __publicField(this, "engine");
418
+ /** 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. */
419
+ __publicField(this, "controlsHideThreshold");
420
+ __publicField(this, "controlsHiddenForDrag", false);
421
+ __publicField(this, "lastDragDelta", 0);
345
422
  /** Horizontal follows the finger 1:1; vertical drives close-feedback instead. Axis-locked, one branch per drag. */
346
423
  __publicField(this, "onDragMove", (direction, delta) => {
347
424
  if (this.host.isZoomed()) return;
@@ -360,6 +437,7 @@ class GestureController {
360
437
  }
361
438
  });
362
439
  this.host = host;
440
+ this.controlsHideThreshold = (options == null ? void 0 : options.swipeThreshold) ?? 50;
363
441
  this.engine = new GestureEngine(
364
442
  host.dialog,
365
443
  {
@@ -407,28 +485,70 @@ class GestureController {
407
485
  }
408
486
  waitForTransitionEnd(settleEl, onSettled);
409
487
  }
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. */
488
+ /**
489
+ * Purely presentational — scales/fades the *image* as it's dragged away,
490
+ * without closing until release decides. Applied to `host.slides.element`,
491
+ * not `host.dialog` — requested directly: toolbar/nav/counter/caption are
492
+ * siblings, not descendants, so they stay anchored, not moving with it.
493
+ */
411
494
  applyVerticalDragFeedback(delta) {
412
495
  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);
496
+ const slides = this.host.slides.element;
497
+ this.lastDragDelta = delta;
498
+ slides.style.transition = "";
499
+ slides.style.transform = `translateY(${delta}px) scale(${1 - progress * 0.15})`;
500
+ slides.style.opacity = String(1 - progress * 0.6);
501
+ const pastThreshold = Math.abs(delta) >= this.controlsHideThreshold;
502
+ if (pastThreshold !== this.controlsHiddenForDrag) {
503
+ this.controlsHiddenForDrag = pastThreshold;
504
+ this.host.setControlsHiddenForDrag(pastThreshold);
505
+ }
417
506
  }
418
507
  clearVerticalDragFeedback(animate) {
419
- const dialog = this.host.dialog;
420
- dialog.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
421
- dialog.style.transform = "";
422
- dialog.style.opacity = "";
508
+ const slides = this.host.slides.element;
509
+ slides.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
510
+ slides.style.transform = "";
511
+ slides.style.opacity = "";
423
512
  }
424
513
  finishVerticalDrag(completed) {
514
+ const wasHiddenForDrag = this.controlsHiddenForDrag;
515
+ this.controlsHiddenForDrag = false;
425
516
  if (completed && this.host.canClose()) {
426
- this.clearVerticalDragFeedback(false);
427
- this.host.close();
517
+ this.host.closeFromSwipe(this.takeFrozenDragTransform());
428
518
  } else {
429
519
  this.clearVerticalDragFeedback(true);
520
+ if (wasHiddenForDrag) this.host.setControlsHiddenForDrag(false);
430
521
  }
431
522
  }
523
+ /**
524
+ * Reads the drag's last live appearance, instantly resets `.shoji-slides`
525
+ * back to neutral (nothing left on it to visibly snap — `Gallery` bakes
526
+ * these same values onto the photo itself in the same synchronous tick,
527
+ * so the rendered result is unchanged, just re-homed), and returns them
528
+ * for that hand-off. Translate is the raw, unclamped drag distance —
529
+ * requested directly: the close animation must continue from exactly
530
+ * where the drag left off, full stop, not recenter first. (A previous
531
+ * version clamped this to the same 160px the dim/scale feedback ramps
532
+ * over, to bound how far away the close animation could start — but
533
+ * clamping is itself an instant correction: for any drag past that
534
+ * distance, release visibly snapped the photo from wherever it actually
535
+ * was back to the clamped point, before the real shrink-to-thumbnail
536
+ * motion continued from there. That snap — not the shrink itself — is
537
+ * what reads as "jumps to a small image in the middle of the screen."
538
+ * Reported from real usage, confirmed on video: released past the clamp,
539
+ * the photo visibly jumped from off-screen back to near-center in a
540
+ * single frame.)
541
+ */
542
+ takeFrozenDragTransform() {
543
+ const progress = Math.min(Math.abs(this.lastDragDelta) / VERTICAL_FEEDBACK_DISTANCE, 1);
544
+ const frozen = {
545
+ translateY: this.lastDragDelta,
546
+ scale: 1 - progress * 0.15,
547
+ opacity: 1 - progress * 0.6
548
+ };
549
+ this.clearVerticalDragFeedback(false);
550
+ return frozen;
551
+ }
432
552
  }
433
553
  class LiveRegion {
434
554
  constructor() {
@@ -1344,7 +1464,7 @@ const DEFAULT_LOCALE = {
1344
1464
  function isBackdropClick(event) {
1345
1465
  return !event.composedPath().some(
1346
1466
  (node) => node instanceof Element && node.matches(
1347
- `.shoji-slide-img, .shoji-slide-provider-video, .shoji-counter, .shoji-caption, ${INTERACTIVE_CONTROL_SELECTOR}`
1467
+ `.shoji-slide-img, .shoji-slide-provider-video, .shoji-counter, ${INTERACTIVE_CONTROL_SELECTOR}`
1348
1468
  )
1349
1469
  );
1350
1470
  }
@@ -1373,6 +1493,8 @@ class Gallery {
1373
1493
  __publicField(this, "autoHidden", false);
1374
1494
  __publicField(this, "hoveredControlCount", 0);
1375
1495
  __publicField(this, "isClosing", false);
1496
+ /** 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. */
1497
+ __publicField(this, "controlsHiddenByDrag", false);
1376
1498
  __publicField(this, "itemList", []);
1377
1499
  __publicField(this, "scannedElements", []);
1378
1500
  __publicField(this, "activeIndex", 0);
@@ -1406,9 +1528,21 @@ class Gallery {
1406
1528
  __publicField(this, "onOuterClick", (event) => {
1407
1529
  if (this.closable && isBackdropClick(event)) this.close();
1408
1530
  });
1409
- /** DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle clock. `autoHideDelay: 0` = "never show controls" — a no-op here. */
1531
+ /**
1532
+ * DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle
1533
+ * clock. `autoHideDelay: 0` = "never show controls" — a no-op here.
1534
+ * `autoHideDelay: false` = "always visible" — also a no-op: nothing to
1535
+ * reveal (already shown) or reschedule (no timer ever runs). Also a no-op
1536
+ * once `isClosing` — a real bug, reported from real usage: moving the
1537
+ * mouse during close()'s own controls-fade-then-zoom-out sequence (§2.6a)
1538
+ * re-showed the just-hidden controls mid-animation, since these listeners
1539
+ * stay wired for the whole close sequence. Same reasoning for
1540
+ * `controlsHiddenByDrag` (§2.4/§2.8): a vertical drag's own `pointermove`
1541
+ * stream would otherwise re-reveal what it just hid, every single frame.
1542
+ */
1410
1543
  __publicField(this, "onActivity", () => {
1411
- if (this.autoHideDelay === 0) return;
1544
+ if (this.autoHideDelay === 0 || this.autoHideDelay === false || this.isClosing || this.controlsHiddenByDrag)
1545
+ return;
1412
1546
  this.showControls();
1413
1547
  this.scheduleAutoHide();
1414
1548
  });
@@ -1538,6 +1672,11 @@ class Gallery {
1538
1672
  this.transition = new SlideTransition(this.slides);
1539
1673
  const dom = buildLightboxDom(this.slides.element, this.locale);
1540
1674
  this.dom = dom;
1675
+ if (this.options.backdropOpacity != null) {
1676
+ const clamped = Math.min(Math.max(this.options.backdropOpacity, 0), 1);
1677
+ dom.outer.style.setProperty("--shoji-backdrop-opacity", String(clamped));
1678
+ }
1679
+ if (this.autoHideDelay === 0) dom.dialog.classList.add("shoji-cursor-visible");
1541
1680
  dom.outer.appendChild(this.liveRegion.element);
1542
1681
  dom.closeButton.addEventListener("click", () => this.close());
1543
1682
  dom.prevButton.addEventListener("click", () => this.prev());
@@ -1580,6 +1719,8 @@ class Gallery {
1580
1719
  next: () => this.navigate(this.nextIndex(), 1, false),
1581
1720
  prev: () => this.navigate(this.prevIndex(), -1, false),
1582
1721
  close: () => this.close(),
1722
+ closeFromSwipe: (frozenDrag) => this.closeFromSwipe(frozenDrag),
1723
+ setControlsHiddenForDrag: (hidden) => this.setControlsHiddenForDrag(hidden),
1583
1724
  canClose: () => this.closable,
1584
1725
  onActivity: () => this.onActivity(),
1585
1726
  isZoomed: () => {
@@ -1761,15 +1902,28 @@ class Gallery {
1761
1902
  this.autoHideTimer = null;
1762
1903
  }
1763
1904
  if (!this.opened) return;
1905
+ if (this.autoHideDelay === false) return;
1764
1906
  if (this.autoHideDelay === 0) {
1765
1907
  this.hideControls();
1766
1908
  return;
1767
1909
  }
1768
1910
  this.autoHideTimer = setTimeout(() => this.hideControls(), this.autoHideDelay);
1769
1911
  }
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. */
1912
+ /**
1913
+ * Forces the same fade §2.8's idle timer would eventually trigger —
1914
+ * public so a plugin can hide controls on its own trigger. Same
1915
+ * `isControlActive()` guard as the timer. Also a no-op under
1916
+ * `autoHideDelay: false` — a real bug, reported from real usage: Autoplay's
1917
+ * tap-to-toggle-chrome behavior called this directly and ignored `false`
1918
+ * entirely, since only the idle timer checked it. `false` has to hold for
1919
+ * every caller, not just the timer — `mobileSettings.controls: false` is
1920
+ * affected the same way, by design. `forceHideControls()`/
1921
+ * `setControlsHiddenForDrag()` (close, drag-to-close) deliberately don't
1922
+ * check this — direct user actions with their own feedback, not auto-hide.
1923
+ */
1771
1924
  hideControls() {
1772
- if (!this.dom || this.autoHidden || this.isControlActive()) return;
1925
+ if (!this.dom || this.autoHidden || this.isControlActive() || this.autoHideDelay === false)
1926
+ return;
1773
1927
  this.autoHidden = true;
1774
1928
  this.dom.dialog.classList.add("shoji-controls-hidden");
1775
1929
  this.bus.emit("controls:hide", {});
@@ -1996,6 +2150,31 @@ class Gallery {
1996
2150
  }
1997
2151
  }
1998
2152
  close() {
2153
+ this.beginClose();
2154
+ }
2155
+ /**
2156
+ * DESIGN.md §2.4/§2.6a — same effect as `close()`, from a completed
2157
+ * vertical swipe. `frozenDrag` is threaded through to `zoomOut()`'s own
2158
+ * `dragStart`, so the whole close continues as one motion from exactly
2159
+ * where the drag left off (see `GestureController.
2160
+ * takeFrozenDragTransform`).
2161
+ */
2162
+ closeFromSwipe(frozenDrag) {
2163
+ this.beginClose(frozenDrag);
2164
+ }
2165
+ /**
2166
+ * `frozenDrag` — see `closeFromSwipe()`'s doc comment; absent for a
2167
+ * button-close, which has no drag to continue from. Controls fade and the
2168
+ * zoom-out run concurrently, both starting the instant `forceHideControls()`
2169
+ * runs — not sequenced one after the other. (A previous version waited for
2170
+ * the controls' own fade to fully finish before starting the zoom-out, for
2171
+ * a button-close specifically — requested directly, to avoid stationary
2172
+ * chrome hovering over an already-shrinking photo. Reversed on later,
2173
+ * explicit feedback: waiting read as two distinct steps rather than one
2174
+ * motion; starting both together still avoids stationary chrome, since
2175
+ * the chrome is disappearing too, just without the pause.)
2176
+ */
2177
+ beginClose(frozenDrag) {
1999
2178
  var _a, _b;
2000
2179
  if (this.destroyed || !this.opened || this.isClosing) return;
2001
2180
  this.bus.emit("beforeClose", {});
@@ -2006,11 +2185,41 @@ class Gallery {
2006
2185
  const naturalSize = this.resolveNaturalSize(this.activeIndex);
2007
2186
  if (media && origin && (((_b = this.slides) == null ? void 0 : _b.isActiveReady()) || naturalSize)) {
2008
2187
  const aspectRatio = this.resolveAspectRatio(this.activeIndex, origin);
2009
- zoomOut({ origin, target: media, aspectRatio, naturalSize }, () => this.finishClose());
2188
+ this.forceHideControls();
2189
+ if (this.dom) this.dom.backdrop.style.opacity = "0";
2190
+ zoomOut(
2191
+ { origin, target: media, aspectRatio, naturalSize, dragStart: frozenDrag },
2192
+ () => this.finishClose()
2193
+ );
2010
2194
  } else {
2011
2195
  this.finishClose();
2012
2196
  }
2013
2197
  }
2198
+ /** 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. */
2199
+ forceHideControls() {
2200
+ if (!this.dom || this.autoHidden) return;
2201
+ this.autoHidden = true;
2202
+ this.dom.dialog.classList.add("shoji-controls-hidden");
2203
+ this.bus.emit("controls:hide", {});
2204
+ }
2205
+ /**
2206
+ * DESIGN.md §2.4/§2.8 — `GestureController`'s live vertical-drag cue: hide
2207
+ * past the same distance a release would close, reveal again on retreat.
2208
+ * `hidden: false` only reveals if visible when *this* gesture started
2209
+ * (`controlsHiddenAtGestureStart`) — shouldn't resurrect controls already
2210
+ * separately hidden (idle, or `autoHideDelay: 0`) before the drag began.
2211
+ * `dragCloseThreshold` (bus event) fires on every crossing regardless of
2212
+ * that guard — Autoplay (§4-autoplay) needs to know about the crossing
2213
+ * itself, not just whether controls visibly moved.
2214
+ */
2215
+ setControlsHiddenForDrag(hidden) {
2216
+ var _a;
2217
+ this.controlsHiddenByDrag = hidden;
2218
+ if (hidden) this.forceHideControls();
2219
+ else if (!this.controlsHiddenAtGestureStart) this.showControls();
2220
+ (_a = this.dom) == null ? void 0 : _a.dialog.classList.toggle("shoji-controls-hidden-for-drag", hidden);
2221
+ this.bus.emit("dragCloseThreshold", { hidden });
2222
+ }
2014
2223
  /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
2015
2224
  resolveAspectRatio(index, origin) {
2016
2225
  const item = this.itemList[index];
@@ -2041,7 +2250,7 @@ class Gallery {
2041
2250
  * instead of a second `close`/`afterClose` emission on a torn-down gallery.
2042
2251
  */
2043
2252
  finishClose() {
2044
- var _a, _b;
2253
+ var _a, _b, _c;
2045
2254
  if (!this.opened) return;
2046
2255
  this.isClosing = false;
2047
2256
  this.opened = false;
@@ -2049,13 +2258,16 @@ class Gallery {
2049
2258
  document.removeEventListener("keydown", this.onKeydown);
2050
2259
  this.focusTrap.deactivate();
2051
2260
  (_a = this.dom) == null ? void 0 : _a.outer.classList.remove("shoji-open");
2261
+ if (this.dom) this.dom.backdrop.style.opacity = "";
2052
2262
  if (this.autoHideTimer !== null) {
2053
2263
  clearTimeout(this.autoHideTimer);
2054
2264
  this.autoHideTimer = null;
2055
2265
  }
2056
2266
  this.autoHidden = false;
2057
2267
  this.hoveredControlCount = 0;
2268
+ this.controlsHiddenByDrag = false;
2058
2269
  (_b = this.dom) == null ? void 0 : _b.dialog.classList.remove("shoji-controls-hidden");
2270
+ (_c = this.dom) == null ? void 0 : _c.dialog.classList.remove("shoji-controls-hidden-for-drag");
2059
2271
  this.bus.emit("close", {});
2060
2272
  this.bus.emit("afterClose", {});
2061
2273
  }