@michaelyagi/shoji 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/esm/core/EventBus.d.ts +16 -0
- package/dist/esm/core/FocusTrap.d.ts +9 -0
- package/dist/esm/core/Gallery.d.ts +221 -0
- package/dist/esm/core/GestureController.d.ts +57 -0
- package/dist/esm/core/LiveRegion.d.ts +6 -0
- package/dist/esm/core/SlideManager.d.ts +85 -0
- package/dist/esm/core/bodyScrollLock.d.ts +2 -0
- package/dist/esm/core/dom.d.ts +27 -0
- package/dist/esm/core/icons.d.ts +6 -0
- package/dist/esm/core/index.d.ts +5 -0
- package/dist/esm/core/index.js +2105 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/plugin.d.ts +54 -0
- package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
- package/dist/esm/core/scan.d.ts +19 -0
- package/dist/esm/core/types.d.ts +290 -0
- package/dist/esm/core/zoomTransition.d.ts +33 -0
- package/dist/esm/gestures/GestureEngine.d.ts +78 -0
- package/dist/esm/index.css +540 -0
- package/dist/esm/index.d.ts +32 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index2.css +22 -0
- package/dist/esm/index3.css +170 -0
- package/dist/esm/index4.css +27 -0
- package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
- package/dist/esm/plugins/activeThumbnail/index.js +63 -0
- package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
- package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
- package/dist/esm/plugins/autoplay/index.d.ts +19 -0
- package/dist/esm/plugins/autoplay/index.js +207 -0
- package/dist/esm/plugins/autoplay/index.js.map +1 -0
- package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
- package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
- package/dist/esm/plugins/fullscreen/index.js +74 -0
- package/dist/esm/plugins/fullscreen/index.js.map +1 -0
- package/dist/esm/plugins/layout/index.d.ts +191 -0
- package/dist/esm/plugins/layout/index.js +752 -0
- package/dist/esm/plugins/layout/index.js.map +1 -0
- package/dist/esm/plugins/layout/justified.d.ts +68 -0
- package/dist/esm/plugins/layout/masonry.d.ts +92 -0
- package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
- package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
- package/dist/esm/plugins/rotateFlip/index.js +138 -0
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
- package/dist/esm/plugins/video/index.d.ts +13 -0
- package/dist/esm/plugins/video/index.js +207 -0
- package/dist/esm/plugins/video/index.js.map +1 -0
- package/dist/esm/plugins/video/vimeo.d.ts +43 -0
- package/dist/esm/plugins/video/youtube.d.ts +61 -0
- package/dist/esm/plugins/zoom/icons.d.ts +4 -0
- package/dist/esm/plugins/zoom/index.d.ts +30 -0
- package/dist/esm/plugins/zoom/index.js +274 -0
- package/dist/esm/plugins/zoom/index.js.map +1 -0
- package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
- package/dist/esm/transitions/SlideTransition.d.ts +25 -0
- package/dist/esm/transitions/presets.d.ts +21 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
- package/dist/shoji.css +759 -0
- package/dist/shoji.js +3907 -0
- package/dist/shoji.js.map +1 -0
- package/dist/shoji.min.css +1 -0
- package/dist/shoji.min.js +2 -0
- package/dist/shoji.min.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { w as waitForTransitionEnd } from "../../zoomTransition-bbKHpVpA.js";
|
|
2
|
+
const ZOOM_IN_ICON = '<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>';
|
|
3
|
+
const ZOOM_OUT_ICON = '<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>';
|
|
4
|
+
const ZOOM_ACTUAL_SIZE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1"/><path d="M9 15l6-6M9 9h0M15 15h0"/></svg>';
|
|
5
|
+
function clampScale(scale, min, max) {
|
|
6
|
+
return Math.min(max, Math.max(min, scale));
|
|
7
|
+
}
|
|
8
|
+
function clampAxis(naturalPos, naturalSize, scale, containerPos, containerSize, offset) {
|
|
9
|
+
const scaledSize = naturalSize * scale;
|
|
10
|
+
if (scaledSize <= containerSize) {
|
|
11
|
+
return containerPos + (containerSize - scaledSize) / 2 - naturalPos;
|
|
12
|
+
}
|
|
13
|
+
const minPos = containerPos + containerSize - scaledSize;
|
|
14
|
+
const maxPos = containerPos;
|
|
15
|
+
const proposedPos = naturalPos + offset;
|
|
16
|
+
return Math.min(maxPos, Math.max(minPos, proposedPos)) - naturalPos;
|
|
17
|
+
}
|
|
18
|
+
function clampPan(natural, container, scale, pan) {
|
|
19
|
+
return {
|
|
20
|
+
tx: clampAxis(natural.left, natural.width, scale, container.left, container.width, pan.tx),
|
|
21
|
+
ty: clampAxis(natural.top, natural.height, scale, container.top, container.height, pan.ty)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function zoomTowardPoint(natural, pan, oldScale, newScale, anchorX, anchorY) {
|
|
25
|
+
const currentLeft = natural.left + pan.tx;
|
|
26
|
+
const currentTop = natural.top + pan.ty;
|
|
27
|
+
const dx = anchorX - currentLeft;
|
|
28
|
+
const dy = anchorY - currentTop;
|
|
29
|
+
const factor = 1 - newScale / oldScale;
|
|
30
|
+
return {
|
|
31
|
+
tx: pan.tx + dx * factor,
|
|
32
|
+
ty: pan.ty + dy * factor
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const ZOOM_EPSILON = 1.001;
|
|
36
|
+
function isRealControl(event) {
|
|
37
|
+
return event.composedPath().some(
|
|
38
|
+
(node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const Zoom = {
|
|
42
|
+
name: "zoom",
|
|
43
|
+
defaults: {
|
|
44
|
+
maxScale: 4,
|
|
45
|
+
doubleTapScale: 2,
|
|
46
|
+
buttonStep: 1.5
|
|
47
|
+
},
|
|
48
|
+
init(ctx) {
|
|
49
|
+
const { gallery } = ctx;
|
|
50
|
+
const maxScale = Number(ctx.options.maxScale ?? 4);
|
|
51
|
+
const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);
|
|
52
|
+
const buttonStep = Number(ctx.options.buttonStep ?? 1.5);
|
|
53
|
+
const locale = gallery.options.locale ?? {};
|
|
54
|
+
const zoomInLabel = locale.zoomIn ?? "Zoom in";
|
|
55
|
+
const zoomOutLabel = locale.zoomOut ?? "Zoom out";
|
|
56
|
+
const actualSizeLabel = locale.zoomActualSize ?? "Actual size";
|
|
57
|
+
let scale = 1;
|
|
58
|
+
let pan = { tx: 0, ty: 0 };
|
|
59
|
+
let natural = null;
|
|
60
|
+
let container = null;
|
|
61
|
+
let pinchStartScale = 1;
|
|
62
|
+
function getImg() {
|
|
63
|
+
const media = gallery.getActiveMedia();
|
|
64
|
+
const child = media == null ? void 0 : media.firstElementChild;
|
|
65
|
+
return child instanceof HTMLImageElement ? child : null;
|
|
66
|
+
}
|
|
67
|
+
function boxOf(el) {
|
|
68
|
+
const rect = el.getBoundingClientRect();
|
|
69
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
70
|
+
}
|
|
71
|
+
function ensureNatural(img) {
|
|
72
|
+
if (natural && container) return true;
|
|
73
|
+
if (scale !== 1) return false;
|
|
74
|
+
natural = boxOf(img);
|
|
75
|
+
container = boxOf(img.parentElement ?? img);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function whenSettled(action) {
|
|
79
|
+
const media = gallery.getActiveMedia();
|
|
80
|
+
if (!media || media.style.transition === "") {
|
|
81
|
+
action();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
waitForTransitionEnd(media, action);
|
|
85
|
+
}
|
|
86
|
+
function apply() {
|
|
87
|
+
const img = getImg();
|
|
88
|
+
if (!img) return;
|
|
89
|
+
img.style.transformOrigin = "0 0";
|
|
90
|
+
img.style.transform = scale === 1 && pan.tx === 0 && pan.ty === 0 ? "none" : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;
|
|
91
|
+
img.classList.toggle("shoji-zoomed", scale > 1);
|
|
92
|
+
}
|
|
93
|
+
function emitChange() {
|
|
94
|
+
ctx.emit("zoomChange", { index: gallery.currentIndex, scale });
|
|
95
|
+
}
|
|
96
|
+
function withTransition(img, run, afterEnd) {
|
|
97
|
+
img.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
|
|
98
|
+
run();
|
|
99
|
+
waitForTransitionEnd(img, () => {
|
|
100
|
+
img.style.transition = "";
|
|
101
|
+
afterEnd == null ? void 0 : afterEnd();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function zoomTo(targetScale, anchorX, anchorY, ceiling = maxScale, animate = false) {
|
|
105
|
+
whenSettled(() => {
|
|
106
|
+
const img = getImg();
|
|
107
|
+
if (!img || !ensureNatural(img)) return;
|
|
108
|
+
const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));
|
|
109
|
+
pan = zoomTowardPoint(natural, pan, scale, clampedScale, anchorX, anchorY);
|
|
110
|
+
scale = clampedScale;
|
|
111
|
+
pan = clampPan(natural, container, scale, pan);
|
|
112
|
+
if (animate) withTransition(img, apply);
|
|
113
|
+
else apply();
|
|
114
|
+
emitChange();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function reset(animate = false) {
|
|
118
|
+
scale = 1;
|
|
119
|
+
pan = { tx: 0, ty: 0 };
|
|
120
|
+
natural = null;
|
|
121
|
+
container = null;
|
|
122
|
+
const img = getImg();
|
|
123
|
+
if (!img) return;
|
|
124
|
+
const clearTransform = () => {
|
|
125
|
+
img.style.transform = "";
|
|
126
|
+
img.classList.remove("shoji-zoomed");
|
|
127
|
+
};
|
|
128
|
+
if (animate) {
|
|
129
|
+
withTransition(img, clearTransform, () => {
|
|
130
|
+
img.style.transformOrigin = "";
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
clearTransform();
|
|
134
|
+
img.style.transformOrigin = "";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function markEnabled() {
|
|
138
|
+
var _a;
|
|
139
|
+
(_a = getImg()) == null ? void 0 : _a.classList.add("shoji-zoom-enabled");
|
|
140
|
+
}
|
|
141
|
+
function toggleZoom(x, y) {
|
|
142
|
+
if (scale > ZOOM_EPSILON) reset(true);
|
|
143
|
+
else zoomTo(doubleTapScale, x, y, maxScale, true);
|
|
144
|
+
}
|
|
145
|
+
const offPinchStart = ctx.on("pinchStart", () => {
|
|
146
|
+
pinchStartScale = scale;
|
|
147
|
+
});
|
|
148
|
+
const offPinchMove = ctx.on("pinchMove", ({ scale: relative, centerX, centerY }) => {
|
|
149
|
+
zoomTo(pinchStartScale * relative, centerX, centerY);
|
|
150
|
+
});
|
|
151
|
+
const offPinchEnd = ctx.on("pinchEnd", () => {
|
|
152
|
+
if (scale <= ZOOM_EPSILON) reset();
|
|
153
|
+
});
|
|
154
|
+
const offDoubleTap = ctx.on("doubleTap", ({ x, y }) => toggleZoom(x, y));
|
|
155
|
+
const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
|
|
156
|
+
zoomTo(scale + deltaScale, x, y);
|
|
157
|
+
});
|
|
158
|
+
let panPointerId = null;
|
|
159
|
+
let lastX = 0;
|
|
160
|
+
let lastY = 0;
|
|
161
|
+
const outer = ctx.ui.outer();
|
|
162
|
+
function onPointerDown(event) {
|
|
163
|
+
if (scale <= ZOOM_EPSILON || isRealControl(event)) return;
|
|
164
|
+
panPointerId = event.pointerId;
|
|
165
|
+
lastX = event.clientX;
|
|
166
|
+
lastY = event.clientY;
|
|
167
|
+
}
|
|
168
|
+
function onPointerMove(event) {
|
|
169
|
+
if (panPointerId !== event.pointerId || !natural || !container) return;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
const dx = event.clientX - lastX;
|
|
172
|
+
const dy = event.clientY - lastY;
|
|
173
|
+
lastX = event.clientX;
|
|
174
|
+
lastY = event.clientY;
|
|
175
|
+
pan = clampPan(natural, container, scale, { tx: pan.tx + dx, ty: pan.ty + dy });
|
|
176
|
+
apply();
|
|
177
|
+
}
|
|
178
|
+
function onPointerUp(event) {
|
|
179
|
+
if (panPointerId === event.pointerId) panPointerId = null;
|
|
180
|
+
}
|
|
181
|
+
outer.addEventListener("pointerdown", onPointerDown);
|
|
182
|
+
outer.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
183
|
+
outer.addEventListener("pointerup", onPointerUp);
|
|
184
|
+
outer.addEventListener("pointercancel", onPointerUp);
|
|
185
|
+
function buildButton(icon, label) {
|
|
186
|
+
const button = document.createElement("button");
|
|
187
|
+
button.type = "button";
|
|
188
|
+
button.className = "shoji-toolbar-button";
|
|
189
|
+
button.innerHTML = icon;
|
|
190
|
+
button.setAttribute("aria-label", label);
|
|
191
|
+
button.title = label;
|
|
192
|
+
return button;
|
|
193
|
+
}
|
|
194
|
+
function centerAnchor() {
|
|
195
|
+
const media = gallery.getActiveMedia();
|
|
196
|
+
const rect = media == null ? void 0 : media.getBoundingClientRect();
|
|
197
|
+
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : { x: 0, y: 0 };
|
|
198
|
+
}
|
|
199
|
+
function zoomInStep() {
|
|
200
|
+
const { x, y } = centerAnchor();
|
|
201
|
+
zoomTo(scale * buttonStep, x, y, maxScale, true);
|
|
202
|
+
}
|
|
203
|
+
function zoomOutStep() {
|
|
204
|
+
const { x, y } = centerAnchor();
|
|
205
|
+
if (scale / buttonStep <= ZOOM_EPSILON) reset(true);
|
|
206
|
+
else zoomTo(scale / buttonStep, x, y, maxScale, true);
|
|
207
|
+
}
|
|
208
|
+
const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);
|
|
209
|
+
const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);
|
|
210
|
+
const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);
|
|
211
|
+
zoomInBtn.addEventListener("click", zoomInStep);
|
|
212
|
+
zoomOutBtn.addEventListener("click", zoomOutStep);
|
|
213
|
+
actualSizeBtn.addEventListener("click", () => {
|
|
214
|
+
whenSettled(() => {
|
|
215
|
+
const img = getImg();
|
|
216
|
+
if (!img || !img.naturalWidth) return;
|
|
217
|
+
if (!ensureNatural(img)) return;
|
|
218
|
+
if (scale > ZOOM_EPSILON) {
|
|
219
|
+
reset(true);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const targetScale = img.naturalWidth / natural.width;
|
|
223
|
+
const { x, y } = centerAnchor();
|
|
224
|
+
zoomTo(targetScale, x, y, targetScale, true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map(
|
|
228
|
+
(button) => ctx.ui.toolbar("right", button)
|
|
229
|
+
);
|
|
230
|
+
const removeShortcuts = [
|
|
231
|
+
ctx.ui.registerShortcut("w", zoomInStep),
|
|
232
|
+
ctx.ui.registerShortcut("W", zoomInStep),
|
|
233
|
+
ctx.ui.registerShortcut("s", zoomOutStep),
|
|
234
|
+
ctx.ui.registerShortcut("S", zoomOutStep)
|
|
235
|
+
];
|
|
236
|
+
const offOpen = ctx.on("afterOpen", () => {
|
|
237
|
+
reset();
|
|
238
|
+
markEnabled();
|
|
239
|
+
});
|
|
240
|
+
const offBeforeSlide = ctx.on("beforeSlide", () => reset());
|
|
241
|
+
const offSlide = ctx.on("afterSlide", () => {
|
|
242
|
+
reset();
|
|
243
|
+
markEnabled();
|
|
244
|
+
});
|
|
245
|
+
const offBeforeClose = ctx.on("beforeClose", () => reset());
|
|
246
|
+
const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);
|
|
247
|
+
markEnabled();
|
|
248
|
+
return () => {
|
|
249
|
+
var _a;
|
|
250
|
+
for (const remove of removeButtons) remove();
|
|
251
|
+
for (const remove of removeShortcuts) remove();
|
|
252
|
+
offOpen();
|
|
253
|
+
offBeforeSlide();
|
|
254
|
+
offSlide();
|
|
255
|
+
offBeforeClose();
|
|
256
|
+
offPinchStart();
|
|
257
|
+
offPinchMove();
|
|
258
|
+
offPinchEnd();
|
|
259
|
+
offDoubleTap();
|
|
260
|
+
offWheelZoom();
|
|
261
|
+
outer.removeEventListener("pointerdown", onPointerDown);
|
|
262
|
+
outer.removeEventListener("pointermove", onPointerMove);
|
|
263
|
+
outer.removeEventListener("pointerup", onPointerUp);
|
|
264
|
+
outer.removeEventListener("pointercancel", onPointerUp);
|
|
265
|
+
unregisterGate();
|
|
266
|
+
reset();
|
|
267
|
+
(_a = getImg()) == null ? void 0 : _a.classList.remove("shoji-zoom-enabled");
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
export {
|
|
272
|
+
Zoom
|
|
273
|
+
};
|
|
274
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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\nexport const ZOOM_ACTUAL_SIZE_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\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"1\"/><path d=\"M9 15l6-6M9 9h0M15 15h0\"/></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/**\n * One axis of `clampPan` below. `natural*` is the element's own unscaled\n * rendered box (captured once at scale 1, before any transform — `apply()`\n * in `index.ts` renders `translate(tx, ty) scale(s)` with `transform-origin:\n * 0 0`, so the natural box's top-left is exactly where the scaled box's own\n * local origin sits on screen, and `tx`/`ty` are the *only* thing moving it\n * from there). If the scaled content is still smaller than the container on\n * this axis, no pan range can avoid a gap on *both* sides at once — centered\n * is the only sensible answer, not an inverted min>max range.\n */\nfunction clampAxis(\n naturalPos: number,\n naturalSize: number,\n scale: number,\n containerPos: number,\n containerSize: number,\n offset: number,\n): number {\n const scaledSize = naturalSize * scale;\n if (scaledSize <= containerSize) {\n return containerPos + (containerSize - scaledSize) / 2 - naturalPos;\n }\n const minPos = containerPos + containerSize - scaledSize;\n const maxPos = containerPos;\n const proposedPos = naturalPos + offset;\n return Math.min(maxPos, Math.max(minPos, proposedPos)) - naturalPos;\n}\n\n/** Keeps the scaled+panned content's edges from retreating inside `container`'s edges (no empty gap beyond the image), on both axes independently. */\nexport function clampPan(\n natural: ZoomBox,\n container: ZoomBox,\n scale: number,\n pan: PanOffset,\n): PanOffset {\n return {\n tx: clampAxis(natural.left, natural.width, scale, container.left, container.width, pan.tx),\n ty: clampAxis(natural.top, natural.height, scale, container.top, container.height, pan.ty),\n };\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` (see `natural`'s\n * doc comment on `clampAxis` above for why that makes this tractable —\n * scaling never moves the natural box's own top-left on screen, only `pan`\n * does). Callers still need to clamp the result with `clampPan` afterward;\n * this only solves the anchor-fixed part, not boundary containment.\n */\nexport function zoomTowardPoint(\n natural: ZoomBox,\n pan: PanOffset,\n oldScale: number,\n newScale: number,\n anchorX: number,\n anchorY: number,\n): PanOffset {\n const currentLeft = natural.left + pan.tx;\n const currentTop = natural.top + pan.ty;\n const dx = anchorX - currentLeft;\n const dy = anchorY - currentTop;\n const factor = 1 - newScale / oldScale;\n return {\n tx: pan.tx + dx * factor,\n ty: pan.ty + dy * factor,\n };\n}\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport { ZOOM_ACTUAL_SIZE_ICON, ZOOM_IN_ICON, ZOOM_OUT_ICON } from './icons';\nimport { clampPan, clampScale, zoomTowardPoint, type PanOffset, type ZoomBox } 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('button, video, input, select, textarea, a[href], [data-shoji-no-drag]'),\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 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) return true;\n if (scale !== 1) return false; // shouldn't happen — defensive\n natural = boxOf(img);\n container = boxOf(img.parentElement ?? img);\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 }\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 pan = zoomTowardPoint(natural!, pan, scale, clampedScale, anchorX, anchorY);\n scale = clampedScale;\n pan = clampPan(natural!, container!, scale, pan);\n if (animate) withTransition(img, apply);\n else apply();\n emitChange();\n });\n }\n\n function reset(animate = false): void {\n scale = 1;\n pan = { tx: 0, ty: 0 };\n natural = null;\n container = null;\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 /** 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 // --- 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 panPointerId = event.pointerId;\n lastX = event.clientX;\n lastY = event.clientY;\n }\n function onPointerMove(event: PointerEvent): void {\n if (panPointerId !== event.pointerId || !natural || !container) return;\n event.preventDefault();\n const dx = event.clientX - lastX;\n const dy = event.clientY - lastY;\n lastX = event.clientX;\n lastY = event.clientY;\n pan = clampPan(natural, container, scale, { tx: pan.tx + dx, ty: pan.ty + dy });\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 const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);\n const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);\n const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);\n\n zoomInBtn.addEventListener('click', zoomInStep);\n zoomOutBtn.addEventListener('click', zoomOutStep);\n actualSizeBtn.addEventListener('click', () => {\n // Reads natural.width directly (unlike every other entry point, which\n // just hands zoomTo a target and lets it call ensureNatural itself) —\n // needs its own whenSettled wrap for that reason, not just zoomTo's.\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 // '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 // 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 });\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 const offBeforeSlide = ctx.on('beforeSlide', () => reset());\n const offSlide = ctx.on('afterSlide', () => {\n reset();\n markEnabled();\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 markEnabled(); // covers the (unusual but possible) case of the gallery already being open when this plugin initializes\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 outer.removeEventListener('pointerdown', onPointerDown);\n outer.removeEventListener('pointermove', onPointerMove);\n outer.removeEventListener('pointerup', onPointerUp);\n outer.removeEventListener('pointercancel', onPointerUp);\n unregisterGate();\n reset();\n getImg()?.classList.remove('shoji-zoom-enabled');\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A viewport-relative rect: `{left, top, width, height}`, same shape as `DOMRect` but plain-object so it's trivial to mock in tests. */
|
|
2
|
+
export interface ZoomBox {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PanOffset {
|
|
9
|
+
tx: number;
|
|
10
|
+
ty: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function clampScale(scale: number, min: number, max: number): number;
|
|
13
|
+
/** Keeps the scaled+panned content's edges from retreating inside `container`'s edges (no empty gap beyond the image), on both axes independently. */
|
|
14
|
+
export declare function clampPan(natural: ZoomBox, container: ZoomBox, scale: number, pan: PanOffset): PanOffset;
|
|
15
|
+
/**
|
|
16
|
+
* The standard "zoom toward a point" formula: adjusts pan so the viewport
|
|
17
|
+
* point `(anchorX, anchorY)` stays visually fixed as scale changes from
|
|
18
|
+
* `oldScale` to `newScale`, given `transform-origin: 0 0` (see `natural`'s
|
|
19
|
+
* doc comment on `clampAxis` above for why that makes this tractable —
|
|
20
|
+
* scaling never moves the natural box's own top-left on screen, only `pan`
|
|
21
|
+
* does). Callers still need to clamp the result with `clampPan` afterward;
|
|
22
|
+
* this only solves the anchor-fixed part, not boundary containment.
|
|
23
|
+
*/
|
|
24
|
+
export declare function zoomTowardPoint(natural: ZoomBox, pan: PanOffset, oldScale: number, newScale: number, anchorX: number, anchorY: number): PanOffset;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SlideManager } from '../core/SlideManager';
|
|
2
|
+
import { TransitionPreset } from './presets';
|
|
3
|
+
/**
|
|
4
|
+
* DESIGN.md §2.5 — animates a slide-to-slide navigation via a temporary
|
|
5
|
+
* "ghost": a clone of the outgoing `.shoji-slide-media`, positioned over
|
|
6
|
+
* the real slot, playing the preset's `leave` keyframe while `swapContent`
|
|
7
|
+
* (the real `SlideManager.render()` reassignment) runs invisibly underneath
|
|
8
|
+
* it. The newly-swapped-in real active media then plays `enter`. Both
|
|
9
|
+
* animate concurrently.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately separate from the gesture engine's own live-drag/settle
|
|
12
|
+
* animation (`GestureController.ts`, §2.4): `mode` is a discrete "jump" for
|
|
13
|
+
* programmatic navigation, not layered onto a continuous drag — a completed
|
|
14
|
+
* swipe always uses its own pool-shift regardless of `mode`. See
|
|
15
|
+
* `Gallery.navigate()`'s `animate` flag.
|
|
16
|
+
*/
|
|
17
|
+
export declare class SlideTransition {
|
|
18
|
+
private readonly slides;
|
|
19
|
+
constructor(slides: SlideManager);
|
|
20
|
+
/** A recognized built-in preset (`TRANSITION_PRESETS`). */
|
|
21
|
+
animate(preset: TransitionPreset, direction: 1 | -1, swapContent: () => void): void;
|
|
22
|
+
/** An unrecognized `mode` string: a host-supplied CSS class pair (`shoji-transition-<mode>-leave`/`-enter`), not an error — §2.5's "custom animation = a CSS class pair, no JS needed." */
|
|
23
|
+
animateCustom(mode: string, direction: 1 | -1, swapContent: () => void): void;
|
|
24
|
+
private run;
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md §2.5 — built-in presets, generated from a small config→transform
|
|
3
|
+
* DSL rather than ~20 hand-written keyframe functions. `enter(direction)` is
|
|
4
|
+
* where the incoming slide starts (then transitions to natural: `transform:
|
|
5
|
+
* none; opacity: 1`); `leave(direction)` is where the outgoing clone (see
|
|
6
|
+
* `SlideTransition.ts`) ends up (starts at natural, transitions to this).
|
|
7
|
+
* `direction` is `1` for next()-style, `-1` for prev()-style — presets that
|
|
8
|
+
* don't care (fade, zoom, flipX, deck, drop...) never read it. transform/
|
|
9
|
+
* opacity only, per CLAUDE.md's hardware-accelerated-CSS3 constraint.
|
|
10
|
+
*/
|
|
11
|
+
export interface TransitionState {
|
|
12
|
+
transform: string;
|
|
13
|
+
opacity: number;
|
|
14
|
+
}
|
|
15
|
+
export interface TransitionPreset {
|
|
16
|
+
name: string;
|
|
17
|
+
enter(direction: 1 | -1): TransitionState;
|
|
18
|
+
leave(direction: 1 | -1): TransitionState;
|
|
19
|
+
}
|
|
20
|
+
/** Keyed by name for `Gallery`'s `mode`/`mobileSettings.mode` lookup — an unrecognized string is treated as a custom CSS-class-pair mode instead (see `SlideTransition.animateCustom`), not an error. */
|
|
21
|
+
export declare const TRANSITION_PRESETS: Readonly<Record<string, TransitionPreset>>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
function prefersReducedMotion() {
|
|
2
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3
|
+
}
|
|
4
|
+
function parseCssTime(value) {
|
|
5
|
+
var _a;
|
|
6
|
+
const first = ((_a = value.split(",")[0]) == null ? void 0 : _a.trim()) ?? "";
|
|
7
|
+
if (first.endsWith("ms")) return parseFloat(first);
|
|
8
|
+
if (first.endsWith("s")) return parseFloat(first) * 1e3;
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
function containedBox(container, aspectRatio) {
|
|
12
|
+
const containerRatio = container.width / container.height;
|
|
13
|
+
const width = aspectRatio > containerRatio ? container.width : container.height * aspectRatio;
|
|
14
|
+
const height = aspectRatio > containerRatio ? container.width / aspectRatio : container.height;
|
|
15
|
+
return {
|
|
16
|
+
left: container.left + (container.width - width) / 2,
|
|
17
|
+
top: container.top + (container.height - height) / 2,
|
|
18
|
+
width,
|
|
19
|
+
height
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function effectiveTargetBox(target, aspectRatio) {
|
|
23
|
+
const child = target.firstElementChild;
|
|
24
|
+
const isPlaceholder = child instanceof HTMLElement && (child.classList.contains("shoji-slide-spinner") || child.classList.contains("shoji-slide-open-placeholder"));
|
|
25
|
+
if (child instanceof HTMLElement && !isPlaceholder) {
|
|
26
|
+
const rect = child.getBoundingClientRect();
|
|
27
|
+
if (rect.width > 0 && rect.height > 0) return rect;
|
|
28
|
+
}
|
|
29
|
+
const containerRect = target.getBoundingClientRect();
|
|
30
|
+
if (aspectRatio && containerRect.width > 0 && containerRect.height > 0) {
|
|
31
|
+
return containedBox(containerRect, aspectRatio);
|
|
32
|
+
}
|
|
33
|
+
return containerRect;
|
|
34
|
+
}
|
|
35
|
+
function computeTransform(origin, target, aspectRatio) {
|
|
36
|
+
const originRect = origin.getBoundingClientRect();
|
|
37
|
+
const targetRect = effectiveTargetBox(target, aspectRatio);
|
|
38
|
+
if (targetRect.width === 0 || targetRect.height === 0 || originRect.width === 0 || originRect.height === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const scale = Math.min(
|
|
42
|
+
originRect.width / targetRect.width,
|
|
43
|
+
originRect.height / targetRect.height
|
|
44
|
+
);
|
|
45
|
+
const translateX = originRect.left + originRect.width / 2 - (targetRect.left + targetRect.width / 2);
|
|
46
|
+
const translateY = originRect.top + originRect.height / 2 - (targetRect.top + targetRect.height / 2);
|
|
47
|
+
return `translate3d(${translateX}px, ${translateY}px, 0) scale3d(${scale}, ${scale}, 1)`;
|
|
48
|
+
}
|
|
49
|
+
function waitForTransitionEnd(target, cb) {
|
|
50
|
+
const durationMs = parseCssTime(getComputedStyle(target).transitionDuration);
|
|
51
|
+
let done = false;
|
|
52
|
+
const finish = () => {
|
|
53
|
+
if (done) return;
|
|
54
|
+
done = true;
|
|
55
|
+
target.removeEventListener("transitionend", onEnd);
|
|
56
|
+
cb();
|
|
57
|
+
};
|
|
58
|
+
const onEnd = (event) => {
|
|
59
|
+
if (event.target === target && event.propertyName === "transform")
|
|
60
|
+
finish();
|
|
61
|
+
};
|
|
62
|
+
target.addEventListener("transitionend", onEnd);
|
|
63
|
+
setTimeout(finish, durationMs + 100);
|
|
64
|
+
}
|
|
65
|
+
function clearInlineTransform(target, expectedTransform) {
|
|
66
|
+
target.style.transition = "";
|
|
67
|
+
target.style.transformOrigin = "";
|
|
68
|
+
target.style.willChange = "";
|
|
69
|
+
if (target.style.transform === expectedTransform) target.style.transform = "";
|
|
70
|
+
}
|
|
71
|
+
function zoomIn({ origin, target, aspectRatio }) {
|
|
72
|
+
if (prefersReducedMotion()) return;
|
|
73
|
+
const transform = computeTransform(origin, target, aspectRatio);
|
|
74
|
+
if (!transform) return;
|
|
75
|
+
target.style.transition = "none";
|
|
76
|
+
target.style.transformOrigin = "center";
|
|
77
|
+
target.style.willChange = "transform";
|
|
78
|
+
target.style.transform = transform;
|
|
79
|
+
void target.offsetHeight;
|
|
80
|
+
target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
|
|
81
|
+
target.style.transform = "none";
|
|
82
|
+
waitForTransitionEnd(target, () => clearInlineTransform(target, "none"));
|
|
83
|
+
}
|
|
84
|
+
function zoomOut({ origin, target, aspectRatio }, onComplete) {
|
|
85
|
+
if (prefersReducedMotion()) {
|
|
86
|
+
onComplete();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const transform = computeTransform(origin, target, aspectRatio);
|
|
90
|
+
if (!transform) {
|
|
91
|
+
onComplete();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
target.style.transformOrigin = "center";
|
|
95
|
+
target.style.willChange = "transform";
|
|
96
|
+
target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
|
|
97
|
+
void target.offsetHeight;
|
|
98
|
+
target.style.transform = transform;
|
|
99
|
+
const appliedTransform = target.style.transform;
|
|
100
|
+
waitForTransitionEnd(target, () => {
|
|
101
|
+
clearInlineTransform(target, appliedTransform);
|
|
102
|
+
onComplete();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
zoomOut as a,
|
|
107
|
+
waitForTransitionEnd as w,
|
|
108
|
+
zoomIn as z
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=zoomTransition-bbKHpVpA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zoomTransition-bbKHpVpA.js","sources":["../../src/core/zoomTransition.ts"],"sourcesContent":["export interface ZoomTransitionTarget {\n /** The thumbnail element to animate to/from. */\n origin: HTMLElement;\n /** The `.shoji-slide-media` element being animated — not `.shoji-slide` itself, whose transform is already owned by pool-offset positioning. */\n target: HTMLElement;\n /** `item.width / item.height`, when known — `target` is always its full flex-box size, never the (usually smaller, letterboxed) rendered photo inside it; see `effectiveTargetBox`. */\n aspectRatio?: number;\n}\n\nfunction prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n\n/** `\"300ms\"` / `\"0.3s\"` → milliseconds. Reads the *actual* computed value, not an assumed default, so a host overriding `--shoji-duration` still gets a correct fallback timeout below. */\nfunction parseCssTime(value: string): number {\n const first = value.split(',')[0]?.trim() ?? '';\n if (first.endsWith('ms')) return parseFloat(first);\n if (first.endsWith('s')) return parseFloat(first) * 1000;\n return 0;\n}\n\ninterface Box {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\n/** The `object-fit: contain` box for `aspectRatio` within `container` — computed analytically since the real image may not be loaded/attached yet. */\nfunction containedBox(container: Box, aspectRatio: number): Box {\n const containerRatio = container.width / container.height;\n const width = aspectRatio > containerRatio ? container.width : container.height * aspectRatio;\n const height = aspectRatio > containerRatio ? container.width / aspectRatio : container.height;\n return {\n left: container.left + (container.width - width) / 2,\n top: container.top + (container.height - height) / 2,\n width,\n height,\n };\n}\n\n/**\n * `target`'s effective visual box, not necessarily its own\n * `getBoundingClientRect()`. `target` is always its full flex-box size; the\n * photo inside is usually *smaller*, letterboxed to its own aspect ratio —\n * a real bug used the container's full rect, making the animation scale\n * from/to a box far bigger than the photo ever renders at, visibly\n * shrinking it to something much smaller than the thumbnail whenever the\n * aspect ratios didn't match (the common case). Preferred sources: the real\n * rendered media child, if attached; else an analytical contain-box from\n * `aspectRatio`; else the container's own rect as a last resort.\n *\n * A real bug this guards against: on a fresh open with nothing decoded yet,\n * `target`'s only child at this point is the loading spinner (§2.3) — a\n * fixed ~40px circle, not the photo. Trusting its rect as \"the real\n * rendered media child\" made the FLIP transition scale toward/from a box\n * far smaller than the actual photo, so the scale factor came out inverted\n * (>1 instead of <1) and the spinner rendered wildly oversized for the\n * transition's first frames before shrinking back down — never the actual\n * photo, which hadn't swapped in yet either way.\n *\n * Same reasoning excludes `.shoji-slide-open-placeholder` (§2.3's low-res\n * open() stand-in): it's a real, non-spinner `<img>`, but its own rendered\n * rect reflects *its* crop/aspect ratio, not the real photo's — trusting it\n * would scale toward the placeholder's shape instead of the real photo's,\n * only to visibly jump again once the real photo swaps in and this\n * transition (already run) no longer applies. Excluding it falls through to\n * the analytical `aspectRatio`-based box below, sized for the real photo\n * regardless of what shape the placeholder happens to be.\n */\nfunction effectiveTargetBox(target: HTMLElement, aspectRatio?: number): Box {\n const child = target.firstElementChild;\n const isPlaceholder =\n child instanceof HTMLElement &&\n (child.classList.contains('shoji-slide-spinner') ||\n child.classList.contains('shoji-slide-open-placeholder'));\n if (child instanceof HTMLElement && !isPlaceholder) {\n const rect = child.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) return rect;\n }\n const containerRect = target.getBoundingClientRect();\n if (aspectRatio && containerRect.width > 0 && containerRect.height > 0) {\n return containedBox(containerRect, aspectRatio);\n }\n return containerRect;\n}\n\n/**\n * Center-to-center translate + a single uniform scale that lands `target`'s\n * effective box (`effectiveTargetBox` above) *within* `origin`'s box,\n * without distorting it. `null` if either has no real size — nothing sane\n * to animate. One scale factor, not independent scaleX/scaleY: origin and\n * the photo essentially never share an exact aspect ratio, and scaling each\n * axis independently to force a rect match visibly squeezes/stretches the\n * image. `Math.min` keeps the box fully contained within origin's rect\n * (same tradeoff `object-fit: contain` makes); center always lands exactly\n * on origin's center, only the unconstrained axis's edges fall short.\n */\nfunction computeTransform(\n origin: HTMLElement,\n target: HTMLElement,\n aspectRatio?: number,\n): string | null {\n const originRect = origin.getBoundingClientRect();\n const targetRect = effectiveTargetBox(target, aspectRatio);\n if (\n targetRect.width === 0 ||\n targetRect.height === 0 ||\n originRect.width === 0 ||\n originRect.height === 0\n ) {\n return null;\n }\n const scale = Math.min(\n originRect.width / targetRect.width,\n originRect.height / targetRect.height,\n );\n const translateX =\n originRect.left + originRect.width / 2 - (targetRect.left + targetRect.width / 2);\n const translateY =\n originRect.top + originRect.height / 2 - (targetRect.top + targetRect.height / 2);\n // translate3d/scale3d, not translate()/scale() — forces the GPU\n // compositing path instead of a main-thread-painted 2D transform, the\n // same fix already validated for the Zoom plugin's own scale animation\n // (DESIGN.md §4.6): this is the identical technique (a large photo\n // scaled via `transform`) on the same element, just driven by open/close\n // instead of pinch/toolbar zoom.\n return `translate3d(${translateX}px, ${translateY}px, 0) scale3d(${scale}, ${scale}, 1)`;\n}\n\n/**\n * Waits for `target`'s own transform transition to end (with a safety-net\n * timeout in case transitionend never fires — an interrupted/removed\n * element, or a browser quirk) then calls `cb` exactly once. Exported: the\n * gesture-driven drag-to-navigate/drag-to-close settle animations (§2.4,\n * `Gallery.ts`) reuse this same wait-with-fallback logic rather than\n * duplicating it — same instant-jump-then-transition FLIP family of moves\n * as the zoom transition, just on a different element/property pairing.\n */\nexport function waitForTransitionEnd(target: HTMLElement, cb: () => void): void {\n const durationMs = parseCssTime(getComputedStyle(target).transitionDuration);\n let done = false;\n const finish = (): void => {\n if (done) return;\n done = true;\n target.removeEventListener('transitionend', onEnd);\n cb();\n };\n const onEnd = (event: Event): void => {\n if (event.target === target && (event as TransitionEvent).propertyName === 'transform')\n finish();\n };\n target.addEventListener('transitionend', onEnd);\n setTimeout(finish, durationMs + 100);\n}\n\n/**\n * A real bug: unconditionally clearing `transform` here could wipe out a\n * value another plugin (rotateFlip) legitimately set on the *same* element\n * in the time between this animation starting and its cleanup callback\n * firing (transitionend, or the fallback timeout — either can land well\n * after a quick click elsewhere). Only clears `transform` if it still\n * matches what this animation itself last wrote — nothing else has touched\n * it since — otherwise leaves it alone. `transition`/`transformOrigin`\n * are always safe to clear; nothing else in this codebase sets them.\n *\n * A second real bug, found via reopening the lightbox: `expectedTransform`\n * must be the value *read back* from `target.style.transform` right after\n * assigning it, not the raw string `computeTransform()` produced. Setting\n * `element.style.transform` to a string containing an arbitrary JS float\n * (e.g. `scale(0.10416666666666667)`, `computeTransform`'s un-rounded\n * `Math.min(...)` result) and reading it back gives a *differently\n * formatted* string — the browser's CSSOM serializer rounds/reformats\n * numeric values on its own (observed in Chromium: `scale(0.104167)`).\n * Comparing the raw JS string against that reformatted one here always\n * failed for any scale factor without a short, clean decimal, silently\n * skipping the clear — permanently leaving `zoomOut`'s shrink transform\n * applied to `.shoji-slide-media` after the lightbox closed. The next\n * `open()`'s `computeTransform` then measured that *already-shrunk*\n * element's `getBoundingClientRect()` as if it were the natural size,\n * computing a near-1 (barely visible) scale instead of a real zoom-in —\n * reads as \"doesn't zoom, just appears,\" and compounds on every further\n * open/close cycle since the stuck transform is never cleared either way.\n * `zoomIn`'s own `'none'` literal never hit this — a fixed string round-trips\n * through the CSSOM unchanged, unlike an arbitrary computed float.\n */\nfunction clearInlineTransform(target: HTMLElement, expectedTransform: string): void {\n target.style.transition = '';\n target.style.transformOrigin = '';\n target.style.willChange = '';\n if (target.style.transform === expectedTransform) target.style.transform = '';\n}\n\n/**\n * FLIP-style open: `target` starts visually at `origin`'s position/size (an\n * instant, untransitioned jump), then transitions to its natural layout —\n * reads as \"growing out of the thumbnail.\" Fire-and-forget: cleans up its\n * own inline styles once the transition ends, nothing to await.\n */\nexport function zoomIn({ origin, target, aspectRatio }: ZoomTransitionTarget): void {\n if (prefersReducedMotion()) return;\n const transform = computeTransform(origin, target, aspectRatio);\n if (!transform) return;\n\n target.style.transition = 'none';\n // computeTransform's translateX/Y is center-to-center math — must pair with\n // a center transform-origin (the CSS default), not 'top left', or the\n // scaled box ends up offset from origin by however far origin's center\n // sits from its own top-left corner.\n target.style.transformOrigin = 'center';\n // Promotes target onto its own compositing layer before the animated\n // transform starts, not mid-animation — same GPU-seam mitigation as the\n // Zoom plugin (DESIGN.md §4.6). Cleared in clearInlineTransform once the\n // transition ends, not left on permanently: this is the offset-0 pool\n // slot, alive for the gallery's whole lifetime, not a class scoped to\n // only-while-zoomed.\n target.style.willChange = 'transform';\n target.style.transform = transform;\n void target.offsetHeight; // force the instant jump to commit before transitioning away from it\n target.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n target.style.transform = 'none';\n\n waitForTransitionEnd(target, () => clearInlineTransform(target, 'none'));\n}\n\n/**\n * Reverse of `zoomIn`: `target` transitions from its natural position down\n * to `origin`'s rect — \"shrinking back into the thumbnail.\" `onComplete`\n * always fires exactly once, synchronously if there's nothing to animate\n * (reduced motion, or no valid rect), otherwise once the transition ends —\n * callers use this to know when it's safe to actually hide/finalize.\n */\nexport function zoomOut(\n { origin, target, aspectRatio }: ZoomTransitionTarget,\n onComplete: () => void,\n): void {\n if (prefersReducedMotion()) {\n onComplete();\n return;\n }\n const transform = computeTransform(origin, target, aspectRatio);\n if (!transform) {\n onComplete();\n return;\n }\n\n // computeTransform's translateX/Y is center-to-center math — must pair with\n // a center transform-origin (the CSS default), not 'top left', or the\n // scaled box ends up offset from origin by however far origin's center\n // sits from its own top-left corner.\n target.style.transformOrigin = 'center';\n target.style.willChange = 'transform'; // see zoomIn's doc comment on this line\n target.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n void target.offsetHeight;\n target.style.transform = transform;\n // Read back what the browser actually stored, not the raw string just\n // assigned — see clearInlineTransform's own doc comment for why the two\n // can differ (CSSOM float reformatting) and why that difference matters.\n const appliedTransform = target.style.transform;\n\n waitForTransitionEnd(target, () => {\n clearInlineTransform(target, appliedTransform);\n onComplete();\n });\n}\n"],"names":[],"mappings":"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"}
|