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
@@ -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<HTMLDivElement>(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) */}
<img
src={beforeSrc}
alt="Original"
className="block w-full max-h-[70vh] object-contain"
className="block w-full max-h-[70dvh] object-contain"
draggable={false}
/>
{/* After panel (clipped, top layer) */}
<div className="absolute inset-0" style={{ clipPath: `inset(0 0 0 ${position}%)` }}>
<div
className="absolute inset-0"
style={{
clipPath: isMobile ? `inset(${position}% 0 0 0)` : `inset(0 0 0 ${position}%)`,
}}
>
{/* Background layers constrained to the image content area */}
{contentBox && (
<div
@@ -189,43 +211,87 @@ export function BeforeAfterSlider({
</div>
{/* Divider line */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none"
style={{ left: `${position}%`, transform: "translateX(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{isMobile ? (
<div
className="absolute inset-x-0 h-0.5 bg-white/80 pointer-events-none"
style={{ top: `${position}%`, transform: "translateY(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary rotate-90"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
</div>
) : (
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none"
style={{ left: `${position}%`, transform: "translateX(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
)}
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
<div
className={cn(
"absolute px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none",
isMobile ? "top-2 start-2" : "top-2 left-2",
)}
>
{t.comparison.original}
</div>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
<div
className={cn(
"absolute px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none",
isMobile ? "bottom-2 end-2" : "top-2 right-2",
)}
>
{t.comparison.processed}
</div>
</div>
@@ -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<HTMLDivElement>(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 */}
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={handleDismiss}
/>
{/* Sheet */}
<div
ref={sheetRef}
className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl flex flex-col animate-in slide-in-from-bottom"
style={{
maxHeight,
transform: translateY > 0 ? `translateY(${translateY}px)` : undefined,
transition: translateY > 0 ? "none" : "transform 0.2s ease-out",
}}
>
{/* Drag handle */}
<div {...bind()} className="flex justify-center pt-2 pb-1 cursor-grab touch-none">
<div className="w-8 h-1 rounded-full bg-muted-foreground/30" />
</div>
{/* Header */}
{title && (
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<button
type="button"
onClick={handleDismiss}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-4 pb-4 min-h-0">{children}</div>
</div>
</>
);
}
@@ -147,7 +147,7 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-lg max-h-[80vh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4">
<div className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<FolderOpen className="h-5 w-5 text-primary" />
@@ -1,6 +1,8 @@
import { useGesture } from "@use-gesture/react";
import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
export interface BgPreviewState {
/** URL of the original image (for blur background) */
@@ -52,8 +54,19 @@ export function ImageViewer({
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [loadError, setLoadError] = useState(false);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const zoomRef = useRef(zoom);
const fitModeRef = useRef(fitMode);
// Keep refs in sync for gesture callbacks
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
useEffect(() => {
fitModeRef.current = fitMode;
}, [fitMode]);
const isSvg = filename.toLowerCase().endsWith(".svg");
@@ -88,6 +101,7 @@ export function ImageViewer({
const fitToContainer = useCallback(() => {
setFitMode("fit");
setZoom(DEFAULT_ZOOM);
setPanOffset({ x: 0, y: 0 });
}, []);
const actualSize = useCallback(() => {
@@ -103,8 +117,49 @@ export function ImageViewer({
setNaturalWidth(null);
setNaturalHeight(null);
setLoadError(false);
setPanOffset({ x: 0, y: 0 });
}, [src]);
// Gesture handlers for pinch-to-zoom, ctrl+wheel zoom, and drag-to-pan
const initialZoomRef = useRef<number | null>(null);
const bind = useGesture(
{
onPinch: ({ first, offset: [scale], memo }) => {
if (first) {
initialZoomRef.current = zoomRef.current;
}
const base = initialZoomRef.current ?? zoomRef.current;
const newZoom = Math.max(10, Math.min(1000, base * scale));
setZoom(newZoom);
setFitMode("actual");
return memo;
},
onWheel: ({ event, delta: [, dy] }) => {
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
const direction = dy < 0 ? 1 : -1;
setZoom((prev) => {
const factor = direction > 0 ? 1.1 : 1 / 1.1;
return Math.max(10, Math.min(1000, prev * factor));
});
setFitMode("actual");
},
onDrag: ({ movement: [mx, my], first, memo }) => {
if (fitModeRef.current !== "actual") return;
if (first) {
memo = { ...panOffset };
}
const start = memo as { x: number; y: number };
setPanOffset({ x: start.x + mx, y: start.y + my });
return memo;
},
},
{
wheel: { eventOptions: { passive: false } },
drag: { filterTaps: true },
},
);
const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "",
cssFlipH ? "scaleX(-1)" : "",
@@ -131,7 +186,7 @@ export function ImageViewer({
...(cssFilter && { filter: cssFilter, transition: "filter 0.15s ease" }),
}
: {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center",
...checkerBg,
...(previewTransform && { transition: "transform 0.25s ease, filter 0.15s ease" }),
@@ -185,7 +240,11 @@ export function ImageViewer({
{/* Image area */}
<div
ref={containerRef}
className="flex-1 flex items-center justify-center overflow-auto p-4"
{...bind()}
className={cn(
"flex-1 flex items-center justify-center overflow-auto p-4",
fitMode === "actual" && "touch-none",
)}
style={{ backgroundColor: "hsl(var(--muted) / 0.2)" }}
>
{loadError ? (
@@ -39,11 +39,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t.comparison.original}
</span>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60dvh]">
<img
src={beforeSrc}
alt="Original"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
className="max-w-full max-h-[56dvh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -66,11 +66,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t.comparison.processed}
</span>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60dvh]">
<img
src={afterSrc}
alt="Processed"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
className="max-w-full max-h-[56dvh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -766,7 +766,7 @@ export function EditorCanvas({
} = {}) {
const containerRef = useRef<HTMLDivElement>(null);
const selectionLayerRef = useRef<Konva.Layer>(null);
const { stageRef, handleWheel, fitToScreen } = useCanvasZoom();
const { stageRef, handleWheel, fitToScreen, handleTouchMove, handleTouchEnd } = useCanvasZoom();
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
@@ -919,6 +919,8 @@ export function EditorCanvas({
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onDragEnd={(e) => {
if (activeTool === "hand") {
const stage = e.target.getStage();
+1 -1
View File
@@ -44,7 +44,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
onClick={onClose}
/>
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">{t.help.heading}</h2>
@@ -157,7 +157,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
<div
role="dialog"
aria-modal="true"
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85vh] flex overflow-hidden"
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85dvh] flex overflow-hidden"
>
{/* Sidebar nav */}
<div className="w-48 border-r border-border bg-muted/30 p-3 space-y-1 shrink-0">
@@ -634,7 +634,7 @@ function SecuritySection() {
setMessage({ type: "error", text: t.settings.security.passwordsMismatch });
return;
}
if (newPassword.length < 4) {
if (newPassword.length < 8) {
setMessage({ type: "error", text: t.settings.security.passwordTooShort });
return;
}
@@ -2545,7 +2545,7 @@ function ToolsSection() {
/>
</div>
<div className="space-y-4 max-h-[50vh] overflow-y-auto">
<div className="space-y-4 max-h-[50dvh] overflow-y-auto">
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id}>
<h4 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
@@ -2706,6 +2706,14 @@ function AboutSection() {
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">{t.settings.about.licenseLabel}</span>
<div>
<span className="font-mono text-foreground">AGPLv3</span>
<p className="text-xs text-muted-foreground">{t.settings.about.licenseDescription}</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{t.settings.about.linksHeading}</h4>
<div className="flex flex-col gap-1.5">
@@ -111,7 +111,7 @@ export function CropCanvas({
src={imageSrc}
alt="Crop preview"
onLoad={handleImageLoad}
className="max-w-full max-h-[calc(100vh-12rem)] select-none"
className="max-w-full max-h-[calc(100dvh-12rem)] select-none"
draggable={false}
/>
</ReactCrop>
@@ -202,7 +202,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
<div className="space-y-3 max-h-[50dvh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
@@ -296,7 +296,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
</div>
{subjectTab === "presets" ? (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
<div className="space-y-3 max-h-[50dvh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
+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)");
}
+1 -1
View File
@@ -546,7 +546,7 @@ export function AutomatePage() {
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileToolPaletteOpen(false)}
/>
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70vh] flex flex-col animate-in slide-in-from-bottom">
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70dvh] flex flex-col animate-in slide-in-from-bottom">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<h2 className="text-sm font-semibold text-foreground">{t.automate.addTool}</h2>
<button
+1 -1
View File
@@ -77,7 +77,7 @@ export function FilesPage() {
if (e.key === "Escape") setShowDetails(false);
}}
>
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto">
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70dvh] overflow-y-auto">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold">{t.files.fileDetailsHeading}</span>
<button type="button" onClick={() => setShowDetails(false)}>
+16 -13
View File
@@ -11,6 +11,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
import type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { ReviewPanel } from "@/components/common/review-panel";
@@ -213,7 +214,7 @@ export function ToolPage() {
},
[navigateNext, navigatePrev],
);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>("");
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
@@ -274,7 +275,7 @@ export function ToolPage() {
setEraserMaskedCount(0);
setEraserBrushSize(30);
setEraserSliderInitPos(null);
setMobileSettingsOpen(true);
setMobileSettingsOpen(false);
}, [toolId]);
const toolAccept = registryEntry?.accept;
@@ -777,7 +778,7 @@ export function ToolPage() {
);
}
// Mobile layout: settings above dropzone (stacked)
// Mobile layout: full-height image area with BottomSheet for settings
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
@@ -795,18 +796,11 @@ export function ToolPage() {
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
{mobileSettingsOpen ? t.toolPage.hideSettings : t.common.settings}
{t.common.settings}
</button>
</div>
{/* Collapsible settings */}
{mobileSettingsOpen && (
<div className="p-4 border-b border-border space-y-3 shrink-0 max-h-[40vh] overflow-y-auto">
{renderSettingsContent()}
</div>
)}
{/* Main area: image viewer */}
{/* Main area: image viewer (full height) */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0 min-w-0"
@@ -825,6 +819,15 @@ export function ToolPage() {
/>
)}
</section>
{/* Settings BottomSheet */}
<BottomSheet
open={mobileSettingsOpen}
onClose={() => setMobileSettingsOpen(false)}
title={t.common.settings}
>
<div className="settings-container space-y-3">{renderSettingsContent()}</div>
</BottomSheet>
</div>
</AppLayout>
);
@@ -835,7 +838,7 @@ export function ToolPage() {
<AppLayout showToolPanel={false}>
<div className="flex h-full w-full">
{/* Tool Settings Panel */}
<div className="w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
<div className="settings-container w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
+21
View File
@@ -105,3 +105,24 @@ input[type="range"]::-moz-range-track {
direction: ltr;
text-align: left;
}
/* Container queries for responsive settings panels */
.settings-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.settings-container .settings-grid-auto {
grid-template-columns: repeat(2, 1fr);
}
}
/* Touch-friendly targets for coarse pointer devices */
@media (pointer: coarse) {
input[type="range"] { height: 8px; padding: 8px 0; }
input[type="range"]::-webkit-slider-thumb { width: 24px; height: 24px; border-width: 3px; }
input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; border-width: 3px; }
input[type="range"]::-moz-range-track { height: 8px; }
input[type="checkbox"] { width: 20px; height: 20px; }
button, [role="button"], a { touch-action: manipulation; }
}