From e510708e16a36392aff05de1bd8a6e20bb4f6c7f Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sun, 19 Apr 2026 15:56:02 +0800 Subject: [PATCH] feat: add use-gesture pan/zoom/pinch, dnd-kit reorder, visible controls, and HEIC spinner to collage --- .../src/components/tools/collage-preview.tsx | 405 +++++++++++++----- 1 file changed, 296 insertions(+), 109 deletions(-) diff --git a/apps/web/src/components/tools/collage-preview.tsx b/apps/web/src/components/tools/collage-preview.tsx index b1cb0eaa..2898a61b 100644 --- a/apps/web/src/components/tools/collage-preview.tsx +++ b/apps/web/src/components/tools/collage-preview.tsx @@ -1,7 +1,21 @@ -import { Download, ImagePlus, Loader2, RotateCcw, Upload, X } from "lucide-react"; -import { type DragEvent, useCallback, useRef, useState } from "react"; +import { + 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 { cn } from "@/lib/utils"; +import type { CollageImage } from "@/stores/collage-store"; import { useCollageStore } from "@/stores/collage-store"; // Checkerboard pattern for transparent background @@ -20,6 +34,10 @@ function getAspectMultiplier(ar: string): number | null { return map[ar] ?? null; } +function displayUrl(img: CollageImage): string { + return img.previewBlobUrl ?? img.blobUrl; +} + export function CollagePreview() { const images = useCollageStore((s) => s.images); const templateId = useCollageStore((s) => s.templateId); @@ -138,14 +156,54 @@ function CollageCanvas({ template }: { template: CollageTemplate }) { const containerRef = useRef(null); const arMultiplier = getAspectMultiplier(aspectRatio); - - // Calculate canvas aspect ratio style const aspectStyle: React.CSSProperties = arMultiplier ? { aspectRatio: `1 / ${arMultiplier}` } : {}; const bgIsTransparent = backgroundColor === "transparent"; + const [activeDragCell, setActiveDragCell] = useState(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 (
-
- {template.cells.map((cell, i) => { - const imgIndex = cellAssignments[i] ?? -1; - const img = imgIndex >= 0 ? images[imgIndex] : null; - const transform = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 }; - const isSelected = selectedCell === i; + +
+ {template.cells.map((cell, i) => { + const imgIndex = cellAssignments[i] ?? -1; + const img = imgIndex >= 0 ? images[imgIndex] : null; + const transform = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 }; + const isSelected = selectedCell === i; + const isDragSource = activeDragCell === i; - return ( - - ); - })} -
+ return ( + + ); + })} +
+ + {activeDragImage ? ( +
+ +
+ ) : null} +
+
); } -/** 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({ cellIndex, image, transform, cornerRadius, isSelected, + isDragSource, gridColumn, gridRow, }: { cellIndex: number; - image: { blobUrl: string } | null; + image: CollageImage | null; transform: { panX: number; panY: number; zoom: number }; cornerRadius: number; isSelected: boolean; + isDragSource: boolean; gridColumn: string; gridRow: string; }) { const store = useCollageStore(); const cellRef = useRef(null); - const isDragging = useRef(false); - const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + const [controlsVisible, setControlsVisible] = useState(false); + const controlsTimer = useRef>(null); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if (!image) return; - 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 { setNodeRef: setDropRef, isOver } = useDroppable({ + id: `cell-drop-${cellIndex}`, + data: { cellIndex }, + }); - const handleMouseMove = (ev: MouseEvent) => { - if (!isDragging.current) return; - const dx = ev.clientX - dragStart.current.x; - const dy = ev.clientY - dragStart.current.y; - const rect = cellRef.current?.getBoundingClientRect(); - if (!rect) return; - // Convert pixel drag to percentage of cell size - const panX = Math.max( - -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 { + attributes, + listeners, + setNodeRef: setDragRef, + } = useDraggable({ + id: `cell-drag-${cellIndex}`, + data: { cellIndex }, + disabled: !image, + }); - const handleMouseUp = () => { - isDragging.current = false; - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; + const showControls = useCallback(() => { + setControlsVisible(true); + if (controlsTimer.current) clearTimeout(controlsTimer.current); + controlsTimer.current = setTimeout(() => setControlsVisible(false), 3000); + }, []); - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); + useEffect(() => { + 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( - (e: React.WheelEvent) => { - if (!image) return; - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.1 : 0.1; - const newZoom = Math.max(1, Math.min(3, transform.zoom + delta)); - store.setCellTransform(cellIndex, { zoom: newZoom }); + const bindPinch = usePinch( + ({ offset: [scale] }) => { + if (!image || !isSelected) return; + showControls(); + const zoom = Math.max(1, Math.min(3, scale)); + store.setCellTransform(cellIndex, { zoom }); + }, + { + scaleBounds: { min: 1, max: 3 }, + from: () => [transform.zoom, 0], }, - [cellIndex, image, store, transform.zoom], ); - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - store.resetCellTransform(cellIndex); + const bindWheel = useWheel( + ({ direction: [, dy] }) => { + if (!image || !isSelected) return; + 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( @@ -289,50 +374,142 @@ function CollageCell({ [cellIndex, store], ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + store.resetCellTransform(cellIndex); + }, + [cellIndex, store], + ); + + const handleZoomSlider = useCallback( + (e: React.ChangeEvent) => { + 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).current = node; + setDropRef(node); + }, + [setDropRef], + ); + + const isLoading = image?.previewLoading ?? false; + return ( - // biome-ignore lint/a11y/useSemanticElements: cell requires drag/zoom interactions incompatible with button element
{ if (e.key === "Enter" || e.key === " ") handleClick(e as unknown as React.MouseEvent); }} - onMouseDown={handleMouseDown} - onWheel={handleWheel} - onDoubleClick={handleDoubleClick} + {...(isSelected ? { ...bindDrag(), ...bindPinch(), ...bindWheel() } : {})} > {image ? ( - + isLoading ? ( +
+ +
+ ) : ( + + ) ) : (
)} - {isSelected && image && transform.zoom > 1 && ( -
- {Math.round(transform.zoom * 100)}% + + {/* Drag handle for reorder — top-right of selected cells */} + {isSelected && image && !isLoading && ( +
e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + +
+ )} + + {/* Zoom controls overlay — bottom of selected cells */} + {isSelected && image && !isLoading && ( +
e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + { + if (controlsTimer.current) clearTimeout(controlsTimer.current); + }} + onPointerUp={showControls} + className="flex-1 h-1 accent-white cursor-pointer" + /> + + {transform.zoom.toFixed(1)}x + +
)}
@@ -364,7 +541,17 @@ function ImageStrip() { key={img.id} className="relative shrink-0 w-14 h-14 rounded-md overflow-hidden border border-border group" > - {img.file.name} + {img.previewLoading ? ( +
+ +
+ ) : ( + {img.file.name} + )}