fix: the image viewer pans without unzooming, scrolls with the wheel, and opens a long strip at full width

- The click that ends a drag no longer resets the zoom.
- The wheel scrolls a zoomed image (shift+wheel goes sideways). Ctrl+wheel or
  a trackpad pinch zooms at the pointer.
- Clicking a long strip or panorama zooms it to fill the canvas, so a comic
  strip opens at reading width. Anything else zooms 2x. The clicked point
  stays under the cursor.
- The zoom limit follows the image's own resolution instead of a flat 4x.
- Panning stops at the image's edges.
- A zoom bar (−, slider, +, size against full resolution, fit) fades in at
  the bottom of the canvas, and the shortcut sheet lists the gestures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 22:18:12 -05:00
co-authored by Claude Opus 5.5
parent 14936b62dc
commit f2e4f94a96
4 changed files with 334 additions and 47 deletions
+112 -11
View File
@@ -2,11 +2,15 @@
<div <div
ref="canvasEl" ref="canvasEl"
class="fc-canvas" class="fc-canvas"
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }" :class="{
'fc-canvas--zoomed': panZoom.state.scale > 1,
'fc-canvas--moving': !panZoom.state.smooth,
}"
@wheel="panZoom.handlers.onWheel" @wheel="panZoom.handlers.onWheel"
@pointerdown="panZoom.handlers.onPointerDown" @pointerdown="panZoom.handlers.onPointerDown"
@pointermove="panZoom.handlers.onPointerMove" @pointermove="panZoom.handlers.onPointerMove"
@pointerup="panZoom.handlers.onPointerUp" @pointerup="panZoom.handlers.onPointerUp"
@pointercancel="panZoom.handlers.onPointerCancel"
@click="onCanvasClick" @click="onCanvasClick"
> >
<img <img
@@ -18,6 +22,34 @@
draggable="false" draggable="false"
@load="onImgLoad" @load="onImgLoad"
> >
<!-- Zoom controls. They sit on the canvas but are not part of it: a press,
click or scroll on them never pans, toggles or closes. -->
<div
class="fc-canvas__zoombar"
@pointerdown.stop @click.stop @wheel.stop
>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Zoom out"
:disabled="panZoom.state.scale <= 1" @click="panZoom.zoomBy(-1)"
><v-icon size="18">mdi-minus</v-icon></button>
<input
type="range" class="fc-canvas__zoomslider" aria-label="Zoom"
min="0" max="1" step="0.001" :value="sliderValue"
@input="onSlider"
>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Zoom in"
:disabled="panZoom.state.scale >= maxScale" @click="panZoom.zoomBy(1)"
><v-icon size="18">mdi-plus</v-icon></button>
<span class="fc-canvas__zoomlabel" :title="'Shown at ' + zoomLabel + ' of its full size'">
{{ zoomLabel }}
</span>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Fit to the window"
title="Fit to the window"
:disabled="panZoom.state.scale <= 1" @click="panZoom.reset()"
><v-icon size="18">mdi-fit-to-screen-outline</v-icon></button>
</div>
<!-- #1206 grounding overlay: tracks the <img>'s live rendered rect (so it's <!-- #1206 grounding overlay: tracks the <img>'s live rendered rect (so it's
correct under object-fit letterboxing + pan/zoom) and draws the crop correct under object-fit letterboxing + pan/zoom) and draws the crop
region a hovered suggestion came from. Null grounding → a subtle region a hovered suggestion came from. Null grounding → a subtle
@@ -60,10 +92,37 @@ const props = defineProps({
hover: { type: Object, default: null }, hover: { type: Object, default: null },
}) })
const emit = defineEmits(['close-request']) const emit = defineEmits(['close-request'])
const panZoom = usePanZoom()
const canvasEl = ref(null) const canvasEl = ref(null)
const imgEl = ref(null) const imgEl = ref(null)
// Where the picture sits before any zoom: the canvas size, the picture's
// layout box (the zoom is a transform on top of it) and its natural width.
// Read again when the picture loads and when the window resizes.
const fitted = ref(null)
function refit() {
const img = imgEl.value
const canvas = canvasEl.value
fitted.value = img && canvas ? {
cw: canvas.clientWidth, ch: canvas.clientHeight,
iw: img.offsetWidth, ih: img.offsetHeight, nw: img.naturalWidth,
} : null
panZoom.clamp()
}
const panZoom = usePanZoom(() => fitted.value)
// The slider runs on a log scale, so each stretch of it is the same zoom step
// whether the picture is at 1.2x or 12x.
const maxScale = computed(() => panZoom.maxScale())
const sliderValue = computed(() =>
Math.log(panZoom.state.scale) / Math.log(maxScale.value))
function onSlider(ev) {
panZoom.setScale(maxScale.value ** Number(ev.target.value))
}
// How big it is against its full-size pixels.
const zoomLabel = computed(() => {
const g = fitted.value
const pct = g?.iw && g?.nw ? (g.iw * panZoom.state.scale) / g.nw : panZoom.state.scale
return `${Math.round(pct * 100)}%`
})
const overlayBox = ref(null) // the <img>'s rect relative to the canvas const overlayBox = ref(null) // the <img>'s rect relative to the canvas
// The <img> renders aspect-fit + transformed, so its getBoundingClientRect is // The <img> renders aspect-fit + transformed, so its getBoundingClientRect is
@@ -107,22 +166,21 @@ watch(
() => [panZoom.state.scale, panZoom.state.x, panZoom.state.y], () => [panZoom.state.scale, panZoom.state.x, panZoom.state.y],
() => { if (props.hover) measure() }, () => { if (props.hover) measure() },
) )
function onResize () { if (props.hover) measure() } function onResize () { refit(); if (props.hover) measure() }
function onImgLoad () { if (props.hover) measure() } function onImgLoad () { refit(); if (props.hover) measure() }
onMounted(() => window.addEventListener('resize', onResize)) onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize)) onUnmounted(() => window.removeEventListener('resize', onResize))
// Reset zoom when the src changes (prev/next nav). // Reset zoom when the src changes (prev/next nav).
watch(() => props.src, () => panZoom.reset()) watch(() => props.src, () => panZoom.reset())
// Click on the image → toggle zoom (IR/expected behavior). Click on // Click on the image → toggle zoom. Click on the haze around it → request
// the haze area around the image → request close. We use the image's // close. The <img> has pointer-events: none so a drag always lands on the
// own bounding rect rather than @click.self because the <img> sets // canvas, which is why "on the image" is worked out from its rect. The click
// pointer-events: none for the pan-drag pathway, so the click always // that ends a drag does neither.
// targets the canvas div regardless of where the cursor was.
function onCanvasClick(ev) { function onCanvasClick(ev) {
if (panZoom.endedDrag()) return
if (panZoom.state.scale > 1) { if (panZoom.state.scale > 1) {
// When zoomed, any click resets zoom — preserve old behavior.
panZoom.handlers.onClick(ev) panZoom.handlers.onClick(ev)
return return
} }
@@ -160,6 +218,49 @@ function onCanvasClick(ev) {
transition: transform 100ms ease-out; transition: transform 100ms ease-out;
pointer-events: none; pointer-events: none;
} }
.fc-canvas--moving img { transition: none; }
/* Zoom controls, bottom centre of the canvas: quiet until the canvas is
hovered or the picture is zoomed. */
.fc-canvas__zoombar {
position: absolute;
bottom: 16px; left: 50%;
transform: translateX(-50%);
z-index: 3;
display: flex; align-items: center; gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(var(--v-theme-background), 0.7);
border: 1px solid rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface));
cursor: default;
opacity: 0;
transition: opacity 150ms ease;
}
.fc-canvas:hover .fc-canvas__zoombar,
.fc-canvas--zoomed .fc-canvas__zoombar,
.fc-canvas__zoombar:focus-within { opacity: 1; }
.fc-canvas__zoombtn {
width: 28px; height: 28px;
display: grid; place-items: center;
border-radius: 50%;
color: inherit;
cursor: pointer;
}
.fc-canvas__zoombtn:hover:not(:disabled) { background: rgb(var(--v-theme-surface)); }
.fc-canvas__zoombtn:disabled { opacity: 0.35; cursor: default; }
.fc-canvas__zoomslider {
width: 140px;
accent-color: rgb(var(--v-theme-accent));
cursor: pointer;
}
.fc-canvas__zoomlabel {
min-width: 44px;
text-align: center;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Grounding overlay — never intercepts pan/zoom/click. */ /* Grounding overlay — never intercepts pan/zoom/click. */
.fc-canvas__overlay { .fc-canvas__overlay {
@@ -18,6 +18,9 @@
<h3>Keyboard shortcuts</h3> <h3>Keyboard shortcuts</h3>
<dl> <dl>
<div><dt>← / →</dt><dd>Previous / next image</dd></div> <div><dt>← / →</dt><dd>Previous / next image</dd></div>
<div><dt>Click</dt><dd>Zoom in (a long strip opens at full width) / back to fit</dd></div>
<div><dt>Scroll · drag</dt><dd>Move around a zoomed image</dd></div>
<div><dt>Ctrl + scroll</dt><dd>Zoom at the pointer</dd></div>
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div> <div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
<div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div> <div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div>
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div> <div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
+144 -36
View File
@@ -1,72 +1,174 @@
// Click-to-toggle zoom + drag-to-pan when zoomed + wheel-to-zoom. // Zoom and pan for one picture in a canvas.
// Returns reactive state + handlers a component can spread onto its //
// root element. // - Click toggles between the fitted view and a zoomed one. A long strip (or a
// wide panorama) zooms to fill the canvas across its short side, so a comic
// strip opens at reading width; anything else zooms to 2x. The clicked point
// stays under the cursor.
// - A drag pans, and the click that ends a drag does not toggle the zoom.
// - The wheel scrolls the zoomed picture (shift+wheel goes sideways); with
// ctrl held, which is also what a trackpad pinch sends, it zooms at the
// cursor.
// - zoomBy/setScale drive the toolbar's buttons and slider.
//
// `measure()` returns the canvas size, the picture's fitted (unzoomed) size
// and its natural width: { cw, ch, iw, ih, nw }, or null before it has laid
// out. The picture is centred in the canvas and scaled about its centre, so a
// translate of (x, y) at scale s puts picture point p at centre + (x, y) + s*p.
import { reactive } from 'vue' import { reactive } from 'vue'
const MIN_SCALE = 1 export const MIN_SCALE = 1
const MAX_SCALE = 4 const CLICK_SCALE = 2
const WHEEL_SENSITIVITY = 0.0015 // A picture that would have to grow more than this to fill the canvas is a
// strip or a panorama: its click zoom fills the canvas instead of doubling.
const LONG_FILL = 2
const MAX_FLOOR = 4 // the most you can zoom a small picture
const MAX_CEIL = 32
const STEP = 1.25 // one press of + or -
const DRAG_PX = 4 // movement before a press counts as a drag
const PINCH_SENSITIVITY = 0.01
const LINE_PX = 40 // a wheel "line" (deltaMode 1)
export function usePanZoom() { export function usePanZoom(measure = () => null) {
const state = reactive({ const state = reactive({
scale: 1, scale: 1,
x: 0, x: 0,
y: 0, y: 0,
panning: false panning: false,
// Off while dragging or scrolling, so the picture follows the hand
// instead of easing behind it.
smooth: true,
}) })
let dragOriginX = 0 let press = null // { x, y, panX, panY, id } while a button is down
let dragOriginY = 0 let dragged = false // the press that is ending moved: swallow its click
let panOriginX = 0
let panOriginY = 0 function geometry() {
const g = measure()
return g && g.iw > 0 && g.ih > 0 && g.cw > 0 && g.ch > 0 ? g : null
}
// The scale at which the picture covers the canvas both ways.
function fillScale(g = geometry()) {
return g ? Math.max(g.cw / g.iw, g.ch / g.ih) : CLICK_SCALE
}
function maxScale(g = geometry()) {
if (!g) return MAX_FLOOR
const actualPixels = g.nw ? g.nw / g.iw : 0
return Math.min(MAX_CEIL, Math.max(MAX_FLOOR, fillScale(g), actualPixels * 2))
}
function clickScale(g = geometry()) {
const fill = fillScale(g)
return Math.min(maxScale(g), fill > LONG_FILL ? fill : CLICK_SCALE)
}
// Keep the picture over the canvas: no panning past its edges, and a side
// that is smaller than the canvas stays centred.
function clamp() {
const g = geometry()
if (!g) return
const mx = Math.max(0, (g.iw * state.scale - g.cw) / 2)
const my = Math.max(0, (g.ih * state.scale - g.ch) / 2)
state.x = Math.min(mx, Math.max(-mx, state.x))
state.y = Math.min(my, Math.max(-my, state.y))
}
function reset() { function reset() {
state.smooth = true
state.scale = 1 state.scale = 1
state.x = 0 state.x = 0
state.y = 0 state.y = 0
} }
// Zoom to `scale` keeping the point (px, py), measured from the canvas
// centre, where it is on screen.
function zoomAt(scale, px = 0, py = 0) {
const next = Math.min(maxScale(), Math.max(MIN_SCALE, scale))
if (next === MIN_SCALE) { state.scale = next; state.x = 0; state.y = 0; return }
const k = next / state.scale
state.x = px - (px - state.x) * k
state.y = py - (py - state.y) * k
state.scale = next
clamp()
}
function setScale(scale) {
state.smooth = true
zoomAt(scale)
}
function zoomBy(steps) {
setScale(state.scale * STEP ** steps)
}
function fromCentre(ev) {
const r = ev.currentTarget?.getBoundingClientRect?.()
if (!r) return [0, 0]
return [ev.clientX - r.left - r.width / 2, ev.clientY - r.top - r.height / 2]
}
function toggleZoom(ev) { function toggleZoom(ev) {
if (state.scale > 1) { reset(); return } if (state.scale > MIN_SCALE) { reset(); return }
state.scale = 2 state.smooth = true
// Zoom centered on click point zoomAt(clickScale(), ...(ev ? fromCentre(ev) : [0, 0]))
if (ev && ev.currentTarget) {
const rect = ev.currentTarget.getBoundingClientRect()
const cx = ev.clientX - rect.left - rect.width / 2
const cy = ev.clientY - rect.top - rect.height / 2
state.x = -cx
state.y = -cy
}
} }
function onWheel(ev) { function onWheel(ev) {
ev.preventDefault() ev.preventDefault()
const delta = -ev.deltaY * WHEEL_SENSITIVITY state.smooth = false
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta * state.scale)) if (ev.ctrlKey) {
state.scale = next const [px, py] = fromCentre(ev)
if (next === MIN_SCALE) { state.x = 0; state.y = 0 } zoomAt(state.scale * Math.exp(-ev.deltaY * PINCH_SENSITIVITY), px, py)
return
}
if (state.scale === MIN_SCALE) return
const unit = ev.deltaMode === 1 ? LINE_PX
: ev.deltaMode === 2 ? (geometry()?.ch || 800) : 1
let dx = ev.deltaX * unit
let dy = ev.deltaY * unit
if (ev.shiftKey && !dx) { dx = dy; dy = 0 }
state.x -= dx
state.y -= dy
clamp()
} }
function onPointerDown(ev) { function onPointerDown(ev) {
if (state.scale === 1) return if (ev.button !== 0) return
dragged = false
press = { x: ev.clientX, y: ev.clientY, panX: state.x, panY: state.y, id: ev.pointerId }
if (state.scale === MIN_SCALE) return
state.panning = true state.panning = true
dragOriginX = ev.clientX state.smooth = false
dragOriginY = ev.clientY try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch {}
panOriginX = state.x
panOriginY = state.y
ev.currentTarget.setPointerCapture(ev.pointerId)
} }
function onPointerMove(ev) { function onPointerMove(ev) {
if (!press) return
const dx = ev.clientX - press.x
const dy = ev.clientY - press.y
if (!dragged && Math.hypot(dx, dy) > DRAG_PX) dragged = true
if (!state.panning) return if (!state.panning) return
state.x = panOriginX + (ev.clientX - dragOriginX) state.x = press.panX + dx
state.y = panOriginY + (ev.clientY - dragOriginY) state.y = press.panY + dy
clamp()
} }
function onPointerUp(ev) { function onPointerUp(ev) {
press = null
if (!state.panning) return
state.panning = false state.panning = false
try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {} try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {}
} }
// True once for the click that ends a drag, so the canvas can ignore it.
function endedDrag() {
const was = dragged
dragged = false
return was
}
return { return {
state, state,
handlers: { handlers: {
@@ -74,8 +176,14 @@ export function usePanZoom() {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onClick: toggleZoom onPointerCancel: onPointerUp,
onClick: toggleZoom,
}, },
reset endedDrag,
zoomBy,
setScale,
maxScale,
clamp,
reset,
} }
} }
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { usePanZoom } from '../src/composables/usePanZoom.js'
// A 1400x1300 canvas. The strip is 3600x24000, fitted to 195x1300.
const STRIP = { cw: 1400, ch: 1300, iw: 195, ih: 1300, nw: 3600 }
const PHOTO = { cw: 1400, ch: 1300, iw: 1200, ih: 900, nw: 4000 }
const RECT = { left: 0, top: 0, width: 1400, height: 1300 }
const target = { getBoundingClientRect: () => RECT, setPointerCapture() {}, releasePointerCapture() {} }
const at = (x, y, extra = {}) => ({ clientX: x, clientY: y, currentTarget: target,
button: 0, pointerId: 1, preventDefault() {}, ...extra })
describe('usePanZoom', () => {
it('opens a long strip at the canvas width, keeping the clicked point under the cursor', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650 - 300))
expect(pz.state.scale).toBeCloseTo(1400 / 195)
expect(pz.state.x).toBe(0)
// The picture point that was 300px above centre (picture y = -300 at
// scale 1) is still 300px above centre: y + scale * -300 === -300.
expect(pz.state.y + pz.state.scale * -300).toBeCloseTo(-300)
})
it('doubles an ordinary picture', () => {
const pz = usePanZoom(() => PHOTO)
pz.handlers.onClick(at(700, 650))
expect(pz.state.scale).toBe(2)
})
it('does not unzoom on the click that ends a drag', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650))
pz.handlers.onPointerDown(at(700, 650))
pz.handlers.onPointerMove(at(700, 450))
pz.handlers.onPointerUp(at(700, 450))
expect(pz.endedDrag()).toBe(true)
expect(pz.state.y).toBe(-200)
// A plain click afterwards is a click again.
pz.handlers.onPointerDown(at(700, 450))
pz.handlers.onPointerUp(at(700, 450))
expect(pz.endedDrag()).toBe(false)
})
it('scrolls a zoomed picture with the wheel and stops at its ends', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650))
const scale = pz.state.scale
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
expect(pz.state.scale).toBe(scale)
expect(pz.state.y).toBe(-100)
pz.handlers.onWheel(at(700, 650, { deltaY: 1e6, deltaX: 0, deltaMode: 0 }))
expect(pz.state.y).toBeCloseTo(-(1300 * scale - 1300) / 2)
})
it('leaves the fitted view alone on a plain wheel and zooms with ctrl held', () => {
const pz = usePanZoom(() => PHOTO)
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
expect(pz.state.scale).toBe(1)
pz.handlers.onWheel(at(700, 650, { deltaY: -100, deltaX: 0, deltaMode: 0, ctrlKey: true }))
expect(pz.state.scale).toBeGreaterThan(1)
})
it('steps with the buttons and never goes past full size twice over', () => {
const pz = usePanZoom(() => PHOTO)
pz.zoomBy(1)
expect(pz.state.scale).toBeCloseTo(1.25)
pz.zoomBy(100)
expect(pz.state.scale).toBeCloseTo(pz.maxScale())
expect(pz.maxScale()).toBeCloseTo((4000 / 1200) * 2)
pz.zoomBy(-100)
expect(pz.state.scale).toBe(1)
expect([pz.state.x, pz.state.y]).toEqual([0, 0])
})
})