import Alpine from "alpinejs"; export interface LightboxItem { thumbnail?: string; src: string; srcset?: string; alt: string; } function touchParams(e: TouchEvent) { const [t0, t1] = e.touches; if (e.touches.length > 2 || !t0) { return; } let x = t0.clientX; let y = t0.clientY; let dist = 0; if (t1) { dist = Math.hypot(x - t1.clientX, y - t1.clientY); x = (x + t1.clientX) / 2; y = (y + t1.clientY) / 2; } return { x, y, dist }; } export function mountLightbox() { const minZoom = 0.9; Alpine.data("lightbox", () => ({ _zoom: minZoom, _offsetX: 0, _offsetY: 0, loading: false, inertia: false, items: [], current: 0, dragging: false, touchStart: { x: 0, y: 0, distance: 0, transformOrigin: { x: 0, y: 0 }, zoom: 1, offsetX: 0, offsetY: 0, timestamp: 0, }, velocity: { measure: false, prevOffsetX: 0, prevOffsetY: 0, timestamp: 0, x: 0, y: 0, }, get zoom() { return this._zoom; }, set zoom(value) { this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value)); /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign this.offsetX = this.offsetX; /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign this.offsetY = this.offsetY; }, get offsetX() { return this._offsetX; }, set offsetX(value) { const limit = this.offsetLimit; this._offsetX = Math.min(limit.x, Math.max(-limit.x, value)); }, get offsetY() { return this._offsetY; }, set offsetY(value) { const limit = this.offsetLimit; this._offsetY = Math.min(limit.y, Math.max(-limit.y, value)); }, get offsetLimit(): { x: number; y: number } { if (!this.$refs.img) { return { x: 0, y: 0 }; } const th = this.$refs.thumbnails?.clientHeight ?? 0; const cw = this.$root.clientWidth; const ch = this.$root.clientHeight - th; const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw); const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch); return { x, y }; }, get open(): boolean { return !!this.$root.hasAttribute("open"); }, get currentItem(): LightboxItem { return this.items[this.current] ?? { src: "", alt: "" }; }, get transformOrigin(): { x: number; y: number } { const img = this.$refs.img; if (!img) { return { x: this.offsetX, y: this.offsetY }; } return { x: img.offsetLeft + img.clientWidth / 2 + this.offsetX, y: img.offsetTop + img.clientHeight / 2 + this.offsetY, }; }, get naturalZoom() { const img = this.$refs.img; return img ? img.naturalWidth / img.clientWidth : 1; }, get maxZoom() { return Math.max(this.naturalZoom, 5); }, get zoomedFull() { return Math.abs(this.zoom - this.naturalZoom) < 0.1; }, init() { const triggerEls = document.querySelectorAll("a[data-lightbox]"); const galleries = new Map(); for (const triggerEl of triggerEls) { const triggerImgEl = triggerEl.querySelector("img"); const key = triggerEl.dataset.lightbox ?? ""; const gallery = galleries.get(key); const item: LightboxItem = { thumbnail: triggerImgEl?.src, src: triggerEl.href, srcset: triggerEl.dataset.lightboxSrcset, alt: "", }; if (gallery) { gallery.push(item); } else { galleries.set(key, [item]); } triggerEl.style.cursor = "zoom-in"; triggerEl.addEventListener("click", (e) => { e.preventDefault(); this.show(galleries.get(key) ?? [], item.src); }); } this.$watch("currentItem.src", () => { this.loading = true; this.reset(); }); }, show(items: LightboxItem[], src?: string) { const current = items.findIndex((item) => item.src === src); this.items = items; this.current = current === -1 ? 0 : current; this.reset(); (this.$root).showModal(); }, close() { (this.$root).close(); }, reset() { this.stopVelocity(); this.stopInertia(); this.dragging = false; this._zoom = minZoom; this._offsetX = 0; this._offsetY = 0; }, prev() { this.current = this.current > 0 ? this.current - 1 : this.items.length - 1; }, next() { this.current = this.current < this.items.length - 1 ? this.current + 1 : 0; }, toggleZoom(x?: number, y?: number) { const zoom = this.zoomedFull ? minZoom : this.naturalZoom; const deltaZoom = zoom / this.zoom - 1; this.zoom = zoom; if (x !== undefined && y !== undefined) { const transformOrigin = this.transformOrigin; const offsetX = transformOrigin.x - x; const offsetY = transformOrigin.y - y; this.offsetX += offsetX * deltaZoom; this.offsetY += offsetY * deltaZoom; } }, measureVelocity(loop?: boolean) { if (!loop) { this.inertia = false; if (this.velocity.measure) { return; } else { this.velocity.prevOffsetX = this.offsetX; this.velocity.prevOffsetY = this.offsetY; this.velocity.x = 0; this.velocity.y = 0; this.velocity.measure = true; } } if (!this.velocity.measure) { return; } const deltaX = this.offsetX - this.velocity.prevOffsetX; const deltaY = this.offsetY - this.velocity.prevOffsetY; let window = 1; if ( Math.sqrt(deltaX ** 2 + deltaY ** 2) < Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2) ) { window = 6; } this.velocity.x -= this.velocity.x / window; this.velocity.x += deltaX / window; this.velocity.y -= this.velocity.y / window; this.velocity.y += deltaY / window; this.velocity.prevOffsetX = this.offsetX; this.velocity.prevOffsetY = this.offsetY; requestAnimationFrame(this.measureVelocity.bind(this, true)); }, stopVelocity() { this.velocity.measure = false; }, runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) { const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2); if (!loop) { this.stopVelocity(); if (this.inertia) { return; } else if (len > 5) { this.inertia = true; this.velocity.timestamp = timestamp; } } if (!this.inertia) { this.stopInertia(); return; } if (loop) { const norm = { x: this.velocity.x / len, y: this.velocity.y / len }; const elapsed = timestamp - this.velocity.timestamp; const friction = 30 * (elapsed / 1000); const decel = { x: norm.x * friction, y: norm.y * friction }; const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2); if (len < decelLen) { this.stopInertia(); return; } this.offsetX += this.velocity.x; this.offsetY += this.velocity.y; this.velocity.x -= decel.x; this.velocity.y -= decel.y; this.velocity.timestamp = timestamp; } requestAnimationFrame(this.runInertia.bind(this, true)); }, stopInertia() { this.inertia = false; }, handleClose() { this.stopVelocity(); this.stopInertia(); }, handleScroll(e: WheelEvent) { this.stopInertia(); const direction = e.deltaY > 0 ? -1 : 1; const transformOrigin = this.transformOrigin; const offsetX = transformOrigin.x - e.clientX; const offsetY = transformOrigin.y - e.clientY; const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1))); const deltaZoom = zoom / this.zoom - 1; this.zoom = zoom; this.offsetX += offsetX * deltaZoom; this.offsetY += offsetY * deltaZoom; }, handleMouseDown(e: MouseEvent) { this.measureVelocity(); if (e.buttons === 1) { this.dragging = true; } }, handleMouseMove(e: MouseEvent) { if (!this.dragging) { return; } this.offsetX += e.movementX; this.offsetY += e.movementY; }, handleMouseUp() { this.dragging = false; this.runInertia(); }, handleDoubleClick(e: MouseEvent) { this.toggleZoom(e.clientX, e.clientY); }, handleTouchStart(e: TouchEvent) { const params = touchParams(e); if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { return; } e.preventDefault(); const now = Date.now(); if (e.touches.length === 1) { if ( now - this.touchStart.timestamp < 200 && Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20 ) { this.toggleZoom(params.x, params.y); } } this.touchStart.x = params.x; this.touchStart.y = params.y; this.touchStart.distance = params.dist; this.touchStart.transformOrigin = this.transformOrigin; this.touchStart.zoom = this.zoom; this.touchStart.offsetX = this.offsetX; this.touchStart.offsetY = this.offsetY; this.touchStart.timestamp = now; this.dragging = false; if (e.touches.length === 1) { this.measureVelocity(); } else { this.stopVelocity(); } }, handleTouchMove(e: TouchEvent) { const params = touchParams(e); if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { return; } e.preventDefault(); const deltaX = params.x - this.touchStart.x; const deltaY = params.y - this.touchStart.y; const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1; const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x; const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y; const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist)); const deltaZoom = zoom / this.touchStart.zoom - 1; this.zoom = zoom; this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom; this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom; }, handleTouchEnd(e: TouchEvent) { this.handleTouchStart(e); if (!e.touches.length) { this.runInertia(); } }, handleKeyDown(e: KeyboardEvent) { if (!this.open || e.defaultPrevented) { return; } this.stopInertia(); switch (e.key) { case "Left": case "ArrowLeft": this.prev(); break; case "Right": case "ArrowRight": this.next(); break; default: return; } e.preventDefault(); }, })); }