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
+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])
})
})