@michaelyagi/shoji 0.1.0-alpha.13 → 0.1.0-alpha.37
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/esm/bodyScrollLock-CC5PJxJp.js +96 -0
- package/dist/esm/bodyScrollLock-CC5PJxJp.js.map +1 -0
- package/dist/esm/core/FocusTrap.d.ts +15 -0
- package/dist/esm/core/Gallery.d.ts +372 -22
- package/dist/esm/core/GestureController.d.ts +14 -1
- package/dist/esm/core/SlideManager.d.ts +3 -8
- package/dist/esm/core/bodyScrollLock.d.ts +2 -0
- package/dist/esm/core/dom.d.ts +13 -0
- package/dist/esm/core/icons.d.ts +1 -1
- package/dist/esm/core/index.js +905 -214
- package/dist/esm/core/index.js.map +1 -1
- package/dist/esm/core/plugin.d.ts +12 -1
- package/dist/esm/core/types.d.ts +32 -18
- package/dist/esm/core/zoomTransition.d.ts +34 -1
- package/dist/esm/index.css +317 -68
- package/dist/esm/index.js +1 -1
- package/dist/esm/index2.css +29 -7
- package/dist/esm/plugins/activeThumbnail/index.js +28 -17
- package/dist/esm/plugins/activeThumbnail/index.js.map +1 -1
- package/dist/esm/plugins/autoplay/index.d.ts +6 -0
- package/dist/esm/plugins/autoplay/index.js +40 -29
- package/dist/esm/plugins/autoplay/index.js.map +1 -1
- package/dist/esm/plugins/layout/index.js +13 -1
- package/dist/esm/plugins/layout/index.js.map +1 -1
- package/dist/esm/plugins/rotateFlip/index.js +23 -1
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -1
- package/dist/esm/plugins/video/index.js +11 -2
- package/dist/esm/plugins/video/index.js.map +1 -1
- package/dist/esm/plugins/video/vimeo.d.ts +8 -3
- package/dist/esm/plugins/video/youtube.d.ts +6 -3
- package/dist/esm/plugins/zoom/index.js +157 -26
- package/dist/esm/plugins/zoom/index.js.map +1 -1
- package/dist/esm/plugins/zoom/zoomMath.d.ts +126 -8
- package/dist/esm/transitions/SlideTransition.d.ts +2 -1
- package/dist/esm/transitions/presets.d.ts +1 -1
- package/dist/esm/{zoomTransition-BeIimqDT.js → zoomTransition-BbnYTxY-.js} +18 -9
- package/dist/esm/zoomTransition-BbnYTxY-.js.map +1 -0
- package/dist/shoji.css +346 -75
- package/dist/shoji.js +1218 -234
- package/dist/shoji.js.map +1 -1
- package/dist/shoji.min.css +1 -1
- package/dist/shoji.min.js +1 -1
- package/dist/shoji.min.js.map +1 -1
- package/package.json +1 -1
- package/dist/esm/zoomTransition-BeIimqDT.js.map +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
let lockCount = 0;
|
|
2
|
+
let savedHtmlOverflow = "";
|
|
3
|
+
let savedHtmlPaddingRight = "";
|
|
4
|
+
let savedHtmlPaddingLeft = "";
|
|
5
|
+
let savedScrollX = 0;
|
|
6
|
+
let savedScrollY = 0;
|
|
7
|
+
let styleObserver = null;
|
|
8
|
+
let intentionalScrollOccurred = false;
|
|
9
|
+
const LIGHTBOX_SELECTOR = ".shoji-outer";
|
|
10
|
+
function markIntentionalScroll() {
|
|
11
|
+
intentionalScrollOccurred = true;
|
|
12
|
+
}
|
|
13
|
+
function isRtl() {
|
|
14
|
+
return getComputedStyle(document.documentElement).direction === "rtl";
|
|
15
|
+
}
|
|
16
|
+
function hasScrollableAncestor(node) {
|
|
17
|
+
let el = node instanceof Element ? node : (node == null ? void 0 : node.parentElement) ?? null;
|
|
18
|
+
while (el && el !== document.documentElement) {
|
|
19
|
+
const style = getComputedStyle(el);
|
|
20
|
+
if ((style.overflowY === "auto" || style.overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
el = el.parentElement;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
let touchAllowsScrollThrough = false;
|
|
28
|
+
function onTouchStart(event) {
|
|
29
|
+
const target = event.target;
|
|
30
|
+
const insideLightbox = target instanceof Element && target.closest(LIGHTBOX_SELECTOR) !== null;
|
|
31
|
+
touchAllowsScrollThrough = !insideLightbox && hasScrollableAncestor(target);
|
|
32
|
+
}
|
|
33
|
+
function onTouchMove(event) {
|
|
34
|
+
const target = event.target;
|
|
35
|
+
const insideLightbox = target instanceof Element && target.closest(LIGHTBOX_SELECTOR) !== null;
|
|
36
|
+
if (!insideLightbox && !touchAllowsScrollThrough) event.preventDefault();
|
|
37
|
+
}
|
|
38
|
+
function onStyleMutation() {
|
|
39
|
+
if (lockCount === 0) return;
|
|
40
|
+
const html = document.documentElement;
|
|
41
|
+
if (getComputedStyle(html).overflow !== "hidden") {
|
|
42
|
+
html.style.overflow = "hidden";
|
|
43
|
+
styleObserver == null ? void 0 : styleObserver.takeRecords();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function lockBodyScroll() {
|
|
47
|
+
if (lockCount === 0) {
|
|
48
|
+
savedScrollX = window.scrollX;
|
|
49
|
+
savedScrollY = window.scrollY;
|
|
50
|
+
intentionalScrollOccurred = false;
|
|
51
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
52
|
+
const rtl = isRtl();
|
|
53
|
+
savedHtmlPaddingRight = document.documentElement.style.paddingRight;
|
|
54
|
+
savedHtmlPaddingLeft = document.documentElement.style.paddingLeft;
|
|
55
|
+
if (scrollbarWidth > 0) {
|
|
56
|
+
if (rtl) {
|
|
57
|
+
const currentPaddingLeft = parseFloat(getComputedStyle(document.documentElement).paddingLeft) || 0;
|
|
58
|
+
document.documentElement.style.paddingLeft = `${currentPaddingLeft + scrollbarWidth}px`;
|
|
59
|
+
} else {
|
|
60
|
+
const currentPaddingRight = parseFloat(getComputedStyle(document.documentElement).paddingRight) || 0;
|
|
61
|
+
document.documentElement.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
savedHtmlOverflow = document.documentElement.style.overflow;
|
|
65
|
+
document.documentElement.style.overflow = "hidden";
|
|
66
|
+
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
67
|
+
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
68
|
+
styleObserver = new MutationObserver(onStyleMutation);
|
|
69
|
+
styleObserver.observe(document.documentElement, {
|
|
70
|
+
attributes: true,
|
|
71
|
+
attributeFilter: ["style"]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
lockCount++;
|
|
75
|
+
}
|
|
76
|
+
function unlockBodyScroll() {
|
|
77
|
+
lockCount = Math.max(0, lockCount - 1);
|
|
78
|
+
if (lockCount === 0) {
|
|
79
|
+
styleObserver == null ? void 0 : styleObserver.disconnect();
|
|
80
|
+
styleObserver = null;
|
|
81
|
+
document.removeEventListener("touchstart", onTouchStart);
|
|
82
|
+
document.removeEventListener("touchmove", onTouchMove);
|
|
83
|
+
document.documentElement.style.overflow = savedHtmlOverflow;
|
|
84
|
+
document.documentElement.style.paddingRight = savedHtmlPaddingRight;
|
|
85
|
+
document.documentElement.style.paddingLeft = savedHtmlPaddingLeft;
|
|
86
|
+
if (!intentionalScrollOccurred) {
|
|
87
|
+
window.scrollTo({ left: savedScrollX, top: savedScrollY, behavior: "instant" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
lockBodyScroll as l,
|
|
93
|
+
markIntentionalScroll as m,
|
|
94
|
+
unlockBodyScroll as u
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=bodyScrollLock-CC5PJxJp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bodyScrollLock-CC5PJxJp.js","sources":["../../src/core/bodyScrollLock.ts"],"sourcesContent":["/** DESIGN.md §2.6a — reference-counted page scroll lock. */\nlet lockCount = 0;\nlet savedHtmlOverflow = '';\nlet savedHtmlPaddingRight = '';\nlet savedHtmlPaddingLeft = '';\nlet savedScrollX = 0;\nlet savedScrollY = 0;\nlet styleObserver: MutationObserver | null = null;\n// Set by markIntentionalScroll() (ActiveThumbnail's own scrollIntoView while\n// locked, DESIGN.md §2.6a/§4.2) — a real bug/regression: unlockBodyScroll()\n// unconditionally restoring savedScrollX/Y (below) was meant to undo\n// *unrelated* code hijacking scroll during the lock (a router, a stray\n// \"scroll to top\" button), but it just as unconditionally undid Shoji's own\n// legitimate background-page scrolling too, making ActiveThumbnail's\n// scrollIntoView a complete no-op the instant the lightbox that triggers it\n// is also what's holding the lock — every navigation while open, always.\n// Once anything marks an intentional scroll during this lock session, skip\n// the restore entirely rather than try to track a smooth-scroll animation's\n// eventual resting position (no reliable completion signal across browsers)\n// — same \"leave it where legitimate code put it\" reasoning, just without\n// pinpointing the exact pixel.\nlet intentionalScrollOccurred = false;\n\nconst LIGHTBOX_SELECTOR = '.shoji-outer';\n\n/** Call after intentionally scrolling something on the host page while the lock is active (e.g. ActiveThumbnail's own `scrollIntoView`) — tells `unlockBodyScroll()` not to undo it. */\nexport function markIntentionalScroll(): void {\n intentionalScrollOccurred = true;\n}\n\nfunction isRtl(): boolean {\n return getComputedStyle(document.documentElement).direction === 'rtl';\n}\n\n/**\n * Walks up from `node` looking for a real scrollable container — lets\n * `onTouchMove` below allow touch-scrolling through to something like a\n * host-app modal/sidebar opened on top of the lightbox, instead of\n * blocking it just because it's outside `.shoji-outer`. Stops at\n * `document.documentElement` deliberately — that's the exact background\n * scroll this lock exists to block, not a container to exempt from it.\n */\nfunction hasScrollableAncestor(node: Node | null): boolean {\n let el = node instanceof Element ? node : (node?.parentElement ?? null);\n while (el && el !== document.documentElement) {\n const style = getComputedStyle(el);\n if (\n (style.overflowY === 'auto' || style.overflowY === 'scroll') &&\n el.scrollHeight > el.clientHeight\n ) {\n return true;\n }\n el = el.parentElement;\n }\n return false;\n}\n\n// Computed once per gesture, in onTouchStart below, not recomputed on every\n// onTouchMove — a touch's `event.target` stays fixed to the original\n// touchstart target for the whole gesture regardless of where the finger\n// moves (Touch Events spec), so re-walking the ancestor chain and calling\n// getComputedStyle again on every move would just repeat the same, not-free\n// answer for no benefit (CLAUDE.md: no forced synchronous layout in hot\n// paths like gestures/scroll).\nlet touchAllowsScrollThrough = false;\n\nfunction onTouchStart(event: TouchEvent): void {\n const target = event.target;\n const insideLightbox = target instanceof Element && target.closest(LIGHTBOX_SELECTOR) !== null;\n touchAllowsScrollThrough = !insideLightbox && hasScrollableAncestor(target as Node | null);\n}\n\n/**\n * A real gap: `overflow: hidden` on `<html>` doesn't reliably block iOS\n * Safari's own touch-driven rubber-band/bounce scroll in every case — a\n * well-known limitation of this technique across the ecosystem. The common\n * workaround (flipping `body` to `position: fixed` while locked) has its\n * own real tradeoffs: a full reflow, plus manual scroll-position capture/\n * restore that's a frequent source of bugs in other libraries (the exact\n * class of bug this whole lock has already had twice — see the other real\n * bugs in this file's history). Lighter alternative: block the browser's\n * default only for a touchmove that didn't start inside a lightbox,\n * leaving Shoji's own in-dialog gesture handling (which already manages\n * its own `preventDefault`, see `GestureEngine.ts`) completely untouched.\n * Not a passive listener — `preventDefault` is genuinely required here to\n * suppress the browser's native scroll, not just observe it.\n *\n * Also untouched: a touch that started on a genuinely scrollable ancestor\n * outside the lightbox (`touchAllowsScrollThrough`, set in `onTouchStart`\n * above) — a real bug, reported from real usage: this used to block\n * touch-scrolling in *any* host-app UI outside `.shoji-outer`, including\n * something like a Bootstrap modal or sidebar opened on top of the\n * lightbox, not just the page body behind it.\n */\nfunction onTouchMove(event: TouchEvent): void {\n const target = event.target;\n const insideLightbox = target instanceof Element && target.closest(LIGHTBOX_SELECTOR) !== null;\n if (!insideLightbox && !touchAllowsScrollThrough) event.preventDefault();\n}\n\n/**\n * A real gap: this lock is reference-counted against other `Gallery`\n * instances, but has no way to know about unrelated code on the host page\n * — another modal/dropdown library that also sets\n * `document.documentElement.style.overflow`, then clears it back to `''`\n * on its own close, would silently undo this lock mid-lightbox with\n * neither library aware of the other. Watching the one attribute this\n * lock itself owns is the only way to detect and correct that reactively.\n * `takeRecords()` discards the mutation record our own corrective write\n * below just queued — otherwise the observer would fire again for a\n * change it caused itself, forever.\n */\nfunction onStyleMutation(): void {\n if (lockCount === 0) return;\n const html = document.documentElement;\n if (getComputedStyle(html).overflow !== 'hidden') {\n html.style.overflow = 'hidden';\n styleObserver?.takeRecords();\n }\n}\n\nexport function lockBodyScroll(): void {\n if (lockCount === 0) {\n // A real gap: `overflow: hidden` blocks wheel/touch/keyboard-driven\n // scrolling, but not a programmatic `window.scrollTo()`/`scrollTop`\n // write — confirmed directly. If the host's own code scrolls the\n // window while the lightbox is open (a router restoring scroll\n // position, an unrelated \"scroll to top\" button), the page ends up\n // somewhere different than where the viewer left it once the lock\n // releases. Restored unconditionally on unlock below — a scroll\n // *lock* implies the background stays frozen in place for the whole\n // time the lightbox is open, not just protected from direct input.\n savedScrollX = window.scrollX;\n savedScrollY = window.scrollY;\n intentionalScrollOccurred = false;\n\n // A real bug: hiding overflow below reclaims the scrollbar's own\n // gutter, widening <html>'s content box by however many px the\n // scrollbar was — on a host page whose content spans the full viewport\n // width, that reflow is visible as a shift right when the lightbox\n // opens/closes (and reverses on close). Measured *before* overflow is\n // hidden (clientWidth only grows once the gutter is actually reclaimed,\n // so measuring after would always read 0) and only compensated for when\n // a real scrollbar was actually there — a page that never had one gets\n // no padding at all, nothing to compensate for.\n //\n // Two rejected approaches, both real bugs of their own: padding-right\n // on `document.body` compensates the wrong element — a page whose body\n // is narrower than the viewport (a centered, `max-width`-capped layout,\n // this docs site included) never touches the scrollbar's gutter in the\n // first place, so padding body just shrinks its own content box by an\n // *extra* scrollbar-width's worth, a new, self-inflicted reflow.\n // `scrollbar-gutter: stable` compensates the right element (`<html>`,\n // the same one the width is measured on) but paints its own visible,\n // if non-interactive, scrollbar-track styling for as long as the lock\n // is active — trading a brief shift for a permanent scrollbar-shaped\n // strip sitting over the page the whole time the lightbox is open,\n // which reads as more wrong, not less. Padding is genuinely invisible\n // — the same blank space a wider margin would be — and, applied to\n // `<html>` (not `body`), compensates the actual element the scrollbar's\n // gutter belongs to either way.\n const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;\n const rtl = isRtl();\n savedHtmlPaddingRight = document.documentElement.style.paddingRight;\n savedHtmlPaddingLeft = document.documentElement.style.paddingLeft;\n if (scrollbarWidth > 0) {\n // A real gap: a classic scrollbar renders on the *left* on a\n // `direction: rtl` page, not the right — compensating the wrong\n // side would reintroduce the exact reflow this exists to prevent,\n // just mirrored. Checked at lock time, not baked in as a fixed\n // assumption.\n if (rtl) {\n const currentPaddingLeft =\n parseFloat(getComputedStyle(document.documentElement).paddingLeft) || 0;\n document.documentElement.style.paddingLeft = `${currentPaddingLeft + scrollbarWidth}px`;\n } else {\n const currentPaddingRight =\n parseFloat(getComputedStyle(document.documentElement).paddingRight) || 0;\n document.documentElement.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;\n }\n }\n\n // A real bug: `document.body.style.overflow = 'hidden'` (this function's\n // original, and until now only, scroll-blocking mechanism) was still\n // set here alongside <html>'s own below — redundant, and not harmless:\n // any value of `overflow` other than `visible` makes an element\n // establish a new block-formatting context, which blocks top-margin\n // collapsing between it and its first child. Confirmed directly via\n // real-browser instrumentation: with body's overflow locked, an h1\n // immediately inside it rendered `bodyMarginTop + h1's own default\n // margin-top` below the viewport top (its margin no longer collapsing\n // into body's own); the instant `unlockBodyScroll()` restored body's\n // overflow, collapsing resumed and the h1 snapped back up by its own\n // margin's worth — reading as the page shifting upward right as the\n // lightbox closes, on *any* page where the first child's margin would\n // otherwise collapse with body's (essentially any page without padding/\n // border on body itself — not a corner case). <html>'s own overflow:\n // hidden below is sufficient on its own to block user-driven scrolling\n // (wheel/touch/keyboard) — confirmed directly — since it's the real\n // scrolling element in standards mode; body's lock was never adding\n // independent protection, only this side effect.\n //\n // <html>'s own overflow, not just body's — mobile viewport-widening\n // bug, see DESIGN.md §2.6a. Both axes, not just overflow-x: setting\n // only one non-'visible' axis forces the browser to silently promote\n // the other from 'visible' to 'auto' (mixing hidden+visible isn't\n // allowed per spec), which revealed a real scrollbar that wasn't\n // there before.\n savedHtmlOverflow = document.documentElement.style.overflow;\n document.documentElement.style.overflow = 'hidden';\n\n // passive: true — onTouchStart only ever reads/caches, never calls\n // preventDefault(), unlike onTouchMove below.\n document.addEventListener('touchstart', onTouchStart, { passive: true });\n document.addEventListener('touchmove', onTouchMove, { passive: false });\n\n // Starts observing only after the writes above have already landed, so\n // it never reacts to its own initial setup — only to a later, external\n // change.\n styleObserver = new MutationObserver(onStyleMutation);\n styleObserver.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['style'],\n });\n }\n lockCount++;\n}\n\nexport function unlockBodyScroll(): void {\n lockCount = Math.max(0, lockCount - 1);\n if (lockCount === 0) {\n styleObserver?.disconnect();\n styleObserver = null;\n document.removeEventListener('touchstart', onTouchStart);\n document.removeEventListener('touchmove', onTouchMove);\n\n document.documentElement.style.overflow = savedHtmlOverflow;\n document.documentElement.style.paddingRight = savedHtmlPaddingRight;\n document.documentElement.style.paddingLeft = savedHtmlPaddingLeft;\n\n if (!intentionalScrollOccurred) {\n window.scrollTo({ left: savedScrollX, top: savedScrollY, behavior: 'instant' });\n }\n }\n}\n"],"names":[],"mappings":"AACA,IAAI,YAAY;AAChB,IAAI,oBAAoB;AACxB,IAAI,wBAAwB;AAC5B,IAAI,uBAAuB;AAC3B,IAAI,eAAe;AACnB,IAAI,eAAe;AACnB,IAAI,gBAAyC;AAc7C,IAAI,4BAA4B;AAEhC,MAAM,oBAAoB;AAGnB,SAAS,wBAA8B;AAC5C,8BAA4B;AAC9B;AAEA,SAAS,QAAiB;AACxB,SAAO,iBAAiB,SAAS,eAAe,EAAE,cAAc;AAClE;AAUA,SAAS,sBAAsB,MAA4B;AACzD,MAAI,KAAK,gBAAgB,UAAU,QAAQ,6BAAM,kBAAiB;AAClE,SAAO,MAAM,OAAO,SAAS,iBAAiB;AAC5C,UAAM,QAAQ,iBAAiB,EAAE;AACjC,SACG,MAAM,cAAc,UAAU,MAAM,cAAc,aACnD,GAAG,eAAe,GAAG,cACrB;AACA,aAAO;AAAA,IACT;AACA,SAAK,GAAG;AAAA,EACV;AACA,SAAO;AACT;AASA,IAAI,2BAA2B;AAE/B,SAAS,aAAa,OAAyB;AAC7C,QAAM,SAAS,MAAM;AACrB,QAAM,iBAAiB,kBAAkB,WAAW,OAAO,QAAQ,iBAAiB,MAAM;AAC1F,6BAA2B,CAAC,kBAAkB,sBAAsB,MAAqB;AAC3F;AAwBA,SAAS,YAAY,OAAyB;AAC5C,QAAM,SAAS,MAAM;AACrB,QAAM,iBAAiB,kBAAkB,WAAW,OAAO,QAAQ,iBAAiB,MAAM;AAC1F,MAAI,CAAC,kBAAkB,CAAC,gCAAgC,eAAA;AAC1D;AAcA,SAAS,kBAAwB;AAC/B,MAAI,cAAc,EAAG;AACrB,QAAM,OAAO,SAAS;AACtB,MAAI,iBAAiB,IAAI,EAAE,aAAa,UAAU;AAChD,SAAK,MAAM,WAAW;AACtB,mDAAe;AAAA,EACjB;AACF;AAEO,SAAS,iBAAuB;AACrC,MAAI,cAAc,GAAG;AAUnB,mBAAe,OAAO;AACtB,mBAAe,OAAO;AACtB,gCAA4B;AA2B5B,UAAM,iBAAiB,OAAO,aAAa,SAAS,gBAAgB;AACpE,UAAM,MAAM,MAAA;AACZ,4BAAwB,SAAS,gBAAgB,MAAM;AACvD,2BAAuB,SAAS,gBAAgB,MAAM;AACtD,QAAI,iBAAiB,GAAG;AAMtB,UAAI,KAAK;AACP,cAAM,qBACJ,WAAW,iBAAiB,SAAS,eAAe,EAAE,WAAW,KAAK;AACxE,iBAAS,gBAAgB,MAAM,cAAc,GAAG,qBAAqB,cAAc;AAAA,MACrF,OAAO;AACL,cAAM,sBACJ,WAAW,iBAAiB,SAAS,eAAe,EAAE,YAAY,KAAK;AACzE,iBAAS,gBAAgB,MAAM,eAAe,GAAG,sBAAsB,cAAc;AAAA,MACvF;AAAA,IACF;AA4BA,wBAAoB,SAAS,gBAAgB,MAAM;AACnD,aAAS,gBAAgB,MAAM,WAAW;AAI1C,aAAS,iBAAiB,cAAc,cAAc,EAAE,SAAS,MAAM;AACvE,aAAS,iBAAiB,aAAa,aAAa,EAAE,SAAS,OAAO;AAKtE,oBAAgB,IAAI,iBAAiB,eAAe;AACpD,kBAAc,QAAQ,SAAS,iBAAiB;AAAA,MAC9C,YAAY;AAAA,MACZ,iBAAiB,CAAC,OAAO;AAAA,IAAA,CAC1B;AAAA,EACH;AACA;AACF;AAEO,SAAS,mBAAyB;AACvC,cAAY,KAAK,IAAI,GAAG,YAAY,CAAC;AACrC,MAAI,cAAc,GAAG;AACnB,mDAAe;AACf,oBAAgB;AAChB,aAAS,oBAAoB,cAAc,YAAY;AACvD,aAAS,oBAAoB,aAAa,WAAW;AAErD,aAAS,gBAAgB,MAAM,WAAW;AAC1C,aAAS,gBAAgB,MAAM,eAAe;AAC9C,aAAS,gBAAgB,MAAM,cAAc;AAE7C,QAAI,CAAC,2BAA2B;AAC9B,aAAO,SAAS,EAAE,MAAM,cAAc,KAAK,cAAc,UAAU,WAAW;AAAA,IAChF;AAAA,EACF;AACF;"}
|
|
@@ -5,5 +5,20 @@ export declare class FocusTrap {
|
|
|
5
5
|
private readonly onKeydown;
|
|
6
6
|
private getFocusable;
|
|
7
7
|
activate(container: HTMLElement): void;
|
|
8
|
+
/**
|
|
9
|
+
* DESIGN.md §2.3a — narrows (or widens back) which subtree Tab cycles
|
|
10
|
+
* within, without touching `previouslyFocused`/the listener registration
|
|
11
|
+
* — a caption modal nested inside the already-trapped dialog needs Tab
|
|
12
|
+
* confined to just itself while open, then back to the whole dialog on
|
|
13
|
+
* close, but must NOT trigger `activate()`'s own focus-capture (that's
|
|
14
|
+
* reserved for the real open/close boundary; re-running it mid-session
|
|
15
|
+
* would capture the wrong "previously focused" element and restore focus
|
|
16
|
+
* to it too early). `getFocusable()` reads `this.container` fresh on
|
|
17
|
+
* every keydown, so simply reassigning it is enough — no need for a
|
|
18
|
+
* second, independent `FocusTrap` instance, which would double-handle
|
|
19
|
+
* every Tab keypress (two capture-phase `document` listeners, each
|
|
20
|
+
* computing its own overlapping focusable list).
|
|
21
|
+
*/
|
|
22
|
+
retarget(container: HTMLElement): void;
|
|
8
23
|
deactivate(): void;
|
|
9
24
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Unsubscribe } from './EventBus';
|
|
2
2
|
import { GalleryEvents, GalleryItem, GalleryItemInput, GalleryOptions } from './types';
|
|
3
|
+
import { Box } from './zoomTransition';
|
|
3
4
|
/**
|
|
4
5
|
* Core lifecycle (DESIGN.md §2.2), item model / DOM scanning (§2.1), and the
|
|
5
6
|
* lightbox itself: pooled slides (§2.3), dialog semantics/focus trap/live
|
|
@@ -21,14 +22,51 @@ export declare class Gallery {
|
|
|
21
22
|
private locale;
|
|
22
23
|
private showCounter;
|
|
23
24
|
private captionVisibleOnVideo;
|
|
25
|
+
private captionFadePending;
|
|
24
26
|
private loop;
|
|
25
27
|
private closable;
|
|
26
28
|
private autoHideDelay;
|
|
29
|
+
/** DESIGN.md §3.1a — GalleryOptions.maxPinnedToolbarButtons; see measureToolbarOverflow(). */
|
|
30
|
+
private maxPinnedToolbarButtons;
|
|
27
31
|
private readonly focusTrap;
|
|
28
32
|
private readonly liveRegion;
|
|
29
33
|
private autoHideTimer;
|
|
30
34
|
private autoHidden;
|
|
31
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Every control `wireControlHover()` has ever wired — the full candidate
|
|
37
|
+
* list `reconcileHover()` (below) checks `:hover` against, kept separate
|
|
38
|
+
* from `hoveringElements` (elements *believed* to be hovered) so
|
|
39
|
+
* reconciliation can also *add* one currently missing from that set, not
|
|
40
|
+
* just remove a stale one.
|
|
41
|
+
*/
|
|
42
|
+
private readonly hoverableElements;
|
|
43
|
+
/**
|
|
44
|
+
* The subset of `hoverableElements` the pointer is currently considered
|
|
45
|
+
* to be over — a `Set`, not a plain incrementing counter, so
|
|
46
|
+
* `onActivity()` (below) can reconcile it against the browser's own live
|
|
47
|
+
* `:hover` ground truth on every real pointer movement, self-healing from
|
|
48
|
+
* a `pointerenter`/`pointerleave` pair that never fired at all. Two real
|
|
49
|
+
* bugs, both reported from real usage, both variations on the same
|
|
50
|
+
* browser behavior already leveraged elsewhere in this file (`hidden`
|
|
51
|
+
* doesn't fire `pointerleave` under a stationary cursor, §2.3a): (1) a
|
|
52
|
+
* control that synchronously replaces its own children while the pointer
|
|
53
|
+
* sits stationary over it (Autoplay's own play/pause icon swap) can
|
|
54
|
+
* desync the browser's internal hover-chain tracking badly enough that
|
|
55
|
+
* the *next* real pointer movement away from it never fires `pointerleave`
|
|
56
|
+
* — a plain counter has no way to detect a leave that never reports
|
|
57
|
+
* itself. (2) the mirror image: a control that *appears* under an
|
|
58
|
+
* already-stationary cursor (the common case right after `open()` —
|
|
59
|
+
* whatever the viewer clicked to get here is often right where a
|
|
60
|
+
* toolbar/caption/nav control ends up) never gets a `pointerenter`
|
|
61
|
+
* either, since browsers only recompute hover state on actual pointer
|
|
62
|
+
* movement, not on an element materializing under one already sitting
|
|
63
|
+
* still — so idle auto-hide could fire and hide a control the viewer's
|
|
64
|
+
* cursor is, visibly, still resting directly on top of. Re-deriving "is
|
|
65
|
+
* this actually hovered" fresh from the browser on every real pointer
|
|
66
|
+
* move fixes both directions at once, since neither depends on any
|
|
67
|
+
* event having fired correctly in the first place.
|
|
68
|
+
*/
|
|
69
|
+
private readonly hoveringElements;
|
|
32
70
|
private isClosing;
|
|
33
71
|
/** 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
72
|
private controlsHiddenByDrag;
|
|
@@ -41,12 +79,48 @@ export declare class Gallery {
|
|
|
41
79
|
private dom;
|
|
42
80
|
private gesture;
|
|
43
81
|
private transition;
|
|
82
|
+
/** DESIGN.md §2.3a — measures `.shoji-toolbar`'s real rendered height (it can wrap to multiple rows on a narrow viewport with many toolbar buttons registered) so the caption's own height cap can reserve exactly that much space, not a fixed single-row guess. */
|
|
83
|
+
private toolbarHeightObserver;
|
|
84
|
+
private toolbarHeightFrame;
|
|
85
|
+
private captionTruncationFrame;
|
|
86
|
+
/** DESIGN.md §2.3a — a truncated caption's own click/Enter/Space target opens this; also gates `GestureController`'s `isZoomed` (alongside the real zoom gate) so a drag over the open modal can't also navigate/close the lightbox underneath it. */
|
|
87
|
+
private captionModalOpen;
|
|
88
|
+
private captionModalReturnFocus;
|
|
89
|
+
/**
|
|
90
|
+
* DESIGN.md §2.3a — every real path into `openCaptionModal()` starts from
|
|
91
|
+
* a click *or* a keydown on the caption, so `captionModalReturnFocus`
|
|
92
|
+
* above is always the caption itself either way; this instead
|
|
93
|
+
* distinguishes *how* it got there, so `closeCaptionModal()` only
|
|
94
|
+
* actually calls `.focus()` for the keyboard path (a real Tab+Enter user,
|
|
95
|
+
* where restoring focus continues their tab sequence correctly) and
|
|
96
|
+
* leaves it alone for a mouse click (where the resulting focus was
|
|
97
|
+
* purely incidental — nothing about clicking to read a caption means the
|
|
98
|
+
* *next* keypress, e.g. a plugin's own Space shortcut, should still
|
|
99
|
+
* silently target it).
|
|
100
|
+
*/
|
|
101
|
+
private captionModalOpenedViaKeyboard;
|
|
102
|
+
/**
|
|
103
|
+
* DESIGN.md §3.1a — every `ctx.ui.toolbar()`-registered button, in
|
|
104
|
+
* registration order, alongside the slot it was registered into (so a
|
|
105
|
+
* collapsed one can be restored to the right place, not just anywhere).
|
|
106
|
+
* Registration order is what decides overflow priority: up to
|
|
107
|
+
* `maxPinnedToolbarButtons` stay pinned, the rest collapse into the
|
|
108
|
+
* popover before them, latest-registered first — see
|
|
109
|
+
* `measureToolbarOverflow()`.
|
|
110
|
+
*/
|
|
111
|
+
private readonly pluginToolbarButtons;
|
|
112
|
+
private toolbarOverflowOpen;
|
|
113
|
+
private toolbarOverflowReturnFocus;
|
|
44
114
|
private readonly shortcuts;
|
|
45
115
|
private readonly pluginStorage;
|
|
46
116
|
/** Backs `getActivePlugins()`. */
|
|
47
117
|
private readonly activePluginNames;
|
|
48
118
|
private readonly videoProviders;
|
|
49
119
|
private zoomGate;
|
|
120
|
+
/** DESIGN.md §2.6a/§4.6 — the zoomed `<img>`'s own real on-screen rect, read by `beginClose()` before `beforeClose` fires (see `registerZoomStartProvider()`), so a button-close continues the zoom-out from wherever the viewer was actually zoomed/panned to instead of snapping back to neutral first. */
|
|
121
|
+
private zoomStartProvider;
|
|
122
|
+
/** DESIGN.md §2.5/§4.5 — plugins with a per-slide visual override (RotateFlip's rotate/flip, Zoom's scale/pan) that reset unanimated on `beforeSlide` (must clear before `SlideManager.render()` reparents the outgoing node). Multi-slot: more than one can be active on the same slide, each targeting a different part of the clone, so they don't conflict. See `registerSlideLeaveDecorator()`. */
|
|
123
|
+
private readonly slideLeaveDecorators;
|
|
50
124
|
private pluginCleanups;
|
|
51
125
|
private readonly onContainerClick;
|
|
52
126
|
private readonly onOuterClick;
|
|
@@ -67,6 +141,27 @@ export declare class Gallery {
|
|
|
67
141
|
private controlsHiddenAtGestureStart;
|
|
68
142
|
private readonly captureGestureStartState;
|
|
69
143
|
private readonly onKeydown;
|
|
144
|
+
/**
|
|
145
|
+
* DESIGN.md §2.3a — capture phase, added only while the caption modal is
|
|
146
|
+
* open (not from `open()` onward like `onKeydown`, which is bubble
|
|
147
|
+
* phase): capture always finishes before bubble starts, so
|
|
148
|
+
* `stopPropagation()` here reliably beats `onKeydown`'s own bubble-phase
|
|
149
|
+
* handling regardless of where in the dialog focus happens to be. A real
|
|
150
|
+
* bug, reported from real usage: an earlier version of this only special-
|
|
151
|
+
* cased `Escape`, leaving every other key (`Space` — Autoplay's own
|
|
152
|
+
* play/pause shortcut if that plugin's loaded, arrow keys, `w`/`s` for
|
|
153
|
+
* Zoom, any plugin-registered shortcut) to fall straight through to
|
|
154
|
+
* `onKeydown` while the modal sat open on screen. A modal dialog should
|
|
155
|
+
* make the background fully inert to keyboard input while it's open, not
|
|
156
|
+
* just for one key — so this now stops propagation for *every* key
|
|
157
|
+
* unconditionally, closing the modal as the one piece of extra behavior
|
|
158
|
+
* layered on top for `Escape` specifically. Deliberately no
|
|
159
|
+
* `preventDefault()` here — the modal's own contents (the close button,
|
|
160
|
+
* a scrollable panel) keep their normal native key behavior (Space
|
|
161
|
+
* activating a focused button, arrow/Space scrolling), only the
|
|
162
|
+
* *background* gallery is what this isolates it from.
|
|
163
|
+
*/
|
|
164
|
+
private readonly onCaptionModalKeydown;
|
|
70
165
|
constructor(target: HTMLElement | string, options?: GalleryOptions);
|
|
71
166
|
/** Everything the constructor does after `this.element` is resolved — shared with `reinit()` (§2.7). */
|
|
72
167
|
private applyOptions;
|
|
@@ -84,22 +179,159 @@ export declare class Gallery {
|
|
|
84
179
|
getActiveMedia(): HTMLElement | null;
|
|
85
180
|
/** DESIGN.md §4-zoom — suspends drag-to-navigate/close while zoomed. Single slot, not a multi-subscriber event. */
|
|
86
181
|
registerZoomGate(isZoomed: () => boolean): () => void;
|
|
182
|
+
/** DESIGN.md §2.6a/§4.6 — returns the zoomed image's current on-screen rect (`getBoundingClientRect()`), or `null` when not zoomed. Single slot, same pattern as `registerZoomGate` above. */
|
|
183
|
+
registerZoomStartProvider(provider: () => Box | null): () => void;
|
|
184
|
+
/**
|
|
185
|
+
* DESIGN.md §2.5/§4.5 — called once per navigation with the leave-
|
|
186
|
+
* ghost's clone, right after `SlideTransition` creates it. Freeze
|
|
187
|
+
* whatever per-slide visual state is about to be reset onto the clone
|
|
188
|
+
* (or a descendant, e.g. Zoom's own `<img>`) and return a `() => void`
|
|
189
|
+
* to trigger the transition back to neutral once committed — or return
|
|
190
|
+
* nothing if there's nothing to animate away this time.
|
|
191
|
+
*/
|
|
192
|
+
registerSlideLeaveDecorator(decorator: (clonedMedia: HTMLElement) => (() => void) | void): () => void;
|
|
87
193
|
on<K extends keyof GalleryEvents>(event: K, fn: (detail: GalleryEvents[K]) => void): Unsubscribe;
|
|
88
194
|
private clampToRange;
|
|
89
195
|
private ensureLightbox;
|
|
90
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* DESIGN.md §3 — which of `declared`'s plugins will actually load,
|
|
198
|
+
* resolved as a fixed point rather than a single pass: start with every
|
|
199
|
+
* *structurally* valid entry (a real object with an `init` function),
|
|
200
|
+
* then repeatedly drop any whose `requires` points at a name no longer
|
|
201
|
+
* in the set, until a full pass drops nothing. That repetition is what
|
|
202
|
+
* makes a chain cascade correctly — if a plugin gets dropped because
|
|
203
|
+
* *its* own requirement failed, anything requiring that plugin drops on
|
|
204
|
+
* the very next pass, the same as if the original problem were its own.
|
|
205
|
+
* A genuine mutual requirement (A needs B, B needs A, both otherwise
|
|
206
|
+
* fine) never gets caught in this — each still finds the other present
|
|
207
|
+
* whenever it's checked, so both stay valid. That's correct, not a
|
|
208
|
+
* missed case: this only ever decides *whether* something loads, never
|
|
209
|
+
* *when* — nothing here reorders execution, so two plugins depending on
|
|
210
|
+
* each other creates no actual ordering conflict to detect in the first
|
|
211
|
+
* place.
|
|
212
|
+
*/
|
|
213
|
+
private resolveValidPluginNames;
|
|
214
|
+
/**
|
|
215
|
+
* DESIGN.md §3 — plugins init here, not the constructor. `requires` is
|
|
216
|
+
* resolved against the *whole* declared `plugins` list up front, not
|
|
217
|
+
* registration order — a real friction point, reported directly: a host
|
|
218
|
+
* with a `requires` chain had to carefully order the array by hand, with
|
|
219
|
+
* no actual reason to (nothing about *execution* order needs to match
|
|
220
|
+
* declaration order for a name-presence check). `resolveValidPluginNames()`
|
|
221
|
+
* decides who's actually going to load, independent of position; this
|
|
222
|
+
* loop still runs — and every `ctx.ui.toolbar()` registration still
|
|
223
|
+
* lands — in exactly the array's own order regardless, so toolbar/
|
|
224
|
+
* collapse-priority order (also array-order-driven, DESIGN.md §3.1a)
|
|
225
|
+
* is completely unaffected by this. Cleared up front so a `reinit()`
|
|
226
|
+
* doesn't inherit names from before.
|
|
227
|
+
*/
|
|
91
228
|
private initPlugins;
|
|
92
229
|
/**
|
|
93
|
-
* DESIGN.md §3.1 — 'right' inserts immediately before the
|
|
94
|
-
* (never after
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
230
|
+
* DESIGN.md §3.1 — 'right' inserts immediately before the overflow caret
|
|
231
|
+
* (DESIGN.md §3.1a — never after, and never after close either), so
|
|
232
|
+
* close stays fixed absolute-rightmost, the caret sits just to its left,
|
|
233
|
+
* and plugins cluster further left still, in registration order:
|
|
234
|
+
* `plugins: [A, B, C]` reads A, B, C, caret, close. 'left'/'center' are
|
|
235
|
+
* independent zones for a plugin that doesn't want to sit next to close.
|
|
98
236
|
*/
|
|
99
237
|
private pluginToolbar;
|
|
100
238
|
private buildToolbarButton;
|
|
239
|
+
/**
|
|
240
|
+
* DESIGN.md §3.1a — coalesces both toolbar-overflow measurement and the
|
|
241
|
+
* pre-existing `--shoji-toolbar-height` update into one rAF-batched pass
|
|
242
|
+
* (CLAUDE.md: batch DOM writes that can thrash layout), triggered by the
|
|
243
|
+
* toolbar's own `ResizeObserver` (width *or* height changes — a busy
|
|
244
|
+
* toolbar wrapping counts as both) and by `pluginToolbar()` registering
|
|
245
|
+
* or unregistering a button. Order matters: overflow collapse has to run
|
|
246
|
+
* *before* the height read, so the height custom property reflects the
|
|
247
|
+
* settled (ideally single-row, post-collapse) toolbar, not a momentarily
|
|
248
|
+
* wrapped one the caption's own height cap would otherwise over-reserve
|
|
249
|
+
* space for.
|
|
250
|
+
*/
|
|
251
|
+
private scheduleToolbarOverflowMeasure;
|
|
252
|
+
/**
|
|
253
|
+
* DESIGN.md §3.1a — collapses plugin toolbar buttons into
|
|
254
|
+
* `toolbarOverflowPanel` once they don't fit in one row, instead of
|
|
255
|
+
* letting the toolbar wrap to a second/third one. Always restores every
|
|
256
|
+
* plugin button to its own original slot first and recomputes from that
|
|
257
|
+
* clean slate, rather than incrementally adjusting whatever the previous
|
|
258
|
+
* pass left behind — the *set* of buttons and the viewport width can
|
|
259
|
+
* both have changed since then, and a fresh, deterministic pass is both
|
|
260
|
+
* simpler and self-correcting than trying to reason about a delta.
|
|
261
|
+
*
|
|
262
|
+
* "Fits in one row" is measured, not assumed: each slot's own rendered
|
|
263
|
+
* height is compared against `closeButton`'s (always exactly one row
|
|
264
|
+
* tall, always present) — a slot has wrapped if it's taller than that,
|
|
265
|
+
* regardless of *which* slot a plugin happened to register into (the
|
|
266
|
+
* three slots wrap independently, DESIGN.md §3.1a's own CSS notes).
|
|
267
|
+
*
|
|
268
|
+
* Collapses latest-registered first (DESIGN.md §3's own registration-
|
|
269
|
+
* order-is-priority convention — `plugins: [A, B, C]` keeps A pinned
|
|
270
|
+
* before B/C). `maxPinnedToolbarButtons` (default 2, see
|
|
271
|
+
* `GalleryOptions.maxPinnedToolbarButtons`) is a ceiling, not a
|
|
272
|
+
* guarantee: collapsing always goes down to at most that many pinned,
|
|
273
|
+
* but keeps going *below* it — down to zero if it must — whenever even
|
|
274
|
+
* that many still leaves a slot wrapped. `closeButton` and the counter
|
|
275
|
+
* (`toolbarLeft`) must never wrap onto their own line, and `fitsOneRow()`
|
|
276
|
+
* already checks every slot's height, not just `toolbarRight`'s, so a
|
|
277
|
+
* wide counter or other left-slot content competing for the same row
|
|
278
|
+
* pushes the pinned count down too, not just the right slot's own
|
|
279
|
+
* button count. `closeButton`/`toolbarOverflowButton` themselves are
|
|
280
|
+
* never candidates for collapse — everything else relocates into the
|
|
281
|
+
* popover, which renders directly below that row.
|
|
282
|
+
*/
|
|
283
|
+
private measureToolbarOverflow;
|
|
284
|
+
private readonly onToolbarOverflowKeydown;
|
|
285
|
+
private toggleToolbarOverflow;
|
|
286
|
+
private openToolbarOverflow;
|
|
287
|
+
/**
|
|
288
|
+
* DESIGN.md §3.1a — the panel's `right` offset is set here, per open,
|
|
289
|
+
* rather than as a fixed CSS value: the caret's own x-position isn't
|
|
290
|
+
* fixed, it shifts with how many buttons are pinned ahead of it
|
|
291
|
+
* (`maxPinnedToolbarButtons` is host-configurable, and can itself be
|
|
292
|
+
* reduced further at measure time, DESIGN.md §3.1a), so a static
|
|
293
|
+
* `right: var(--shoji-spacing-md)` only happened to line up with the
|
|
294
|
+
* caret at one particular pinned-button count and viewport width. It
|
|
295
|
+
* otherwise anchored to the toolbar/dialog's own right edge — under
|
|
296
|
+
* `closeButton`, which sits to the right of the caret, not under the
|
|
297
|
+
* caret that actually opens it.
|
|
298
|
+
*
|
|
299
|
+
* Aligns the panel's own *content* edge (inside its padding), not just
|
|
300
|
+
* its border box, with the caret's right edge — the panel's grid pitch
|
|
301
|
+
* (44px columns, `--shoji-spacing-sm` gaps, `.shoji-toolbar-overflow-
|
|
302
|
+
* panel` in shoji.css) already matches the toolbar row's own button
|
|
303
|
+
* size/gap, so subtracting the panel's own right padding here is what
|
|
304
|
+
* makes the popover's icon columns actually line up with the toolbar
|
|
305
|
+
* row's icons above them, not just the panel block sitting roughly
|
|
306
|
+
* nearby.
|
|
307
|
+
*
|
|
308
|
+
* Also sets the grid's own column count to match the toolbar row's own
|
|
309
|
+
* column count right now: however many buttons are *actually* still
|
|
310
|
+
* pinned on `toolbarRight`, *plus the caret itself* — requested directly:
|
|
311
|
+
* the popover should read as the same row of columns continuing
|
|
312
|
+
* downward, caret included, not a fixed 3 columns regardless of how many
|
|
313
|
+
* ended up pinned. At the default `maxPinnedToolbarButtons` (2), that's 2
|
|
314
|
+
* pinned + the caret = 3 columns. Collapsed buttons beyond that count
|
|
315
|
+
* still wrap onto further rows within it, same as before.
|
|
316
|
+
*
|
|
317
|
+
* **A real bug: "pinned" is counted from `this.pluginToolbarButtons`
|
|
318
|
+
* (registered via `ctx.ui.toolbar()`), never by querying `toolbarRight`'s
|
|
319
|
+
* DOM children.** The old filter (`toolbarRight.children`, excluding
|
|
320
|
+
* `closeButton`/`toolbarOverflowButton` by identity, `!el.hidden`) broke
|
|
321
|
+
* once a host appended an unrelated element straight into `toolbarRight`
|
|
322
|
+
* — not a documented extension point, but nothing stops it. Reported from
|
|
323
|
+
* real usage: a plugin's own loading spinner, toggled via `style.display`
|
|
324
|
+
* rather than the `hidden` attribute, passed `!el.hidden` and got
|
|
325
|
+
* miscounted as a pinned button, turning a 3-column popover into 4.
|
|
326
|
+
* Counting from the registry instead is immune to this regardless of how
|
|
327
|
+
* such an element manages its own visibility. `closeButton`/
|
|
328
|
+
* `toolbarOverflowButton` were never in `pluginToolbarButtons`, so
|
|
329
|
+
* excluding them by identity is no longer needed either.
|
|
330
|
+
*/
|
|
331
|
+
private positionToolbarOverflowPanel;
|
|
332
|
+
private closeToolbarOverflow;
|
|
101
333
|
private pluginOverlay;
|
|
102
|
-
/** DESIGN.md §2.8/§3 — pauses auto-hide while genuinely hovered: controls, caption, and any plugin overlay (`ctx.ui.overlay()`). Unsubscribe also corrects the
|
|
334
|
+
/** DESIGN.md §2.8/§3 — pauses auto-hide while genuinely hovered: controls, caption, and any plugin overlay (`ctx.ui.overlay()`). Unsubscribe also corrects the set if removed mid-hover — a real risk for overlay content a plugin can toggle while the gallery stays open, unlike static buttons. */
|
|
103
335
|
private wireControlHover;
|
|
104
336
|
/**
|
|
105
337
|
* DESIGN.md §2.8 — paused only by a real *hover*. Used to also treat a
|
|
@@ -109,6 +341,20 @@ export declare class Gallery {
|
|
|
109
341
|
* `onActivity()`), it just no longer blocks the eventual hide.
|
|
110
342
|
*/
|
|
111
343
|
private isControlActive;
|
|
344
|
+
/**
|
|
345
|
+
* Syncs `hoveringElements` to the browser's own live `:hover` truth for
|
|
346
|
+
* every registered control — both directions, not just dropping a stale
|
|
347
|
+
* entry: also picks up one that's genuinely hovered but never got a
|
|
348
|
+
* `pointerenter` of its own (see `hoveringElements`'s own doc comment for
|
|
349
|
+
* why either direction can happen). Whichever ends up true here is what
|
|
350
|
+
* `isControlActive()` reads immediately after, in the same `onActivity()`
|
|
351
|
+
* call — no separate reveal step needed for a newly-added element; that
|
|
352
|
+
* call already does it for any activity, hover included. Cheap in
|
|
353
|
+
* practice: `hoverableElements` is normally single digits, and `:hover`
|
|
354
|
+
* matching is a native, already-computed browser check, not a
|
|
355
|
+
* layout-triggering one.
|
|
356
|
+
*/
|
|
357
|
+
private reconcileHover;
|
|
112
358
|
/**
|
|
113
359
|
* The thumbnail for `index` — what the zoom transition animates to/from,
|
|
114
360
|
* and what `activeThumbnail` marks/scrolls-to. `data-shoji-id` is an
|
|
@@ -125,8 +371,7 @@ export declare class Gallery {
|
|
|
125
371
|
* `autoHideDelay: false` — a real bug, reported from real usage: Autoplay's
|
|
126
372
|
* tap-to-toggle-chrome behavior called this directly and ignored `false`
|
|
127
373
|
* entirely, since only the idle timer checked it. `false` has to hold for
|
|
128
|
-
* every caller, not just the timer
|
|
129
|
-
* affected the same way, by design. `forceHideControls()`/
|
|
374
|
+
* every caller, not just the timer. `forceHideControls()`/
|
|
130
375
|
* `setControlsHiddenForDrag()` (close, drag-to-close) deliberately don't
|
|
131
376
|
* check this — direct user actions with their own feedback, not auto-hide.
|
|
132
377
|
*/
|
|
@@ -152,6 +397,21 @@ export declare class Gallery {
|
|
|
152
397
|
* activating an already-focused button.
|
|
153
398
|
*/
|
|
154
399
|
private setSlideLoading;
|
|
400
|
+
/**
|
|
401
|
+
* DESIGN.md §2.5 — the caption's disappear-then-reappear fits inside the
|
|
402
|
+
* *same* `--shoji-duration` window the slide's own mode animation runs
|
|
403
|
+
* in, not a separate one after it: fades out over the first half, swaps
|
|
404
|
+
* content invisibly at the midpoint, fades back in over the second half
|
|
405
|
+
* — timed to finish exactly when the mode animation does, not still
|
|
406
|
+
* catching up a beat behind it. Self-timed off the resolved
|
|
407
|
+
* `--shoji-duration` value rather than hooked to the mode animation's
|
|
408
|
+
* own completion event: both are driven by the same duration either way,
|
|
409
|
+
* and the fade-in's own completion is what has to line up, which a
|
|
410
|
+
* "start on completion, then animate" hook can't give — by definition,
|
|
411
|
+
* a fade started only once something else finishes hasn't finished
|
|
412
|
+
* *with* it.
|
|
413
|
+
*/
|
|
414
|
+
private transitionCaption;
|
|
155
415
|
/**
|
|
156
416
|
* Content is always kept current (correct the instant loading finishes,
|
|
157
417
|
* no text flash) — only `hidden` also gates on `isActiveReady()`, so a
|
|
@@ -159,6 +419,89 @@ export declare class Gallery {
|
|
|
159
419
|
* image/video it describes is still a spinner.
|
|
160
420
|
*/
|
|
161
421
|
private updateCaptionVisibility;
|
|
422
|
+
/**
|
|
423
|
+
* DESIGN.md §2.3a — the caption's own default height cap (above) already
|
|
424
|
+
* keeps it clear of the toolbar, but says nothing about the vertically-
|
|
425
|
+
* centered prev/next nav arrows sharing its same left edge; a long
|
|
426
|
+
* enough caption could still grow up over one of those. `--shoji-*`
|
|
427
|
+
* collapses it to roughly one line by default (shoji.css) regardless, so
|
|
428
|
+
* this only ever needs to detect whether that collapse actually clipped
|
|
429
|
+
* something — `scrollHeight > clientHeight` after layout, the same
|
|
430
|
+
* technique already used elsewhere in this codebase (and its own tests)
|
|
431
|
+
* to detect caption overflow. Marks it truncated/interactive only when
|
|
432
|
+
* there's genuinely more to read; a caption that already fits shouldn't
|
|
433
|
+
* look or behave clickable.
|
|
434
|
+
*
|
|
435
|
+
* A real bug, reported from real usage: the first version only capped
|
|
436
|
+
* `max-height` + `overflow: hidden` — a plain pixel clip with no regard
|
|
437
|
+
* for where a line of text actually ends, so the cutoff routinely sliced
|
|
438
|
+
* straight through the middle of the last visible line instead of
|
|
439
|
+
* stopping at a clean line boundary, reading as broken rather than
|
|
440
|
+
* intentionally truncated.
|
|
441
|
+
*
|
|
442
|
+
* Two follow-up attempts, both also wrong, both worth recording so they
|
|
443
|
+
* aren't retried: `-webkit-line-clamp` never cleanly stopped at a line
|
|
444
|
+
* boundary here regardless of the line count fed into it. Replacing it
|
|
445
|
+
* with `lines * lineHeight + paddingY` arithmetic (using
|
|
446
|
+
* `getComputedStyle(el).lineHeight`) still left a sliver of the next
|
|
447
|
+
* line visible in a real browser — confirmed by screenshot, not just
|
|
448
|
+
* this sandbox. Root cause: a browser doesn't necessarily lay out N
|
|
449
|
+
* stacked lines at exactly N times the CSS `line-height` value: text
|
|
450
|
+
* layout does its own sub-pixel rounding per line, so arithmetic
|
|
451
|
+
* multiplication drifts from the real rendered geometry by enough to
|
|
452
|
+
* expose part of an extra line, and no fixed safety margin is correct
|
|
453
|
+
* for every font/zoom/line-count combination.
|
|
454
|
+
*
|
|
455
|
+
* This version doesn't compute line boundaries at all — it reads them
|
|
456
|
+
* straight from the browser's own layout via `Range.getClientRects()`,
|
|
457
|
+
* which returns one rect per actual rendered line fragment (rich
|
|
458
|
+
* captions with inline markup can put more than one rect on the same
|
|
459
|
+
* visual row; each is handled independently below rather than grouped,
|
|
460
|
+
* since only the topmost/bottommost edges per row matter here). The cap
|
|
461
|
+
* is set to the bottom edge of the last line whose bottom still fits
|
|
462
|
+
* inside the current (arbitrary, CSS-calc'd) height budget — a real
|
|
463
|
+
* measured boundary, never an assumed one.
|
|
464
|
+
*
|
|
465
|
+
* A third real bug, caught after switching to this measured approach:
|
|
466
|
+
* padding the box out by a full `padding-bottom` past that last line's
|
|
467
|
+
* *measured* bottom still isn't safe, because line boxes butt up much
|
|
468
|
+
* closer together than the padding value — the next (excluded) line's
|
|
469
|
+
* own top can fall well inside that padding band, so its glyph
|
|
470
|
+
* ascenders paint into what was supposed to be empty breathing room.
|
|
471
|
+
* Fixed by also finding that next line's top and never letting the cap
|
|
472
|
+
* reach it, regardless of how much of `padding-bottom` that leaves.
|
|
473
|
+
*/
|
|
474
|
+
private updateCaptionTruncation;
|
|
475
|
+
private readonly onCaptionActivate;
|
|
476
|
+
/**
|
|
477
|
+
* DESIGN.md §2.3a — shows the full caption (re-rendered via the same
|
|
478
|
+
* `renderCaption()` the truncated one already used, so a rich-HTML
|
|
479
|
+
* caption's own links/formatting are identical in both places) in a
|
|
480
|
+
* small nested dialog, scrollable if it's still taller than the
|
|
481
|
+
* viewport allows. `FocusTrap.retarget()` narrows Tab-cycling to just
|
|
482
|
+
* this modal without touching the outer trap's own focus-restore state
|
|
483
|
+
* (see its own doc comment for why a second `FocusTrap` instance isn't
|
|
484
|
+
* used instead). Requested directly: the modal *replaces* the truncated
|
|
485
|
+
* caption rather than just visually sitting on top of it — hiding
|
|
486
|
+
* `dom.caption` too, not only because the modal's own opaque backdrop
|
|
487
|
+
* already covers it, but so it can't still be reached by a screen
|
|
488
|
+
* reader's browse-mode cursor (unlike Tab, `FocusTrap`/`retarget()`
|
|
489
|
+
* don't affect that) while a completely different dialog is the one
|
|
490
|
+
* actually open.
|
|
491
|
+
*/
|
|
492
|
+
private openCaptionModal;
|
|
493
|
+
/**
|
|
494
|
+
* Requested directly: closing should leave the viewer looking at a fully
|
|
495
|
+
* normal, fully visible gallery — not just the modal gone, but the
|
|
496
|
+
* truncated caption back (via `updateCaptionVisibility()`, which
|
|
497
|
+
* recomputes its real hidden state from scratch rather than blindly
|
|
498
|
+
* flipping a flag back — the more robust "recompute from source of
|
|
499
|
+
* truth" this codebase already prefers elsewhere) and any auto-hidden
|
|
500
|
+
* toolbar/nav explicitly re-shown (`onActivity()`, same pairing every
|
|
501
|
+
* other real interaction already uses) rather than left hidden until
|
|
502
|
+
* the viewer happens to move the mouse.
|
|
503
|
+
*/
|
|
504
|
+
private closeCaptionModal;
|
|
162
505
|
private renderCurrentSlide;
|
|
163
506
|
open(index?: number): void;
|
|
164
507
|
/** DESIGN.md §2.3 — low-res open() placeholder source, checked in order: item.thumb, a live data-shoji-thumb on origin, else origin's own rendered <img>. */
|
|
@@ -188,18 +531,7 @@ export declare class Gallery {
|
|
|
188
531
|
* the real, configured transition.
|
|
189
532
|
*/
|
|
190
533
|
private navigate;
|
|
191
|
-
/** DESIGN.md §2.5 — `mobileSettings.mode` overrides `mode` on a coarse-pointer device, evaluated fresh each navigation. */
|
|
192
534
|
private resolveTransitionMode;
|
|
193
|
-
private isMobileQuery;
|
|
194
|
-
/**
|
|
195
|
-
* DESIGN.md §2.5 — `mobileSettings.controls: false` starts controls
|
|
196
|
-
* hidden on a coarse-pointer device, reusing the existing §2.8 auto-hide
|
|
197
|
-
* mechanism (`hideControls()`) rather than a separate permanent-hide
|
|
198
|
-
* state: auto-hide is opacity-only, never removed from the tab order,
|
|
199
|
-
* and already reveals on any activity — a genuinely *permanent* hide
|
|
200
|
-
* would strand a touch user with no way to ever reach Close.
|
|
201
|
-
*/
|
|
202
|
-
private applyMobileControlsSetting;
|
|
203
535
|
close(): void;
|
|
204
536
|
/**
|
|
205
537
|
* DESIGN.md §2.4/§2.6a — same effect as `close()`, from a completed
|
|
@@ -222,7 +554,25 @@ export declare class Gallery {
|
|
|
222
554
|
* the chrome is disappearing too, just without the pause.)
|
|
223
555
|
*/
|
|
224
556
|
private beginClose;
|
|
225
|
-
/**
|
|
557
|
+
/**
|
|
558
|
+
* 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.
|
|
559
|
+
*
|
|
560
|
+
* Also marks `.shoji-controls-hidden-for-close` alongside the ordinary
|
|
561
|
+
* class — a real bug, reported from real usage: a plugin's own overlay
|
|
562
|
+
* (Autoplay's progress bar, `autoplay.css`) was wired to fade on plain
|
|
563
|
+
* `.shoji-controls-hidden`, the same class *ordinary idle auto-hide* also
|
|
564
|
+
* applies — so it faded out and stayed gone through every idle period
|
|
565
|
+
* too, not just the close animation the original request was actually
|
|
566
|
+
* about. That went unnoticed for a while because until recently, tapping
|
|
567
|
+
* the image toggled play/pause *and* revealed controls on the same tap,
|
|
568
|
+
* papering over how often it was actually gone; once that tap-to-toggle
|
|
569
|
+
* was removed (§4.1 point 15) as a separate, unrelated fix, an idle
|
|
570
|
+
* slideshow now visibly loses its own progress indicator and never gets
|
|
571
|
+
* it back without an unrelated interaction — surfacing this as a real
|
|
572
|
+
* regression in practice, even though no code touching this class had
|
|
573
|
+
* changed. This second class is the hook a plugin's CSS can key off
|
|
574
|
+
* specifically for "closing," without it ever matching ordinary idle-hide.
|
|
575
|
+
*/
|
|
226
576
|
private forceHideControls;
|
|
227
577
|
/**
|
|
228
578
|
* DESIGN.md §2.4/§2.8 — `GestureController`'s live vertical-drag cue: hide
|