mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user