mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -3398,6 +3398,7 @@ export const ar: TranslationKeys = {
|
||||
zoomOut: "تصغير",
|
||||
fitToView: "ملاءمة العرض",
|
||||
actualSize: "الحجم الفعلي",
|
||||
pan: "تحريك",
|
||||
previousImage: "الملف السابق",
|
||||
nextImage: "الملف التالي",
|
||||
imageArea: "منطقة المعاينة",
|
||||
|
||||
@@ -3440,6 +3440,7 @@ export const de: TranslationKeys = {
|
||||
zoomOut: "Verkleinern",
|
||||
fitToView: "Einpassen",
|
||||
actualSize: "Originalgröße",
|
||||
pan: "Verschieben",
|
||||
previousImage: "Vorherige Datei",
|
||||
nextImage: "Nächste Datei",
|
||||
imageArea: "Vorschaubereich",
|
||||
|
||||
@@ -3368,6 +3368,7 @@ export const en = {
|
||||
zoomOut: "Zoom out",
|
||||
fitToView: "Fit to view",
|
||||
actualSize: "Actual size",
|
||||
pan: "Pan",
|
||||
previousImage: "Previous file",
|
||||
nextImage: "Next file",
|
||||
imageArea: "Preview area",
|
||||
|
||||
@@ -3422,6 +3422,7 @@ export const es: TranslationKeys = {
|
||||
zoomOut: "Alejar",
|
||||
fitToView: "Ajustar a la vista",
|
||||
actualSize: "Tamaño real",
|
||||
pan: "Desplazar",
|
||||
previousImage: "Archivo anterior",
|
||||
nextImage: "Siguiente archivo",
|
||||
imageArea: "Área de vista previa",
|
||||
|
||||
@@ -3446,6 +3446,7 @@ export const fr: TranslationKeys = {
|
||||
zoomOut: "Zoom arrière",
|
||||
fitToView: "Ajuster à la vue",
|
||||
actualSize: "Taille réelle",
|
||||
pan: "Déplacer",
|
||||
previousImage: "Fichier précédent",
|
||||
nextImage: "Fichier suivant",
|
||||
imageArea: "Zone d'aperçu",
|
||||
|
||||
@@ -3393,6 +3393,7 @@ export const hi: TranslationKeys = {
|
||||
zoomOut: "ज़ूम आउट",
|
||||
fitToView: "दृश्य में फ़िट करें",
|
||||
actualSize: "वास्तविक आकार",
|
||||
pan: "पैन करें",
|
||||
previousImage: "पिछली फ़ाइल",
|
||||
nextImage: "अगली फ़ाइल",
|
||||
imageArea: "पूर्वावलोकन क्षेत्र",
|
||||
|
||||
@@ -3421,6 +3421,7 @@ export const id: TranslationKeys = {
|
||||
zoomOut: "Perkecil",
|
||||
fitToView: "Sesuaikan tampilan",
|
||||
actualSize: "Ukuran asli",
|
||||
pan: "Geser",
|
||||
previousImage: "Previous image",
|
||||
nextImage: "Next image",
|
||||
imageArea: "Image area",
|
||||
|
||||
@@ -3435,6 +3435,7 @@ export const it: TranslationKeys = {
|
||||
zoomOut: "Riduci",
|
||||
fitToView: "Adatta alla vista",
|
||||
actualSize: "Dimensione reale",
|
||||
pan: "Sposta",
|
||||
previousImage: "Immagine precedente",
|
||||
nextImage: "Immagine successiva",
|
||||
imageArea: "Area immagine",
|
||||
|
||||
@@ -3373,6 +3373,7 @@ export const ja: TranslationKeys = {
|
||||
zoomOut: "縮小",
|
||||
fitToView: "画面に合わせる",
|
||||
actualSize: "実寸表示",
|
||||
pan: "移動",
|
||||
previousImage: "Previous image",
|
||||
nextImage: "Next image",
|
||||
imageArea: "Image area",
|
||||
|
||||
@@ -3355,6 +3355,7 @@ export const ko: TranslationKeys = {
|
||||
zoomOut: "축소",
|
||||
fitToView: "화면에 맞추기",
|
||||
actualSize: "실제 크기",
|
||||
pan: "이동",
|
||||
previousImage: "Previous image",
|
||||
nextImage: "Next image",
|
||||
imageArea: "Image area",
|
||||
|
||||
@@ -3429,6 +3429,7 @@ export const nl: TranslationKeys = {
|
||||
zoomOut: "Uitzoomen",
|
||||
fitToView: "Passend in beeld",
|
||||
actualSize: "Werkelijke grootte",
|
||||
pan: "Verschuiven",
|
||||
previousImage: "Previous image",
|
||||
nextImage: "Next image",
|
||||
imageArea: "Image area",
|
||||
|
||||
@@ -3437,6 +3437,7 @@ export const pl: TranslationKeys = {
|
||||
zoomOut: "Pomniejsz",
|
||||
fitToView: "Dopasuj do widoku",
|
||||
actualSize: "Rzeczywisty rozmiar",
|
||||
pan: "Przesuń",
|
||||
previousImage: "Poprzedni plik",
|
||||
nextImage: "Następny plik",
|
||||
imageArea: "Obszar podglądu",
|
||||
|
||||
@@ -3429,6 +3429,7 @@ export const ptBR: TranslationKeys = {
|
||||
zoomOut: "Diminuir zoom",
|
||||
fitToView: "Ajustar à tela",
|
||||
actualSize: "Tamanho real",
|
||||
pan: "Mover",
|
||||
previousImage: "Arquivo anterior",
|
||||
nextImage: "Próximo arquivo",
|
||||
imageArea: "Área de visualização",
|
||||
|
||||
@@ -3424,6 +3424,7 @@ export const ru: TranslationKeys = {
|
||||
zoomOut: "Уменьшить",
|
||||
fitToView: "Вписать в окно",
|
||||
actualSize: "Реальный размер",
|
||||
pan: "Перемещение",
|
||||
previousImage: "Предыдущий файл",
|
||||
nextImage: "Следующий файл",
|
||||
imageArea: "Область предпросмотра",
|
||||
|
||||
@@ -3416,6 +3416,7 @@ export const sv: TranslationKeys = {
|
||||
zoomOut: "Zooma ut",
|
||||
fitToView: "Anpassa till vy",
|
||||
actualSize: "Verklig storlek",
|
||||
pan: "Panorera",
|
||||
previousImage: "Föregående fil",
|
||||
nextImage: "Nästa fil",
|
||||
imageArea: "Förhandsvisningsområde",
|
||||
|
||||
@@ -3379,6 +3379,7 @@ export const th: TranslationKeys = {
|
||||
zoomOut: "ซูมออก",
|
||||
fitToView: "พอดีกับหน้าจอ",
|
||||
actualSize: "ขนาดจริง",
|
||||
pan: "เลื่อน",
|
||||
previousImage: "ไฟล์ก่อนหน้า",
|
||||
nextImage: "ไฟล์ถัดไป",
|
||||
imageArea: "พื้นที่แสดงตัวอย่าง",
|
||||
|
||||
@@ -3431,6 +3431,7 @@ export const tr: TranslationKeys = {
|
||||
zoomOut: "Uzaklaştır",
|
||||
fitToView: "Görünüme sığdır",
|
||||
actualSize: "Gerçek boyut",
|
||||
pan: "Kaydır",
|
||||
previousImage: "Önceki dosya",
|
||||
nextImage: "Sonraki dosya",
|
||||
imageArea: "Önizleme alanı",
|
||||
|
||||
@@ -3426,6 +3426,7 @@ export const uk: TranslationKeys = {
|
||||
zoomOut: "Зменшити",
|
||||
fitToView: "Вписати у вікно",
|
||||
actualSize: "Справжній розмір",
|
||||
pan: "Переміщення",
|
||||
previousImage: "Попередній файл",
|
||||
nextImage: "Наступний файл",
|
||||
imageArea: "Область попереднього перегляду",
|
||||
|
||||
@@ -3417,6 +3417,7 @@ export const vi: TranslationKeys = {
|
||||
zoomOut: "Thu nhỏ",
|
||||
fitToView: "Vừa khung nhìn",
|
||||
actualSize: "Kích thước thực",
|
||||
pan: "Di chuyển",
|
||||
previousImage: "Tệp trước",
|
||||
nextImage: "Tệp tiếp theo",
|
||||
imageArea: "Vùng xem trước",
|
||||
|
||||
@@ -3328,6 +3328,7 @@ export const zhCN: TranslationKeys = {
|
||||
zoomOut: "缩小",
|
||||
fitToView: "适应视图",
|
||||
actualSize: "实际大小",
|
||||
pan: "平移",
|
||||
previousImage: "上一个文件",
|
||||
nextImage: "下一个文件",
|
||||
imageArea: "预览区域",
|
||||
|
||||
@@ -3328,6 +3328,7 @@ export const zhTW: TranslationKeys = {
|
||||
zoomOut: "縮小",
|
||||
fitToView: "符合檢視大小",
|
||||
actualSize: "實際大小",
|
||||
pan: "平移",
|
||||
previousImage: "上一個檔案",
|
||||
nextImage: "下一個檔案",
|
||||
imageArea: "預覽區域",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./helpers";
|
||||
|
||||
function fixturePath(name: string): string {
|
||||
return path.join(process.cwd(), "tests", "fixtures", name);
|
||||
}
|
||||
|
||||
async function uploadFile(page: import("@playwright/test").Page, filePath: string) {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const dropzone = page.locator("[class*='border-dashed']").first();
|
||||
await dropzone.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(filePath);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe("In-canvas zoom & pan", () => {
|
||||
test("split: toolbar, wheel zoom, and hand-tool drag pan", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/image/split");
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
const viewport = page.getByTestId("zoom-viewport");
|
||||
const content = page.getByTestId("zoom-content");
|
||||
const percent = page.getByTestId("zoom-percent");
|
||||
await expect(page.getByTestId("zoom-toolbar")).toBeVisible();
|
||||
await content.waitFor({ state: "visible", timeout: 5_000 });
|
||||
|
||||
const box = await viewport.boundingBox();
|
||||
if (!box) throw new Error("no viewport box");
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
|
||||
// Wheel up over the canvas zooms toward the cursor (large delta -> near max zoom).
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.wheel(0, -3000);
|
||||
await page.waitForTimeout(150);
|
||||
await expect(percent).not.toHaveText("100%");
|
||||
expect(await content.getAttribute("style")).toMatch(/scale\((?!1\))/); // scale != 1
|
||||
|
||||
// Hand-tool toggle, then drag pans (deterministic; translate becomes non-zero).
|
||||
await page.getByTestId("zoom-pan").click();
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx - 80, cy - 50, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(150);
|
||||
expect(await content.getAttribute("style")).toMatch(/translate\(\s*-?[1-9][0-9.]*px/);
|
||||
});
|
||||
|
||||
test("eraser: zoom in, then draw a stroke while zoomed", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/image/erase-object");
|
||||
// Skip if the AI bundle isn't installed (no masking canvas to draw on).
|
||||
try {
|
||||
await page.getByTestId("erase-object-submit").waitFor({ state: "visible", timeout: 15_000 });
|
||||
} catch {
|
||||
test.skip(true, "object-eraser-colorize feature bundle not installed");
|
||||
}
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
const viewport = page.getByTestId("zoom-viewport");
|
||||
const content = page.getByTestId("zoom-content");
|
||||
await content.waitFor({ state: "visible", timeout: 5_000 });
|
||||
const box = await viewport.boundingBox();
|
||||
if (!box) throw new Error("no viewport box");
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
|
||||
// Zoom in.
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.wheel(0, -1200);
|
||||
await page.waitForTimeout(150);
|
||||
expect(await content.getAttribute("style")).toMatch(/scale\((?!1\))/);
|
||||
|
||||
// Draw a stroke while zoomed (coordinate mapping must stay correct under the transform).
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 20, cy + 10);
|
||||
await page.mouse.move(cx + 40, cy + 20);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actualSizeZoom,
|
||||
anchorPan,
|
||||
canActualSize,
|
||||
clampPan,
|
||||
clampZoom,
|
||||
fitScaleOf,
|
||||
MAX_RENDER_DIM,
|
||||
maxZoomOf,
|
||||
percentOf,
|
||||
renderSize,
|
||||
toContentPoint,
|
||||
wheelZoomFactor,
|
||||
} from "@/hooks/zoom-pan-math";
|
||||
|
||||
// 4000x3000 image fit into an 800x600 frame -> fitScale 0.2 (downscaled).
|
||||
const big = { natural: { w: 4000, h: 3000 }, fitted: { w: 800, h: 600 } };
|
||||
// 800x600 image shown 1:1 -> fitScale 1.
|
||||
const exact = { natural: { w: 800, h: 600 }, fitted: { w: 800, h: 600 } };
|
||||
// 100x75 image upscaled to fill an 800x600 frame -> fitScale 8 (upscaled).
|
||||
const tiny = { natural: { w: 100, h: 75 }, fitted: { w: 800, h: 600 } };
|
||||
|
||||
describe("fitScaleOf / percent", () => {
|
||||
it("computes fit scale", () => {
|
||||
expect(fitScaleOf(big)).toBeCloseTo(0.2, 6);
|
||||
expect(fitScaleOf(exact)).toBeCloseTo(1, 6);
|
||||
expect(fitScaleOf(tiny)).toBeCloseTo(8, 6);
|
||||
});
|
||||
it("shows actual-pixel percentage", () => {
|
||||
expect(percentOf(1, big)).toBe(20);
|
||||
expect(percentOf(5, big)).toBe(100);
|
||||
expect(percentOf(1, exact)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampZoom / maxZoom", () => {
|
||||
it("never below fit", () => {
|
||||
expect(clampZoom(0.3, big)).toBe(1);
|
||||
});
|
||||
it("reaches actual size plus headroom", () => {
|
||||
expect(maxZoomOf(big)).toBeGreaterThanOrEqual(5);
|
||||
expect(clampZoom(999, big)).toBe(maxZoomOf(big));
|
||||
});
|
||||
});
|
||||
|
||||
describe("actualSize", () => {
|
||||
it("targets natural resolution for large images", () => {
|
||||
expect(actualSizeZoom(big)).toBeCloseTo(5, 6);
|
||||
expect(canActualSize(big)).toBe(true);
|
||||
});
|
||||
it("is disabled when the image is not larger than the frame", () => {
|
||||
expect(canActualSize(exact)).toBe(false);
|
||||
expect(canActualSize(tiny)).toBe(false);
|
||||
expect(actualSizeZoom(tiny)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wheelZoomFactor", () => {
|
||||
it("zooms in on scroll up, out on scroll down", () => {
|
||||
expect(wheelZoomFactor(-100)).toBeGreaterThan(1);
|
||||
expect(wheelZoomFactor(100)).toBeLessThan(1);
|
||||
expect(wheelZoomFactor(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchorPan keeps the cursor point fixed", () => {
|
||||
it("a content point under the cursor stays under it after zoom", () => {
|
||||
const viewportCenter = { x: 400, y: 300 };
|
||||
const cursor = { x: 550, y: 420 };
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
const newPan = anchorPan(pan, oldZoom, newZoom, cursor, viewportCenter);
|
||||
const p = {
|
||||
x: (cursor.x - viewportCenter.x - pan.x) / oldZoom,
|
||||
y: (cursor.y - viewportCenter.y - pan.y) / oldZoom,
|
||||
};
|
||||
const screenAfter = {
|
||||
x: viewportCenter.x + newPan.x + p.x * newZoom,
|
||||
y: viewportCenter.y + newPan.y + p.y * newZoom,
|
||||
};
|
||||
expect(screenAfter.x).toBeCloseTo(cursor.x, 6);
|
||||
expect(screenAfter.y).toBeCloseTo(cursor.y, 6);
|
||||
});
|
||||
it("is a no-op when zoom does not change", () => {
|
||||
const pan = { x: 12, y: -7 };
|
||||
expect(anchorPan(pan, 2, 2, { x: 10, y: 10 }, { x: 0, y: 0 })).toEqual(pan);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampPan", () => {
|
||||
const fitted = { w: 800, h: 600 };
|
||||
const viewport = { w: 800, h: 600 };
|
||||
it("locks to center when content fits the viewport (zoom 1)", () => {
|
||||
expect(clampPan({ x: 50, y: 50 }, 1, fitted, viewport)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
it("bounds pan to the overflow when zoomed in", () => {
|
||||
// zoom 2 -> content 1600x1200 -> overflowX (1600-800)/2 = 400, overflowY (1200-600)/2 = 300
|
||||
expect(clampPan({ x: 999, y: -999 }, 2, fitted, viewport)).toEqual({ x: 400, y: -300 });
|
||||
expect(clampPan({ x: 100, y: 100 }, 2, fitted, viewport)).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toContentPoint is transform-agnostic", () => {
|
||||
const fitted = { w: 800, h: 600 };
|
||||
it("maps screen to content at zoom 1", () => {
|
||||
const rect = { left: 0, top: 0, width: 800, height: 600 };
|
||||
expect(toContentPoint(400, 300, rect, fitted)).toEqual({ x: 400, y: 300 });
|
||||
});
|
||||
it("divides out a 2x zoom encoded in the rect", () => {
|
||||
const rect = { left: -400, top: -300, width: 1600, height: 1200 };
|
||||
expect(toContentPoint(400, 300, rect, fitted)).toEqual({ x: 400, y: 300 });
|
||||
expect(toContentPoint(-400, -300, rect, fitted)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
it("returns origin for a degenerate rect", () => {
|
||||
expect(toContentPoint(10, 10, { left: 0, top: 0, width: 0, height: 0 }, fitted)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSize", () => {
|
||||
it("uses natural resolution when within the cap", () => {
|
||||
expect(renderSize(big)).toEqual({ w: 4000, h: 3000 });
|
||||
});
|
||||
it("caps the longest side at MAX_RENDER_DIM", () => {
|
||||
const huge = { natural: { w: 8000, h: 6000 }, fitted: { w: 800, h: 600 } };
|
||||
const r = renderSize(huge);
|
||||
expect(Math.max(r.w, r.h)).toBe(MAX_RENDER_DIM);
|
||||
expect(r).toEqual({ w: 4096, h: 3072 });
|
||||
});
|
||||
it("floors at the fitted size for small upscaled images", () => {
|
||||
expect(renderSize(tiny)).toEqual({ w: 800, h: 600 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user