Files
SnapOtter/apps/web/src/hooks/use-canvas-zoom.ts
T
SnapOtter 6a8d9dcd8d feat: implement 6 research-backed mobile responsive features
- Bottom sheet for mobile tool settings (replaces top-collapsible panel)
- Pinch-to-zoom and wheel zoom on image viewer via @use-gesture/react
- Replace all vh units with dvh for dynamic viewport height
- Vertical before-after comparison on mobile devices
- Konva multi-touch pinch-to-zoom on editor canvas
- Container queries for adaptive tool settings + touch-friendly CSS
2026-06-05 22:30:43 +08:00

174 lines
5.7 KiB
TypeScript

// apps/web/src/hooks/use-canvas-zoom.ts
import Konva from "konva";
import { useCallback, useRef } from "react";
import { useEditorStore } from "@/stores/editor-store";
const ZOOM_SENSITIVITY = 1.1;
const ZOOM_ANIMATION_DURATION = 0.15;
export function useCanvasZoom() {
const stageRef = useRef<Konva.Stage>(null);
const setZoom = useEditorStore((s) => s.setZoom);
const setPanOffset = useEditorStore((s) => s.setPanOffset);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const tweenRef = useRef<Konva.Tween | null>(null);
const lastDistRef = useRef<number | null>(null);
const lastCenterRef = useRef<{ x: number; y: number } | null>(null);
const animateZoom = useCallback(
(targetZoom: number, targetPos: { x: number; y: number }) => {
const stage = stageRef.current;
if (!stage) {
setZoom(targetZoom);
setPanOffset(targetPos);
return;
}
if (tweenRef.current) {
tweenRef.current.destroy();
}
tweenRef.current = new Konva.Tween({
node: stage,
scaleX: targetZoom,
scaleY: targetZoom,
x: targetPos.x,
y: targetPos.y,
duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut,
onUpdate: () => {
// Progressively sync store with the stage's current animated values
// so tools always have accurate coordinates during the tween.
if (stage) {
setZoom(stage.scaleX());
setPanOffset({ x: stage.x(), y: stage.y() });
}
},
onFinish: () => {
// Ensure final values are exact (no floating-point drift)
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current?.destroy();
tweenRef.current = null;
},
});
tweenRef.current.play();
},
[setZoom, setPanOffset],
);
const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = stageRef.current;
if (!stage) return;
const isZoom = e.evt.ctrlKey || e.evt.metaKey;
if (isZoom) {
const pointer = stage.getPointerPosition();
if (!pointer) return;
const oldZoom = zoom;
const direction = e.evt.deltaY < 0 ? 1 : -1;
const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY;
const clampedZoom = Math.max(0.01, Math.min(64, newZoom));
const mousePointTo = {
x: (pointer.x - panOffset.x) / oldZoom,
y: (pointer.y - panOffset.y) / oldZoom,
};
const newPos = {
x: pointer.x - mousePointTo.x * clampedZoom,
y: pointer.y - mousePointTo.y * clampedZoom,
};
animateZoom(clampedZoom, newPos);
} else {
const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX;
const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY;
setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy });
}
},
[zoom, panOffset, setPanOffset, animateZoom],
);
const fitToScreen = useCallback(
(viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => {
const scaleX = viewportWidth / imageWidth;
const scaleY = viewportHeight / imageHeight;
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
const offsetX = (viewportWidth - imageWidth * fitZoom) / 2;
const offsetY = (viewportHeight - imageHeight * fitZoom) / 2;
animateZoom(fitZoom, { x: offsetX, y: offsetY });
},
[animateZoom],
);
const zoomTo = useCallback(
(targetZoom: number, viewportWidth: number, viewportHeight: number) => {
const canvasSize = useEditorStore.getState().canvasSize;
const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2;
const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2;
animateZoom(targetZoom, { x: offsetX, y: offsetY });
},
[animateZoom],
);
const handleTouchMove = useCallback(
(e: Konva.KonvaEventObject<TouchEvent>) => {
const touches = e.evt.touches;
if (touches.length < 2) return;
const t1 = touches[0];
const t2 = touches[1];
const newDist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const newCenter = {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
};
if (lastDistRef.current !== null && lastCenterRef.current !== null) {
const scaleDelta = newDist / lastDistRef.current;
const currentZoom = useEditorStore.getState().zoom;
const currentPan = useEditorStore.getState().panOffset;
const newZoom = Math.max(0.01, Math.min(64, currentZoom * scaleDelta));
// Keep pinch center stable
const mousePointTo = {
x: (newCenter.x - currentPan.x) / currentZoom,
y: (newCenter.y - currentPan.y) / currentZoom,
};
const newPos = {
x: newCenter.x - mousePointTo.x * newZoom,
y: newCenter.y - mousePointTo.y * newZoom,
};
const stage = stageRef.current;
if (stage) {
stage.scaleX(newZoom);
stage.scaleY(newZoom);
stage.x(newPos.x);
stage.y(newPos.y);
stage.batchDraw();
}
setZoom(newZoom);
setPanOffset(newPos);
}
lastDistRef.current = newDist;
lastCenterRef.current = newCenter;
e.evt.preventDefault();
},
[setZoom, setPanOffset],
);
const handleTouchEnd = useCallback(() => {
lastDistRef.current = null;
lastCenterRef.current = null;
}, []);
return { stageRef, handleWheel, fitToScreen, zoomTo, handleTouchMove, handleTouchEnd };
}