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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user