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 PointerEvent, useCallback, useEffect, useRef, useState } from "react";
import type { BgPreviewState } from "@/components/common/image-viewer"; import type { BgPreviewState } from "@/components/common/image-viewer";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
interface BeforeAfterSliderProps { interface BeforeAfterSliderProps {
/** URL or data URL of original image. */ /** URL or data URL of original image. */
@@ -40,25 +42,35 @@ export function BeforeAfterSlider({
bgPreview, bgPreview,
}: BeforeAfterSliderProps) { }: BeforeAfterSliderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useMobile();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(initialPosition); // percentage 0-100 const [position, setPosition] = useState(initialPosition); // percentage 0-100
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const updatePosition = useCallback((clientX: number) => { const updatePosition = useCallback(
const container = containerRef.current; (clientX: number, clientY: number) => {
if (!container) return; const container = containerRef.current;
const rect = container.getBoundingClientRect(); if (!container) return;
const x = clientX - rect.left; const rect = container.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); if (isMobile) {
setPosition(pct); 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( const handlePointerDown = useCallback(
(e: PointerEvent) => { (e: PointerEvent) => {
e.preventDefault(); e.preventDefault();
setIsDragging(true); setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
updatePosition(e.clientX); updatePosition(e.clientX, e.clientY);
}, },
[updatePosition], [updatePosition],
); );
@@ -66,7 +78,7 @@ export function BeforeAfterSlider({
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: PointerEvent) => { (e: PointerEvent) => {
if (!isDragging) return; if (!isDragging) return;
updatePosition(e.clientX); updatePosition(e.clientX, e.clientY);
}, },
[isDragging, updatePosition], [isDragging, updatePosition],
); );
@@ -128,26 +140,36 @@ export function BeforeAfterSlider({
aria-valuemax={100} aria-valuemax={100}
tabIndex={0} tabIndex={0}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none" 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} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1)); if (isMobile) {
else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1)); 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) */} {/* Before image (full width, bottom layer) */}
<img <img
src={beforeSrc} src={beforeSrc}
alt="Original" alt="Original"
className="block w-full max-h-[70vh] object-contain" className="block w-full max-h-[70dvh] object-contain"
draggable={false} draggable={false}
/> />
{/* After panel (clipped, top layer) */} {/* 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 */} {/* Background layers constrained to the image content area */}
{contentBox && ( {contentBox && (
<div <div
@@ -189,43 +211,87 @@ export function BeforeAfterSlider({
</div> </div>
{/* Divider line */} {/* Divider line */}
<div {isMobile ? (
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none" <div
style={{ left: `${position}%`, transform: "translateX(-50%)" }} 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"> {/* Handle grip */}
<svg <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">
width="14" <svg
height="14" width="14"
viewBox="0 0 14 14" height="14"
fill="none" viewBox="0 0 14 14"
className="text-primary" fill="none"
aria-hidden="true" className="text-primary rotate-90"
> aria-hidden="true"
<path >
d="M4 3L1 7L4 11" <path
stroke="currentColor" d="M4 3L1 7L4 11"
strokeWidth="1.5" stroke="currentColor"
strokeLinecap="round" strokeWidth="1.5"
strokeLinejoin="round" strokeLinecap="round"
/> strokeLinejoin="round"
<path />
d="M10 3L13 7L10 11" <path
stroke="currentColor" d="M10 3L13 7L10 11"
strokeWidth="1.5" stroke="currentColor"
strokeLinecap="round" strokeWidth="1.5"
strokeLinejoin="round" strokeLinecap="round"
/> strokeLinejoin="round"
</svg> />
</svg>
</div>
</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 */} {/* 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} {t.comparison.original}
</div> </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} {t.comparison.processed}
</div> </div>
</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" className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose} 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 */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0"> <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" /> <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 { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { formatFileSize } from "@/lib/download"; import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
export interface BgPreviewState { export interface BgPreviewState {
/** URL of the original image (for blur background) */ /** URL of the original image (for blur background) */
@@ -52,8 +54,19 @@ export function ImageViewer({
const [naturalHeight, setNaturalHeight] = useState<number | null>(null); const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit"); const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(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"); const isSvg = filename.toLowerCase().endsWith(".svg");
@@ -88,6 +101,7 @@ export function ImageViewer({
const fitToContainer = useCallback(() => { const fitToContainer = useCallback(() => {
setFitMode("fit"); setFitMode("fit");
setZoom(DEFAULT_ZOOM); setZoom(DEFAULT_ZOOM);
setPanOffset({ x: 0, y: 0 });
}, []); }, []);
const actualSize = useCallback(() => { const actualSize = useCallback(() => {
@@ -103,8 +117,49 @@ export function ImageViewer({
setNaturalWidth(null); setNaturalWidth(null);
setNaturalHeight(null); setNaturalHeight(null);
setLoadError(false); setLoadError(false);
setPanOffset({ x: 0, y: 0 });
}, [src]); }, [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 = [ const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "", cssRotate ? `rotate(${cssRotate}deg)` : "",
cssFlipH ? "scaleX(-1)" : "", cssFlipH ? "scaleX(-1)" : "",
@@ -131,7 +186,7 @@ export function ImageViewer({
...(cssFilter && { filter: cssFilter, transition: "filter 0.15s ease" }), ...(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", transformOrigin: "center center",
...checkerBg, ...checkerBg,
...(previewTransform && { transition: "transform 0.25s ease, filter 0.15s ease" }), ...(previewTransform && { transition: "transform 0.25s ease, filter 0.15s ease" }),
@@ -185,7 +240,11 @@ export function ImageViewer({
{/* Image area */} {/* Image area */}
<div <div
ref={containerRef} 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)" }} style={{ backgroundColor: "hsl(var(--muted) / 0.2)" }}
> >
{loadError ? ( {loadError ? (
@@ -39,11 +39,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t.comparison.original} {t.comparison.original}
</span> </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 <img
src={beforeSrc} src={beforeSrc}
alt="Original" 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} draggable={false}
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
@@ -66,11 +66,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t.comparison.processed} {t.comparison.processed}
</span> </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 <img
src={afterSrc} src={afterSrc}
alt="Processed" 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} draggable={false}
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
@@ -766,7 +766,7 @@ export function EditorCanvas({
} = {}) { } = {}) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const selectionLayerRef = useRef<Konva.Layer>(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 zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset); const panOffset = useEditorStore((s) => s.panOffset);
@@ -919,6 +919,8 @@ export function EditorCanvas({
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onDragEnd={(e) => { onDragEnd={(e) => {
if (activeTool === "hand") { if (activeTool === "hand") {
const stage = e.target.getStage(); const stage = e.target.getStage();
+1 -1
View File
@@ -44,7 +44,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
onClick={onClose} 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 */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0"> <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> <h2 className="text-lg font-semibold text-foreground">{t.help.heading}</h2>
@@ -157,7 +157,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
<div <div
role="dialog" role="dialog"
aria-modal="true" 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 */} {/* Sidebar nav */}
<div className="w-48 border-r border-border bg-muted/30 p-3 space-y-1 shrink-0"> <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 }); setMessage({ type: "error", text: t.settings.security.passwordsMismatch });
return; return;
} }
if (newPassword.length < 4) { if (newPassword.length < 8) {
setMessage({ type: "error", text: t.settings.security.passwordTooShort }); setMessage({ type: "error", text: t.settings.security.passwordTooShort });
return; return;
} }
@@ -2545,7 +2545,7 @@ function ToolsSection() {
/> />
</div> </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) => ( {CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id}> <div key={category.id}>
<h4 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2"> <h4 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
@@ -2706,6 +2706,14 @@ function AboutSection() {
</div> </div>
</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"> <div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{t.settings.about.linksHeading}</h4> <h4 className="text-sm font-medium text-foreground">{t.settings.about.linksHeading}</h4>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -111,7 +111,7 @@ export function CropCanvas({
src={imageSrc} src={imageSrc}
alt="Crop preview" alt="Crop preview"
onLoad={handleImageLoad} 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} draggable={false}
/> />
</ReactCrop> </ReactCrop>
@@ -202,7 +202,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
{/* Presets tab */} {/* Presets tab */}
{tab === "presets" && ( {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) => ( {platforms.map((platform) => (
<div key={platform}> <div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p> <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> </div>
{subjectTab === "presets" ? ( {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) => ( {platforms.map((platform) => (
<div key={platform}> <div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p> <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 zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset); const panOffset = useEditorStore((s) => s.panOffset);
const tweenRef = useRef<Konva.Tween | null>(null); 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( const animateZoom = useCallback(
(targetZoom: number, targetPos: { x: number; y: number }) => { (targetZoom: number, targetPos: { x: number; y: number }) => {
@@ -114,5 +116,58 @@ export function useCanvasZoom() {
[animateZoom], [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; 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). * Returns true when the viewport width is below the mobile breakpoint (768px).
*/ */
export function useMobile(): boolean { export function useMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : false, }
);
useEffect(() => { /**
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); * Returns true when the primary input device is a coarse pointer (touch).
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); */
mq.addEventListener("change", handler); export function useTouchDevice(): boolean {
setIsMobile(mq.matches); return useMediaQuery("(pointer: coarse)");
return () => mq.removeEventListener("change", handler);
}, []);
return isMobile;
} }
+1 -1
View File
@@ -546,7 +546,7 @@ export function AutomatePage() {
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileToolPaletteOpen(false)} 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"> <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> <h2 className="text-sm font-semibold text-foreground">{t.automate.addTool}</h2>
<button <button
+1 -1
View File
@@ -77,7 +77,7 @@ export function FilesPage() {
if (e.key === "Escape") setShowDetails(false); 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"> <div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold">{t.files.fileDetailsHeading}</span> <span className="text-sm font-semibold">{t.files.fileDetailsHeading}</span>
<button type="button" onClick={() => setShowDetails(false)}> <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 type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone"; import { Dropzone } from "@/components/common/dropzone";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer"; import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { ReviewPanel } from "@/components/common/review-panel"; import { ReviewPanel } from "@/components/common/review-panel";
@@ -213,7 +214,7 @@ export function ToolPage() {
}, },
[navigateNext, navigatePrev], [navigateNext, navigatePrev],
); );
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null); const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>(""); const [previewFilter, setPreviewFilter] = useState<string>("");
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null); const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
@@ -274,7 +275,7 @@ export function ToolPage() {
setEraserMaskedCount(0); setEraserMaskedCount(0);
setEraserBrushSize(30); setEraserBrushSize(30);
setEraserSliderInitPos(null); setEraserSliderInitPos(null);
setMobileSettingsOpen(true); setMobileSettingsOpen(false);
}, [toolId]); }, [toolId]);
const toolAccept = registryEntry?.accept; 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) { if (isMobile) {
return ( return (
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
@@ -795,18 +796,11 @@ export function ToolPage() {
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)} onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted" 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> </button>
</div> </div>
{/* Collapsible settings */} {/* Main area: image viewer (full height) */}
{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 */}
<section <section
aria-label="Image area" aria-label="Image area"
className="flex-1 flex flex-col min-h-0 min-w-0" className="flex-1 flex flex-col min-h-0 min-w-0"
@@ -825,6 +819,15 @@ export function ToolPage() {
/> />
)} )}
</section> </section>
{/* Settings BottomSheet */}
<BottomSheet
open={mobileSettingsOpen}
onClose={() => setMobileSettingsOpen(false)}
title={t.common.settings}
>
<div className="settings-container space-y-3">{renderSettingsContent()}</div>
</BottomSheet>
</div> </div>
</AppLayout> </AppLayout>
); );
@@ -835,7 +838,7 @@ export function ToolPage() {
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
<div className="flex h-full w-full"> <div className="flex h-full w-full">
{/* Tool Settings Panel */} {/* 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="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary text-primary-foreground"> <div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" /> <IconComponent className="h-5 w-5" />
+21
View File
@@ -105,3 +105,24 @@ input[type="range"]::-moz-range-track {
direction: ltr; direction: ltr;
text-align: left; 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; }
}