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
This commit is contained in:
SnapOtter
2026-06-05 22:30:43 +08:00
parent 91e90b390e
commit 6a8d9dcd8d
17 changed files with 429 additions and 90 deletions
+56 -1
View File
@@ -14,6 +14,8 @@ export function useCanvasZoom() {
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 }) => {
@@ -114,5 +116,58 @@ export function useCanvasZoom() {
[animateZoom],
);
return { stageRef, handleWheel, fitToScreen, zoomTo };
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 };
}
+29 -12
View File
@@ -2,21 +2,38 @@ import { useEffect, useState } from "react";
const MOBILE_BREAKPOINT = 768;
/**
* Generic media-query hook. Returns true when the query matches.
* Guards against missing `window.matchMedia` for test environments.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
setMatches(mq.matches);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/**
* Returns true when the viewport width is below the mobile breakpoint (768px).
*/
export function useMobile(): boolean {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : false,
);
return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
}
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
setIsMobile(mq.matches);
return () => mq.removeEventListener("change", handler);
}, []);
return isMobile;
/**
* Returns true when the primary input device is a coarse pointer (touch).
*/
export function useTouchDevice(): boolean {
return useMediaQuery("(pointer: coarse)");
}