From 34ff7f1a7f7b04683087a544129937879cbf7b38 Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 15 Jan 2026 19:56:08 +0100 Subject: [PATCH] image zooming --- script.js | 427 ++++++++++++++++++++++++++++++++++-------------------- style.css | 2 + 2 files changed, 273 insertions(+), 156 deletions(-) diff --git a/script.js b/script.js index 4f12933..660120d 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,267 @@ (function () { 'use strict'; + // ============================================================ + // Lightbox Module + // ============================================================ + var Lightbox = (function () { + var lb = null; + var img = null; + var cap = null; + var items = []; + var index = 0; + + // Zoom/pan state + var scale = 1; + var panX = 0; + var panY = 0; + var minScale = 1; + var maxScale = 5; + var isPanning = false; + var panStartX = 0; + var panStartY = 0; + + // History state + var pushedHistory = false; + var closingFromPopstate = false; + + function ensureElement() { + if (lb) return; + + lb = document.createElement('div'); + lb.className = 'luxtools-lightbox'; + lb.setAttribute('role', 'dialog'); + lb.setAttribute('aria-modal', 'true'); + lb.setAttribute('aria-hidden', 'true'); + + lb.innerHTML = + '
' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
'; + + document.body.appendChild(lb); + img = lb.querySelector('img.luxtools-lightbox-img'); + cap = lb.querySelector('.luxtools-lightbox-caption'); + + lb.addEventListener('click', onClick); + } + + function clampIndex(n) { + if (n < 0) return items.length - 1; + if (n >= items.length) return 0; + return n; + } + + function applyTransform() { + if (scale <= 1 && panX === 0 && panY === 0) { + img.style.transform = ''; + } else { + img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)'; + } + img.style.cursor = scale > 1 ? 'grab' : ''; + } + + function resetZoom() { + scale = 1; + panX = 0; + panY = 0; + applyTransform(); + } + + function render() { + var it = items[index]; + img.src = it.full; + img.setAttribute('data-luxtools-index', String(index)); + if (cap) cap.textContent = (it.name || '').trim(); + resetZoom(); + } + + function next() { + index = clampIndex(index + 1); + render(); + } + + function prev() { + index = clampIndex(index - 1); + render(); + } + + // Event handlers + function onWheel(e) { + e.preventDefault(); + var delta = e.deltaY > 0 ? -0.15 : 0.15; + scale = Math.max(minScale, Math.min(maxScale, scale + delta)); + if (scale <= 1) { panX = 0; panY = 0; } + applyTransform(); + } + + function onDblClick(e) { + e.preventDefault(); + if (scale > 1) { + scale = 1; + panX = 0; + panY = 0; + } else { + scale = 2.5; + } + applyTransform(); + } + + function onMouseDown(e) { + if (scale > 1 && e.button === 0) { + isPanning = true; + panStartX = e.clientX - panX * scale; + panStartY = e.clientY - panY * scale; + img.style.cursor = 'grabbing'; + e.preventDefault(); + } + } + + function onMouseMove(e) { + if (isPanning && scale > 1) { + panX = (e.clientX - panStartX) / scale; + panY = (e.clientY - panStartY) / scale; + applyTransform(); + img.style.cursor = 'grabbing'; + } + } + + function onMouseUp() { + isPanning = false; + img.style.cursor = scale > 1 ? 'grab' : ''; + } + + function onKeyDown(e) { + if (!lb || !lb.classList.contains('is-open')) return; + var key = e.key || ''; + if (key === 'Escape') { + e.preventDefault(); + close(); + } else if (key === 'ArrowRight') { + e.preventDefault(); + next(); + } else if (key === 'ArrowLeft') { + e.preventDefault(); + prev(); + } + } + + function onPopState() { + if (!lb || !lb.classList.contains('is-open')) return; + closingFromPopstate = true; + try { close(); } finally { closingFromPopstate = false; } + } + + function onClick(e) { + var t = e.target; + if (!t || !t.getAttribute) return; + var action = t.getAttribute('data-luxtools-action') || ''; + if (action === 'close') { e.preventDefault(); close(); return; } + if (action === 'next') { e.preventDefault(); next(); return; } + if (action === 'prev') { e.preventDefault(); prev(); return; } + + if (t.closest && t.closest('button.luxtools-lightbox-zone')) return; + if (t.closest && t.closest('img.luxtools-lightbox-img')) return; + e.preventDefault(); + close(); + } + + function attachListeners() { + document.addEventListener('keydown', onKeyDown, true); + window.addEventListener('popstate', onPopState, true); + img.addEventListener('wheel', onWheel, { passive: false }); + img.addEventListener('dblclick', onDblClick); + img.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function detachListeners() { + document.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('popstate', onPopState, true); + img.removeEventListener('wheel', onWheel); + img.removeEventListener('dblclick', onDblClick); + img.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + function open(galleryEl, startEl) { + var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]'); + items = []; + links.forEach(function (a) { + var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || ''; + var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || ''; + if (!full) return; + items.push({ el: a, full: full, name: name }); + }); + if (!items.length) return; + + index = 0; + for (var i = 0; i < items.length; i++) { + if (items[i].el === startEl) { index = i; break; } + } + + ensureElement(); + pushedHistory = false; + closingFromPopstate = false; + + lb.classList.add('is-open'); + lb.setAttribute('aria-hidden', 'false'); + try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {} + try { document.body.style.overflow = 'hidden'; } catch (e) {} + + attachListeners(); + + try { + if (window.history && window.history.pushState) { + window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href); + pushedHistory = true; + } + } catch (e) {} + + render(); + } + + function close() { + if (!lb) return; + + lb.classList.remove('is-open'); + lb.setAttribute('aria-hidden', 'true'); + try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {} + try { document.body.style.overflow = ''; } catch (e) {} + img.src = ''; + resetZoom(); + + detachListeners(); + + if (pushedHistory && !closingFromPopstate) { + try { + if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) { + window.history.back(); + } + } catch (e) {} + } + + items = []; + } + + return { + open: open, + close: close + }; + })(); + + // ============================================================ + // Gallery Thumbnails + // ============================================================ function initGalleryThumbs() { var imgs = document.querySelectorAll('div.luxtools-gallery img[data-thumb-src]'); if (!imgs || !imgs.length) return; @@ -43,161 +304,9 @@ } } - function ensureLightbox() { - var existing = document.querySelector('.luxtools-lightbox'); - if (existing) return existing; - - var root = document.createElement('div'); - root.className = 'luxtools-lightbox'; - root.setAttribute('role', 'dialog'); - root.setAttribute('aria-modal', 'true'); - root.setAttribute('aria-hidden', 'true'); - - root.innerHTML = - '
' + - '
' + - '' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
'; - - document.body.appendChild(root); - return root; - } - - function getGalleryItems(galleryEl) { - var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]'); - var items = []; - links.forEach(function (a) { - var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || ''; - var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || ''; - if (!full) return; - items.push({ el: a, full: full, name: name }); - }); - return items; - } - - function openLightboxFor(galleryEl, startEl) { - var items = getGalleryItems(galleryEl); - if (!items.length) return; - - var index = 0; - for (var i = 0; i < items.length; i++) { - if (items[i].el === startEl) { index = i; break; } - } - - var lb = ensureLightbox(); - var img = lb.querySelector('img.luxtools-lightbox-img'); - var cap = lb.querySelector('.luxtools-lightbox-caption'); - - var pushedHistory = false; - var closingFromPopstate = false; - - function clamp(n) { - if (n < 0) return items.length - 1; - if (n >= items.length) return 0; - return n; - } - - function render() { - var it = items[index]; - img.src = it.full; - img.setAttribute('data-luxtools-index', String(index)); - if (cap) cap.textContent = (it.name || '').trim(); - } - - function close() { - lb.classList.remove('is-open'); - lb.setAttribute('aria-hidden', 'true'); - try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {} - try { document.body.style.overflow = ''; } catch (e) {} - // Clear src to stop downloads on close. - img.src = ''; - document.removeEventListener('keydown', onKeyDown, true); - window.removeEventListener('popstate', onPopState, true); - - // If we opened by pushing a state, pop it when closing via UI. - if (pushedHistory && !closingFromPopstate) { - try { - if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) { - window.history.back(); - } - } catch (e) { - // ignore - } - } - } - - function next() { - index = clamp(index + 1); - render(); - } - - function prev() { - index = clamp(index - 1); - render(); - } - - function onKeyDown(e) { - if (!lb.classList.contains('is-open')) return; - var key = e.key || ''; - if (key === 'Escape') { - e.preventDefault(); - close(); - } else if (key === 'ArrowRight') { - e.preventDefault(); - next(); - } else if (key === 'ArrowLeft') { - e.preventDefault(); - prev(); - } - } - - function onPopState() { - if (!lb.classList.contains('is-open')) return; - closingFromPopstate = true; - try { close(); } finally { closingFromPopstate = false; } - } - - lb.onclick = function (e) { - var t = e.target; - if (!t || !t.getAttribute) return; - var action = t.getAttribute('data-luxtools-action') || ''; - if (action === 'close') { e.preventDefault(); close(); return; } - if (action === 'next') { e.preventDefault(); next(); return; } - if (action === 'prev') { e.preventDefault(); prev(); return; } - - // Click outside the image closes (but don't interfere with controls). - if (t.closest && t.closest('button.luxtools-lightbox-zone')) return; - if (t.closest && t.closest('img.luxtools-lightbox-img')) return; - e.preventDefault(); - close(); - }; - - // Open - lb.classList.add('is-open'); - lb.setAttribute('aria-hidden', 'false'); - try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {} - try { document.body.style.overflow = 'hidden'; } catch (e) {} - document.addEventListener('keydown', onKeyDown, true); - window.addEventListener('popstate', onPopState, true); - - // Allow closing via browser back button. - try { - if (window.history && window.history.pushState) { - window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href); - pushedHistory = true; - } - } catch (e) { - // ignore - } - render(); - } - + // ============================================================ + // Open Service (file:// links) + // ============================================================ function getServiceUrl(el) { var url = el.getAttribute('data-service-url') || ''; url = (url || '').trim(); @@ -444,6 +553,9 @@ }, true); } + // ============================================================ + // Click Handlers + // ============================================================ function findOpenElement(target) { var el = target; while (el && el !== document) { @@ -469,7 +581,7 @@ var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null; if (gallery) { event.preventDefault(); - openLightboxFor(gallery, galleryItem); + Lightbox.open(gallery, galleryItem); return; } } @@ -508,6 +620,9 @@ }); } + // ============================================================ + // Initialize + // ============================================================ document.addEventListener('click', onClick, false); document.addEventListener('DOMContentLoaded', initGalleryThumbs, false); document.addEventListener('DOMContentLoaded', initScratchpads, false); diff --git a/style.css b/style.css index 88592cd..e3f8d7b 100644 --- a/style.css +++ b/style.css @@ -291,6 +291,8 @@ html.luxtools-noscroll body { */ max-width: calc(100vw - 1.6em); max-height: calc(100vh - 1.6em); + transform-origin: center center; + transition: transform 0.1s ease-out; } .luxtools-lightbox .luxtools-lightbox-caption {