mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add use-gesture pan/zoom/pinch, dnd-kit reorder, visible controls, and HEIC spinner to collage
This commit is contained in:
@@ -1,7 +1,21 @@
|
|||||||
import { Download, ImagePlus, Loader2, RotateCcw, Upload, X } from "lucide-react";
|
import {
|
||||||
import { type DragEvent, useCallback, useRef, useState } from "react";
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
type DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { useDrag, usePinch, useWheel } from "@use-gesture/react";
|
||||||
|
import { Download, GripVertical, ImagePlus, Loader2, RotateCcw, Upload, X } from "lucide-react";
|
||||||
|
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
|
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CollageImage } from "@/stores/collage-store";
|
||||||
import { useCollageStore } from "@/stores/collage-store";
|
import { useCollageStore } from "@/stores/collage-store";
|
||||||
|
|
||||||
// Checkerboard pattern for transparent background
|
// Checkerboard pattern for transparent background
|
||||||
@@ -20,6 +34,10 @@ function getAspectMultiplier(ar: string): number | null {
|
|||||||
return map[ar] ?? null;
|
return map[ar] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayUrl(img: CollageImage): string {
|
||||||
|
return img.previewBlobUrl ?? img.blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export function CollagePreview() {
|
export function CollagePreview() {
|
||||||
const images = useCollageStore((s) => s.images);
|
const images = useCollageStore((s) => s.images);
|
||||||
const templateId = useCollageStore((s) => s.templateId);
|
const templateId = useCollageStore((s) => s.templateId);
|
||||||
@@ -138,14 +156,54 @@ function CollageCanvas({ template }: { template: CollageTemplate }) {
|
|||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const arMultiplier = getAspectMultiplier(aspectRatio);
|
const arMultiplier = getAspectMultiplier(aspectRatio);
|
||||||
|
|
||||||
// Calculate canvas aspect ratio style
|
|
||||||
const aspectStyle: React.CSSProperties = arMultiplier
|
const aspectStyle: React.CSSProperties = arMultiplier
|
||||||
? { aspectRatio: `1 / ${arMultiplier}` }
|
? { aspectRatio: `1 / ${arMultiplier}` }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const bgIsTransparent = backgroundColor === "transparent";
|
const bgIsTransparent = backgroundColor === "transparent";
|
||||||
|
|
||||||
|
const [activeDragCell, setActiveDragCell] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { delay: 300, tolerance: 5 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
const cellIndex = event.active.data.current?.cellIndex as number | undefined;
|
||||||
|
if (cellIndex != null) setActiveDragCell(cellIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
setActiveDragCell(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
const fromCell = active.data.current?.cellIndex as number;
|
||||||
|
const toCell = over.data.current?.cellIndex as number;
|
||||||
|
if (fromCell == null || toCell == null || fromCell === toCell) return;
|
||||||
|
|
||||||
|
const toImgIdx = cellAssignments[toCell] ?? -1;
|
||||||
|
if (toImgIdx >= 0) {
|
||||||
|
store.swapCells(fromCell, toCell);
|
||||||
|
} else {
|
||||||
|
const fromImgIdx = cellAssignments[fromCell];
|
||||||
|
if (fromImgIdx == null || fromImgIdx < 0) return;
|
||||||
|
store.setCellAssignment(toCell, fromImgIdx);
|
||||||
|
store.setCellAssignment(fromCell, -1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cellAssignments, store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeDragImage =
|
||||||
|
activeDragCell != null
|
||||||
|
? (() => {
|
||||||
|
const imgIdx = cellAssignments[activeDragCell] ?? -1;
|
||||||
|
return imgIdx >= 0 ? images[imgIdx] : null;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-auto"
|
className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-auto"
|
||||||
@@ -158,127 +216,154 @@ function CollageCanvas({ template }: { template: CollageTemplate }) {
|
|||||||
className="relative w-full max-w-[800px] max-h-full"
|
className="relative w-full max-w-[800px] max-h-full"
|
||||||
style={aspectStyle}
|
style={aspectStyle}
|
||||||
>
|
>
|
||||||
<div
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
className="w-full h-full rounded-lg overflow-hidden shadow-lg"
|
<div
|
||||||
style={{
|
className="w-full h-full rounded-lg overflow-hidden shadow-lg"
|
||||||
background: bgIsTransparent ? CHECKER_BG : backgroundColor,
|
style={{
|
||||||
display: "grid",
|
background: bgIsTransparent ? CHECKER_BG : backgroundColor,
|
||||||
gridTemplateColumns: template.gridTemplateColumns,
|
display: "grid",
|
||||||
gridTemplateRows: template.gridTemplateRows,
|
gridTemplateColumns: template.gridTemplateColumns,
|
||||||
gap: `${gap}px`,
|
gridTemplateRows: template.gridTemplateRows,
|
||||||
padding: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
...(arMultiplier ? { aspectRatio: `1 / ${arMultiplier}` } : { aspectRatio: "4 / 3" }),
|
padding: `${gap}px`,
|
||||||
}}
|
...(arMultiplier ? { aspectRatio: `1 / ${arMultiplier}` } : { aspectRatio: "4 / 3" }),
|
||||||
>
|
}}
|
||||||
{template.cells.map((cell, i) => {
|
>
|
||||||
const imgIndex = cellAssignments[i] ?? -1;
|
{template.cells.map((cell, i) => {
|
||||||
const img = imgIndex >= 0 ? images[imgIndex] : null;
|
const imgIndex = cellAssignments[i] ?? -1;
|
||||||
const transform = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 };
|
const img = imgIndex >= 0 ? images[imgIndex] : null;
|
||||||
const isSelected = selectedCell === i;
|
const transform = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 };
|
||||||
|
const isSelected = selectedCell === i;
|
||||||
|
const isDragSource = activeDragCell === i;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollageCell
|
<CollageCell
|
||||||
key={`${template.id}-${cell.gridColumn}-${cell.gridRow}`}
|
key={`${template.id}-${cell.gridColumn}-${cell.gridRow}`}
|
||||||
cellIndex={i}
|
cellIndex={i}
|
||||||
image={img}
|
image={img}
|
||||||
transform={transform}
|
transform={transform}
|
||||||
cornerRadius={cornerRadius}
|
cornerRadius={cornerRadius}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
gridColumn={cell.gridColumn}
|
isDragSource={isDragSource}
|
||||||
gridRow={cell.gridRow}
|
gridColumn={cell.gridColumn}
|
||||||
/>
|
gridRow={cell.gridRow}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeDragImage ? (
|
||||||
|
<div className="w-20 h-20 rounded-lg overflow-hidden shadow-xl border-2 border-primary opacity-90">
|
||||||
|
<img
|
||||||
|
src={displayUrl(activeDragImage)}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single cell in the collage grid with pan/zoom support. */
|
/** A single cell in the collage grid with use-gesture pan/zoom/pinch and dnd-kit reorder. */
|
||||||
function CollageCell({
|
function CollageCell({
|
||||||
cellIndex,
|
cellIndex,
|
||||||
image,
|
image,
|
||||||
transform,
|
transform,
|
||||||
cornerRadius,
|
cornerRadius,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isDragSource,
|
||||||
gridColumn,
|
gridColumn,
|
||||||
gridRow,
|
gridRow,
|
||||||
}: {
|
}: {
|
||||||
cellIndex: number;
|
cellIndex: number;
|
||||||
image: { blobUrl: string } | null;
|
image: CollageImage | null;
|
||||||
transform: { panX: number; panY: number; zoom: number };
|
transform: { panX: number; panY: number; zoom: number };
|
||||||
cornerRadius: number;
|
cornerRadius: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isDragSource: boolean;
|
||||||
gridColumn: string;
|
gridColumn: string;
|
||||||
gridRow: string;
|
gridRow: string;
|
||||||
}) {
|
}) {
|
||||||
const store = useCollageStore();
|
const store = useCollageStore();
|
||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const isDragging = useRef(false);
|
const [controlsVisible, setControlsVisible] = useState(false);
|
||||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
const controlsTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||||
(e: React.MouseEvent) => {
|
id: `cell-drop-${cellIndex}`,
|
||||||
if (!image) return;
|
data: { cellIndex },
|
||||||
e.preventDefault();
|
});
|
||||||
e.stopPropagation();
|
|
||||||
store.setSelectedCell(cellIndex);
|
|
||||||
isDragging.current = true;
|
|
||||||
dragStart.current = {
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
panX: transform.panX,
|
|
||||||
panY: transform.panY,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (ev: MouseEvent) => {
|
const {
|
||||||
if (!isDragging.current) return;
|
attributes,
|
||||||
const dx = ev.clientX - dragStart.current.x;
|
listeners,
|
||||||
const dy = ev.clientY - dragStart.current.y;
|
setNodeRef: setDragRef,
|
||||||
const rect = cellRef.current?.getBoundingClientRect();
|
} = useDraggable({
|
||||||
if (!rect) return;
|
id: `cell-drag-${cellIndex}`,
|
||||||
// Convert pixel drag to percentage of cell size
|
data: { cellIndex },
|
||||||
const panX = Math.max(
|
disabled: !image,
|
||||||
-100,
|
});
|
||||||
Math.min(100, dragStart.current.panX + (dx / rect.width) * 100),
|
|
||||||
);
|
|
||||||
const panY = Math.max(
|
|
||||||
-100,
|
|
||||||
Math.min(100, dragStart.current.panY + (dy / rect.height) * 100),
|
|
||||||
);
|
|
||||||
store.setCellTransform(cellIndex, { panX, panY });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const showControls = useCallback(() => {
|
||||||
isDragging.current = false;
|
setControlsVisible(true);
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
if (controlsTimer.current) clearTimeout(controlsTimer.current);
|
||||||
window.removeEventListener("mouseup", handleMouseUp);
|
controlsTimer.current = setTimeout(() => setControlsVisible(false), 3000);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
useEffect(() => {
|
||||||
window.addEventListener("mouseup", handleMouseUp);
|
if (isSelected && image) showControls();
|
||||||
|
if (!isSelected) setControlsVisible(false);
|
||||||
|
}, [isSelected, image, showControls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (controlsTimer.current) clearTimeout(controlsTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bindDrag = useDrag(
|
||||||
|
({ delta: [dx, dy], first, memo }) => {
|
||||||
|
if (!image || !isSelected) return memo;
|
||||||
|
if (first) {
|
||||||
|
showControls();
|
||||||
|
memo = { panX: transform.panX, panY: transform.panY };
|
||||||
|
}
|
||||||
|
const rect = cellRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect || !memo) return memo;
|
||||||
|
const panX = Math.max(-100, Math.min(100, memo.panX + (dx / rect.width) * 100));
|
||||||
|
const panY = Math.max(-100, Math.min(100, memo.panY + (dy / rect.height) * 100));
|
||||||
|
store.setCellTransform(cellIndex, { panX, panY });
|
||||||
|
return memo;
|
||||||
},
|
},
|
||||||
[cellIndex, image, store, transform.panX, transform.panY],
|
{ pointer: { touch: true }, filterTaps: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const bindPinch = usePinch(
|
||||||
(e: React.WheelEvent) => {
|
({ offset: [scale] }) => {
|
||||||
if (!image) return;
|
if (!image || !isSelected) return;
|
||||||
e.preventDefault();
|
showControls();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const zoom = Math.max(1, Math.min(3, scale));
|
||||||
const newZoom = Math.max(1, Math.min(3, transform.zoom + delta));
|
store.setCellTransform(cellIndex, { zoom });
|
||||||
store.setCellTransform(cellIndex, { zoom: newZoom });
|
},
|
||||||
|
{
|
||||||
|
scaleBounds: { min: 1, max: 3 },
|
||||||
|
from: () => [transform.zoom, 0],
|
||||||
},
|
},
|
||||||
[cellIndex, image, store, transform.zoom],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const bindWheel = useWheel(
|
||||||
(e: React.MouseEvent) => {
|
({ direction: [, dy] }) => {
|
||||||
e.stopPropagation();
|
if (!image || !isSelected) return;
|
||||||
store.resetCellTransform(cellIndex);
|
showControls();
|
||||||
|
const delta = dy > 0 ? -0.1 : 0.1;
|
||||||
|
const zoom = Math.max(1, Math.min(3, transform.zoom + delta));
|
||||||
|
store.setCellTransform(cellIndex, { zoom });
|
||||||
},
|
},
|
||||||
[cellIndex, store],
|
{ eventOptions: { passive: false } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
@@ -289,50 +374,142 @@ function CollageCell({
|
|||||||
[cellIndex, store],
|
[cellIndex, store],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
store.resetCellTransform(cellIndex);
|
||||||
|
},
|
||||||
|
[cellIndex, store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleZoomSlider = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showControls();
|
||||||
|
store.setCellTransform(cellIndex, { zoom: Number.parseFloat(e.target.value) });
|
||||||
|
},
|
||||||
|
[cellIndex, store, showControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReset = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
store.resetCellTransform(cellIndex);
|
||||||
|
},
|
||||||
|
[cellIndex, store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedRef = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
setDropRef(node);
|
||||||
|
},
|
||||||
|
[setDropRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = image?.previewLoading ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/useSemanticElements: cell requires drag/zoom interactions incompatible with button element
|
|
||||||
<div
|
<div
|
||||||
ref={cellRef}
|
ref={mergedRef}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Collage cell ${cellIndex + 1}`}
|
aria-label={`Collage cell ${cellIndex + 1}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden cursor-grab active:cursor-grabbing transition-shadow",
|
"relative overflow-hidden transition-shadow",
|
||||||
isSelected && "ring-2 ring-primary ring-offset-1",
|
isSelected && "ring-2 ring-primary ring-offset-1",
|
||||||
|
isOver && !isSelected && "ring-2 ring-blue-500",
|
||||||
!image && "border-2 border-dashed border-border/50",
|
!image && "border-2 border-dashed border-border/50",
|
||||||
|
image && !isSelected && "cursor-grab",
|
||||||
|
image && isSelected && "cursor-grab active:cursor-grabbing",
|
||||||
|
isDragSource && "opacity-50",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
gridColumn,
|
gridColumn,
|
||||||
gridRow,
|
gridRow,
|
||||||
borderRadius: `${cornerRadius}px`,
|
borderRadius: `${cornerRadius}px`,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
touchAction: isSelected ? "none" : "auto",
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") handleClick(e as unknown as React.MouseEvent);
|
if (e.key === "Enter" || e.key === " ") handleClick(e as unknown as React.MouseEvent);
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
{...(isSelected ? { ...bindDrag(), ...bindPinch(), ...bindWheel() } : {})}
|
||||||
onWheel={handleWheel}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
>
|
>
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
isLoading ? (
|
||||||
src={image.blobUrl}
|
<div className="w-full h-full flex items-center justify-center bg-muted/30">
|
||||||
alt=""
|
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||||
draggable={false}
|
</div>
|
||||||
className="w-full h-full object-cover select-none pointer-events-none"
|
) : (
|
||||||
style={{
|
<img
|
||||||
transform: `translate(${transform.panX}%, ${transform.panY}%) scale(${transform.zoom})`,
|
src={displayUrl(image)}
|
||||||
}}
|
alt=""
|
||||||
/>
|
draggable={false}
|
||||||
|
className="w-full h-full object-cover select-none pointer-events-none"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${transform.panX}%, ${transform.panY}%) scale(${transform.zoom})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-muted/30">
|
<div className="w-full h-full flex items-center justify-center bg-muted/30">
|
||||||
<ImagePlus className="h-6 w-6 text-muted-foreground/30" />
|
<ImagePlus className="h-6 w-6 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSelected && image && transform.zoom > 1 && (
|
|
||||||
<div className="absolute bottom-1 right-1 bg-background/80 px-1.5 py-0.5 rounded text-[10px] text-muted-foreground">
|
{/* Drag handle for reorder — top-right of selected cells */}
|
||||||
{Math.round(transform.zoom * 100)}%
|
{isSelected && image && !isLoading && (
|
||||||
|
<div
|
||||||
|
ref={setDragRef}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className="absolute top-1 right-1 bg-black/50 backdrop-blur-sm text-white rounded p-1 cursor-grab active:cursor-grabbing hover:bg-black/70 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zoom controls overlay — bottom of selected cells */}
|
||||||
|
{isSelected && image && !isLoading && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 left-0 right-0 flex items-center gap-2 px-2 py-1.5 bg-black/50 backdrop-blur-sm transition-opacity duration-300",
|
||||||
|
controlsVisible ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={transform.zoom}
|
||||||
|
onChange={handleZoomSlider}
|
||||||
|
onPointerDown={() => {
|
||||||
|
if (controlsTimer.current) clearTimeout(controlsTimer.current);
|
||||||
|
}}
|
||||||
|
onPointerUp={showControls}
|
||||||
|
className="flex-1 h-1 accent-white cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-white text-[10px] font-mono w-7 text-right shrink-0">
|
||||||
|
{transform.zoom.toFixed(1)}x
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-white hover:text-white/80 transition-colors shrink-0"
|
||||||
|
title="Reset position and zoom"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,7 +541,17 @@ function ImageStrip() {
|
|||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative shrink-0 w-14 h-14 rounded-md overflow-hidden border border-border group"
|
className="relative shrink-0 w-14 h-14 rounded-md overflow-hidden border border-border group"
|
||||||
>
|
>
|
||||||
<img src={img.blobUrl} alt={img.file.name} className="w-full h-full object-cover" />
|
{img.previewLoading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-muted/30">
|
||||||
|
<Loader2 className="h-4 w-4 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={displayUrl(img)}
|
||||||
|
alt={img.file.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => store.removeImage(i)}
|
onClick={() => store.removeImage(i)}
|
||||||
|
|||||||
Reference in New Issue
Block a user