feat(web): in-canvas zoom & pan for the object eraser and split tools (#320)

* feat(web): add pure zoom/pan math module with unit tests

* feat(i18n): add a11y.pan key across all locales (English, matching adjacent zoom labels)

* feat(web): add useZoomPan hook (state + gestures over pure math)

* feat(web): add ZoomToolbar component

* feat(web): zoom & pan in the object eraser canvas

* feat(web): zoom & pan in the split tool preview

* fix(web): synchronous pan-mode refs so drag-pan is race-free under fast input

* test(e2e): zoom & pan acceptance (split always-on, eraser bundle-gated)
This commit is contained in:
SnapOtter
2026-06-22 20:50:28 +08:00
committed by GitHub
parent 313d4ae04e
commit 95d100c20b
28 changed files with 907 additions and 56 deletions
@@ -0,0 +1,104 @@
import { Hand, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
interface ZoomToolbarProps {
percent: number;
canZoomIn: boolean;
canZoomOut: boolean;
canActualSize: boolean;
handToolActive: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
onActualSize: () => void;
onToggleHandTool: () => void;
}
const btn =
"p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed";
export function ZoomToolbar({
percent,
canZoomIn,
canZoomOut,
canActualSize,
handToolActive,
onZoomIn,
onZoomOut,
onFit,
onActualSize,
onToggleHandTool,
}: ZoomToolbarProps) {
const { t } = useTranslation();
return (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-10">
<div
data-testid="zoom-toolbar"
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-background/90 px-2 py-1 shadow-sm backdrop-blur-sm"
>
<button
type="button"
data-testid="zoom-out"
onClick={onZoomOut}
disabled={!canZoomOut}
title={t.a11y.zoomOut}
aria-label={t.a11y.zoomOut}
className={btn}
>
<ZoomOut className="h-4 w-4" />
</button>
<span
data-testid="zoom-percent"
className="min-w-[3.25rem] text-center text-xs tabular-nums text-muted-foreground"
>
{percent}%
</span>
<button
type="button"
data-testid="zoom-in"
onClick={onZoomIn}
disabled={!canZoomIn}
title={t.a11y.zoomIn}
aria-label={t.a11y.zoomIn}
className={btn}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border" />
<button
type="button"
data-testid="zoom-fit"
onClick={onFit}
title={t.a11y.fitToView}
aria-label={t.a11y.fitToView}
className={btn}
>
<Maximize className="h-4 w-4" />
</button>
<button
type="button"
data-testid="zoom-actual"
onClick={onActualSize}
disabled={!canActualSize}
title={t.a11y.actualSize}
aria-label={t.a11y.actualSize}
className={btn}
>
<Minimize2 className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border" />
<button
type="button"
data-testid="zoom-pan"
onClick={onToggleHandTool}
title={t.a11y.pan}
aria-label={t.a11y.pan}
aria-pressed={handToolActive}
className={`${btn} ${handToolActive ? "bg-primary/10 text-primary hover:text-primary" : ""}`}
>
<Hand className="h-4 w-4" />
</button>
</div>
</div>
);
}
+135 -54
View File
@@ -1,4 +1,7 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { ZoomToolbar } from "@/components/common/zoom-toolbar";
import { useZoomPan } from "@/hooks/use-zoom-pan";
import { renderSize } from "@/hooks/zoom-pan-math";
type Point = { x: number; y: number };
type Stroke = { points: Point[]; size: number };
@@ -43,6 +46,17 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
// Cursor position for brush preview
const [cursorPos, setCursorPos] = useState<Point | null>(null);
// Zoom & pan. Sizes become available once the image is measured.
const sizes =
canvasSize && naturalRef.current.w
? { natural: { ...naturalRef.current }, fitted: { w: canvasSize.w, h: canvasSize.h } }
: null;
const zp = useZoomPan({ sizes, viewportRef: wrapperRef, resetKey: imageSrc });
const { isPanMode, beginPan, movePan, endPan, toContent } = zp;
// Mask backing-store resolution: natural res (capped) so the overlay stays crisp when zoomed.
const renderDims = sizes ? renderSize(sizes) : null;
// Measure and fit image to container
const measure = useCallback(() => {
const img = imgRef.current;
@@ -60,6 +74,17 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
});
}, []);
// Acquire the mask context with the backing-store scale applied, so all drawing
// happens in fitted (canvasSize) coordinates but renders at natural resolution.
const prepCtx = useCallback((): CanvasRenderingContext2D | null => {
const canvas = canvasRef.current;
if (!canvas || !canvasSize) return null;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.setTransform(canvas.width / canvasSize.w, 0, 0, canvas.height / canvasSize.h, 0, 0);
return ctx;
}, [canvasSize]);
// Persist current strokes to the per-image map
const persistStrokes = useCallback(() => {
if (!canvasSize || !naturalRef.current.w) return;
@@ -114,7 +139,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
// Redraw all strokes
const redraw = useCallback(() => {
const ctx = canvasRef.current?.getContext("2d");
const ctx = prepCtx();
if (!ctx || !canvasSize) return;
ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
@@ -122,7 +147,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
for (const stroke of strokesRef.current) {
drawStroke(ctx, stroke);
}
}, [canvasSize, drawStroke]);
}, [canvasSize, drawStroke, prepCtx]);
// Keyboard shortcut: Ctrl+Z for undo
useEffect(() => {
@@ -140,19 +165,33 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
return () => window.removeEventListener("keydown", handler);
}, [onStrokeChange, onMaskedCountChange, redraw, persistStrokes]);
// Get canvas-relative point from event
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const raw = "touches" in e ? e.touches[0] : e;
if (!raw) return null;
return { x: raw.clientX - rect.left, y: raw.clientY - rect.top };
}, []);
// Get canvas-relative point from event (transform-agnostic: divides out zoom/pan)
const getPoint = useCallback(
(e: React.MouseEvent | React.TouchEvent): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const raw = "touches" in e ? e.touches[0] : e;
if (!raw) return null;
return toContent(raw.clientX, raw.clientY, canvas.getBoundingClientRect());
},
[toContent],
);
const handleDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
// Multi-touch -> hand off to pinch-zoom; never paint a stray stroke.
// Return BEFORE preventDefault so the gesture sees the pinch.
if ("touches" in e && e.touches.length > 1) {
drawingRef.current = false;
currentPointsRef.current = [];
return;
}
// Pan mode -> drag pans instead of painting.
if (isPanMode) {
const raw = "touches" in e ? e.touches[0] : e;
if (raw) beginPan(raw.clientX, raw.clientY);
return;
}
if ("touches" in e) e.preventDefault();
const pt = getPoint(e);
if (!pt) return;
@@ -160,7 +199,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
currentPointsRef.current = [pt];
// Immediate dot
const ctx = canvasRef.current?.getContext("2d");
const ctx = prepCtx();
if (ctx) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
@@ -168,11 +207,23 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.fill();
}
},
[getPoint, brushSize],
[getPoint, brushSize, isPanMode, beginPan, prepCtx],
);
const handleMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if ("touches" in e && e.touches.length > 1) {
drawingRef.current = false;
return;
}
// Pan mode -> drag pans; hide the brush preview.
if (isPanMode) {
const raw = "touches" in e ? e.touches[0] : e;
if (raw) movePan(raw.clientX, raw.clientY);
setCursorPos(null);
return;
}
// Update cursor position for brush preview
const pt = getPoint(e);
if (pt) setCursorPos(pt);
@@ -183,7 +234,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
currentPointsRef.current.push(pt);
const ctx = canvasRef.current?.getContext("2d");
const ctx = prepCtx();
if (!ctx) return;
const pts = currentPointsRef.current;
if (pts.length < 2) return;
@@ -196,10 +247,11 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
},
[getPoint, brushSize],
[getPoint, brushSize, isPanMode, movePan, prepCtx],
);
const handleUp = useCallback(() => {
endPan();
if (!drawingRef.current) return;
drawingRef.current = false;
@@ -214,7 +266,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
persistStrokes();
onMaskedCountChange?.(allStrokesRef.current.size);
}
}, [brushSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes]);
}, [brushSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes, endPan]);
const handleLeave = useCallback(() => {
setCursorPos(null);
@@ -363,7 +415,12 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
);
return (
<div ref={wrapperRef} className="relative flex items-center justify-center w-full h-full">
<div
ref={wrapperRef}
data-testid="zoom-viewport"
className="relative flex items-center justify-center w-full h-full overflow-hidden touch-none"
{...zp.bindGestures()}
>
{/* Hidden img for measuring natural size before canvas is ready */}
{!canvasSize && (
<img
@@ -374,43 +431,67 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
className="max-w-full max-h-full object-contain"
/>
)}
{canvasSize && (
<div className="relative" style={{ width: canvasSize.w, height: canvasSize.h }}>
<img
ref={imgRef}
src={imageSrc}
alt="Paint over objects to erase"
className="block"
style={{ width: canvasSize.w, height: canvasSize.h }}
/>
<canvas
ref={canvasRef}
width={canvasSize.w}
height={canvasSize.h}
className="absolute inset-0 touch-none"
style={{ cursor: "none" }}
onMouseDown={handleDown}
onMouseMove={handleMove}
onMouseUp={handleUp}
onMouseLeave={handleLeave}
onTouchStart={handleDown}
onTouchMove={handleMove}
onTouchEnd={handleUp}
/>
{/* Brush cursor preview */}
{cursorPos && (
<div
className="pointer-events-none absolute rounded-full border-2 border-white/80"
style={{
width: brushSize,
height: brushSize,
left: cursorPos.x - brushSize / 2,
top: cursorPos.y - brushSize / 2,
boxShadow: "0 0 0 1px rgba(0,0,0,0.3)",
}}
{canvasSize && renderDims && (
<>
<div
data-testid="zoom-content"
className="relative"
style={{
width: canvasSize.w,
height: canvasSize.h,
transform: zp.transform,
transformOrigin: "center center",
}}
>
<img
ref={imgRef}
src={imageSrc}
alt="Paint over objects to erase"
className="block"
style={{ width: canvasSize.w, height: canvasSize.h }}
draggable={false}
/>
)}
</div>
<canvas
ref={canvasRef}
width={renderDims.w}
height={renderDims.h}
className="absolute inset-0 touch-none"
style={{ cursor: isPanMode ? "grab" : "none" }}
onMouseDown={handleDown}
onMouseMove={handleMove}
onMouseUp={handleUp}
onMouseLeave={handleLeave}
onTouchStart={handleDown}
onTouchMove={handleMove}
onTouchEnd={handleUp}
/>
{/* Brush cursor preview */}
{cursorPos && !isPanMode && (
<div
className="pointer-events-none absolute rounded-full border-2 border-white/80"
style={{
width: brushSize,
height: brushSize,
left: cursorPos.x - brushSize / 2,
top: cursorPos.y - brushSize / 2,
boxShadow: "0 0 0 1px rgba(0,0,0,0.3)",
}}
/>
)}
</div>
<ZoomToolbar
percent={zp.percent}
canZoomIn={zp.canZoomIn}
canZoomOut={zp.canZoomOut}
canActualSize={zp.canActualSize}
handToolActive={zp.handToolActive}
onZoomIn={zp.zoomIn}
onZoomOut={zp.zoomOut}
onFit={zp.fit}
onActualSize={zp.actualSize}
onToggleHandTool={zp.toggleHandTool}
/>
</>
)}
</div>
);
+42 -2
View File
@@ -1,5 +1,7 @@
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ZoomToolbar } from "@/components/common/zoom-toolbar";
import { useZoomPan } from "@/hooks/use-zoom-pan";
import { useFileStore } from "@/stores/file-store";
import { useSplitStore } from "@/stores/split-store";
@@ -73,6 +75,20 @@ export function SplitCanvas() {
updateDisplaySize();
}, [grid.columns, grid.rows, updateDisplaySize]);
const zoomSizes =
displaySize && imageDimensions
? {
natural: { w: imageDimensions.width, h: imageDimensions.height },
fitted: { w: displaySize.width, h: displaySize.height },
}
: null;
const zp = useZoomPan({
sizes: zoomSizes,
viewportRef: containerRef,
resetKey: src ?? undefined,
enableDragPan: true,
});
if (!src) return null;
// Show spinner while HEIC/HEIF preview is being decoded server-side
@@ -169,8 +185,17 @@ export function SplitCanvas() {
const showDimLabels = cols * rows <= 25;
return (
<div ref={containerRef} className="relative w-full h-full flex items-center justify-center">
<div className="relative inline-block max-w-full max-h-full">
<div
ref={containerRef}
data-testid="zoom-viewport"
className="relative w-full h-full flex items-center justify-center overflow-hidden touch-none"
{...zp.bindGestures()}
>
<div
data-testid="zoom-content"
className="relative inline-block max-w-full max-h-full"
style={{ transform: zp.transform, transformOrigin: "center center" }}
>
<img
ref={imgRef}
src={src}
@@ -261,6 +286,21 @@ export function SplitCanvas() {
)}
</div>
{displaySize && (
<ZoomToolbar
percent={zp.percent}
canZoomIn={zp.canZoomIn}
canZoomOut={zp.canZoomOut}
canActualSize={zp.canActualSize}
handToolActive={zp.handToolActive}
onZoomIn={zp.zoomIn}
onZoomOut={zp.zoomOut}
onFit={zp.fit}
onActualSize={zp.actualSize}
onToggleHandTool={zp.toggleHandTool}
/>
)}
{/* Image info bar */}
{imageDimensions && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/80 border border-border rounded-full px-3 py-1 text-[11px] text-muted-foreground tabular-nums backdrop-blur-sm">
+258
View File
@@ -0,0 +1,258 @@
import { useGesture } from "@use-gesture/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
actualSizeZoom,
anchorPan,
canActualSize as canActualSizeOf,
clampPan,
clampZoom,
MIN_ZOOM,
maxZoomOf,
type Point,
percentOf,
type Size,
toContentPoint,
wheelZoomFactor,
type ZoomPanSizes,
} from "./zoom-pan-math";
interface UseZoomPanOptions {
/** null until the image has been measured. */
sizes: ZoomPanSizes | null;
/** Clipping viewport element (overflow-hidden). Used for pan bounds + resize. */
viewportRef: React.RefObject<HTMLElement | null>;
/** Reset zoom/pan when this changes (e.g. the image source). Defaults to natural dims. */
resetKey?: string;
/**
* Enable drag-to-pan via the gesture binding (split). The eraser keeps this false
* and drives pan through its own pointer handlers, so the gesture never captures
* pointers away from the drawing canvas.
*/
enableDragPan?: boolean;
}
export function useZoomPan({
sizes,
viewportRef,
resetKey,
enableDragPan = false,
}: UseZoomPanOptions) {
const [zoom, setZoom] = useState(MIN_ZOOM);
const [pan, setPan] = useState<Point>({ x: 0, y: 0 });
const [handToolActive, setHandToolActive] = useState(false);
const [spaceHeld, setSpaceHeld] = useState(false);
const zoomRef = useRef(zoom);
const panRef = useRef(pan);
const sizesRef = useRef(sizes);
const pointerOverRef = useRef(false);
// Synchronous mirrors of pan-mode state so the gesture's drag handler reads the
// current value immediately (a passive effect lags fast keydown->pointerdown input).
const spaceHeldRef = useRef(false);
const handToolRef = useRef(false);
const panStartRef = useRef<{ pan: Point; cx: number; cy: number } | null>(null);
const pinchRef = useRef<{ base: number; ox: number; oy: number } | null>(null);
const isPanMode = spaceHeld || handToolActive;
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
useEffect(() => {
panRef.current = pan;
}, [pan]);
useEffect(() => {
sizesRef.current = sizes;
}, [sizes]);
// Reset when the image changes (not on resize).
const resetSignal = resetKey ?? (sizes ? `${sizes.natural.w}x${sizes.natural.h}` : "none");
// biome-ignore lint/correctness/useExhaustiveDependencies: resetSignal drives an intentional reset
useEffect(() => {
setZoom(MIN_ZOOM);
setPan({ x: 0, y: 0 });
setHandToolActive(false);
handToolRef.current = false;
}, [resetSignal]);
const viewportSize = useCallback((): Size => {
const el = viewportRef.current;
return { w: el?.clientWidth ?? 0, h: el?.clientHeight ?? 0 };
}, [viewportRef]);
const viewportCenter = useCallback((): Point => {
const rect = viewportRef.current?.getBoundingClientRect();
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : { x: 0, y: 0 };
}, [viewportRef]);
const applyZoom = useCallback(
(next: number, cursor?: Point) => {
const s = sizesRef.current;
if (!s) return;
const target = clampZoom(next, s);
const center = viewportCenter();
const anchored = anchorPan(panRef.current, zoomRef.current, target, cursor ?? center, center);
setZoom(target);
setPan(clampPan(anchored, target, s.fitted, viewportSize()));
},
[viewportCenter, viewportSize],
);
const zoomIn = useCallback(() => applyZoom(zoomRef.current * 1.5), [applyZoom]);
const zoomOut = useCallback(() => applyZoom(zoomRef.current / 1.5), [applyZoom]);
const fit = useCallback(() => {
setZoom(MIN_ZOOM);
setPan({ x: 0, y: 0 });
}, []);
const actualSize = useCallback(() => {
const s = sizesRef.current;
if (s) applyZoom(actualSizeZoom(s));
}, [applyZoom]);
const toggleHandTool = useCallback(() => {
handToolRef.current = !handToolRef.current;
setHandToolActive(handToolRef.current);
}, []);
const zoomAtPoint = useCallback(
(clientX: number, clientY: number, factor: number) =>
applyZoom(zoomRef.current * factor, { x: clientX, y: clientY }),
[applyZoom],
);
const beginPan = useCallback((clientX: number, clientY: number) => {
panStartRef.current = { pan: panRef.current, cx: clientX, cy: clientY };
}, []);
const movePan = useCallback(
(clientX: number, clientY: number) => {
const start = panStartRef.current;
const s = sizesRef.current;
if (!start || !s) return;
const next = {
x: start.pan.x + (clientX - start.cx),
y: start.pan.y + (clientY - start.cy),
};
setPan(clampPan(next, zoomRef.current, s.fitted, viewportSize()));
},
[viewportSize],
);
const endPan = useCallback(() => {
panStartRef.current = null;
}, []);
const toContent = useCallback((clientX: number, clientY: number, rect: DOMRect): Point => {
const s = sizesRef.current;
if (!s) return { x: 0, y: 0 };
return toContentPoint(clientX, clientY, rect, s.fitted);
}, []);
// Space-to-pan, scoped to pointer-over-viewport and not while typing.
useEffect(() => {
const isTyping = () => {
const el = document.activeElement as HTMLElement | null;
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
};
const down = (e: KeyboardEvent) => {
if (e.key === " " && pointerOverRef.current && !isTyping()) {
e.preventDefault();
spaceHeldRef.current = true;
setSpaceHeld(true);
}
};
const up = (e: KeyboardEvent) => {
if (e.key === " ") {
spaceHeldRef.current = false;
setSpaceHeld(false);
}
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, []);
// Re-clamp pan on viewport resize (never refit).
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const s = sizesRef.current;
if (!s) return;
setPan((p) => clampPan(p, zoomRef.current, s.fitted, viewportSize()));
});
ro.observe(el);
return () => ro.disconnect();
}, [viewportRef, viewportSize]);
const gesture = useGesture(
{
onHover: ({ hovering }) => {
pointerOverRef.current = !!hovering;
},
onMove: () => {
pointerOverRef.current = true;
},
onWheel: ({ event, delta: [, dy] }) => {
event.preventDefault();
zoomAtPoint(event.clientX, event.clientY, wheelZoomFactor(dy));
},
onPinch: ({ first, last, origin: [ox, oy], offset: [scale] }) => {
const s = sizesRef.current;
if (!s) return;
if (first || !pinchRef.current) {
pinchRef.current = { base: zoomRef.current, ox, oy };
}
const pr = pinchRef.current;
const moved = { x: panRef.current.x + (ox - pr.ox), y: panRef.current.y + (oy - pr.oy) };
pr.ox = ox;
pr.oy = oy;
const target = clampZoom(pr.base * scale, s);
const anchored = anchorPan(
moved,
zoomRef.current,
target,
{ x: ox, y: oy },
viewportCenter(),
);
setZoom(target);
setPan(clampPan(anchored, target, s.fitted, viewportSize()));
if (last) pinchRef.current = null;
},
onDrag: ({ first, last, xy: [px, py] }) => {
if (!(spaceHeldRef.current || handToolRef.current)) return;
if (first) beginPan(px, py);
movePan(px, py);
if (last) endPan();
},
},
{
wheel: { eventOptions: { passive: false } },
drag: { enabled: enableDragPan, filterTaps: true },
},
);
const bindGestures = useCallback(() => gesture(), [gesture]);
return {
zoom,
panOffset: pan,
isPanMode,
handToolActive,
percent: sizes ? percentOf(zoom, sizes) : 100,
canZoomIn: sizes ? zoom < maxZoomOf(sizes) - 1e-3 : false,
canZoomOut: zoom > MIN_ZOOM + 1e-3,
canActualSize: sizes ? canActualSizeOf(sizes) : false,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
zoomIn,
zoomOut,
fit,
actualSize,
toggleHandTool,
zoomAtPoint,
beginPan,
movePan,
endPan,
toContent,
bindGestures,
};
}
+127
View File
@@ -0,0 +1,127 @@
// Pure, framework-free math for in-canvas zoom & pan. No React, no DOM, no @use-gesture.
// Kept separate so it can be unit-tested without layout (jsdom has none).
export interface Size {
w: number;
h: number;
}
export interface Point {
x: number;
y: number;
}
export interface ZoomPanSizes {
/** Intrinsic image pixels. */
natural: Size;
/** Current fit-to-container display size (eraser canvasSize / split displaySize). */
fitted: Size;
}
/** Smallest zoom multiplier: 1 === fit-to-container. */
export const MIN_ZOOM = 1;
/** Longest-side cap for the eraser's mask backing store (bounds memory). */
export const MAX_RENDER_DIM = 4096;
/** fitted / natural; the scale already applied to fit the image in its frame. */
export function fitScaleOf(sizes: ZoomPanSizes): number {
return sizes.natural.w > 0 ? sizes.fitted.w / sizes.natural.w : 1;
}
/** Upper zoom bound: always lets you reach actual size (1/fitScale) plus headroom. */
export function maxZoomOf(sizes: ZoomPanSizes): number {
const fs = fitScaleOf(sizes);
return fs > 0 ? Math.max(8, (1 / fs) * 1.25) : 8;
}
export function clampZoom(zoom: number, sizes: ZoomPanSizes): number {
return Math.min(maxZoomOf(sizes), Math.max(MIN_ZOOM, zoom));
}
/** Actual-pixel percentage shown in the toolbar (100% === 1 image px : 1 screen px). */
export function percentOf(zoom: number, sizes: ZoomPanSizes): number {
return Math.round(zoom * fitScaleOf(sizes) * 100);
}
/** Zoom multiplier at which the image renders at its natural resolution. */
export function actualSizeZoom(sizes: ZoomPanSizes): number {
const fs = fitScaleOf(sizes);
return fs > 0 ? clampZoom(1 / fs, sizes) : MIN_ZOOM;
}
/** "Actual size" only makes sense when the image is larger than its frame. */
export function canActualSize(sizes: ZoomPanSizes): boolean {
const fs = fitScaleOf(sizes);
return fs > 0 && 1 / fs > 1.001;
}
/** Continuous wheel/trackpad zoom factor; only the vertical delta matters. */
export function wheelZoomFactor(deltaY: number): number {
return Math.exp(-deltaY * 0.0015);
}
/**
* Cursor-anchored pan: keep the content point under `cursor` fixed when zoom
* changes oldZoom -> newZoom. Assumes transform-origin: center center.
* d = cursor - viewportCenter ; k = newZoom/oldZoom ; pan' = d(1-k) + pan*k
*/
export function anchorPan(
pan: Point,
oldZoom: number,
newZoom: number,
cursor: Point,
viewportCenter: Point,
): Point {
const k = oldZoom !== 0 ? newZoom / oldZoom : 1;
return {
x: (cursor.x - viewportCenter.x) * (1 - k) + pan.x * k,
y: (cursor.y - viewportCenter.y) * (1 - k) + pan.y * k,
};
}
/**
* Clamp pan so the scaled content (fitted * zoom) never reveals empty gutters;
* locks to centered (0) on any axis where content fits within the viewport.
*/
export function clampPan(pan: Point, zoom: number, fitted: Size, viewport: Size): Point {
const overflowX = Math.max(0, (fitted.w * zoom - viewport.w) / 2);
const overflowY = Math.max(0, (fitted.h * zoom - viewport.h) / 2);
return {
x: Math.min(overflowX, Math.max(-overflowX, pan.x)),
y: Math.min(overflowY, Math.max(-overflowY, pan.y)),
};
}
/**
* Map a screen point to fitted (content) coordinates. Transform-agnostic:
* `rect` already reflects any zoom (rect.width === fitted.w * zoom), so the
* zoom divides out without being known here. Keeps eraser masks accurate.
*/
export function toContentPoint(
clientX: number,
clientY: number,
rect: { left: number; top: number; width: number; height: number },
fitted: Size,
): Point {
if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 };
return {
x: (clientX - rect.left) * (fitted.w / rect.width),
y: (clientY - rect.top) * (fitted.h / rect.height),
};
}
/**
* Backing-store resolution for the eraser mask canvas: natural resolution,
* longest side capped at MAX_RENDER_DIM, floored at the fitted size so small
* upscaled images never blur. Stroke coordinates remain in fitted space.
*/
export function renderSize(sizes: ZoomPanSizes): Size {
const { natural, fitted } = sizes;
const longest = Math.max(natural.w, natural.h);
const cap = longest > MAX_RENDER_DIM ? MAX_RENDER_DIM / longest : 1;
return {
w: Math.max(fitted.w, Math.round(natural.w * cap)),
h: Math.max(fitted.h, Math.round(natural.h * cap)),
};
}