@michaelyagi/shoji 0.1.0-beta.14 → 0.1.0-beta.15
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/index.js +1 -1
- package/dist/esm/plugins/zoom/index.d.ts +18 -0
- package/dist/esm/plugins/zoom/index.js +12 -5
- package/dist/esm/plugins/zoom/index.js.map +1 -1
- package/dist/plugins/zoom.js +12 -5
- package/dist/plugins/zoom.js.map +1 -1
- package/dist/plugins/zoom.min.js +1 -1
- package/dist/plugins/zoom.min.js.map +1 -1
- package/dist/shoji.js +13 -6
- package/dist/shoji.js.map +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/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Layout } from "./plugins/layout/index.js";
|
|
|
6
6
|
import { RotateFlip } from "./plugins/rotateFlip/index.js";
|
|
7
7
|
import { Video } from "./plugins/video/index.js";
|
|
8
8
|
import { Zoom } from "./plugins/zoom/index.js";
|
|
9
|
-
const version = "0.1.0-beta.
|
|
9
|
+
const version = "0.1.0-beta.15";
|
|
10
10
|
const Shoji = Object.assign(Gallery, {
|
|
11
11
|
Autoplay,
|
|
12
12
|
Layout,
|
|
@@ -6,6 +6,24 @@ export interface ZoomOptions {
|
|
|
6
6
|
doubleTapScale?: number;
|
|
7
7
|
/** Multiplier applied per zoom-in/zoom-out toolbar button click. Default 1.5. */
|
|
8
8
|
buttonStep?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Which wheel/trackpad-scroll input zooms the active photo, on top of
|
|
11
|
+
* pinch, the toolbar buttons, and `w`/`s`. `true` (default): any wheel
|
|
12
|
+
* event zooms, `ctrl` or not. This is the only way to make a trackpad's
|
|
13
|
+
* plain two-finger *drag* zoom — trackpads never expose raw multi-touch
|
|
14
|
+
* to the browser at all, so a two-finger drag/scroll and an ordinary
|
|
15
|
+
* one-finger scroll (or a bare mouse wheel) are all just the same plain
|
|
16
|
+
* `wheel` event, indistinguishable from each other. As an unavoidable
|
|
17
|
+
* side effect, a bare mouse wheel also zooms, with nothing to tell that
|
|
18
|
+
* apart from a trackpad either — inside a full-screen modal with no page
|
|
19
|
+
* behind it to scroll, this reads as a feature (a wheel/trackpad zoom
|
|
20
|
+
* shortcut, on top of everything else), not a conflict with anything.
|
|
21
|
+
* `'ctrl'`: only `ctrl`+wheel — the one case a trackpad's own gesture
|
|
22
|
+
* recognition reports distinctly, since that's how the OS/browser
|
|
23
|
+
* already reports a genuine two-finger *pinch* specifically (unlike a
|
|
24
|
+
* plain drag). `false`: wheel/trackpad input never zooms at all.
|
|
25
|
+
*/
|
|
26
|
+
mouseWheelZoom?: boolean | 'ctrl';
|
|
9
27
|
}
|
|
10
28
|
/**
|
|
11
29
|
* DESIGN.md §4-zoom — pinch, double-tap/click, wheel+ctrl, and three toolbar
|
|
@@ -111,13 +111,15 @@ const Zoom = {
|
|
|
111
111
|
defaults: {
|
|
112
112
|
maxScale: 4,
|
|
113
113
|
doubleTapScale: 2,
|
|
114
|
-
buttonStep: 1.5
|
|
114
|
+
buttonStep: 1.5,
|
|
115
|
+
mouseWheelZoom: true
|
|
115
116
|
},
|
|
116
117
|
init(ctx) {
|
|
117
118
|
const { gallery } = ctx;
|
|
118
119
|
const maxScale = Number(ctx.options.maxScale ?? 4);
|
|
119
120
|
const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);
|
|
120
121
|
const buttonStep = Number(ctx.options.buttonStep ?? 1.5);
|
|
122
|
+
const mouseWheelZoom = ctx.options.mouseWheelZoom ?? true;
|
|
121
123
|
const locale = gallery.options.locale ?? {};
|
|
122
124
|
const zoomInLabel = locale.zoomIn ?? "Zoom in";
|
|
123
125
|
const zoomOutLabel = locale.zoomOut ?? "Zoom out";
|
|
@@ -254,9 +256,6 @@ const Zoom = {
|
|
|
254
256
|
if (scale <= ZOOM_EPSILON) reset();
|
|
255
257
|
});
|
|
256
258
|
const offDoubleTap = ctx.on("doubleTap", ({ x, y }) => toggleZoom(x, y));
|
|
257
|
-
const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
|
|
258
|
-
zoomTo(scale + deltaScale, x, y);
|
|
259
|
-
});
|
|
260
259
|
const offRequestZoomIn = ctx.on("requestZoomIn", zoomInStep);
|
|
261
260
|
const offRequestZoomOut = ctx.on("requestZoomOut", zoomOutStep);
|
|
262
261
|
const offRequestZoomActualSize = ctx.on("requestZoomActualSize", actualSizeToggle);
|
|
@@ -265,6 +264,14 @@ const Zoom = {
|
|
|
265
264
|
let lastX = 0;
|
|
266
265
|
let lastY = 0;
|
|
267
266
|
const outer = ctx.ui.outer();
|
|
267
|
+
function onWheel(event) {
|
|
268
|
+
if (mouseWheelZoom === false) return;
|
|
269
|
+
if (mouseWheelZoom === "ctrl" && !event.ctrlKey) return;
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const deltaScale = -event.deltaY * 15e-4;
|
|
272
|
+
zoomTo(scale * (1 + deltaScale), event.clientX, event.clientY);
|
|
273
|
+
}
|
|
274
|
+
outer.addEventListener("wheel", onWheel, { passive: false });
|
|
268
275
|
function onPointerDown(event) {
|
|
269
276
|
if (scale <= ZOOM_EPSILON || isRealControl(event)) return;
|
|
270
277
|
const img = getImg();
|
|
@@ -419,7 +426,7 @@ const Zoom = {
|
|
|
419
426
|
offPinchMove();
|
|
420
427
|
offPinchEnd();
|
|
421
428
|
offDoubleTap();
|
|
422
|
-
|
|
429
|
+
outer.removeEventListener("wheel", onWheel);
|
|
423
430
|
offRequestZoomIn();
|
|
424
431
|
offRequestZoomOut();
|
|
425
432
|
offRequestZoomActualSize();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../../../../src/plugins/zoom/icons.ts","../../../../src/plugins/zoom/zoomMath.ts","../../../../src/plugins/zoom/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. A magnifying glass with a +/− in the lens, and a plain expand-corners glyph for \"actual size\" (distinct from fullscreen's EXPAND_ICON — no diagonal corner arrows, just a frame, so the two aren't visually confusable when both plugins are enabled). */\nexport const ZOOM_IN_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M10 7v6M7 10h6\"/></svg>';\n\nexport const ZOOM_OUT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M7 10h6\"/></svg>';\n\n/**\n * Live state icons for the actual-size button (index.ts's icon-swap wiring)\n * — a diagonal double-arrow pair, matching the shape of Bootstrap Icons'\n * `arrows-angle-expand`/`arrows-angle-contract` (requested directly), not\n * literally that icon set's own path data. Still distinct from Fullscreen's\n * own EXPAND_ICON/COMPRESS_ICON (fullscreen/icons.ts) despite both being\n * diagonal-corner glyphs: Fullscreen draws four independent corner brackets\n * with no connecting line between them; these draw one continuous diagonal\n * shaft with an arrowhead-style bracket at each end, a different enough\n * shape that the two read as separate icons at a glance, not near-copies of\n * each other.\n */\nexport const ZOOM_ACTUAL_SIZE_EXPAND_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 9L3 3M3 8V3h5M15 15l6 6M21 16v5h-5\"/></svg>';\n\nexport const ZOOM_ACTUAL_SIZE_CONTRACT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 3l6 6M9 3v6H3M21 21l-6-6M15 21v-6h6\"/></svg>';\n","/** A viewport-relative rect: `{left, top, width, height}`, same shape as `DOMRect` but plain-object so it's trivial to mock in tests. */\nexport interface ZoomBox {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface PanOffset {\n tx: number;\n ty: number;\n}\n\nexport function clampScale(scale: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, scale));\n}\n\n/** The 2x2 linear part of a CSS transform matrix — `{a, b, c, d}` from `matrix(a, b, c, d, e, f)`, translation (`e`/`f`) deliberately excluded, since everything below only ever uses this to remap a *delta* or a *local offset*, never an absolute position. */\nexport interface LinearTransform {\n a: number;\n b: number;\n c: number;\n d: number;\n}\n\n/** No parent transform — used as the default wherever a caller doesn't have (or need) one, so every function below stays backward-compatible with a pre-RotateFlip-aware caller. */\nexport const IDENTITY_TRANSFORM: LinearTransform = { a: 1, b: 0, c: 0, d: 1 };\n\n/**\n * `getComputedStyle().transform`'s own `matrix(a, b, c, d, e, f)` (or\n * `none`) string, parsed by hand rather than via the `DOMMatrix`\n * constructor — jsdom (`tests/unit/`) doesn't implement it, so relying on\n * it broke every unit test that exercises a pan drag; a plain regex needs\n * nothing environment-specific and works identically in a real browser.\n * RotateFlip only ever emits a 2D `matrix(...)` (never `matrix3d(...)`),\n * so that's the only form handled — `none` and anything unparseable both\n * fall back to the identity matrix.\n */\nexport function parseLinearTransform(transform: string): LinearTransform {\n const match = /^matrix\\(\\s*([^,]+),\\s*([^,]+),\\s*([^,]+),\\s*([^,]+),/.exec(transform);\n if (!match) return { ...IDENTITY_TRANSFORM };\n return { a: Number(match[1]), b: Number(match[2]), c: Number(match[3]), d: Number(match[4]) };\n}\n\n/**\n * DESIGN.md §4.6 — a real bug, reported from real usage: dragging to pan a\n * zoomed photo that RotateFlip (§4.5) had also rotated moved it in the\n * *wrong* direction (e.g. dragging down moved the image sideways instead)\n * — `onPointerMove`'s raw `event.clientX/Y` delta (screen space) was being\n * applied directly as `pan.tx`/`ty` (the `<img>`'s own *local* space,\n * nested inside `.shoji-slide-media`), with nothing correcting for\n * whatever transform that parent has — RotateFlip's rotation, in this\n * case, but this doesn't need to know that specifically: it corrects for\n * *any* linear transform on the parent, read live off its own computed\n * style, not by reaching into RotateFlip.\n *\n * Standard 2x2 matrix inversion: for `M = [[a, c], [b, d]]`, `M⁻¹ =\n * 1/det * [[d, -c], [-b, a]]` — applying `M⁻¹` to a screen-space delta\n * converts it into the local space `M` itself maps *from*, which is\n * exactly what a child nested inside that transformed parent needs to\n * move by to track the pointer 1:1 on screen regardless of the parent's\n * own rotation/scale/flip. `det === 0` is a degenerate parent transform\n * (zero scale on some axis) that can't be un-done at all — returns the\n * raw delta unchanged rather than dividing by zero into `NaN`.\n */\nexport function screenDeltaToLocal(dx: number, dy: number, m: LinearTransform): PanOffset {\n const det = m.a * m.d - m.b * m.c;\n if (det === 0) return { tx: dx, ty: dy };\n return {\n tx: (m.d * dx - m.c * dy) / det,\n ty: (m.a * dy - m.b * dx) / det,\n };\n}\n\n/** The forward companion to `screenDeltaToLocal` above: `M * [dx; dy]`, converting a *local* delta into the screen-space delta it produces once composed through the parent's current transform `m`. */\nexport function localDeltaToScreen(dx: number, dy: number, m: LinearTransform): PanOffset {\n return { tx: m.a * dx + m.c * dy, ty: m.b * dx + m.d * dy };\n}\n\n/**\n * DESIGN.md §4.6 — the img's own true, *unrotated* layout size (its\n * `offsetWidth`/`offsetHeight` — CSS `transform` is paint-only and never\n * affects these, unlike `natural`'s own `getBoundingClientRect()`, which\n * *does* reflect the parent's current rotation) as an offset from the\n * img's own local origin `(0, 0)` to its own layout center, negated:\n * `{tx: -width/2, ty: -height/2}`. See `localOriginScreen`'s own doc\n * comment for why this specific offset is what a rotated parent needs.\n * Defaults to deriving it from `natural` itself (assuming `natural` *is*\n * already the true, unrotated size) — correct whenever the parent has no\n * rotation, wrong whenever it does; real callers with a possibly-rotated\n * parent should always pass the img's real `offsetWidth`/`offsetHeight`\n * explicitly instead of relying on this default.\n */\nexport function trueOriginOffset(natural: ZoomBox): PanOffset {\n return { tx: -natural.width / 2, ty: -natural.height / 2 };\n}\n\n/**\n * DESIGN.md §4.6 — a real bug, found immediately after the pan-drag fix\n * above while testing the analogous double-click-to-zoom case: `natural`'s\n * own `left`/`top` (from `getBoundingClientRect()` on the *rotated* img)\n * is **not** the screen position of the img's local origin `(0, 0)` —\n * rotating a rectangle moves *which of its own corners* ends up at the\n * bounding box's own top-left. For a 90°-rotated landscape photo, the\n * corner that lands at `natural`'s own top-left is the photo's original\n * *bottom*-left, not its top-left — confirmed directly, by hand, against\n * real `getBoundingClientRect()` numbers: treating `natural.left + pan.tx`\n * as \"the origin's screen position\" (what `zoomTowardPoint`/`clampPan`\n * both did) was off by the img's own full width on one axis, which is\n * exactly why double-clicking to zoom toward the pointer, after a\n * rotation, zoomed toward the wrong point instead — sometimes a\n * plausible-looking wrong point, sometimes (as reported) toward an edge\n * of the image instead of the actual click.\n *\n * The one point immune to this ambiguity is the bounding box's own\n * *center* — rotating a rectangle around its own center (the default\n * `transform-origin`, and where `.shoji-slide-media`'s flex-centering\n * already puts the img) never moves that center, on screen or off. Used\n * as a pivot: the img's local origin, relative to its own *true* (layout,\n * pre-rotation) center, is a fixed offset (`originOffset`, `-width/2,\n * -height/2` in the img's own local space) — that offset plus the current\n * `pan`, mapped through the parent's rotation matrix `m` and added back to\n * the pivot's own (rotation-invariant) screen position, gives the origin's\n * *real* current screen position, correct for any axis-aligned rotation\n * RotateFlip can produce. At the default identity `m` and the default\n * `originOffset` (derived from `natural` itself, correct exactly when\n * there's no rotation to begin with), this reduces to plain\n * `natural.left + pan.tx` — the original, pre-fix formula.\n */\nexport function localOriginScreen(\n natural: ZoomBox,\n pan: PanOffset,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): { x: number; y: number } {\n const pivotX = natural.left + natural.width / 2;\n const pivotY = natural.top + natural.height / 2;\n const fromPivot = localDeltaToScreen(originOffset.tx + pan.tx, originOffset.ty + pan.ty, m);\n return { x: pivotX + fromPivot.tx, y: pivotY + fromPivot.ty };\n}\n\n/** One axis of `clampEdgeToContainer` below — see `clampPan`'s own doc comment for the geometry. Keeps the scaled content's edge from retreating inside the container's edge (no empty gap beyond the image); when the content is already smaller than the container on this axis, no position can avoid a gap on *both* sides at once, so centered is the only sensible answer. */\nfunction clampEdge(\n contentPos: number,\n contentSize: number,\n containerPos: number,\n containerSize: number,\n): number {\n if (contentSize <= containerSize) {\n return containerPos + (containerSize - contentSize) / 2;\n }\n const minPos = containerPos + containerSize - contentSize;\n const maxPos = containerPos;\n return Math.min(maxPos, Math.max(minPos, contentPos));\n}\n\n/**\n * The content's own current screen-space bounding box, for `clampPan`\n * below — the four corners of the img's true (unrotated) `width x height`\n * rect (from `originOffset`, negated), scaled and panned in the wrapper's\n * own unrotated frame, then each mapped to screen space individually via\n * `m` before taking the min/max. Four corners, not one edge scaled\n * directly, because `m` can swap which axis grows with which — RotateFlip\n * only ever produces axis-aligned (90°-multiple) rotations, so the result\n * is still a plain axis-aligned box, just not derivable from a single\n * corner the way an unrotated one is.\n */\nfunction contentScreenBounds(\n natural: ZoomBox,\n pan: PanOffset,\n scale: number,\n m: LinearTransform,\n originOffset: PanOffset,\n): { left: number; top: number; right: number; bottom: number } {\n const trueWidth = -originOffset.tx * 2;\n const trueHeight = -originOffset.ty * 2;\n const pivotX = natural.left + natural.width / 2;\n const pivotY = natural.top + natural.height / 2;\n const localCorners = [\n { x: 0, y: 0 },\n { x: trueWidth, y: 0 },\n { x: 0, y: trueHeight },\n { x: trueWidth, y: trueHeight },\n ];\n const screenCorners = localCorners.map(({ x, y }) => {\n const wrapperFrame = {\n x: originOffset.tx + pan.tx + x * scale,\n y: originOffset.ty + pan.ty + y * scale,\n };\n const screen = localDeltaToScreen(wrapperFrame.x, wrapperFrame.y, m);\n return { x: pivotX + screen.tx, y: pivotY + screen.ty };\n });\n const xs = screenCorners.map((p) => p.x);\n const ys = screenCorners.map((p) => p.y);\n return {\n left: Math.min(...xs),\n top: Math.min(...ys),\n right: Math.max(...xs),\n bottom: Math.max(...ys),\n };\n}\n\n/**\n * Keeps the scaled+panned content's edges from retreating inside\n * `container`'s edges (no empty gap beyond the image), on both screen axes\n * independently.\n *\n * `m`/`originOffset` (default identity / derived from `natural`, DESIGN.md\n * §4.6) — same real bug and same root cause as `localOriginScreen`'s own\n * doc comment: the old per-axis formula compared `natural.left + pan.tx`\n * (mixing screen-space and, once rotated, the wrong corner entirely)\n * against the container's screen bounds. The content's actual screen\n * bounding box is computed properly via `contentScreenBounds` (which\n * itself only ever needs `m`/`originOffset` to do that correctly), each\n * edge is clamped against the container the same way as before, and the\n * *difference* — a screen-space delta — is mapped back to local space and\n * folded into `pan`. At the defaults, every step is a no-op and this\n * reduces to exactly the original per-axis formula.\n */\nexport function clampPan(\n natural: ZoomBox,\n container: ZoomBox,\n scale: number,\n pan: PanOffset,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): PanOffset {\n const bounds = contentScreenBounds(natural, pan, scale, m, originOffset);\n const clampedLeft = clampEdge(\n bounds.left,\n bounds.right - bounds.left,\n container.left,\n container.width,\n );\n const clampedTop = clampEdge(\n bounds.top,\n bounds.bottom - bounds.top,\n container.top,\n container.height,\n );\n const screenDelta = { tx: clampedLeft - bounds.left, ty: clampedTop - bounds.top };\n const localDelta = screenDeltaToLocal(screenDelta.tx, screenDelta.ty, m);\n return { tx: pan.tx + localDelta.tx, ty: pan.ty + localDelta.ty };\n}\n\n/**\n * The standard \"zoom toward a point\" formula: adjusts pan so the viewport\n * point `(anchorX, anchorY)` stays visually fixed as scale changes from\n * `oldScale` to `newScale`, given `transform-origin: 0 0` (scaling never\n * moves the img's own local origin on screen, only `pan` does). Callers\n * still need to clamp the result with `clampPan` afterward; this only\n * solves the anchor-fixed part, not boundary containment.\n *\n * `m`/`originOffset` (default identity / derived from `natural`, DESIGN.md\n * §4.6) — a real bug, reported from real usage: double-clicking to zoom\n * toward the pointer, after RotateFlip had rotated the slide, zoomed\n * toward the wrong point — sometimes badly (see `localOriginScreen`'s own\n * doc comment for the root cause and how these two parameters fix it,\n * shared with `clampPan` above). At the defaults, every step is a no-op\n * and this reduces to exactly the original formula.\n */\nexport function zoomTowardPoint(\n natural: ZoomBox,\n pan: PanOffset,\n oldScale: number,\n newScale: number,\n anchorX: number,\n anchorY: number,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): PanOffset {\n const origin = localOriginScreen(natural, pan, m, originOffset);\n const screenDx = anchorX - origin.x;\n const screenDy = anchorY - origin.y;\n const local = screenDeltaToLocal(screenDx, screenDy, m);\n const factor = 1 - newScale / oldScale;\n return {\n tx: pan.tx + local.tx * factor,\n ty: pan.ty + local.ty * factor,\n };\n}\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { createIconSwap } from '../../core/iconSwap';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport {\n ZOOM_ACTUAL_SIZE_CONTRACT_ICON,\n ZOOM_ACTUAL_SIZE_EXPAND_ICON,\n ZOOM_IN_ICON,\n ZOOM_OUT_ICON,\n} from './icons';\nimport {\n clampPan,\n clampScale,\n parseLinearTransform,\n screenDeltaToLocal,\n zoomTowardPoint,\n type PanOffset,\n type ZoomBox,\n} from './zoomMath';\nimport './zoom.css';\n\nexport interface ZoomOptions {\n /** Multiplier cap for pinch/wheel/button zoom. \"Actual size\" can exceed this deliberately — it's an explicit action, not continuous gesture zoom. Default 4. */\n maxScale?: number;\n /** Scale a double-tap/double-click jumps to; a second one while already zoomed resets to 1 instead. Default 2. */\n doubleTapScale?: number;\n /** Multiplier applied per zoom-in/zoom-out toolbar button click. Default 1.5. */\n buttonStep?: number;\n}\n\nconst ZOOM_EPSILON = 1.001; // treat \"just barely above 1\" as unzoomed — avoids float residue pinning isZoomed() true forever\n\n/** A click/drag starting on a real control shouldn't engage pan — same exclusion list GestureController's shouldIgnoreGesture uses, duplicated rather than imported since that function isn't part of core's exported surface. */\nfunction isRealControl(event: PointerEvent): boolean {\n return event\n .composedPath()\n .some(\n (node) =>\n node instanceof Element &&\n node.matches(\n 'button, video, input, select, textarea, a[href], [data-shoji-no-drag], .shoji-caption',\n ),\n );\n}\n\n/**\n * DESIGN.md §4-zoom — pinch, double-tap/click, wheel+ctrl, and three toolbar\n * buttons (zoom in/out/actual-size) all drive a single `scale`/pan state on\n * the active slide's `<img>` — never `.shoji-slide-media` itself, which the\n * rotateFlip plugin (§4) already transforms; nesting on the inner element\n * instead of fighting over the same transform string means a rotated *and*\n * zoomed photo behaves correctly for free (the outer rotate carries the\n * inner pan/scale along with it as a rigid unit, which is also the visually\n * expected result — see DESIGN.md's note on this plugin for the full\n * reasoning). Pinch/double-tap/wheel are core's own gesture relay (§2.4) —\n * scaffolding that existed specifically for this plugin to consume, not\n * reimplemented here. Pan (single-pointer drag while zoomed) is the one\n * piece core's relay doesn't cover — core's own drag-to-navigate/\n * drag-to-close would otherwise fight over the same drag — so this plugin\n * registers a zoom gate (`Gallery.registerZoomGate`, §4-zoom) that suspends\n * core's drag handling entirely while zoomed, and tracks pan with its own\n * minimal raw pointer listeners instead of reusing `GestureEngine` (whose\n * axis-locked model — pick horizontal *or* vertical per gesture — is the\n * wrong shape for a 2D pan that needs both at once).\n */\nexport const Zoom: ShojiPlugin = {\n name: 'zoom',\n defaults: {\n maxScale: 4,\n doubleTapScale: 2,\n buttonStep: 1.5,\n } satisfies ZoomOptions,\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const maxScale = Number(ctx.options.maxScale ?? 4);\n const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);\n const buttonStep = Number(ctx.options.buttonStep ?? 1.5);\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const zoomInLabel = locale.zoomIn ?? 'Zoom in';\n const zoomOutLabel = locale.zoomOut ?? 'Zoom out';\n const actualSizeLabel = locale.zoomActualSize ?? 'Actual size';\n\n let scale = 1;\n let pan: PanOffset = { tx: 0, ty: 0 };\n let natural: ZoomBox | null = null;\n let container: ZoomBox | null = null;\n // The img's own true, unrotated local origin, as an offset from its\n // (rotation-invariant) layout center — see zoomTowardPoint/clampPan's\n // own doc comments (DESIGN.md §4.6) for why natural's left/top/width/\n // height alone aren't a safe stand-in for this once RotateFlip has\n // rotated the parent.\n let originOffset: PanOffset | null = null;\n let pinchStartScale = 1;\n\n function getImg(): HTMLImageElement | null {\n const media = gallery.getActiveMedia();\n const child = media?.firstElementChild;\n return child instanceof HTMLImageElement ? child : null;\n }\n\n function boxOf(el: Element): ZoomBox {\n const rect = el.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };\n }\n\n /** Only valid to measure while scale===1 (untransformed) — the very first zoom action on a slide; every subsequent action within the same slide reuses the cached box, since measuring an already-scaled element would capture the scaled size, not the natural one. */\n function ensureNatural(img: HTMLImageElement): boolean {\n if (natural && container && originOffset) return true;\n if (scale !== 1) return false; // shouldn't happen — defensive\n natural = boxOf(img);\n container = boxOf(img.parentElement ?? img);\n // offsetWidth/Height, not natural's own width/height above — CSS\n // transform (RotateFlip's rotation on the parent) is paint-only and\n // never affects layout size, unlike getBoundingClientRect(), which\n // reports the *rotated* bounding box (DESIGN.md §4.6). Falls back to\n // natural's own (unrotated-assuming) size when offsetWidth/Height\n // read 0 — real layout engines only report 0 for a genuinely\n // unrendered element, but jsdom (tests/unit/) never computes layout\n // at all and always reports 0, so this also keeps every rotation-\n // unaware unit test's mocked getBoundingClientRect() meaningful.\n originOffset = {\n tx: -(img.offsetWidth || natural.width) / 2,\n ty: -(img.offsetHeight || natural.height) / 2,\n };\n return true;\n }\n\n /**\n * A real bug: `ensureNatural`'s first measurement of a slide is only\n * trustworthy once the lightbox's own open FLIP transition (`zoomIn`,\n * `core/zoomTransition.ts`) has actually settled — that transition\n * applies its own transform directly to `.shoji-slide-media`, the exact\n * element `ensureNatural` measures as `container`. A zoom action firing\n * before it settles (any interaction within `--shoji-duration` of\n * opening — a fast click, or a test that only waits for the dialog to\n * become visible) captured a wildly wrong, mid-animation rect,\n * permanently poisoning that slide's zoom math (every later action\n * reuses the same cached, wrong box). `zoomIn` is fire-and-forget by\n * design (nothing to await when opening) and clears this exact inline\n * style once its own transition ends — the one signal available for\n * \"is it still running.\" Runs `action` immediately once settled, which\n * is right away in the overwhelming common case (any real interaction\n * more than ~300ms after open).\n */\n function whenSettled(action: () => void): void {\n const media = gallery.getActiveMedia();\n if (!media || media.style.transition === '') {\n action();\n return;\n }\n waitForTransitionEnd(media, action);\n }\n\n /**\n * `translate3d`/`scale3d`, not the 2D `translate`/`scale` this used to\n * use — a real bug, reported from real usage: evenly-spaced horizontal\n * lines visible across a zoomed photo, at certain zoom levels, on real\n * GPU hardware (not reproducible in headless/software rendering, so\n * this can't be verified here). The regular spacing matches Chromium's\n * own raster-tile boundaries — a known quirk where scaling large\n * content via a 2D `scale()` transform can show seams between GPU\n * tiles. Forcing the fully 3D compositing path instead (functionally\n * identical — `scale3d(s, s, 1)` and `scale(s)` produce the same\n * on-screen result) is the commonly effective fix, since it takes a\n * different rasterization path than the 2D one.\n */\n function apply(): void {\n const img = getImg();\n if (!img) return;\n img.style.transformOrigin = '0 0';\n img.style.transform =\n scale === 1 && pan.tx === 0 && pan.ty === 0\n ? 'none'\n : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;\n img.classList.toggle('shoji-zoomed', scale > 1);\n }\n\n function emitChange(): void {\n ctx.emit('zoomChange', { index: gallery.currentIndex, scale });\n updateActualSizeIcon();\n }\n\n /**\n * The actual-size button's icon reflects live state, requested directly:\n * `arrows-angle-expand` at fit, `arrows-angle-contract` while zoomed in\n * at all — by pinch, wheel, the zoom-in/out buttons, or this button\n * itself, not just specifically at native pixel size. Matches\n * `actualSizeToggle()`'s own real click behavior exactly: it resets to\n * fit for *any* `scale > 1`, regardless of how that zoom was reached,\n * and only attempts to zoom to native size from exactly `scale === 1`\n * (a no-op there for a photo whose native resolution is at or below its\n * fitted size — `clampScale(targetScale, 1, ...)`'s own floor — which\n * this correctly still shows as expand, since scale is still 1 in that\n * case). A real bug in an earlier version of this: tracking a separate\n * \"are we exactly at native size\" cache (naturalWidth / natural.width,\n * refreshed on slideItemLoad) went stale the moment `reset()` cleared\n * it without every call site re-populating it, so only the *first*\n * actual-size press of a session ever updated the icon at all — using\n * `scale` directly instead, already the single live source of truth\n * `zoomChange` itself is built on, has no cache to go stale in the\n * first place.\n */\n function updateActualSizeIcon(): void {\n actualSizeIconSwap.setState(scale > ZOOM_EPSILON);\n }\n\n /** Wraps a transform-setting `run` in a transition, for discrete jumps (buttons, double-tap, actual-size) — never for pinch/pan/wheel, which already track the input 1:1 and would visibly lag behind it under a transition. `afterEnd`, if given, runs once the transition actually completes, not before — `reset()` uses it to clear `transformOrigin` only once it's safe to (see its own comment for why clearing it any earlier is a real bug). The transition itself is always cleared afterward, so it doesn't linger onto the next, possibly-continuous, zoom action. */\n function withTransition(img: HTMLImageElement, run: () => void, afterEnd?: () => void): void {\n img.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n run();\n waitForTransitionEnd(img, () => {\n img.style.transition = '';\n afterEnd?.();\n });\n }\n\n /** Shared by every zoom-in/out entry point (pinch, wheel, buttons, double-tap, actual-size) — anchors on (anchorX, anchorY), clamps scale to [1, ceiling] and pan to the container bounds. `ceiling` defaults to maxScale; actual-size passes its own (possibly larger) target so it isn't capped by the gesture-zoom limit. Deferred via `whenSettled` — see its doc comment — so a zoom action landing right as the lightbox opens doesn't measure mid-animation. `animate` — see `withTransition`. */\n function zoomTo(\n targetScale: number,\n anchorX: number,\n anchorY: number,\n ceiling = maxScale,\n animate = false,\n ): void {\n whenSettled(() => {\n const img = getImg();\n if (!img || !ensureNatural(img)) return;\n const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));\n // DESIGN.md §4.6 — zoomTowardPoint's own doc comment has the full\n // reasoning: the anchor point needs the same screen-vs-local\n // correction as onPointerMove's own pan drag, since RotateFlip may\n // have rotated/flipped `.shoji-slide-media` in the meantime.\n const media = gallery.getActiveMedia();\n const parentTransform = parseLinearTransform(\n media ? getComputedStyle(media).transform : 'none',\n );\n pan = zoomTowardPoint(\n natural!,\n pan,\n scale,\n clampedScale,\n anchorX,\n anchorY,\n parentTransform,\n originOffset!,\n );\n scale = clampedScale;\n // Same correction, same reason — clampAxis compares against the\n // container's screen bounds, so the candidate pan needs to be in\n // screen space too, or a 90/270deg rotation clamps the wrong edge\n // and can undo the anchor-preserving pan just computed above.\n pan = clampPan(natural!, container!, scale, pan, parentTransform, originOffset!);\n if (animate) withTransition(img, apply);\n else apply();\n emitChange();\n });\n }\n\n function reset(animate = false): void {\n // A real gap, found auditing this against its own documented contract\n // (\"emits zoomChange on every scale change, gesture or button-driven\",\n // DESIGN.md §4.6): resetting from an engaged scale back to 1 is a\n // scale change like any other, but every call site here (beforeSlide,\n // afterSlide, beforeClose, afterOpen, and zoomOutStep/toggleZoom\n // reaching neutral) silently skipped emitting it — a host listening\n // for zoomChange to reflect \"is this slide currently zoomed\" would\n // never learn it stopped being true unless something else zoomed in\n // again first. Only when there's an actual change: the overwhelming\n // majority of these calls fire while already at scale 1 (nothing\n // engaged to begin with), and emitting on every no-op reset would be\n // noisy against the \"on every *change*\" contract, not a fix for it.\n const wasEngaged = scale !== 1;\n scale = 1;\n pan = { tx: 0, ty: 0 };\n natural = null;\n container = null;\n originOffset = null;\n updateActualSizeIcon(); // scale is already 1 here, so this is always the expand state\n if (wasEngaged) emitChange(); // after scale is already 1, so listeners see the real new value\n const img = getImg();\n if (!img) return;\n const clearTransform = (): void => {\n img.style.transform = '';\n img.classList.remove('shoji-zoomed');\n };\n if (animate) {\n // transform-origin has to stay put (0 0) for the duration of the\n // transition — clearing it to the browser default (center) in the\n // same tick as starting the transition snaps the scale anchor\n // instantly, which visibly jumped the image before it eased down\n // to neutral. Deferred to `afterEnd`, once the transition is done\n // and transform-origin no longer affects anything visible.\n withTransition(img, clearTransform, () => {\n img.style.transformOrigin = '';\n });\n } else {\n clearTransform();\n img.style.transformOrigin = '';\n }\n }\n\n /** DESIGN.md §2.5/§4.6 — same fix, same reasoning, as RotateFlip's own equivalent (`rotateFlip/index.ts`): `beforeSlide`'s unanimated `reset()` above can't itself animate (it has to finish before `SlideManager.render()` reparents the outgoing image), so the live `transform`/`transformOrigin` about to be wiped are captured here first and handed to `SlideTransition` via `registerSlideLeaveDecorator()` below, to animate away on the leave-ghost's own clone instead of just vanishing. */\n let pendingLeaveTransform: string | null = null;\n let pendingLeaveOrigin = '';\n function captureLeaveTransform(): void {\n const img = getImg();\n const transform = img?.style.transform;\n pendingLeaveTransform = transform && transform !== 'none' ? transform : null;\n pendingLeaveOrigin = img?.style.transformOrigin || '0 0';\n }\n\n /** Each slide gets a freshly-created `<img>` (SlideManager never reuses elements across renders), so the cursor-affordance marker (`zoom.css`) needs reapplying every time the active media changes, not just once. */\n function markEnabled(): void {\n getImg()?.classList.add('shoji-zoom-enabled');\n }\n\n function toggleZoom(x: number, y: number): void {\n if (scale > ZOOM_EPSILON) reset(true);\n else zoomTo(doubleTapScale, x, y, maxScale, true);\n }\n\n // --- pinch (relayed by core, §2.4 — no built-in effect until this plugin exists) ---\n const offPinchStart = ctx.on('pinchStart', () => {\n pinchStartScale = scale;\n });\n const offPinchMove = ctx.on('pinchMove', ({ scale: relative, centerX, centerY }) => {\n zoomTo(pinchStartScale * relative, centerX, centerY);\n });\n const offPinchEnd = ctx.on('pinchEnd', () => {\n if (scale <= ZOOM_EPSILON) reset(); // snap fully back to neutral rather than leaving float residue\n });\n\n // --- double-tap / double-click (relayed by core; Pointer Events unify the two, see GestureEngine) ---\n const offDoubleTap = ctx.on('doubleTap', ({ x, y }) => toggleZoom(x, y));\n\n // --- ctrl+wheel / trackpad pinch (relayed by core) ---\n const offWheelZoom = ctx.on('wheelZoom', ({ deltaScale, x, y }) => {\n zoomTo(scale + deltaScale, x, y);\n });\n\n /**\n * A generic command surface, requested directly (DESIGN.md §4.6), so a\n * *custom* (host-authored) plugin's own button can drive zoom without\n * importing this plugin at all — same \"events over inheritance\"\n * decoupling `pinchStart`/`doubleTap`/`wheelZoom` above already use,\n * just in the opposite direction (a command in, not a gesture relay).\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestZoomIn', {})` from any plugin —\n * official or custom — type-checks with zero core changes; this is\n * just the listening half. Each mirrors its real toolbar button\n * exactly — same functions, same behavior on a video slide (a no-op,\n * `zoomInStep`/`zoomOutStep`/`actualSizeToggle` all bail via `getImg()`\n * returning null there).\n */\n const offRequestZoomIn = ctx.on('requestZoomIn', zoomInStep);\n const offRequestZoomOut = ctx.on('requestZoomOut', zoomOutStep);\n const offRequestZoomActualSize = ctx.on('requestZoomActualSize', actualSizeToggle);\n const offRequestZoomReset = ctx.on('requestZoomReset', () => reset(true));\n\n // --- pan while zoomed — the one gesture core's relay doesn't cover; see the plugin doc comment for why this can't reuse GestureEngine. ---\n let panPointerId: number | null = null;\n let lastX = 0;\n let lastY = 0;\n const outer = ctx.ui.outer();\n\n function onPointerDown(event: PointerEvent): void {\n if (scale <= ZOOM_EPSILON || isRealControl(event)) return;\n const img = getImg();\n // A real bug, reported from real usage: this listens on `outer` (the\n // whole lightbox, not just the image) so a fast pan can be tracked\n // even once the pointer leaves the image's own bounds — but with no\n // check on where the pointerdown itself landed, a click on the plain\n // backdrop (between the image and a nav arrow, say) engaged pan and\n // captured the pointer onto `img` regardless, which — see below —\n // retargets the click and makes it misread as \"on the image,\" not\n // backdrop, silently defeating click-to-close while zoomed.\n if (!img || !event.composedPath().includes(img)) return;\n panPointerId = event.pointerId;\n lastX = event.clientX;\n lastY = event.clientY;\n // Without this, a fast pan whose pointer exits `outer`'s bounds\n // stops receiving pointermove/pointerup entirely (no capture = only\n // elements actually under the cursor get events), leaving\n // panPointerId stuck non-null until the next pointerdown — the\n // gesture just goes dead mid-drag. Captured on the `<img>` itself,\n // not `outer`: capturing retargets the subsequent synthetic `click`\n // to whatever captured it, and `img` — unlike `outer` — already\n // matches isBackdropClick's own exclusion selector (Gallery.ts), so\n // a captured pan's release still can't misread as a backdrop click.\n // GestureEngine's own capture needs a separate suppressRetargetedClick\n // step for exactly this reason; this doesn't, since the retarget\n // lands somewhere already excluded.\n img.setPointerCapture(event.pointerId);\n }\n function onPointerMove(event: PointerEvent): void {\n if (panPointerId !== event.pointerId || !natural || !container || !originOffset) return;\n event.preventDefault();\n const rawDx = event.clientX - lastX;\n const rawDy = event.clientY - lastY;\n lastX = event.clientX;\n lastY = event.clientY;\n // DESIGN.md §4.6 — screenDeltaToLocal's own doc comment has the full\n // reasoning: the raw pointer delta is screen space, but pan.tx/ty are\n // local to the <img>, nested inside whatever transform (e.g.\n // RotateFlip's rotation) `.shoji-slide-media` currently has.\n const media = gallery.getActiveMedia();\n const m = parseLinearTransform(media ? getComputedStyle(media).transform : 'none');\n const { tx: dx, ty: dy } = screenDeltaToLocal(rawDx, rawDy, m);\n pan = clampPan(\n natural,\n container,\n scale,\n { tx: pan.tx + dx, ty: pan.ty + dy },\n m,\n originOffset,\n );\n apply();\n }\n function onPointerUp(event: PointerEvent): void {\n if (panPointerId === event.pointerId) panPointerId = null;\n }\n\n outer.addEventListener('pointerdown', onPointerDown);\n outer.addEventListener('pointermove', onPointerMove, { passive: false });\n outer.addEventListener('pointerup', onPointerUp);\n outer.addEventListener('pointercancel', onPointerUp);\n\n // --- toolbar buttons ---\n function buildButton(icon: string, label: string): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = icon;\n button.setAttribute('aria-label', label);\n button.title = label;\n return button;\n }\n\n function centerAnchor(): { x: number; y: number } {\n const media = gallery.getActiveMedia();\n const rect = media?.getBoundingClientRect();\n return rect\n ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }\n : { x: 0, y: 0 };\n }\n\n /** Shared by the zoom-in toolbar button and the `w` keyboard shortcut — same fixed `buttonStep` multiplier either way. */\n function zoomInStep(): void {\n const { x, y } = centerAnchor();\n zoomTo(scale * buttonStep, x, y, maxScale, true);\n }\n\n /** Shared by the zoom-out toolbar button and the `s` keyboard shortcut. */\n function zoomOutStep(): void {\n const { x, y } = centerAnchor();\n if (scale / buttonStep <= ZOOM_EPSILON) reset(true);\n else zoomTo(scale / buttonStep, x, y, maxScale, true);\n }\n\n /** Shared by the actual-size toolbar button and `requestZoomActualSize` below. Reads natural.width directly (unlike every other entry point, which just hands zoomTo a target and lets it call ensureNatural itself) — needs its own whenSettled wrap for that reason, not just zoomTo's. */\n function actualSizeToggle(): void {\n whenSettled(() => {\n const img = getImg();\n if (!img || !img.naturalWidth) return;\n if (!ensureNatural(img)) return;\n if (scale > ZOOM_EPSILON) {\n reset(true);\n return;\n }\n const targetScale = img.naturalWidth / natural!.width;\n const { x, y } = centerAnchor();\n zoomTo(targetScale, x, y, targetScale, true); // ceiling = targetScale — bypasses maxScale deliberately\n });\n }\n\n const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);\n const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);\n\n const actualSizeBtn = document.createElement('button');\n actualSizeBtn.type = 'button';\n actualSizeBtn.className = 'shoji-toolbar-button';\n const actualSizeIconSwap = createIconSwap(\n ZOOM_ACTUAL_SIZE_EXPAND_ICON,\n ZOOM_ACTUAL_SIZE_CONTRACT_ICON,\n );\n actualSizeBtn.appendChild(actualSizeIconSwap.el);\n actualSizeBtn.setAttribute('aria-label', actualSizeLabel);\n actualSizeBtn.title = actualSizeLabel;\n\n zoomInBtn.addEventListener('click', zoomInStep);\n zoomOutBtn.addEventListener('click', zoomOutStep);\n actualSizeBtn.addEventListener('click', actualSizeToggle);\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // zoomIn, zoomOut, actualSize, then whatever later plugin (or the close\n // button) follows (DESIGN.md §3.1).\n const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n /** All three zoom buttons are no-ops on a video slide — `getImg()` returns null, so `apply()`/`ensureNatural()` bail out immediately. Hidden rather than left clickable-but-dead. */\n function updateButtonVisibility(): void {\n const isVideo = !!gallery.items[gallery.currentIndex]?.video;\n zoomInBtn.hidden = isVideo;\n zoomOutBtn.hidden = isVideo;\n actualSizeBtn.hidden = isVideo;\n }\n\n // w/s zoom in/out, same step as the toolbar buttons — both cases\n // registered explicitly (registerShortcut matches event.key verbatim,\n // no case-insensitive matching of its own) so Shift/CapsLock still work.\n const removeShortcuts = [\n ctx.ui.registerShortcut('w', zoomInStep),\n ctx.ui.registerShortcut('W', zoomInStep),\n ctx.ui.registerShortcut('s', zoomOutStep),\n ctx.ui.registerShortcut('S', zoomOutStep),\n ];\n\n const offOpen = ctx.on('afterOpen', () => {\n reset();\n markEnabled();\n updateButtonVisibility();\n });\n // Un-animated, and on beforeSlide rather than only afterSlide below:\n // SlideManager.render() (called synchronously between the two) reuses a\n // still-cached slide's node via a plain reparent (moveIn(), no state\n // clearing of its own) into whichever pool slot its new offset needs —\n // there is no code path afterward that can still find *this* image to\n // reset it. A real bug, reported from real usage: zoom in via \"Actual\n // size\", click next — the old, still-scaled image, now reparented into\n // the (unclipped, per shoji.css) neighboring slot, visibly bled into the\n // new slide instead of being invisible off-screen like an unzoomed one\n // always is. Resetting here, while getActiveMedia() still resolves to\n // the about-to-move image, clears it before that reparent ever happens.\n // captureLeaveTransform() (see registerSlideLeaveDecorator below) reads\n // the live transform first, while it's still there to read.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n captureLeaveTransform();\n reset();\n });\n const offSlide = ctx.on('afterSlide', () => {\n reset();\n markEnabled();\n updateButtonVisibility();\n });\n // Fires synchronously, before Gallery.close() measures the active\n // media's rect to compute the zoom-out-to-thumbnail animation — reset\n // here (not just afterOpen/afterSlide) so that measurement sees the\n // image at its natural position/scale, not wherever it was left\n // zoomed/panned to. Skipping this made closing while zoomed animate\n // from the image's current (zoomed, often partly off-screen) rect\n // instead of its real thumbnail-relative size, landing \"closed\" at a\n // seemingly random spot instead of visibly shrinking into the thumbnail.\n const offBeforeClose = ctx.on('beforeClose', () => reset());\n const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);\n // Read by Gallery.beginClose() *before* the beforeClose reset above\n // runs, so a button-close continues the zoom-out from wherever the\n // viewer was actually zoomed/panned to, instead of the reset above\n // making it (correctly, for the measurement) but also making the\n // close itself snap back to neutral first. The image's own real\n // rendered rect, not this plugin's raw scale/pan numbers — see\n // zoomTransition.ts's ZoomTransitionTarget.zoomStart for why a direct\n // scale/pan replay doesn't work once it lands on a different element.\n const unregisterZoomStart = gallery.registerZoomStartProvider(() =>\n scale > ZOOM_EPSILON ? (getImg()?.getBoundingClientRect() ?? null) : null,\n );\n const unregisterLeaveDecorator = gallery.registerSlideLeaveDecorator((clonedMedia) => {\n if (!pendingLeaveTransform) return;\n const transform = pendingLeaveTransform;\n const origin = pendingLeaveOrigin;\n pendingLeaveTransform = null;\n const clonedImg = clonedMedia.querySelector<HTMLImageElement>('img');\n if (!clonedImg) return;\n clonedImg.style.transformOrigin = origin;\n clonedImg.style.transform = transform;\n return () => {\n clonedImg.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n clonedImg.style.transform = 'none';\n };\n });\n markEnabled(); // covers the (unusual but possible) case of the gallery already being open when this plugin initializes\n updateButtonVisibility();\n\n return () => {\n for (const remove of removeButtons) remove();\n for (const remove of removeShortcuts) remove();\n offOpen();\n offBeforeSlide();\n offSlide();\n offBeforeClose();\n offPinchStart();\n offPinchMove();\n offPinchEnd();\n offDoubleTap();\n offWheelZoom();\n offRequestZoomIn();\n offRequestZoomOut();\n offRequestZoomActualSize();\n offRequestZoomReset();\n outer.removeEventListener('pointerdown', onPointerDown);\n outer.removeEventListener('pointermove', onPointerMove);\n outer.removeEventListener('pointerup', onPointerUp);\n outer.removeEventListener('pointercancel', onPointerUp);\n unregisterGate();\n unregisterZoomStart();\n unregisterLeaveDecorator();\n reset();\n getImg()?.classList.remove('shoji-zoom-enabled');\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/zoom/icons.ts","../../../../src/plugins/zoom/zoomMath.ts","../../../../src/plugins/zoom/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. A magnifying glass with a +/− in the lens, and a plain expand-corners glyph for \"actual size\" (distinct from fullscreen's EXPAND_ICON — no diagonal corner arrows, just a frame, so the two aren't visually confusable when both plugins are enabled). */\nexport const ZOOM_IN_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M10 7v6M7 10h6\"/></svg>';\n\nexport const ZOOM_OUT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M7 10h6\"/></svg>';\n\n/**\n * Live state icons for the actual-size button (index.ts's icon-swap wiring)\n * — a diagonal double-arrow pair, matching the shape of Bootstrap Icons'\n * `arrows-angle-expand`/`arrows-angle-contract` (requested directly), not\n * literally that icon set's own path data. Still distinct from Fullscreen's\n * own EXPAND_ICON/COMPRESS_ICON (fullscreen/icons.ts) despite both being\n * diagonal-corner glyphs: Fullscreen draws four independent corner brackets\n * with no connecting line between them; these draw one continuous diagonal\n * shaft with an arrowhead-style bracket at each end, a different enough\n * shape that the two read as separate icons at a glance, not near-copies of\n * each other.\n */\nexport const ZOOM_ACTUAL_SIZE_EXPAND_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 9L3 3M3 8V3h5M15 15l6 6M21 16v5h-5\"/></svg>';\n\nexport const ZOOM_ACTUAL_SIZE_CONTRACT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 3l6 6M9 3v6H3M21 21l-6-6M15 21v-6h6\"/></svg>';\n","/** A viewport-relative rect: `{left, top, width, height}`, same shape as `DOMRect` but plain-object so it's trivial to mock in tests. */\nexport interface ZoomBox {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface PanOffset {\n tx: number;\n ty: number;\n}\n\nexport function clampScale(scale: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, scale));\n}\n\n/** The 2x2 linear part of a CSS transform matrix — `{a, b, c, d}` from `matrix(a, b, c, d, e, f)`, translation (`e`/`f`) deliberately excluded, since everything below only ever uses this to remap a *delta* or a *local offset*, never an absolute position. */\nexport interface LinearTransform {\n a: number;\n b: number;\n c: number;\n d: number;\n}\n\n/** No parent transform — used as the default wherever a caller doesn't have (or need) one, so every function below stays backward-compatible with a pre-RotateFlip-aware caller. */\nexport const IDENTITY_TRANSFORM: LinearTransform = { a: 1, b: 0, c: 0, d: 1 };\n\n/**\n * `getComputedStyle().transform`'s own `matrix(a, b, c, d, e, f)` (or\n * `none`) string, parsed by hand rather than via the `DOMMatrix`\n * constructor — jsdom (`tests/unit/`) doesn't implement it, so relying on\n * it broke every unit test that exercises a pan drag; a plain regex needs\n * nothing environment-specific and works identically in a real browser.\n * RotateFlip only ever emits a 2D `matrix(...)` (never `matrix3d(...)`),\n * so that's the only form handled — `none` and anything unparseable both\n * fall back to the identity matrix.\n */\nexport function parseLinearTransform(transform: string): LinearTransform {\n const match = /^matrix\\(\\s*([^,]+),\\s*([^,]+),\\s*([^,]+),\\s*([^,]+),/.exec(transform);\n if (!match) return { ...IDENTITY_TRANSFORM };\n return { a: Number(match[1]), b: Number(match[2]), c: Number(match[3]), d: Number(match[4]) };\n}\n\n/**\n * DESIGN.md §4.6 — a real bug, reported from real usage: dragging to pan a\n * zoomed photo that RotateFlip (§4.5) had also rotated moved it in the\n * *wrong* direction (e.g. dragging down moved the image sideways instead)\n * — `onPointerMove`'s raw `event.clientX/Y` delta (screen space) was being\n * applied directly as `pan.tx`/`ty` (the `<img>`'s own *local* space,\n * nested inside `.shoji-slide-media`), with nothing correcting for\n * whatever transform that parent has — RotateFlip's rotation, in this\n * case, but this doesn't need to know that specifically: it corrects for\n * *any* linear transform on the parent, read live off its own computed\n * style, not by reaching into RotateFlip.\n *\n * Standard 2x2 matrix inversion: for `M = [[a, c], [b, d]]`, `M⁻¹ =\n * 1/det * [[d, -c], [-b, a]]` — applying `M⁻¹` to a screen-space delta\n * converts it into the local space `M` itself maps *from*, which is\n * exactly what a child nested inside that transformed parent needs to\n * move by to track the pointer 1:1 on screen regardless of the parent's\n * own rotation/scale/flip. `det === 0` is a degenerate parent transform\n * (zero scale on some axis) that can't be un-done at all — returns the\n * raw delta unchanged rather than dividing by zero into `NaN`.\n */\nexport function screenDeltaToLocal(dx: number, dy: number, m: LinearTransform): PanOffset {\n const det = m.a * m.d - m.b * m.c;\n if (det === 0) return { tx: dx, ty: dy };\n return {\n tx: (m.d * dx - m.c * dy) / det,\n ty: (m.a * dy - m.b * dx) / det,\n };\n}\n\n/** The forward companion to `screenDeltaToLocal` above: `M * [dx; dy]`, converting a *local* delta into the screen-space delta it produces once composed through the parent's current transform `m`. */\nexport function localDeltaToScreen(dx: number, dy: number, m: LinearTransform): PanOffset {\n return { tx: m.a * dx + m.c * dy, ty: m.b * dx + m.d * dy };\n}\n\n/**\n * DESIGN.md §4.6 — the img's own true, *unrotated* layout size (its\n * `offsetWidth`/`offsetHeight` — CSS `transform` is paint-only and never\n * affects these, unlike `natural`'s own `getBoundingClientRect()`, which\n * *does* reflect the parent's current rotation) as an offset from the\n * img's own local origin `(0, 0)` to its own layout center, negated:\n * `{tx: -width/2, ty: -height/2}`. See `localOriginScreen`'s own doc\n * comment for why this specific offset is what a rotated parent needs.\n * Defaults to deriving it from `natural` itself (assuming `natural` *is*\n * already the true, unrotated size) — correct whenever the parent has no\n * rotation, wrong whenever it does; real callers with a possibly-rotated\n * parent should always pass the img's real `offsetWidth`/`offsetHeight`\n * explicitly instead of relying on this default.\n */\nexport function trueOriginOffset(natural: ZoomBox): PanOffset {\n return { tx: -natural.width / 2, ty: -natural.height / 2 };\n}\n\n/**\n * DESIGN.md §4.6 — a real bug, found immediately after the pan-drag fix\n * above while testing the analogous double-click-to-zoom case: `natural`'s\n * own `left`/`top` (from `getBoundingClientRect()` on the *rotated* img)\n * is **not** the screen position of the img's local origin `(0, 0)` —\n * rotating a rectangle moves *which of its own corners* ends up at the\n * bounding box's own top-left. For a 90°-rotated landscape photo, the\n * corner that lands at `natural`'s own top-left is the photo's original\n * *bottom*-left, not its top-left — confirmed directly, by hand, against\n * real `getBoundingClientRect()` numbers: treating `natural.left + pan.tx`\n * as \"the origin's screen position\" (what `zoomTowardPoint`/`clampPan`\n * both did) was off by the img's own full width on one axis, which is\n * exactly why double-clicking to zoom toward the pointer, after a\n * rotation, zoomed toward the wrong point instead — sometimes a\n * plausible-looking wrong point, sometimes (as reported) toward an edge\n * of the image instead of the actual click.\n *\n * The one point immune to this ambiguity is the bounding box's own\n * *center* — rotating a rectangle around its own center (the default\n * `transform-origin`, and where `.shoji-slide-media`'s flex-centering\n * already puts the img) never moves that center, on screen or off. Used\n * as a pivot: the img's local origin, relative to its own *true* (layout,\n * pre-rotation) center, is a fixed offset (`originOffset`, `-width/2,\n * -height/2` in the img's own local space) — that offset plus the current\n * `pan`, mapped through the parent's rotation matrix `m` and added back to\n * the pivot's own (rotation-invariant) screen position, gives the origin's\n * *real* current screen position, correct for any axis-aligned rotation\n * RotateFlip can produce. At the default identity `m` and the default\n * `originOffset` (derived from `natural` itself, correct exactly when\n * there's no rotation to begin with), this reduces to plain\n * `natural.left + pan.tx` — the original, pre-fix formula.\n */\nexport function localOriginScreen(\n natural: ZoomBox,\n pan: PanOffset,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): { x: number; y: number } {\n const pivotX = natural.left + natural.width / 2;\n const pivotY = natural.top + natural.height / 2;\n const fromPivot = localDeltaToScreen(originOffset.tx + pan.tx, originOffset.ty + pan.ty, m);\n return { x: pivotX + fromPivot.tx, y: pivotY + fromPivot.ty };\n}\n\n/** One axis of `clampEdgeToContainer` below — see `clampPan`'s own doc comment for the geometry. Keeps the scaled content's edge from retreating inside the container's edge (no empty gap beyond the image); when the content is already smaller than the container on this axis, no position can avoid a gap on *both* sides at once, so centered is the only sensible answer. */\nfunction clampEdge(\n contentPos: number,\n contentSize: number,\n containerPos: number,\n containerSize: number,\n): number {\n if (contentSize <= containerSize) {\n return containerPos + (containerSize - contentSize) / 2;\n }\n const minPos = containerPos + containerSize - contentSize;\n const maxPos = containerPos;\n return Math.min(maxPos, Math.max(minPos, contentPos));\n}\n\n/**\n * The content's own current screen-space bounding box, for `clampPan`\n * below — the four corners of the img's true (unrotated) `width x height`\n * rect (from `originOffset`, negated), scaled and panned in the wrapper's\n * own unrotated frame, then each mapped to screen space individually via\n * `m` before taking the min/max. Four corners, not one edge scaled\n * directly, because `m` can swap which axis grows with which — RotateFlip\n * only ever produces axis-aligned (90°-multiple) rotations, so the result\n * is still a plain axis-aligned box, just not derivable from a single\n * corner the way an unrotated one is.\n */\nfunction contentScreenBounds(\n natural: ZoomBox,\n pan: PanOffset,\n scale: number,\n m: LinearTransform,\n originOffset: PanOffset,\n): { left: number; top: number; right: number; bottom: number } {\n const trueWidth = -originOffset.tx * 2;\n const trueHeight = -originOffset.ty * 2;\n const pivotX = natural.left + natural.width / 2;\n const pivotY = natural.top + natural.height / 2;\n const localCorners = [\n { x: 0, y: 0 },\n { x: trueWidth, y: 0 },\n { x: 0, y: trueHeight },\n { x: trueWidth, y: trueHeight },\n ];\n const screenCorners = localCorners.map(({ x, y }) => {\n const wrapperFrame = {\n x: originOffset.tx + pan.tx + x * scale,\n y: originOffset.ty + pan.ty + y * scale,\n };\n const screen = localDeltaToScreen(wrapperFrame.x, wrapperFrame.y, m);\n return { x: pivotX + screen.tx, y: pivotY + screen.ty };\n });\n const xs = screenCorners.map((p) => p.x);\n const ys = screenCorners.map((p) => p.y);\n return {\n left: Math.min(...xs),\n top: Math.min(...ys),\n right: Math.max(...xs),\n bottom: Math.max(...ys),\n };\n}\n\n/**\n * Keeps the scaled+panned content's edges from retreating inside\n * `container`'s edges (no empty gap beyond the image), on both screen axes\n * independently.\n *\n * `m`/`originOffset` (default identity / derived from `natural`, DESIGN.md\n * §4.6) — same real bug and same root cause as `localOriginScreen`'s own\n * doc comment: the old per-axis formula compared `natural.left + pan.tx`\n * (mixing screen-space and, once rotated, the wrong corner entirely)\n * against the container's screen bounds. The content's actual screen\n * bounding box is computed properly via `contentScreenBounds` (which\n * itself only ever needs `m`/`originOffset` to do that correctly), each\n * edge is clamped against the container the same way as before, and the\n * *difference* — a screen-space delta — is mapped back to local space and\n * folded into `pan`. At the defaults, every step is a no-op and this\n * reduces to exactly the original per-axis formula.\n */\nexport function clampPan(\n natural: ZoomBox,\n container: ZoomBox,\n scale: number,\n pan: PanOffset,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): PanOffset {\n const bounds = contentScreenBounds(natural, pan, scale, m, originOffset);\n const clampedLeft = clampEdge(\n bounds.left,\n bounds.right - bounds.left,\n container.left,\n container.width,\n );\n const clampedTop = clampEdge(\n bounds.top,\n bounds.bottom - bounds.top,\n container.top,\n container.height,\n );\n const screenDelta = { tx: clampedLeft - bounds.left, ty: clampedTop - bounds.top };\n const localDelta = screenDeltaToLocal(screenDelta.tx, screenDelta.ty, m);\n return { tx: pan.tx + localDelta.tx, ty: pan.ty + localDelta.ty };\n}\n\n/**\n * The standard \"zoom toward a point\" formula: adjusts pan so the viewport\n * point `(anchorX, anchorY)` stays visually fixed as scale changes from\n * `oldScale` to `newScale`, given `transform-origin: 0 0` (scaling never\n * moves the img's own local origin on screen, only `pan` does). Callers\n * still need to clamp the result with `clampPan` afterward; this only\n * solves the anchor-fixed part, not boundary containment.\n *\n * `m`/`originOffset` (default identity / derived from `natural`, DESIGN.md\n * §4.6) — a real bug, reported from real usage: double-clicking to zoom\n * toward the pointer, after RotateFlip had rotated the slide, zoomed\n * toward the wrong point — sometimes badly (see `localOriginScreen`'s own\n * doc comment for the root cause and how these two parameters fix it,\n * shared with `clampPan` above). At the defaults, every step is a no-op\n * and this reduces to exactly the original formula.\n */\nexport function zoomTowardPoint(\n natural: ZoomBox,\n pan: PanOffset,\n oldScale: number,\n newScale: number,\n anchorX: number,\n anchorY: number,\n m: LinearTransform = IDENTITY_TRANSFORM,\n originOffset: PanOffset = trueOriginOffset(natural),\n): PanOffset {\n const origin = localOriginScreen(natural, pan, m, originOffset);\n const screenDx = anchorX - origin.x;\n const screenDy = anchorY - origin.y;\n const local = screenDeltaToLocal(screenDx, screenDy, m);\n const factor = 1 - newScale / oldScale;\n return {\n tx: pan.tx + local.tx * factor,\n ty: pan.ty + local.ty * factor,\n };\n}\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { createIconSwap } from '../../core/iconSwap';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport {\n ZOOM_ACTUAL_SIZE_CONTRACT_ICON,\n ZOOM_ACTUAL_SIZE_EXPAND_ICON,\n ZOOM_IN_ICON,\n ZOOM_OUT_ICON,\n} from './icons';\nimport {\n clampPan,\n clampScale,\n parseLinearTransform,\n screenDeltaToLocal,\n zoomTowardPoint,\n type PanOffset,\n type ZoomBox,\n} from './zoomMath';\nimport './zoom.css';\n\nexport interface ZoomOptions {\n /** Multiplier cap for pinch/wheel/button zoom. \"Actual size\" can exceed this deliberately — it's an explicit action, not continuous gesture zoom. Default 4. */\n maxScale?: number;\n /** Scale a double-tap/double-click jumps to; a second one while already zoomed resets to 1 instead. Default 2. */\n doubleTapScale?: number;\n /** Multiplier applied per zoom-in/zoom-out toolbar button click. Default 1.5. */\n buttonStep?: number;\n /**\n * Which wheel/trackpad-scroll input zooms the active photo, on top of\n * pinch, the toolbar buttons, and `w`/`s`. `true` (default): any wheel\n * event zooms, `ctrl` or not. This is the only way to make a trackpad's\n * plain two-finger *drag* zoom — trackpads never expose raw multi-touch\n * to the browser at all, so a two-finger drag/scroll and an ordinary\n * one-finger scroll (or a bare mouse wheel) are all just the same plain\n * `wheel` event, indistinguishable from each other. As an unavoidable\n * side effect, a bare mouse wheel also zooms, with nothing to tell that\n * apart from a trackpad either — inside a full-screen modal with no page\n * behind it to scroll, this reads as a feature (a wheel/trackpad zoom\n * shortcut, on top of everything else), not a conflict with anything.\n * `'ctrl'`: only `ctrl`+wheel — the one case a trackpad's own gesture\n * recognition reports distinctly, since that's how the OS/browser\n * already reports a genuine two-finger *pinch* specifically (unlike a\n * plain drag). `false`: wheel/trackpad input never zooms at all.\n */\n mouseWheelZoom?: boolean | 'ctrl';\n}\n\nconst ZOOM_EPSILON = 1.001; // treat \"just barely above 1\" as unzoomed — avoids float residue pinning isZoomed() true forever\n\n/** A click/drag starting on a real control shouldn't engage pan — same exclusion list GestureController's shouldIgnoreGesture uses, duplicated rather than imported since that function isn't part of core's exported surface. */\nfunction isRealControl(event: PointerEvent): boolean {\n return event\n .composedPath()\n .some(\n (node) =>\n node instanceof Element &&\n node.matches(\n 'button, video, input, select, textarea, a[href], [data-shoji-no-drag], .shoji-caption',\n ),\n );\n}\n\n/**\n * DESIGN.md §4-zoom — pinch, double-tap/click, wheel+ctrl, and three toolbar\n * buttons (zoom in/out/actual-size) all drive a single `scale`/pan state on\n * the active slide's `<img>` — never `.shoji-slide-media` itself, which the\n * rotateFlip plugin (§4) already transforms; nesting on the inner element\n * instead of fighting over the same transform string means a rotated *and*\n * zoomed photo behaves correctly for free (the outer rotate carries the\n * inner pan/scale along with it as a rigid unit, which is also the visually\n * expected result — see DESIGN.md's note on this plugin for the full\n * reasoning). Pinch/double-tap/wheel are core's own gesture relay (§2.4) —\n * scaffolding that existed specifically for this plugin to consume, not\n * reimplemented here. Pan (single-pointer drag while zoomed) is the one\n * piece core's relay doesn't cover — core's own drag-to-navigate/\n * drag-to-close would otherwise fight over the same drag — so this plugin\n * registers a zoom gate (`Gallery.registerZoomGate`, §4-zoom) that suspends\n * core's drag handling entirely while zoomed, and tracks pan with its own\n * minimal raw pointer listeners instead of reusing `GestureEngine` (whose\n * axis-locked model — pick horizontal *or* vertical per gesture — is the\n * wrong shape for a 2D pan that needs both at once).\n */\nexport const Zoom: ShojiPlugin = {\n name: 'zoom',\n defaults: {\n maxScale: 4,\n doubleTapScale: 2,\n buttonStep: 1.5,\n mouseWheelZoom: true,\n } satisfies ZoomOptions,\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const maxScale = Number(ctx.options.maxScale ?? 4);\n const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);\n const buttonStep = Number(ctx.options.buttonStep ?? 1.5);\n const mouseWheelZoom = (ctx.options.mouseWheelZoom as boolean | 'ctrl' | undefined) ?? true;\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const zoomInLabel = locale.zoomIn ?? 'Zoom in';\n const zoomOutLabel = locale.zoomOut ?? 'Zoom out';\n const actualSizeLabel = locale.zoomActualSize ?? 'Actual size';\n\n let scale = 1;\n let pan: PanOffset = { tx: 0, ty: 0 };\n let natural: ZoomBox | null = null;\n let container: ZoomBox | null = null;\n // The img's own true, unrotated local origin, as an offset from its\n // (rotation-invariant) layout center — see zoomTowardPoint/clampPan's\n // own doc comments (DESIGN.md §4.6) for why natural's left/top/width/\n // height alone aren't a safe stand-in for this once RotateFlip has\n // rotated the parent.\n let originOffset: PanOffset | null = null;\n let pinchStartScale = 1;\n\n function getImg(): HTMLImageElement | null {\n const media = gallery.getActiveMedia();\n const child = media?.firstElementChild;\n return child instanceof HTMLImageElement ? child : null;\n }\n\n function boxOf(el: Element): ZoomBox {\n const rect = el.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };\n }\n\n /** Only valid to measure while scale===1 (untransformed) — the very first zoom action on a slide; every subsequent action within the same slide reuses the cached box, since measuring an already-scaled element would capture the scaled size, not the natural one. */\n function ensureNatural(img: HTMLImageElement): boolean {\n if (natural && container && originOffset) return true;\n if (scale !== 1) return false; // shouldn't happen — defensive\n natural = boxOf(img);\n container = boxOf(img.parentElement ?? img);\n // offsetWidth/Height, not natural's own width/height above — CSS\n // transform (RotateFlip's rotation on the parent) is paint-only and\n // never affects layout size, unlike getBoundingClientRect(), which\n // reports the *rotated* bounding box (DESIGN.md §4.6). Falls back to\n // natural's own (unrotated-assuming) size when offsetWidth/Height\n // read 0 — real layout engines only report 0 for a genuinely\n // unrendered element, but jsdom (tests/unit/) never computes layout\n // at all and always reports 0, so this also keeps every rotation-\n // unaware unit test's mocked getBoundingClientRect() meaningful.\n originOffset = {\n tx: -(img.offsetWidth || natural.width) / 2,\n ty: -(img.offsetHeight || natural.height) / 2,\n };\n return true;\n }\n\n /**\n * A real bug: `ensureNatural`'s first measurement of a slide is only\n * trustworthy once the lightbox's own open FLIP transition (`zoomIn`,\n * `core/zoomTransition.ts`) has actually settled — that transition\n * applies its own transform directly to `.shoji-slide-media`, the exact\n * element `ensureNatural` measures as `container`. A zoom action firing\n * before it settles (any interaction within `--shoji-duration` of\n * opening — a fast click, or a test that only waits for the dialog to\n * become visible) captured a wildly wrong, mid-animation rect,\n * permanently poisoning that slide's zoom math (every later action\n * reuses the same cached, wrong box). `zoomIn` is fire-and-forget by\n * design (nothing to await when opening) and clears this exact inline\n * style once its own transition ends — the one signal available for\n * \"is it still running.\" Runs `action` immediately once settled, which\n * is right away in the overwhelming common case (any real interaction\n * more than ~300ms after open).\n */\n function whenSettled(action: () => void): void {\n const media = gallery.getActiveMedia();\n if (!media || media.style.transition === '') {\n action();\n return;\n }\n waitForTransitionEnd(media, action);\n }\n\n /**\n * `translate3d`/`scale3d`, not the 2D `translate`/`scale` this used to\n * use — a real bug, reported from real usage: evenly-spaced horizontal\n * lines visible across a zoomed photo, at certain zoom levels, on real\n * GPU hardware (not reproducible in headless/software rendering, so\n * this can't be verified here). The regular spacing matches Chromium's\n * own raster-tile boundaries — a known quirk where scaling large\n * content via a 2D `scale()` transform can show seams between GPU\n * tiles. Forcing the fully 3D compositing path instead (functionally\n * identical — `scale3d(s, s, 1)` and `scale(s)` produce the same\n * on-screen result) is the commonly effective fix, since it takes a\n * different rasterization path than the 2D one.\n */\n function apply(): void {\n const img = getImg();\n if (!img) return;\n img.style.transformOrigin = '0 0';\n img.style.transform =\n scale === 1 && pan.tx === 0 && pan.ty === 0\n ? 'none'\n : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;\n img.classList.toggle('shoji-zoomed', scale > 1);\n }\n\n function emitChange(): void {\n ctx.emit('zoomChange', { index: gallery.currentIndex, scale });\n updateActualSizeIcon();\n }\n\n /**\n * The actual-size button's icon reflects live state, requested directly:\n * `arrows-angle-expand` at fit, `arrows-angle-contract` while zoomed in\n * at all — by pinch, wheel, the zoom-in/out buttons, or this button\n * itself, not just specifically at native pixel size. Matches\n * `actualSizeToggle()`'s own real click behavior exactly: it resets to\n * fit for *any* `scale > 1`, regardless of how that zoom was reached,\n * and only attempts to zoom to native size from exactly `scale === 1`\n * (a no-op there for a photo whose native resolution is at or below its\n * fitted size — `clampScale(targetScale, 1, ...)`'s own floor — which\n * this correctly still shows as expand, since scale is still 1 in that\n * case). A real bug in an earlier version of this: tracking a separate\n * \"are we exactly at native size\" cache (naturalWidth / natural.width,\n * refreshed on slideItemLoad) went stale the moment `reset()` cleared\n * it without every call site re-populating it, so only the *first*\n * actual-size press of a session ever updated the icon at all — using\n * `scale` directly instead, already the single live source of truth\n * `zoomChange` itself is built on, has no cache to go stale in the\n * first place.\n */\n function updateActualSizeIcon(): void {\n actualSizeIconSwap.setState(scale > ZOOM_EPSILON);\n }\n\n /** Wraps a transform-setting `run` in a transition, for discrete jumps (buttons, double-tap, actual-size) — never for pinch/pan/wheel, which already track the input 1:1 and would visibly lag behind it under a transition. `afterEnd`, if given, runs once the transition actually completes, not before — `reset()` uses it to clear `transformOrigin` only once it's safe to (see its own comment for why clearing it any earlier is a real bug). The transition itself is always cleared afterward, so it doesn't linger onto the next, possibly-continuous, zoom action. */\n function withTransition(img: HTMLImageElement, run: () => void, afterEnd?: () => void): void {\n img.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n run();\n waitForTransitionEnd(img, () => {\n img.style.transition = '';\n afterEnd?.();\n });\n }\n\n /** Shared by every zoom-in/out entry point (pinch, wheel, buttons, double-tap, actual-size) — anchors on (anchorX, anchorY), clamps scale to [1, ceiling] and pan to the container bounds. `ceiling` defaults to maxScale; actual-size passes its own (possibly larger) target so it isn't capped by the gesture-zoom limit. Deferred via `whenSettled` — see its doc comment — so a zoom action landing right as the lightbox opens doesn't measure mid-animation. `animate` — see `withTransition`. */\n function zoomTo(\n targetScale: number,\n anchorX: number,\n anchorY: number,\n ceiling = maxScale,\n animate = false,\n ): void {\n whenSettled(() => {\n const img = getImg();\n if (!img || !ensureNatural(img)) return;\n const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));\n // DESIGN.md §4.6 — zoomTowardPoint's own doc comment has the full\n // reasoning: the anchor point needs the same screen-vs-local\n // correction as onPointerMove's own pan drag, since RotateFlip may\n // have rotated/flipped `.shoji-slide-media` in the meantime.\n const media = gallery.getActiveMedia();\n const parentTransform = parseLinearTransform(\n media ? getComputedStyle(media).transform : 'none',\n );\n pan = zoomTowardPoint(\n natural!,\n pan,\n scale,\n clampedScale,\n anchorX,\n anchorY,\n parentTransform,\n originOffset!,\n );\n scale = clampedScale;\n // Same correction, same reason — clampAxis compares against the\n // container's screen bounds, so the candidate pan needs to be in\n // screen space too, or a 90/270deg rotation clamps the wrong edge\n // and can undo the anchor-preserving pan just computed above.\n pan = clampPan(natural!, container!, scale, pan, parentTransform, originOffset!);\n if (animate) withTransition(img, apply);\n else apply();\n emitChange();\n });\n }\n\n function reset(animate = false): void {\n // A real gap, found auditing this against its own documented contract\n // (\"emits zoomChange on every scale change, gesture or button-driven\",\n // DESIGN.md §4.6): resetting from an engaged scale back to 1 is a\n // scale change like any other, but every call site here (beforeSlide,\n // afterSlide, beforeClose, afterOpen, and zoomOutStep/toggleZoom\n // reaching neutral) silently skipped emitting it — a host listening\n // for zoomChange to reflect \"is this slide currently zoomed\" would\n // never learn it stopped being true unless something else zoomed in\n // again first. Only when there's an actual change: the overwhelming\n // majority of these calls fire while already at scale 1 (nothing\n // engaged to begin with), and emitting on every no-op reset would be\n // noisy against the \"on every *change*\" contract, not a fix for it.\n const wasEngaged = scale !== 1;\n scale = 1;\n pan = { tx: 0, ty: 0 };\n natural = null;\n container = null;\n originOffset = null;\n updateActualSizeIcon(); // scale is already 1 here, so this is always the expand state\n if (wasEngaged) emitChange(); // after scale is already 1, so listeners see the real new value\n const img = getImg();\n if (!img) return;\n const clearTransform = (): void => {\n img.style.transform = '';\n img.classList.remove('shoji-zoomed');\n };\n if (animate) {\n // transform-origin has to stay put (0 0) for the duration of the\n // transition — clearing it to the browser default (center) in the\n // same tick as starting the transition snaps the scale anchor\n // instantly, which visibly jumped the image before it eased down\n // to neutral. Deferred to `afterEnd`, once the transition is done\n // and transform-origin no longer affects anything visible.\n withTransition(img, clearTransform, () => {\n img.style.transformOrigin = '';\n });\n } else {\n clearTransform();\n img.style.transformOrigin = '';\n }\n }\n\n /** DESIGN.md §2.5/§4.6 — same fix, same reasoning, as RotateFlip's own equivalent (`rotateFlip/index.ts`): `beforeSlide`'s unanimated `reset()` above can't itself animate (it has to finish before `SlideManager.render()` reparents the outgoing image), so the live `transform`/`transformOrigin` about to be wiped are captured here first and handed to `SlideTransition` via `registerSlideLeaveDecorator()` below, to animate away on the leave-ghost's own clone instead of just vanishing. */\n let pendingLeaveTransform: string | null = null;\n let pendingLeaveOrigin = '';\n function captureLeaveTransform(): void {\n const img = getImg();\n const transform = img?.style.transform;\n pendingLeaveTransform = transform && transform !== 'none' ? transform : null;\n pendingLeaveOrigin = img?.style.transformOrigin || '0 0';\n }\n\n /** Each slide gets a freshly-created `<img>` (SlideManager never reuses elements across renders), so the cursor-affordance marker (`zoom.css`) needs reapplying every time the active media changes, not just once. */\n function markEnabled(): void {\n getImg()?.classList.add('shoji-zoom-enabled');\n }\n\n function toggleZoom(x: number, y: number): void {\n if (scale > ZOOM_EPSILON) reset(true);\n else zoomTo(doubleTapScale, x, y, maxScale, true);\n }\n\n // --- pinch (relayed by core, §2.4 — no built-in effect until this plugin exists) ---\n const offPinchStart = ctx.on('pinchStart', () => {\n pinchStartScale = scale;\n });\n const offPinchMove = ctx.on('pinchMove', ({ scale: relative, centerX, centerY }) => {\n zoomTo(pinchStartScale * relative, centerX, centerY);\n });\n const offPinchEnd = ctx.on('pinchEnd', () => {\n if (scale <= ZOOM_EPSILON) reset(); // snap fully back to neutral rather than leaving float residue\n });\n\n // --- double-tap / double-click (relayed by core; Pointer Events unify the two, see GestureEngine) ---\n const offDoubleTap = ctx.on('doubleTap', ({ x, y }) => toggleZoom(x, y));\n\n /**\n * A generic command surface, requested directly (DESIGN.md §4.6), so a\n * *custom* (host-authored) plugin's own button can drive zoom without\n * importing this plugin at all — same \"events over inheritance\"\n * decoupling `pinchStart`/`doubleTap`/`wheelZoom` above already use,\n * just in the opposite direction (a command in, not a gesture relay).\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestZoomIn', {})` from any plugin —\n * official or custom — type-checks with zero core changes; this is\n * just the listening half. Each mirrors its real toolbar button\n * exactly — same functions, same behavior on a video slide (a no-op,\n * `zoomInStep`/`zoomOutStep`/`actualSizeToggle` all bail via `getImg()`\n * returning null there).\n */\n const offRequestZoomIn = ctx.on('requestZoomIn', zoomInStep);\n const offRequestZoomOut = ctx.on('requestZoomOut', zoomOutStep);\n const offRequestZoomActualSize = ctx.on('requestZoomActualSize', actualSizeToggle);\n const offRequestZoomReset = ctx.on('requestZoomReset', () => reset(true));\n\n // --- pan while zoomed — the one gesture core's relay doesn't cover; see the plugin doc comment for why this can't reuse GestureEngine. ---\n let panPointerId: number | null = null;\n let lastX = 0;\n let lastY = 0;\n const outer = ctx.ui.outer();\n\n /**\n * `mouseWheelZoom`'s own doc comment above — a raw listener here rather\n * than the core `wheelZoom` bus event (still relayed for anyone else,\n * e.g. a custom plugin — GestureEngine.ts is untouched): that event is\n * only ever emitted for `ctrl`+wheel, `GestureEngine`'s own hard-coded\n * gate, no way to opt into the default (`true`, any wheel event)\n * through it. `'ctrl'` reproduces the *original*, pre-this-option\n * behavior (only `ctrl`+wheel); `false` turns wheel/trackpad zoom off\n * entirely. Multiplicative-of-current-`scale`, not additive, and a much\n * smaller coefficient than `GestureEngine`'s own ctrl+wheel path (0.01,\n * additive) — matching Kiri's own proven-comfortable feel exactly\n * (0.0015, `zoom * (1 + delta)`) rather than reusing GestureEngine's,\n * which was tuned for a single discrete pinch gesture, not the stream of\n * many small ticks a trackpad's two-finger drag/scroll actually sends;\n * at GestureEngine's coefficient that stream felt aggressive.\n */\n function onWheel(event: WheelEvent): void {\n if (mouseWheelZoom === false) return;\n if (mouseWheelZoom === 'ctrl' && !event.ctrlKey) return;\n event.preventDefault();\n const deltaScale = -event.deltaY * 0.0015;\n zoomTo(scale * (1 + deltaScale), event.clientX, event.clientY);\n }\n outer.addEventListener('wheel', onWheel, { passive: false });\n\n function onPointerDown(event: PointerEvent): void {\n if (scale <= ZOOM_EPSILON || isRealControl(event)) return;\n const img = getImg();\n // A real bug, reported from real usage: this listens on `outer` (the\n // whole lightbox, not just the image) so a fast pan can be tracked\n // even once the pointer leaves the image's own bounds — but with no\n // check on where the pointerdown itself landed, a click on the plain\n // backdrop (between the image and a nav arrow, say) engaged pan and\n // captured the pointer onto `img` regardless, which — see below —\n // retargets the click and makes it misread as \"on the image,\" not\n // backdrop, silently defeating click-to-close while zoomed.\n if (!img || !event.composedPath().includes(img)) return;\n panPointerId = event.pointerId;\n lastX = event.clientX;\n lastY = event.clientY;\n // Without this, a fast pan whose pointer exits `outer`'s bounds\n // stops receiving pointermove/pointerup entirely (no capture = only\n // elements actually under the cursor get events), leaving\n // panPointerId stuck non-null until the next pointerdown — the\n // gesture just goes dead mid-drag. Captured on the `<img>` itself,\n // not `outer`: capturing retargets the subsequent synthetic `click`\n // to whatever captured it, and `img` — unlike `outer` — already\n // matches isBackdropClick's own exclusion selector (Gallery.ts), so\n // a captured pan's release still can't misread as a backdrop click.\n // GestureEngine's own capture needs a separate suppressRetargetedClick\n // step for exactly this reason; this doesn't, since the retarget\n // lands somewhere already excluded.\n img.setPointerCapture(event.pointerId);\n }\n function onPointerMove(event: PointerEvent): void {\n if (panPointerId !== event.pointerId || !natural || !container || !originOffset) return;\n event.preventDefault();\n const rawDx = event.clientX - lastX;\n const rawDy = event.clientY - lastY;\n lastX = event.clientX;\n lastY = event.clientY;\n // DESIGN.md §4.6 — screenDeltaToLocal's own doc comment has the full\n // reasoning: the raw pointer delta is screen space, but pan.tx/ty are\n // local to the <img>, nested inside whatever transform (e.g.\n // RotateFlip's rotation) `.shoji-slide-media` currently has.\n const media = gallery.getActiveMedia();\n const m = parseLinearTransform(media ? getComputedStyle(media).transform : 'none');\n const { tx: dx, ty: dy } = screenDeltaToLocal(rawDx, rawDy, m);\n pan = clampPan(\n natural,\n container,\n scale,\n { tx: pan.tx + dx, ty: pan.ty + dy },\n m,\n originOffset,\n );\n apply();\n }\n function onPointerUp(event: PointerEvent): void {\n if (panPointerId === event.pointerId) panPointerId = null;\n }\n\n outer.addEventListener('pointerdown', onPointerDown);\n outer.addEventListener('pointermove', onPointerMove, { passive: false });\n outer.addEventListener('pointerup', onPointerUp);\n outer.addEventListener('pointercancel', onPointerUp);\n\n // --- toolbar buttons ---\n function buildButton(icon: string, label: string): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = icon;\n button.setAttribute('aria-label', label);\n button.title = label;\n return button;\n }\n\n function centerAnchor(): { x: number; y: number } {\n const media = gallery.getActiveMedia();\n const rect = media?.getBoundingClientRect();\n return rect\n ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }\n : { x: 0, y: 0 };\n }\n\n /** Shared by the zoom-in toolbar button and the `w` keyboard shortcut — same fixed `buttonStep` multiplier either way. */\n function zoomInStep(): void {\n const { x, y } = centerAnchor();\n zoomTo(scale * buttonStep, x, y, maxScale, true);\n }\n\n /** Shared by the zoom-out toolbar button and the `s` keyboard shortcut. */\n function zoomOutStep(): void {\n const { x, y } = centerAnchor();\n if (scale / buttonStep <= ZOOM_EPSILON) reset(true);\n else zoomTo(scale / buttonStep, x, y, maxScale, true);\n }\n\n /** Shared by the actual-size toolbar button and `requestZoomActualSize` below. Reads natural.width directly (unlike every other entry point, which just hands zoomTo a target and lets it call ensureNatural itself) — needs its own whenSettled wrap for that reason, not just zoomTo's. */\n function actualSizeToggle(): void {\n whenSettled(() => {\n const img = getImg();\n if (!img || !img.naturalWidth) return;\n if (!ensureNatural(img)) return;\n if (scale > ZOOM_EPSILON) {\n reset(true);\n return;\n }\n const targetScale = img.naturalWidth / natural!.width;\n const { x, y } = centerAnchor();\n zoomTo(targetScale, x, y, targetScale, true); // ceiling = targetScale — bypasses maxScale deliberately\n });\n }\n\n const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);\n const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);\n\n const actualSizeBtn = document.createElement('button');\n actualSizeBtn.type = 'button';\n actualSizeBtn.className = 'shoji-toolbar-button';\n const actualSizeIconSwap = createIconSwap(\n ZOOM_ACTUAL_SIZE_EXPAND_ICON,\n ZOOM_ACTUAL_SIZE_CONTRACT_ICON,\n );\n actualSizeBtn.appendChild(actualSizeIconSwap.el);\n actualSizeBtn.setAttribute('aria-label', actualSizeLabel);\n actualSizeBtn.title = actualSizeLabel;\n\n zoomInBtn.addEventListener('click', zoomInStep);\n zoomOutBtn.addEventListener('click', zoomOutStep);\n actualSizeBtn.addEventListener('click', actualSizeToggle);\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // zoomIn, zoomOut, actualSize, then whatever later plugin (or the close\n // button) follows (DESIGN.md §3.1).\n const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n /** All three zoom buttons are no-ops on a video slide — `getImg()` returns null, so `apply()`/`ensureNatural()` bail out immediately. Hidden rather than left clickable-but-dead. */\n function updateButtonVisibility(): void {\n const isVideo = !!gallery.items[gallery.currentIndex]?.video;\n zoomInBtn.hidden = isVideo;\n zoomOutBtn.hidden = isVideo;\n actualSizeBtn.hidden = isVideo;\n }\n\n // w/s zoom in/out, same step as the toolbar buttons — both cases\n // registered explicitly (registerShortcut matches event.key verbatim,\n // no case-insensitive matching of its own) so Shift/CapsLock still work.\n const removeShortcuts = [\n ctx.ui.registerShortcut('w', zoomInStep),\n ctx.ui.registerShortcut('W', zoomInStep),\n ctx.ui.registerShortcut('s', zoomOutStep),\n ctx.ui.registerShortcut('S', zoomOutStep),\n ];\n\n const offOpen = ctx.on('afterOpen', () => {\n reset();\n markEnabled();\n updateButtonVisibility();\n });\n // Un-animated, and on beforeSlide rather than only afterSlide below:\n // SlideManager.render() (called synchronously between the two) reuses a\n // still-cached slide's node via a plain reparent (moveIn(), no state\n // clearing of its own) into whichever pool slot its new offset needs —\n // there is no code path afterward that can still find *this* image to\n // reset it. A real bug, reported from real usage: zoom in via \"Actual\n // size\", click next — the old, still-scaled image, now reparented into\n // the (unclipped, per shoji.css) neighboring slot, visibly bled into the\n // new slide instead of being invisible off-screen like an unzoomed one\n // always is. Resetting here, while getActiveMedia() still resolves to\n // the about-to-move image, clears it before that reparent ever happens.\n // captureLeaveTransform() (see registerSlideLeaveDecorator below) reads\n // the live transform first, while it's still there to read.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n captureLeaveTransform();\n reset();\n });\n const offSlide = ctx.on('afterSlide', () => {\n reset();\n markEnabled();\n updateButtonVisibility();\n });\n // Fires synchronously, before Gallery.close() measures the active\n // media's rect to compute the zoom-out-to-thumbnail animation — reset\n // here (not just afterOpen/afterSlide) so that measurement sees the\n // image at its natural position/scale, not wherever it was left\n // zoomed/panned to. Skipping this made closing while zoomed animate\n // from the image's current (zoomed, often partly off-screen) rect\n // instead of its real thumbnail-relative size, landing \"closed\" at a\n // seemingly random spot instead of visibly shrinking into the thumbnail.\n const offBeforeClose = ctx.on('beforeClose', () => reset());\n const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);\n // Read by Gallery.beginClose() *before* the beforeClose reset above\n // runs, so a button-close continues the zoom-out from wherever the\n // viewer was actually zoomed/panned to, instead of the reset above\n // making it (correctly, for the measurement) but also making the\n // close itself snap back to neutral first. The image's own real\n // rendered rect, not this plugin's raw scale/pan numbers — see\n // zoomTransition.ts's ZoomTransitionTarget.zoomStart for why a direct\n // scale/pan replay doesn't work once it lands on a different element.\n const unregisterZoomStart = gallery.registerZoomStartProvider(() =>\n scale > ZOOM_EPSILON ? (getImg()?.getBoundingClientRect() ?? null) : null,\n );\n const unregisterLeaveDecorator = gallery.registerSlideLeaveDecorator((clonedMedia) => {\n if (!pendingLeaveTransform) return;\n const transform = pendingLeaveTransform;\n const origin = pendingLeaveOrigin;\n pendingLeaveTransform = null;\n const clonedImg = clonedMedia.querySelector<HTMLImageElement>('img');\n if (!clonedImg) return;\n clonedImg.style.transformOrigin = origin;\n clonedImg.style.transform = transform;\n return () => {\n clonedImg.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n clonedImg.style.transform = 'none';\n };\n });\n markEnabled(); // covers the (unusual but possible) case of the gallery already being open when this plugin initializes\n updateButtonVisibility();\n\n return () => {\n for (const remove of removeButtons) remove();\n for (const remove of removeShortcuts) remove();\n offOpen();\n offBeforeSlide();\n offSlide();\n offBeforeClose();\n offPinchStart();\n offPinchMove();\n offPinchEnd();\n offDoubleTap();\n outer.removeEventListener('wheel', onWheel);\n offRequestZoomIn();\n offRequestZoomOut();\n offRequestZoomActualSize();\n offRequestZoomReset();\n outer.removeEventListener('pointerdown', onPointerDown);\n outer.removeEventListener('pointermove', onPointerMove);\n outer.removeEventListener('pointerup', onPointerUp);\n outer.removeEventListener('pointercancel', onPointerUp);\n unregisterGate();\n unregisterZoomStart();\n unregisterLeaveDecorator();\n reset();\n getImg()?.classList.remove('shoji-zoom-enabled');\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
package/dist/plugins/zoom.js
CHANGED
|
@@ -152,13 +152,15 @@
|
|
|
152
152
|
defaults: {
|
|
153
153
|
maxScale: 4,
|
|
154
154
|
doubleTapScale: 2,
|
|
155
|
-
buttonStep: 1.5
|
|
155
|
+
buttonStep: 1.5,
|
|
156
|
+
mouseWheelZoom: true
|
|
156
157
|
},
|
|
157
158
|
init(ctx) {
|
|
158
159
|
const { gallery } = ctx;
|
|
159
160
|
const maxScale = Number(ctx.options.maxScale ?? 4);
|
|
160
161
|
const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);
|
|
161
162
|
const buttonStep = Number(ctx.options.buttonStep ?? 1.5);
|
|
163
|
+
const mouseWheelZoom = ctx.options.mouseWheelZoom ?? true;
|
|
162
164
|
const locale = gallery.options.locale ?? {};
|
|
163
165
|
const zoomInLabel = locale.zoomIn ?? "Zoom in";
|
|
164
166
|
const zoomOutLabel = locale.zoomOut ?? "Zoom out";
|
|
@@ -295,9 +297,6 @@
|
|
|
295
297
|
if (scale <= ZOOM_EPSILON) reset();
|
|
296
298
|
});
|
|
297
299
|
const offDoubleTap = ctx.on("doubleTap", ({ x, y }) => toggleZoom(x, y));
|
|
298
|
-
const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
|
|
299
|
-
zoomTo(scale + deltaScale, x, y);
|
|
300
|
-
});
|
|
301
300
|
const offRequestZoomIn = ctx.on("requestZoomIn", zoomInStep);
|
|
302
301
|
const offRequestZoomOut = ctx.on("requestZoomOut", zoomOutStep);
|
|
303
302
|
const offRequestZoomActualSize = ctx.on("requestZoomActualSize", actualSizeToggle);
|
|
@@ -306,6 +305,14 @@
|
|
|
306
305
|
let lastX = 0;
|
|
307
306
|
let lastY = 0;
|
|
308
307
|
const outer = ctx.ui.outer();
|
|
308
|
+
function onWheel(event) {
|
|
309
|
+
if (mouseWheelZoom === false) return;
|
|
310
|
+
if (mouseWheelZoom === "ctrl" && !event.ctrlKey) return;
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
const deltaScale = -event.deltaY * 15e-4;
|
|
313
|
+
zoomTo(scale * (1 + deltaScale), event.clientX, event.clientY);
|
|
314
|
+
}
|
|
315
|
+
outer.addEventListener("wheel", onWheel, { passive: false });
|
|
309
316
|
function onPointerDown(event) {
|
|
310
317
|
if (scale <= ZOOM_EPSILON || isRealControl(event)) return;
|
|
311
318
|
const img = getImg();
|
|
@@ -460,7 +467,7 @@
|
|
|
460
467
|
offPinchMove();
|
|
461
468
|
offPinchEnd();
|
|
462
469
|
offDoubleTap();
|
|
463
|
-
|
|
470
|
+
outer.removeEventListener("wheel", onWheel);
|
|
464
471
|
offRequestZoomIn();
|
|
465
472
|
offRequestZoomOut();
|
|
466
473
|
offRequestZoomActualSize();
|