From c1e28224aa2e1450031669d9a7e359b63e04687d Mon Sep 17 00:00:00 2001 From: Volpeon Date: Wed, 22 Jul 2026 19:40:59 +0200 Subject: Init --- src/scripts/lib/lightbox.ts | 450 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 src/scripts/lib/lightbox.ts (limited to 'src/scripts/lib/lightbox.ts') diff --git a/src/scripts/lib/lightbox.ts b/src/scripts/lib/lightbox.ts new file mode 100644 index 0000000..ac5b581 --- /dev/null +++ b/src/scripts/lib/lightbox.ts @@ -0,0 +1,450 @@ +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(); + }, + })); +} -- cgit v1.3.1