From 6a8d9dcd8ddf498c46d831cc02abf7c904b441d6 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 5 Jun 2026 22:30:43 +0800 Subject: [PATCH] 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 --- .../components/common/before-after-slider.tsx | 160 +++++++++++++----- .../src/components/common/bottom-sheet.tsx | 108 ++++++++++++ .../components/common/file-library-modal.tsx | 2 +- .../src/components/common/image-viewer.tsx | 63 ++++++- .../common/side-by-side-comparison.tsx | 8 +- .../src/components/editor/editor-canvas.tsx | 4 +- apps/web/src/components/help/help-dialog.tsx | 2 +- .../components/settings/settings-dialog.tsx | 14 +- apps/web/src/components/tools/crop-canvas.tsx | 2 +- .../src/components/tools/resize-settings.tsx | 2 +- .../components/tools/smart-crop-settings.tsx | 2 +- apps/web/src/hooks/use-canvas-zoom.ts | 57 ++++++- apps/web/src/hooks/use-mobile.ts | 41 +++-- apps/web/src/pages/automate-page.tsx | 2 +- apps/web/src/pages/files-page.tsx | 2 +- apps/web/src/pages/tool-page.tsx | 29 ++-- apps/web/src/styles/globals.css | 21 +++ 17 files changed, 429 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/common/bottom-sheet.tsx diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index 5fae8b9c..61ae9c11 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -1,6 +1,8 @@ import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; import type { BgPreviewState } from "@/components/common/image-viewer"; import { useTranslation } from "@/contexts/i18n-context"; +import { useMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; interface BeforeAfterSliderProps { /** URL or data URL of original image. */ @@ -40,25 +42,35 @@ export function BeforeAfterSlider({ bgPreview, }: BeforeAfterSliderProps) { const { t } = useTranslation(); + const isMobile = useMobile(); const containerRef = useRef(null); const [position, setPosition] = useState(initialPosition); // percentage 0-100 const [isDragging, setIsDragging] = useState(false); - const updatePosition = useCallback((clientX: number) => { - const container = containerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const x = clientX - rect.left; - const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); - setPosition(pct); - }, []); + const updatePosition = useCallback( + (clientX: number, clientY: number) => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + if (isMobile) { + const y = clientY - rect.top; + const pct = Math.max(0, Math.min(100, (y / rect.height) * 100)); + setPosition(pct); + } else { + const x = clientX - rect.left; + const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setPosition(pct); + } + }, + [isMobile], + ); const handlePointerDown = useCallback( (e: PointerEvent) => { e.preventDefault(); setIsDragging(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); - updatePosition(e.clientX); + updatePosition(e.clientX, e.clientY); }, [updatePosition], ); @@ -66,7 +78,7 @@ export function BeforeAfterSlider({ const handlePointerMove = useCallback( (e: PointerEvent) => { if (!isDragging) return; - updatePosition(e.clientX); + updatePosition(e.clientX, e.clientY); }, [isDragging, updatePosition], ); @@ -128,26 +140,36 @@ export function BeforeAfterSlider({ aria-valuemax={100} tabIndex={0} className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none" - style={{ cursor: isDragging ? "ew-resize" : "default" }} + style={{ cursor: isDragging ? (isMobile ? "ns-resize" : "ew-resize") : "default" }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} onKeyDown={(e) => { - if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1)); - else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1)); + if (isMobile) { + if (e.key === "ArrowUp") setPosition((p) => Math.max(0, p - 1)); + else if (e.key === "ArrowDown") setPosition((p) => Math.min(100, p + 1)); + } else { + if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1)); + else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1)); + } }} > {/* Before image (full width, bottom layer) */} Original {/* After panel (clipped, top layer) */} -
+
{/* Background layers constrained to the image content area */} {contentBox && (
{/* Divider line */} -
- {/* Handle grip */} -
- + {isMobile ? ( +
+ {/* Handle grip */} +
+ +
-
+ ) : ( +
+ {/* Handle grip */} +
+ +
+
+ )} {/* Labels */} -
+
{t.comparison.original}
-
+
{t.comparison.processed}
diff --git a/apps/web/src/components/common/bottom-sheet.tsx b/apps/web/src/components/common/bottom-sheet.tsx new file mode 100644 index 00000000..beff292a --- /dev/null +++ b/apps/web/src/components/common/bottom-sheet.tsx @@ -0,0 +1,108 @@ +import { useDrag } from "@use-gesture/react"; +import { X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface BottomSheetProps { + open: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + maxHeight?: string; +} + +export function BottomSheet({ + open, + onClose, + title, + children, + maxHeight = "70dvh", +}: BottomSheetProps) { + const sheetRef = useRef(null); + const [translateY, setTranslateY] = useState(0); + + // Close on Escape key + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + // Reset translation when opened + useEffect(() => { + if (open) setTranslateY(0); + }, [open]); + + const handleDismiss = useCallback(() => { + setTranslateY(0); + onClose(); + }, [onClose]); + + const bind = useDrag( + ({ movement: [, my], last, cancel }) => { + // Only allow downward dragging + if (my < 0) { + setTranslateY(0); + return; + } + if (last) { + if (my > 100) { + handleDismiss(); + } else { + setTranslateY(0); + } + return; + } + setTranslateY(my); + }, + { axis: "y", filterTaps: true }, + ); + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +