Repository navigation
feat(web): pinch to zoom and scroll expanded images #10718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 4 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
ff71f0f
feat(web): pinch, wheel and double-click zoom on expanded images
t3dotgg 475c467
fix(web): drop unused MIN_IMAGE_ZOOM export
t3dotgg 7ca2362
fix(web): reset zoom state and wheel listener per image
t3dotgg 3684b21
fix(web): keep a zoomed image covering the viewport
t3dotgg 6181b7e
fix(web): scale line-mode wheel deltas before zooming
t3dotgg 0c3dc5e
fix(web): match native image viewer zoom and pan
t3dotgg 0497878
fix(web): tolerate a few pixels of jitter on image click
t3dotgg ff68b2c
fix(web): anchor Safari pinch at the pointer when the gesture has no …
t3dotgg d836a8a
fix(web): use native scrolling for expanded images
t3dotgg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| clampImagePan, | ||
| IMAGE_ZOOM_IDENTITY, | ||
| MAX_IMAGE_ZOOM, | ||
| panImage, | ||
| wheelZoomFactor, | ||
| zoomImageAt, | ||
| } from "./imageZoom"; | ||
|
|
||
| const frame = { | ||
| width: 400, | ||
| height: 300, | ||
| viewportWidth: 1000, | ||
| viewportHeight: 800, | ||
| centerX: 0, | ||
| centerY: 0, | ||
| }; | ||
|
|
||
| describe("zoomImageAt", () => { | ||
| it("keeps the anchored point fixed on screen", () => { | ||
| const anchor = { x: 100, y: -50 }; | ||
| // 4x overflows the viewport on both axes, so the clamp leaves room to | ||
| // keep the anchor fixed. The image point under the anchor before the zoom | ||
| // is `anchor` itself, and after the zoom it renders at x + scale * anchor. | ||
| const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, anchor, frame); | ||
| expect(zoomed.scale).toBe(4); | ||
| expect(zoomed.x + zoomed.scale * anchor.x).toBeCloseTo(anchor.x); | ||
| expect(zoomed.y + zoomed.scale * anchor.y).toBeCloseTo(anchor.y); | ||
| }); | ||
|
|
||
| it("recenters when the zoomed image still fits the viewport", () => { | ||
| expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 2, { x: 100, y: 100 }, frame)).toEqual({ | ||
| scale: 2, | ||
| x: 0, | ||
| y: 0, | ||
| }); | ||
| }); | ||
|
|
||
| it("clamps the scale and returns the same state when nothing changes", () => { | ||
| const max = zoomImageAt(IMAGE_ZOOM_IDENTITY, 100, { x: 0, y: 0 }, frame); | ||
| expect(max.scale).toBe(MAX_IMAGE_ZOOM); | ||
| expect(zoomImageAt(max, 2, { x: 0, y: 0 }, frame)).toBe(max); | ||
| expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 0.5, { x: 0, y: 0 }, frame)).toBe(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
|
|
||
| it("recenters when zooming back out to 1x", () => { | ||
| const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, { x: 150, y: 100 }, frame); | ||
| expect(zoomImageAt(zoomed, 0.25, { x: 0, y: 0 }, frame)).toEqual(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
| }); | ||
|
|
||
| describe("clampImagePan", () => { | ||
| it("centers an axis where the image still fits the viewport", () => { | ||
| expect(clampImagePan({ scale: 2, x: 300, y: 200 }, frame)).toEqual({ scale: 2, x: 0, y: 0 }); | ||
| }); | ||
|
|
||
| it("stops the far edge at the viewport edge once the image overflows", () => { | ||
| // 400 * 4 = 1600 wide in a 1000 viewport leaves 300 of slack per side. | ||
| expect(clampImagePan({ scale: 4, x: 900, y: -900 }, frame)).toEqual({ | ||
| scale: 4, | ||
| x: 300, | ||
| y: -200, | ||
| }); | ||
| }); | ||
|
|
||
| it("shifts the bounds by the image center offset so the viewport stays covered", () => { | ||
| // The caption sits the image 12px above the viewport center. Dragging down | ||
| // must stop 12px earlier and dragging up may go 12px further. | ||
| const offset = { ...frame, centerY: -12 }; | ||
| expect(clampImagePan({ scale: 4, x: 0, y: 900 }, offset).y).toBe(212); | ||
| expect(clampImagePan({ scale: 4, x: 0, y: -900 }, offset).y).toBe(-188); | ||
| }); | ||
| }); | ||
|
|
||
| describe("panImage", () => { | ||
| it("ignores pans at 1x", () => { | ||
| expect(panImage(IMAGE_ZOOM_IDENTITY, 40, 40, frame)).toBe(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
|
|
||
| it("moves a zoomed image within the clamp", () => { | ||
| expect(panImage({ scale: 4, x: 0, y: 0 }, -100, 50, frame)).toEqual({ | ||
| scale: 4, | ||
| x: -100, | ||
| y: 50, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("wheelZoomFactor", () => { | ||
| it("zooms in on negative deltas and caps a mouse notch", () => { | ||
| expect(wheelZoomFactor(-5)).toBeGreaterThan(1); | ||
| expect(wheelZoomFactor(5)).toBeLessThan(1); | ||
| expect(wheelZoomFactor(-100)).toBe(wheelZoomFactor(-50)); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| /** | ||
| * Pure zoom and pan math for the expanded image dialog. The image keeps its | ||
| * layout box and is scaled around its center with a CSS transform, so `x` and | ||
| * `y` are the translate applied after scaling, in CSS pixels. | ||
| */ | ||
| export interface ImageZoomState { | ||
| readonly scale: number; | ||
| readonly x: number; | ||
| readonly y: number; | ||
| } | ||
|
|
||
| /** | ||
| * The untransformed image size, the viewport it must stay visible in, and the | ||
| * offset of the untransformed image center from the viewport center. The | ||
| * caption below the image pushes the image above the viewport center, so the | ||
| * pan bounds are not symmetric. | ||
| */ | ||
| export interface ImageZoomFrame { | ||
| readonly width: number; | ||
| readonly height: number; | ||
| readonly viewportWidth: number; | ||
| readonly viewportHeight: number; | ||
| readonly centerX: number; | ||
| readonly centerY: number; | ||
| } | ||
|
|
||
| export interface Point { | ||
| readonly x: number; | ||
| readonly y: number; | ||
| } | ||
|
|
||
| export const IMAGE_ZOOM_IDENTITY: ImageZoomState = { scale: 1, x: 0, y: 0 }; | ||
| const MIN_IMAGE_ZOOM = 1; | ||
| export const MAX_IMAGE_ZOOM = 8; | ||
| /** Zoom applied by a double click on an unzoomed image. */ | ||
| export const DOUBLE_CLICK_IMAGE_ZOOM = 2.5; | ||
|
|
||
| function clampAxis( | ||
| translate: number, | ||
| scaledSize: number, | ||
| viewportSize: number, | ||
| center: number, | ||
| ): number { | ||
| // An image that fits on this axis keeps its layout position. One that | ||
| // overflows may be panned until its far edge meets the viewport edge, never | ||
| // past it, so it always covers the viewport on this axis. | ||
| if (scaledSize <= viewportSize) return 0; | ||
| const overflow = (scaledSize - viewportSize) / 2; | ||
| // `|| 0` turns a -0 from clamping into 0 so identity states compare equal. | ||
| return Math.min(overflow - center, Math.max(-overflow - center, translate)) || 0; | ||
| } | ||
|
|
||
| export function clampImagePan(state: ImageZoomState, frame: ImageZoomFrame): ImageZoomState { | ||
| return { | ||
| scale: state.scale, | ||
| x: clampAxis(state.x, frame.width * state.scale, frame.viewportWidth, frame.centerX), | ||
| y: clampAxis(state.y, frame.height * state.scale, frame.viewportHeight, frame.centerY), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Multiplies the scale by `factor` while keeping `anchor` fixed on screen. | ||
| * `anchor` is the pointer position relative to the untransformed image center. | ||
| */ | ||
| export function zoomImageAt( | ||
| state: ImageZoomState, | ||
| factor: number, | ||
| anchor: Point, | ||
| frame: ImageZoomFrame, | ||
| ): ImageZoomState { | ||
| const scale = Math.min(MAX_IMAGE_ZOOM, Math.max(MIN_IMAGE_ZOOM, state.scale * factor)); | ||
| if (scale === state.scale) return state; | ||
| const ratio = scale / state.scale; | ||
| return clampImagePan( | ||
| { | ||
| scale, | ||
| x: anchor.x - ratio * (anchor.x - state.x), | ||
| y: anchor.y - ratio * (anchor.y - state.y), | ||
| }, | ||
| frame, | ||
| ); | ||
| } | ||
|
|
||
| export function panImage( | ||
| state: ImageZoomState, | ||
| dx: number, | ||
| dy: number, | ||
| frame: ImageZoomFrame, | ||
| ): ImageZoomState { | ||
| if (state.scale <= MIN_IMAGE_ZOOM) return state; | ||
| return clampImagePan({ scale: state.scale, x: state.x + dx, y: state.y + dy }, frame); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a wheel delta into a zoom factor. Trackpad pinch on macOS arrives | ||
| * as ctrl+wheel with small deltas, mouse wheels send about 100 per notch, so | ||
| * the delta is capped to keep a notch from jumping more than about 1.6x. | ||
| */ | ||
| export function wheelZoomFactor(deltaY: number): number { | ||
| return Math.exp(-Math.max(-50, Math.min(50, deltaY)) * 0.01); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.