Feature 328 Interaction

Zoom In/Out Image ซูมรูปภาพ

A lightbox that zooms with the wheel, buttons, double-click and pinch, and pans by dragging.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Click a photo. In the viewer: mouse wheel, + - 0, double-click, drag to pan, pinch with two fingers, ← → for the next photo.

What to notice

  • Only a CSS transform (translate + scale) changes, so zoom is smooth.
  • Wheel zoom keeps the point under the cursor still.
  • Two pointers = pinch; the distance between them sets the scale.

Try it

Source code

3 parts, read from the files in this project
src/main/resources/db/migration/V1__schema.sql gallery_photo table
CREATE TABLE gallery_photo (
    id       INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_gallery_photo PRIMARY KEY,
    title    NVARCHAR(100) NOT NULL,
    art_seed INT           NOT NULL,
    width    INT           NOT NULL,
    height   INT           NOT NULL
);
src/main/resources/static/css/app.css Lightbox: only transform changes while zooming
.lightbox { position: fixed; inset: 0; z-index: 450; background: rgb(2 6 23 / .92); display: flex; flex-direction: column; animation: fade-in .2s; color: #fff; }
.lb-bar { display: flex; align-items: center; gap: .4rem; padding: .6rem .9rem; flex-wrap: wrap; }
.lb-bar .lb-title { font-weight: 600; margin-right: auto; font-size: .9rem; }
.lb-bar button { width: 2.4rem; height: 2.4rem; border-radius: .6rem; border: 0; background: rgb(255 255 255 / .1); color: #fff; display: grid; place-items: center; cursor: pointer; }
.lb-bar button:hover { background: rgb(255 255 255 / .2); }
.lb-zoom { font-family: var(--mono); font-size: .8rem; width: 3.5rem; text-align: center; }
.lb-stage { flex: 1; overflow: hidden; position: relative; touch-action: none; cursor: grab; display: grid; place-items: center; }
.lb-stage.panning { cursor: grabbing; }
.lb-stage img { max-width: 92%; max-height: 92%; transform-origin: 0 0; user-select: none; -webkit-user-drag: none; will-change: transform; position: absolute; left: 0; top: 0; }
.lb-hint { text-align: center; font-size: .75rem; color: rgb(255 255 255 / .6); padding: .5rem; }
src/main/resources/static/js/demos.js Lightbox: scale + translate, zoom around the pointer, pinch with two pointers
demos[328] = () => {
  const buttons = $$('#zoomGallery button');
  let lb = null, img, scale = 1, x = 0, y = 0, current = 0;
  const pointers = new Map();
  let pinchStart = null, panStart = null;
  function apply() {
    img.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
    $('.lb-zoom', lb).textContent = Math.round(scale * 100) + '%';
  }
  function fit() {
    const stage = $('.lb-stage', lb).getBoundingClientRect();
    const w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
    scale = Math.min(stage.width * 0.92 / w, stage.height * 0.92 / h, 1);
    x = (stage.width - w * scale) / 2; y = (stage.height - h * scale) / 2;
    apply();
    return scale;
  }
  function zoomAt(factor, cx, cy) {
    const stage = $('.lb-stage', lb).getBoundingClientRect();
    const px = cx - stage.left, py = cy - stage.top;
    const next = Math.max(0.1, Math.min(8, scale * factor));
    // keep the point under the cursor still: p = x + s*u  =>  x' = p - s'*u
    x = px - (px - x) * (next / scale);
    y = py - (py - y) * (next / scale);
    scale = next;
    apply();
  }
  function zoomCenter(factor) { const r = $('.lb-stage', lb).getBoundingClientRect(); zoomAt(factor, r.left + r.width / 2, r.top + r.height / 2); }
  function load(i) {
    current = (i + buttons.length) % buttons.length;
    const b = buttons[current];
    $('.lb-title', lb).textContent = `${b.dataset.title} (${current + 1} / ${buttons.length})`;
    img.onload = fit;
    img.src = `/art/${b.dataset.seed}.svg?w=${b.dataset.w}&h=${b.dataset.h}`;
    img.width = b.dataset.w; img.height = b.dataset.h;
  }
  function open(i, opener) {
    lb = document.createElement('div');
    lb.className = 'lightbox';
    lb.setAttribute('role', 'dialog'); lb.setAttribute('aria-modal', 'true'); lb.setAttribute('aria-label', 'Photo viewer');
    lb.innerHTML = `
      <div class="lb-bar">
        <span class="lb-title"></span>
        <button type="button" data-z="out" aria-label="Zoom out">${icon('zoom-out')}</button>
        <span class="lb-zoom">100%</span>
        <button type="button" data-z="in" aria-label="Zoom in">${icon('zoom')}</button>
        <button type="button" data-z="fit" aria-label="Fit to screen">${icon('refresh')}</button>
        <button type="button" data-z="prev" aria-label="Previous photo">${icon('chevron-left')}</button>
        <button type="button" data-z="next" aria-label="Next photo">${icon('chevron-right')}</button>
        <button type="button" data-z="fs" aria-label="Full screen">${icon('maximize')}</button>
        <button type="button" data-z="close" aria-label="Close">${icon('x')}</button>
      </div>
      <div class="lb-stage"><img alt="" draggable="false"></div>
      <div class="lb-hint">Wheel / + − to zoom · drag to pan · double-click to zoom · pinch on touch · ← → next · Esc close</div>`;
    document.body.appendChild(lb);
    document.body.style.overflow = 'hidden';
    img = $('img', lb);
    const stage = $('.lb-stage', lb);
    lb._opener = opener;
    load(i);
    $('[data-z="close"]', lb).focus();
    lb.addEventListener('click', (e) => {
      const z = e.target.closest('[data-z]')?.dataset.z;
      if (z === 'in') zoomCenter(1.25); else if (z === 'out') zoomCenter(0.8); else if (z === 'fit') fit();
      else if (z === 'prev') load(current - 1); else if (z === 'next') load(current + 1);
      else if (z === 'fs') UI.fullscreen.toggle(lb); else if (z === 'close') close();
    });
    stage.addEventListener('wheel', (e) => { e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX, e.clientY); }, { passive: false });
    stage.addEventListener('dblclick', (e) => { const base = scale; fit(); if (base <= scale * 1.05) zoomAt(2.5, e.clientX, e.clientY); });
    stage.addEventListener('pointerdown', (e) => {
      stage.setPointerCapture(e.pointerId);
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
      if (pointers.size === 2) {
        const [a, b] = [...pointers.values()];
        pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale };
      } else { panStart = { px: e.clientX, py: e.clientY, x, y }; stage.classList.add('panning'); }
    });
    stage.addEventListener('pointermove', (e) => {
      if (!pointers.has(e.pointerId)) return;
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
      if (pointers.size === 2 && pinchStart) {
        const [a, b] = [...pointers.values()];
        const factor = (pinchStart.scale * Math.hypot(a.x - b.x, a.y - b.y) / pinchStart.dist) / scale;
        zoomAt(factor, (a.x + b.x) / 2, (a.y + b.y) / 2);
      } else if (panStart) {
        x = panStart.x + e.clientX - panStart.px; y = panStart.y + e.clientY - panStart.py; apply();
      }
    });
    const up = (e) => { pointers.delete(e.pointerId); if (pointers.size < 2) pinchStart = null; if (!pointers.size) { panStart = null; stage.classList.remove('panning'); } };
    stage.addEventListener('pointerup', up);
    stage.addEventListener('pointercancel', up);
    lb.addEventListener('keydown', keys);
    addEventListener('resize', fit);
  }
  function keys(e) {
    const k = e.key;
    if (k === '+' || k === '=') zoomCenter(1.25); else if (k === '-') zoomCenter(0.8); else if (k === '0') fit();
    else if (k === 'ArrowRight') load(current + 1); else if (k === 'ArrowLeft') load(current - 1);
    else if (k === 'Escape') { if (!document.fullscreenElement) close(); return; }
    else if (k === 'Tab') { // keep focus inside the viewer
      const f = $$('button', lb), i = f.indexOf(document.activeElement);
      e.preventDefault(); f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus(); return;
    } else return;
    e.preventDefault();
    e.stopPropagation();
  }
  function close() {
    removeEventListener('resize', fit);
    document.body.style.overflow = '';
    const opener = lb._opener;
    lb.remove(); lb = null;
    opener?.focus();
  }
  buttons.forEach((b, i) => b.addEventListener('click', () => open(i, b)));
};

Are you sure?

Rename

↑↓ moveEnter openEsc close