nuxt-image-viewer 0.0.0-stage → 1.0.0
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 +260 -2
- package/dist/module.d.mts +31 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +61 -0
- package/dist/runtime/components/ImageViewer.d.vue.ts +42 -0
- package/dist/runtime/components/ImageViewer.vue +681 -0
- package/dist/runtime/components/ImageViewer.vue.d.ts +42 -0
- package/dist/runtime/components/ZoomImage.d.vue.ts +14 -0
- package/dist/runtime/components/ZoomImage.vue +24 -0
- package/dist/runtime/components/ZoomImage.vue.d.ts +14 -0
- package/dist/runtime/components/ZoomNuxtImg.d.vue.ts +14 -0
- package/dist/runtime/components/ZoomNuxtImg.vue +24 -0
- package/dist/runtime/components/ZoomNuxtImg.vue.d.ts +14 -0
- package/dist/runtime/composables/useImageViewer.d.ts +9 -0
- package/dist/runtime/composables/useImageViewer.js +17 -0
- package/dist/runtime/image-viewer.css +1 -0
- package/dist/runtime/plugin.client.d.ts +12 -0
- package/dist/runtime/plugin.client.js +113 -0
- package/dist/runtime/types.d.ts +145 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +5 -0
- package/package.json +76 -4
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
items: { type: Array, required: true },
|
|
5
|
+
index: { type: Number, required: true },
|
|
6
|
+
options: { type: Object, required: true }
|
|
7
|
+
});
|
|
8
|
+
const emit = defineEmits(["change", "close", "closed"]);
|
|
9
|
+
const EASE = "cubic-bezier(.4, 0, .22, 1)";
|
|
10
|
+
const DURATION = 333;
|
|
11
|
+
const VERTICAL_DRAG_FRICTION = 0.6;
|
|
12
|
+
const EDGE_FRICTION = 0.35;
|
|
13
|
+
const MIN_RATIO_TO_CLOSE = 0.4;
|
|
14
|
+
const MIN_NEXT_SLIDE_SPEED = 0.5;
|
|
15
|
+
const DECELERATION = 0.995;
|
|
16
|
+
const { items, options } = props;
|
|
17
|
+
const animated = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
18
|
+
const stage = useTemplateRef("stage");
|
|
19
|
+
const backdrop = useTemplateRef("backdrop");
|
|
20
|
+
const ui = useTemplateRef("ui");
|
|
21
|
+
const W = ref(window.innerWidth);
|
|
22
|
+
const H = ref(window.innerHeight);
|
|
23
|
+
function onResize() {
|
|
24
|
+
W.value = window.innerWidth;
|
|
25
|
+
H.value = window.innerHeight;
|
|
26
|
+
}
|
|
27
|
+
const canFullscreen = document.fullscreenEnabled;
|
|
28
|
+
const isFullscreen = ref(false);
|
|
29
|
+
function onFullscreenChange() {
|
|
30
|
+
isFullscreen.value = !!stage.value && document.fullscreenElement === stage.value;
|
|
31
|
+
}
|
|
32
|
+
function toggleFullscreen() {
|
|
33
|
+
if (isFullscreen.value)
|
|
34
|
+
void document.exitFullscreen();
|
|
35
|
+
else
|
|
36
|
+
void stage.value?.requestFullscreen().catch(() => {
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const index = ref(props.index);
|
|
40
|
+
const current = computed(() => items[index.value]);
|
|
41
|
+
const total = items.length;
|
|
42
|
+
const slideWidth = computed(() => W.value * (1 + options.spacing));
|
|
43
|
+
function fit(item) {
|
|
44
|
+
const mx = W.value < 768 || !options.ui.arrows ? 0 : 80;
|
|
45
|
+
const my = W.value < 768 ? 56 : 64;
|
|
46
|
+
const s = Math.min((W.value - mx * 2) / item.width, (H.value - my * 2) / item.height, 1);
|
|
47
|
+
return { w: item.width * s, h: item.height * s, s };
|
|
48
|
+
}
|
|
49
|
+
function frameBox(item) {
|
|
50
|
+
const { w, h } = fit(item);
|
|
51
|
+
return { left: (W.value - w) / 2, top: (H.value - h) / 2, w, h };
|
|
52
|
+
}
|
|
53
|
+
function zoomLimit(item) {
|
|
54
|
+
return Math.min(Math.max(2, 1 / fit(item).s), options.maxZoom);
|
|
55
|
+
}
|
|
56
|
+
function spring(from, to, velocity2, onUpdate, { frequency = 12, damping = 1 } = {}) {
|
|
57
|
+
if (!animated) {
|
|
58
|
+
onUpdate(to);
|
|
59
|
+
return () => {
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const damped = damping < 1 ? frequency * Math.sqrt(1 - damping * damping) : frequency;
|
|
63
|
+
let delta = from - to;
|
|
64
|
+
let speed = velocity2;
|
|
65
|
+
let prev = performance.now();
|
|
66
|
+
let raf = 0;
|
|
67
|
+
const frame = (now) => {
|
|
68
|
+
const dt = (now - prev) / 1e3;
|
|
69
|
+
prev = now;
|
|
70
|
+
const decay = Math.exp(-damping * frequency * dt);
|
|
71
|
+
if (damping === 1) {
|
|
72
|
+
const coeff = speed + frequency * delta;
|
|
73
|
+
delta = (delta + coeff * dt) * decay;
|
|
74
|
+
speed = delta * -frequency + coeff * decay;
|
|
75
|
+
} else {
|
|
76
|
+
const coeff = (damping * frequency * delta + speed) / damped;
|
|
77
|
+
const cos = Math.cos(damped * dt);
|
|
78
|
+
const sin = Math.sin(damped * dt);
|
|
79
|
+
const next = decay * (delta * cos + coeff * sin);
|
|
80
|
+
speed = next * -frequency * damping + decay * (-damped * delta * sin + damped * coeff * cos);
|
|
81
|
+
delta = next;
|
|
82
|
+
}
|
|
83
|
+
if (Math.abs(delta) < 1 && Math.abs(speed) < 50) {
|
|
84
|
+
onUpdate(to);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
onUpdate(to + delta);
|
|
88
|
+
raf = requestAnimationFrame(frame);
|
|
89
|
+
};
|
|
90
|
+
raf = requestAnimationFrame(frame);
|
|
91
|
+
return () => cancelAnimationFrame(raf);
|
|
92
|
+
}
|
|
93
|
+
function project(velocity2) {
|
|
94
|
+
return velocity2 * DECELERATION / (1 - DECELERATION);
|
|
95
|
+
}
|
|
96
|
+
const phase = ref("open");
|
|
97
|
+
const gesture = ref("none");
|
|
98
|
+
const trackX = ref(0);
|
|
99
|
+
const closeY = ref(0);
|
|
100
|
+
const scale = ref(1);
|
|
101
|
+
const pan = reactive({ x: 0, y: 0 });
|
|
102
|
+
const panSprings = ref(0);
|
|
103
|
+
const loaded = reactive(/* @__PURE__ */ new Set());
|
|
104
|
+
let stopTrack = () => {
|
|
105
|
+
};
|
|
106
|
+
let stopClose = () => {
|
|
107
|
+
};
|
|
108
|
+
let stopPan = [];
|
|
109
|
+
function stopMotion() {
|
|
110
|
+
stopTrack();
|
|
111
|
+
stopClose();
|
|
112
|
+
stopPan.forEach((stop) => stop());
|
|
113
|
+
stopPan = [];
|
|
114
|
+
panSprings.value = 0;
|
|
115
|
+
}
|
|
116
|
+
const bgOpacity = computed(() => options.bgOpacity * Math.max(0, 1 - Math.abs(closeY.value) / (H.value / 3)));
|
|
117
|
+
const trackStyle = computed(() => ({ transform: `translate3d(${trackX.value}px, 0, 0)` }));
|
|
118
|
+
const closeStyle = computed(() => ({ transform: closeY.value ? `translate3d(0, ${closeY.value}px, 0)` : "" }));
|
|
119
|
+
const zoomStyle = computed(() => ({
|
|
120
|
+
transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${scale.value})`,
|
|
121
|
+
transition: gesture.value === "pan" || gesture.value === "pinch" || panSprings.value || !animated ? "none" : `transform ${DURATION}ms ${EASE}`
|
|
122
|
+
}));
|
|
123
|
+
const zoomed = computed(() => scale.value > 1.01);
|
|
124
|
+
function panBounds(s = scale.value) {
|
|
125
|
+
const item = current.value;
|
|
126
|
+
if (!item)
|
|
127
|
+
return { x: 0, y: 0 };
|
|
128
|
+
const { w, h } = fit(item);
|
|
129
|
+
return { x: Math.max(0, (w * s - W.value) / 2), y: Math.max(0, (h * s - H.value) / 2) };
|
|
130
|
+
}
|
|
131
|
+
function clampPan() {
|
|
132
|
+
const b = panBounds();
|
|
133
|
+
pan.x = Math.min(b.x, Math.max(-b.x, pan.x));
|
|
134
|
+
pan.y = Math.min(b.y, Math.max(-b.y, pan.y));
|
|
135
|
+
}
|
|
136
|
+
function zoomTo(next, point = { x: W.value / 2, y: H.value / 2 }) {
|
|
137
|
+
const item = current.value;
|
|
138
|
+
if (!item)
|
|
139
|
+
return;
|
|
140
|
+
const target = Math.min(Math.max(next, 1), zoomLimit(item));
|
|
141
|
+
const px = point.x - W.value / 2;
|
|
142
|
+
const py = point.y - H.value / 2;
|
|
143
|
+
pan.x = px - (px - pan.x) * (target / scale.value);
|
|
144
|
+
pan.y = py - (py - pan.y) * (target / scale.value);
|
|
145
|
+
scale.value = target;
|
|
146
|
+
if (target === 1) {
|
|
147
|
+
pan.x = 0;
|
|
148
|
+
pan.y = 0;
|
|
149
|
+
}
|
|
150
|
+
clampPan();
|
|
151
|
+
}
|
|
152
|
+
function toggleZoom(point) {
|
|
153
|
+
const item = current.value;
|
|
154
|
+
if (item)
|
|
155
|
+
zoomTo(zoomed.value ? 1 : zoomLimit(item), point);
|
|
156
|
+
}
|
|
157
|
+
const loop = options.loop && total >= 3;
|
|
158
|
+
const pos = ref(index.value);
|
|
159
|
+
function slot(i) {
|
|
160
|
+
return loop ? i + total * Math.round((pos.value - i) / total) : i;
|
|
161
|
+
}
|
|
162
|
+
function near(i) {
|
|
163
|
+
return Math.abs(slot(i) - pos.value) <= 1;
|
|
164
|
+
}
|
|
165
|
+
function settleTrack(velocity2 = 0) {
|
|
166
|
+
stopTrack();
|
|
167
|
+
stopTrack = spring(trackX.value, -pos.value * slideWidth.value, velocity2, (x) => trackX.value = x, { frequency: 30 });
|
|
168
|
+
}
|
|
169
|
+
function step(delta, velocity2 = 0) {
|
|
170
|
+
const target = loop ? pos.value + delta : Math.min(Math.max(pos.value + delta, 0), total - 1);
|
|
171
|
+
if (target !== pos.value) {
|
|
172
|
+
scale.value = 1;
|
|
173
|
+
pan.x = 0;
|
|
174
|
+
pan.y = 0;
|
|
175
|
+
pos.value = target;
|
|
176
|
+
index.value = (target % total + total) % total;
|
|
177
|
+
}
|
|
178
|
+
settleTrack(velocity2);
|
|
179
|
+
}
|
|
180
|
+
function goTo(i) {
|
|
181
|
+
if (phase.value !== "open" || !items[i])
|
|
182
|
+
return;
|
|
183
|
+
let delta = i - index.value;
|
|
184
|
+
if (loop && Math.abs(delta) > total / 2)
|
|
185
|
+
delta -= Math.sign(delta) * total;
|
|
186
|
+
step(delta);
|
|
187
|
+
}
|
|
188
|
+
watch(index, (next, prev) => {
|
|
189
|
+
const prevItem = items[prev];
|
|
190
|
+
const nextItem = items[next];
|
|
191
|
+
if (prevItem?.element)
|
|
192
|
+
prevItem.element.style.visibility = "";
|
|
193
|
+
if (nextItem?.element)
|
|
194
|
+
nextItem.element.style.visibility = "hidden";
|
|
195
|
+
emit("change", next);
|
|
196
|
+
});
|
|
197
|
+
watch(slideWidth, () => {
|
|
198
|
+
stopTrack();
|
|
199
|
+
trackX.value = -pos.value * slideWidth.value;
|
|
200
|
+
});
|
|
201
|
+
function frameEl() {
|
|
202
|
+
return stage.value?.querySelector(`[data-frame="${index.value}"]`) ?? null;
|
|
203
|
+
}
|
|
204
|
+
function flip(rect, box) {
|
|
205
|
+
return `translate(${rect.left - box.left}px, ${rect.top - box.top}px) scale(${rect.width / box.w}, ${rect.height / box.h})`;
|
|
206
|
+
}
|
|
207
|
+
function onScreen(rect) {
|
|
208
|
+
return rect.width > 0 && rect.bottom > 0 && rect.top < H.value && rect.right > 0 && rect.left < W.value;
|
|
209
|
+
}
|
|
210
|
+
function pageShape(item, box) {
|
|
211
|
+
const el = item.element;
|
|
212
|
+
const r = el?.getBoundingClientRect();
|
|
213
|
+
if (!el || !r?.width || !r.height)
|
|
214
|
+
return null;
|
|
215
|
+
const style = getComputedStyle(el);
|
|
216
|
+
const fit2 = style.objectFit;
|
|
217
|
+
const s = fit2 === "cover" ? Math.max(r.width / item.width, r.height / item.height) : fit2 === "contain" || fit2 === "scale-down" ? Math.min(r.width / item.width, r.height / item.height) : 0;
|
|
218
|
+
const w = s ? item.width * s : r.width;
|
|
219
|
+
const h = s ? item.height * s : r.height;
|
|
220
|
+
const [px = 0.5, py = 0.5] = style.objectPosition.split(" ").map((v) => v.endsWith("%") ? Number.parseFloat(v) / 100 : 0.5);
|
|
221
|
+
const rect = { left: r.left + (r.width - w) * px, top: r.top + (r.height - h) * py, width: w, height: h };
|
|
222
|
+
const k = box.w / w;
|
|
223
|
+
const top = Math.max(0, r.top - rect.top) * k;
|
|
224
|
+
const right = Math.max(0, rect.left + w - r.right) * k;
|
|
225
|
+
const bottom = Math.max(0, rect.top + h - r.bottom) * k;
|
|
226
|
+
const left = Math.max(0, r.left - rect.left) * k;
|
|
227
|
+
const radius = (Number.parseFloat(style.borderTopLeftRadius) || 0) * k;
|
|
228
|
+
return { rect, visible: r, clip: `inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)` };
|
|
229
|
+
}
|
|
230
|
+
const NO_CLIP = "inset(0px 0px 0px 0px round 0px)";
|
|
231
|
+
function fade(el, from, to) {
|
|
232
|
+
return el?.animate([{ opacity: from }, { opacity: to }], { duration: DURATION, easing: EASE, fill: "forwards" }).finished;
|
|
233
|
+
}
|
|
234
|
+
const root = document.documentElement;
|
|
235
|
+
const restore = { overflow: root.style.overflow, paddingRight: document.body.style.paddingRight };
|
|
236
|
+
function lockScroll() {
|
|
237
|
+
const bar = window.innerWidth - root.clientWidth;
|
|
238
|
+
if (bar > 0 && !getComputedStyle(root).scrollbarGutter?.startsWith("stable"))
|
|
239
|
+
document.body.style.paddingRight = `${Number.parseFloat(getComputedStyle(document.body).paddingRight) + bar}px`;
|
|
240
|
+
root.style.overflow = "hidden";
|
|
241
|
+
}
|
|
242
|
+
function unlockScroll() {
|
|
243
|
+
root.style.overflow = restore.overflow;
|
|
244
|
+
document.body.style.paddingRight = restore.paddingRight;
|
|
245
|
+
}
|
|
246
|
+
const returnFocus = document.activeElement;
|
|
247
|
+
onMounted(() => {
|
|
248
|
+
trackX.value = -pos.value * slideWidth.value;
|
|
249
|
+
lockScroll();
|
|
250
|
+
root.classList.add(options.openClass);
|
|
251
|
+
window.addEventListener("resize", onResize);
|
|
252
|
+
document.addEventListener("keydown", onKeydown);
|
|
253
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
254
|
+
const item = current.value;
|
|
255
|
+
if (item?.element)
|
|
256
|
+
item.element.style.visibility = "hidden";
|
|
257
|
+
stage.value?.focus({ preventScroll: true });
|
|
258
|
+
const frame = frameEl();
|
|
259
|
+
if (!item || !frame || !animated)
|
|
260
|
+
return;
|
|
261
|
+
const box = frameBox(item);
|
|
262
|
+
const shape = pageShape(item, box);
|
|
263
|
+
if (shape && onScreen(shape.visible)) {
|
|
264
|
+
frame.animate([
|
|
265
|
+
{ transform: flip(shape.rect, box), transformOrigin: "0 0", clipPath: shape.clip },
|
|
266
|
+
{ transform: "none", transformOrigin: "0 0", clipPath: NO_CLIP }
|
|
267
|
+
], { duration: DURATION, easing: EASE });
|
|
268
|
+
} else {
|
|
269
|
+
frame.animate([{ opacity: 0 }, { opacity: 1 }], { duration: DURATION, easing: EASE });
|
|
270
|
+
}
|
|
271
|
+
backdrop.value?.animate([{ opacity: 0 }, { opacity: options.bgOpacity }], { duration: DURATION, easing: EASE });
|
|
272
|
+
ui.value?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: DURATION, easing: EASE });
|
|
273
|
+
});
|
|
274
|
+
onBeforeUnmount(() => {
|
|
275
|
+
stopMotion();
|
|
276
|
+
unlockScroll();
|
|
277
|
+
root.classList.remove(options.openClass);
|
|
278
|
+
window.removeEventListener("resize", onResize);
|
|
279
|
+
document.removeEventListener("keydown", onKeydown);
|
|
280
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
281
|
+
});
|
|
282
|
+
async function close() {
|
|
283
|
+
if (phase.value === "closing")
|
|
284
|
+
return;
|
|
285
|
+
phase.value = "closing";
|
|
286
|
+
gesture.value = "none";
|
|
287
|
+
emit("close");
|
|
288
|
+
stopMotion();
|
|
289
|
+
if (isFullscreen.value)
|
|
290
|
+
void document.exitFullscreen();
|
|
291
|
+
const item = current.value;
|
|
292
|
+
const frame = frameEl();
|
|
293
|
+
if (item && frame && animated) {
|
|
294
|
+
const box = frameBox(item);
|
|
295
|
+
const now = frame.getBoundingClientRect();
|
|
296
|
+
const shape = pageShape(item, box);
|
|
297
|
+
const zoomer = frame.firstElementChild;
|
|
298
|
+
if (zoomer)
|
|
299
|
+
zoomer.style.transform = "";
|
|
300
|
+
const flight = shape ? frame.animate([
|
|
301
|
+
{ transform: flip(now, box), transformOrigin: "0 0", clipPath: NO_CLIP },
|
|
302
|
+
{ transform: flip(shape.rect, box), transformOrigin: "0 0", clipPath: shape.clip }
|
|
303
|
+
], { duration: DURATION, easing: EASE, fill: "forwards" }) : frame.animate([
|
|
304
|
+
{ transform: flip(now, box), transformOrigin: "0 0", opacity: 1 },
|
|
305
|
+
{ transform: flip(now, box), transformOrigin: "0 0", opacity: 0 }
|
|
306
|
+
], { duration: DURATION, easing: EASE, fill: "forwards" });
|
|
307
|
+
await Promise.all([flight.finished, fade(backdrop.value, bgOpacity.value, 0), fade(ui.value, 1, 0)]);
|
|
308
|
+
}
|
|
309
|
+
if (item?.element) {
|
|
310
|
+
item.element.style.visibility = "";
|
|
311
|
+
item.element.focus({ preventScroll: true });
|
|
312
|
+
} else {
|
|
313
|
+
returnFocus?.focus({ preventScroll: true });
|
|
314
|
+
}
|
|
315
|
+
emit("closed");
|
|
316
|
+
}
|
|
317
|
+
function onKeydown(event) {
|
|
318
|
+
if (event.key === "Escape")
|
|
319
|
+
void close();
|
|
320
|
+
else if (event.key === "ArrowLeft")
|
|
321
|
+
step(-1);
|
|
322
|
+
else if (event.key === "ArrowRight")
|
|
323
|
+
step(1);
|
|
324
|
+
else if (event.key === "Tab")
|
|
325
|
+
trapFocus(event);
|
|
326
|
+
else
|
|
327
|
+
return;
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
}
|
|
330
|
+
function trapFocus(event) {
|
|
331
|
+
const buttons = [...stage.value?.querySelectorAll("button:not(:disabled)") ?? []].filter((button) => button.offsetParent);
|
|
332
|
+
if (!buttons.length)
|
|
333
|
+
return;
|
|
334
|
+
const at = buttons.indexOf(document.activeElement);
|
|
335
|
+
const next = event.shiftKey ? at <= 0 ? buttons.length - 1 : at - 1 : (at + 1) % buttons.length;
|
|
336
|
+
buttons[next].focus();
|
|
337
|
+
}
|
|
338
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
339
|
+
let start = { x: 0, y: 0, panX: 0, panY: 0, scale: 1, dist: 1, cx: 0, cy: 0, trackX: 0, closeY: 0 };
|
|
340
|
+
let last = { x: 0, y: 0, t: 0 };
|
|
341
|
+
let velocity = { x: 0, y: 0 };
|
|
342
|
+
let moved = false;
|
|
343
|
+
let lastTap = { t: 0, x: 0, y: 0 };
|
|
344
|
+
let downOnImage = false;
|
|
345
|
+
function withFriction(value, bound) {
|
|
346
|
+
if (value > bound)
|
|
347
|
+
return bound + (value - bound) * EDGE_FRICTION;
|
|
348
|
+
if (value < -bound)
|
|
349
|
+
return -bound + (value + bound) * EDGE_FRICTION;
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
function beginSingle(x, y) {
|
|
353
|
+
start = { ...start, x, y, panX: pan.x, panY: pan.y, scale: scale.value, trackX: trackX.value, closeY: closeY.value };
|
|
354
|
+
last = { x, y, t: performance.now() };
|
|
355
|
+
velocity = { x: 0, y: 0 };
|
|
356
|
+
}
|
|
357
|
+
function onPointerDown(event) {
|
|
358
|
+
if (phase.value !== "open" || event.target.closest("button"))
|
|
359
|
+
return;
|
|
360
|
+
stage.value?.setPointerCapture(event.pointerId);
|
|
361
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
362
|
+
stopMotion();
|
|
363
|
+
if (pointers.size === 1) {
|
|
364
|
+
beginSingle(event.clientX, event.clientY);
|
|
365
|
+
downOnImage = !!event.target.closest(".image-viewer__zoom");
|
|
366
|
+
moved = false;
|
|
367
|
+
gesture.value = "none";
|
|
368
|
+
} else if (pointers.size === 2) {
|
|
369
|
+
const [a, b] = [...pointers.values()];
|
|
370
|
+
start = { ...start, panX: pan.x, panY: pan.y, scale: scale.value, dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2 };
|
|
371
|
+
moved = true;
|
|
372
|
+
settleTrack();
|
|
373
|
+
closeY.value = 0;
|
|
374
|
+
gesture.value = "pinch";
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function onPointerMove(event) {
|
|
378
|
+
if (!pointers.has(event.pointerId))
|
|
379
|
+
return;
|
|
380
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
381
|
+
if (gesture.value === "pinch") {
|
|
382
|
+
const [a, b] = [...pointers.values()];
|
|
383
|
+
if (!b)
|
|
384
|
+
return;
|
|
385
|
+
const item = current.value;
|
|
386
|
+
const next = Math.min(Math.max(start.scale * Math.hypot(a.x - b.x, a.y - b.y) / start.dist, 0.7), (item ? zoomLimit(item) : 2) * 1.3);
|
|
387
|
+
const cx = (a.x + b.x) / 2 - W.value / 2;
|
|
388
|
+
const cy = (a.y + b.y) / 2 - H.value / 2;
|
|
389
|
+
const sx = start.cx - W.value / 2;
|
|
390
|
+
const sy = start.cy - H.value / 2;
|
|
391
|
+
pan.x = cx - (sx - start.panX) * (next / start.scale);
|
|
392
|
+
pan.y = cy - (sy - start.panY) * (next / start.scale);
|
|
393
|
+
scale.value = next;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const dx = event.clientX - start.x;
|
|
397
|
+
const dy = event.clientY - start.y;
|
|
398
|
+
if (gesture.value === "none") {
|
|
399
|
+
if (Math.hypot(dx, dy) < 6)
|
|
400
|
+
return;
|
|
401
|
+
moved = true;
|
|
402
|
+
gesture.value = zoomed.value ? "pan" : Math.abs(dx) > Math.abs(dy) || !options.closeOnVerticalDrag ? "swipe" : "close";
|
|
403
|
+
}
|
|
404
|
+
const now = performance.now();
|
|
405
|
+
const dt = Math.max(1, now - last.t);
|
|
406
|
+
velocity = { x: (event.clientX - last.x) / dt * 0.8 + velocity.x * 0.2, y: (event.clientY - last.y) / dt * 0.8 + velocity.y * 0.2 };
|
|
407
|
+
last = { x: event.clientX, y: event.clientY, t: now };
|
|
408
|
+
if (gesture.value === "pan") {
|
|
409
|
+
const bounds = panBounds();
|
|
410
|
+
pan.x = withFriction(start.panX + dx, bounds.x);
|
|
411
|
+
pan.y = withFriction(start.panY + dy, bounds.y);
|
|
412
|
+
} else if (gesture.value === "swipe") {
|
|
413
|
+
const base = -pos.value * slideWidth.value;
|
|
414
|
+
const shift = start.trackX - base + dx;
|
|
415
|
+
const atEdge = !loop && (pos.value === 0 && shift > 0 || pos.value === total - 1 && shift < 0);
|
|
416
|
+
trackX.value = base + (atEdge ? shift * EDGE_FRICTION : shift);
|
|
417
|
+
} else if (gesture.value === "close") {
|
|
418
|
+
closeY.value = start.closeY + dy * VERTICAL_DRAG_FRICTION;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function onPointerUp(event) {
|
|
422
|
+
if (!pointers.delete(event.pointerId))
|
|
423
|
+
return;
|
|
424
|
+
if (gesture.value === "pinch") {
|
|
425
|
+
const rest = [...pointers.values()][0];
|
|
426
|
+
if (rest) {
|
|
427
|
+
beginSingle(rest.x, rest.y);
|
|
428
|
+
gesture.value = zoomed.value ? "pan" : "none";
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
gesture.value = "none";
|
|
432
|
+
zoomTo(scale.value);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (pointers.size)
|
|
436
|
+
return;
|
|
437
|
+
const finished = gesture.value;
|
|
438
|
+
gesture.value = "none";
|
|
439
|
+
if (performance.now() - last.t > 80)
|
|
440
|
+
velocity = { x: 0, y: 0 };
|
|
441
|
+
if (finished === "swipe") {
|
|
442
|
+
const ratio = (trackX.value + pos.value * slideWidth.value) / W.value;
|
|
443
|
+
const v = velocity.x;
|
|
444
|
+
const dx = last.x - start.x;
|
|
445
|
+
let delta = 0;
|
|
446
|
+
if (v < -MIN_NEXT_SLIDE_SPEED && dx < 0 || v < 0.1 && ratio < -0.5)
|
|
447
|
+
delta = 1;
|
|
448
|
+
else if (v > MIN_NEXT_SLIDE_SPEED && dx > 0 || v > -0.1 && ratio > 0.5)
|
|
449
|
+
delta = -1;
|
|
450
|
+
step(delta, delta > 0 ? Math.min(v, 0) : delta < 0 ? Math.max(v, 0) : v);
|
|
451
|
+
} else if (finished === "close") {
|
|
452
|
+
const third = H.value / 3;
|
|
453
|
+
const ratio = closeY.value / third;
|
|
454
|
+
const projected = (closeY.value + project(velocity.y)) / third;
|
|
455
|
+
if (ratio > 0 && projected > MIN_RATIO_TO_CLOSE || ratio < 0 && projected < -MIN_RATIO_TO_CLOSE) {
|
|
456
|
+
void close();
|
|
457
|
+
} else {
|
|
458
|
+
stopClose();
|
|
459
|
+
stopClose = spring(closeY.value, 0, velocity.y, (y) => closeY.value = y);
|
|
460
|
+
}
|
|
461
|
+
} else if (finished === "pan") {
|
|
462
|
+
const bounds = panBounds();
|
|
463
|
+
for (const axis of ["x", "y"]) {
|
|
464
|
+
const projected = pan[axis] + project(velocity[axis]);
|
|
465
|
+
const corrected = Math.min(bounds[axis], Math.max(-bounds[axis], projected));
|
|
466
|
+
if (corrected === pan[axis])
|
|
467
|
+
continue;
|
|
468
|
+
panSprings.value++;
|
|
469
|
+
stopPan.push(spring(pan[axis], corrected, velocity[axis], (value) => {
|
|
470
|
+
pan[axis] = value;
|
|
471
|
+
if (value === corrected)
|
|
472
|
+
panSprings.value = Math.max(0, panSprings.value - 1);
|
|
473
|
+
}, { damping: corrected === projected ? 1 : 0.82 }));
|
|
474
|
+
}
|
|
475
|
+
} else if (!moved) {
|
|
476
|
+
tap(event);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function tap(event) {
|
|
480
|
+
if (!downOnImage) {
|
|
481
|
+
if (options.closeOnBackdropClick)
|
|
482
|
+
void close();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
486
|
+
if (event.pointerType === "mouse") {
|
|
487
|
+
toggleZoom(point);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const now = performance.now();
|
|
491
|
+
if (now - lastTap.t < 300 && Math.hypot(point.x - lastTap.x, point.y - lastTap.y) < 30) {
|
|
492
|
+
toggleZoom(point);
|
|
493
|
+
lastTap = { t: 0, x: 0, y: 0 };
|
|
494
|
+
} else {
|
|
495
|
+
lastTap = { t: now, ...point };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function onWheel(event) {
|
|
499
|
+
if (phase.value === "open")
|
|
500
|
+
zoomTo(scale.value * Math.exp(-event.deltaY * 2e-3), { x: event.clientX, y: event.clientY });
|
|
501
|
+
}
|
|
502
|
+
defineExpose({ close, next: () => step(1), prev: () => step(-1), goTo });
|
|
503
|
+
</script>
|
|
504
|
+
|
|
505
|
+
<template>
|
|
506
|
+
<div
|
|
507
|
+
ref="stage"
|
|
508
|
+
role="dialog"
|
|
509
|
+
aria-modal="true"
|
|
510
|
+
:aria-label="options.labels.dialog"
|
|
511
|
+
tabindex="-1"
|
|
512
|
+
class="image-viewer"
|
|
513
|
+
:class="{ 'image-viewer--zoomed': zoomed, 'image-viewer--closing': phase === 'closing' }"
|
|
514
|
+
:style="{ zIndex: options.zIndex }"
|
|
515
|
+
@pointerdown="onPointerDown"
|
|
516
|
+
@pointermove="onPointerMove"
|
|
517
|
+
@pointerup="onPointerUp"
|
|
518
|
+
@pointercancel="onPointerUp"
|
|
519
|
+
@wheel.prevent="onWheel"
|
|
520
|
+
>
|
|
521
|
+
<div
|
|
522
|
+
ref="backdrop"
|
|
523
|
+
class="image-viewer__backdrop"
|
|
524
|
+
:style="{ opacity: bgOpacity }"
|
|
525
|
+
/>
|
|
526
|
+
|
|
527
|
+
<div
|
|
528
|
+
class="image-viewer__track"
|
|
529
|
+
:style="trackStyle"
|
|
530
|
+
>
|
|
531
|
+
<div
|
|
532
|
+
v-for="(item, i) in items"
|
|
533
|
+
:key="i"
|
|
534
|
+
class="image-viewer__slide"
|
|
535
|
+
:class="{ 'image-viewer__slide--current': i === index }"
|
|
536
|
+
:aria-hidden="i !== index"
|
|
537
|
+
:style="{ left: `${slot(i) * slideWidth}px`, width: `${W}px` }"
|
|
538
|
+
>
|
|
539
|
+
<div
|
|
540
|
+
:data-frame="i"
|
|
541
|
+
class="image-viewer__frame"
|
|
542
|
+
:style="{
|
|
543
|
+
left: `${frameBox(item).left}px`,
|
|
544
|
+
top: `${frameBox(item).top}px`,
|
|
545
|
+
width: `${frameBox(item).w}px`,
|
|
546
|
+
height: `${frameBox(item).h}px`,
|
|
547
|
+
...i === index ? closeStyle : {}
|
|
548
|
+
}"
|
|
549
|
+
>
|
|
550
|
+
<div
|
|
551
|
+
class="image-viewer__zoom"
|
|
552
|
+
:style="i === index ? zoomStyle : void 0"
|
|
553
|
+
>
|
|
554
|
+
<img
|
|
555
|
+
:src="item.thumb"
|
|
556
|
+
:alt="item.alt"
|
|
557
|
+
draggable="false"
|
|
558
|
+
class="image-viewer__img image-viewer__img--thumb"
|
|
559
|
+
>
|
|
560
|
+
|
|
561
|
+
<img
|
|
562
|
+
v-if="item.src !== item.thumb && (near(i) || loaded.has(i))"
|
|
563
|
+
:src="item.src"
|
|
564
|
+
alt=""
|
|
565
|
+
draggable="false"
|
|
566
|
+
class="image-viewer__img image-viewer__img--full"
|
|
567
|
+
:class="{ 'image-viewer__img--loaded': loaded.has(i) }"
|
|
568
|
+
@load="loaded.add(i)"
|
|
569
|
+
>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<div
|
|
576
|
+
ref="ui"
|
|
577
|
+
class="image-viewer__ui"
|
|
578
|
+
>
|
|
579
|
+
<div class="image-viewer__toolbar">
|
|
580
|
+
<span
|
|
581
|
+
v-if="options.ui.counter && total > 1"
|
|
582
|
+
class="image-viewer__counter"
|
|
583
|
+
aria-live="polite"
|
|
584
|
+
>
|
|
585
|
+
{{ index + 1 }} / {{ total }}
|
|
586
|
+
</span>
|
|
587
|
+
|
|
588
|
+
<div class="image-viewer__buttons">
|
|
589
|
+
<button
|
|
590
|
+
v-if="options.ui.zoom"
|
|
591
|
+
type="button"
|
|
592
|
+
class="image-viewer__button image-viewer__button--zoom"
|
|
593
|
+
:aria-label="zoomed ? options.labels.zoomOut : options.labels.zoomIn"
|
|
594
|
+
@click="toggleZoom()"
|
|
595
|
+
>
|
|
596
|
+
<svg
|
|
597
|
+
class="image-viewer__icon"
|
|
598
|
+
viewBox="0 0 24 24"
|
|
599
|
+
aria-hidden="true"
|
|
600
|
+
>
|
|
601
|
+
<path :d="zoomed ? 'M15 15L21 21M8 11H14M18 11C18 14.866 14.866 18 11 18C7.13401 18 4 14.866 4 11C4 7.13401 7.13401 4 11 4C14.866 4 18 7.13401 18 11Z' : 'M15 15L21 21M11 8V14M8 11H14M18 11C18 14.866 14.866 18 11 18C7.13401 18 4 14.866 4 11C4 7.13401 7.13401 4 11 4C14.866 4 18 7.13401 18 11Z'" />
|
|
602
|
+
</svg>
|
|
603
|
+
</button>
|
|
604
|
+
|
|
605
|
+
<button
|
|
606
|
+
v-if="options.ui.fullscreen && canFullscreen"
|
|
607
|
+
type="button"
|
|
608
|
+
class="image-viewer__button image-viewer__button--fullscreen"
|
|
609
|
+
:aria-label="isFullscreen ? options.labels.exitFullscreen : options.labels.fullscreen"
|
|
610
|
+
@click="toggleFullscreen()"
|
|
611
|
+
>
|
|
612
|
+
<svg
|
|
613
|
+
class="image-viewer__icon"
|
|
614
|
+
viewBox="0 0 24 24"
|
|
615
|
+
aria-hidden="true"
|
|
616
|
+
>
|
|
617
|
+
<path :d="isFullscreen ? 'M9 4V9H4M20 9H15V4M15 20V15H20M4 15H9V20' : 'M4 9V4H9M15 4H20V9M20 15V20H15M9 20H4V15'" />
|
|
618
|
+
</svg>
|
|
619
|
+
</button>
|
|
620
|
+
|
|
621
|
+
<button
|
|
622
|
+
v-if="options.ui.close"
|
|
623
|
+
type="button"
|
|
624
|
+
class="image-viewer__button image-viewer__button--close"
|
|
625
|
+
:aria-label="options.labels.close"
|
|
626
|
+
@click="close()"
|
|
627
|
+
>
|
|
628
|
+
<svg
|
|
629
|
+
class="image-viewer__icon"
|
|
630
|
+
viewBox="0 0 24 24"
|
|
631
|
+
aria-hidden="true"
|
|
632
|
+
>
|
|
633
|
+
<path d="M18 18L12 12M12 12L6 6M12 12L18 6M12 12L6 18" />
|
|
634
|
+
</svg>
|
|
635
|
+
</button>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<template v-if="options.ui.arrows && total > 1">
|
|
640
|
+
<button
|
|
641
|
+
type="button"
|
|
642
|
+
class="image-viewer__button image-viewer__arrow image-viewer__arrow--prev"
|
|
643
|
+
:aria-label="options.labels.prev"
|
|
644
|
+
:disabled="!loop && pos === 0"
|
|
645
|
+
@click="step(-1)"
|
|
646
|
+
>
|
|
647
|
+
<svg
|
|
648
|
+
class="image-viewer__icon"
|
|
649
|
+
viewBox="0 0 24 24"
|
|
650
|
+
aria-hidden="true"
|
|
651
|
+
>
|
|
652
|
+
<path d="M15 19L8 12L15 5" />
|
|
653
|
+
</svg>
|
|
654
|
+
</button>
|
|
655
|
+
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
class="image-viewer__button image-viewer__arrow image-viewer__arrow--next"
|
|
659
|
+
:aria-label="options.labels.next"
|
|
660
|
+
:disabled="!loop && pos === total - 1"
|
|
661
|
+
@click="step(1)"
|
|
662
|
+
>
|
|
663
|
+
<svg
|
|
664
|
+
class="image-viewer__icon"
|
|
665
|
+
viewBox="0 0 24 24"
|
|
666
|
+
aria-hidden="true"
|
|
667
|
+
>
|
|
668
|
+
<path d="M9 5L16 12L9 19" />
|
|
669
|
+
</svg>
|
|
670
|
+
</button>
|
|
671
|
+
</template>
|
|
672
|
+
|
|
673
|
+
<div
|
|
674
|
+
v-if="options.ui.caption && current?.caption"
|
|
675
|
+
class="image-viewer__caption"
|
|
676
|
+
>
|
|
677
|
+
<span class="image-viewer__caption-text">{{ current.caption }}</span>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</template>
|