From 00a50bdebad02e3cba80987c7f39f96d70b408ba Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:42:20 +0700 Subject: [PATCH] feat: drag-and-drop multi-image import onto canvas Allow users to drag multiple PNG/JPG files from Finder onto the canvas to bulk-create screens in a single gesture. Images are auto-arranged in a grid from the drop point with dynamic row heights based on the tallest image per row, anti-overlap detection against existing screens, and filename-derived screen names. The entire batch is a single undo step. --- src/Drawd.jsx | 42 ++++++++- src/components/CanvasArea.jsx | 30 ++++++ src/components/CanvasArea.test.jsx | 22 ++++- src/components/Toast.jsx | 25 +++++ src/constants.js | 1 + src/hooks/useScreenManager.js | 93 +++++++++++++++++-- src/hooks/useScreenManager.test.js | 79 ++++++++++++++++ src/utils/dropImport.js | 95 +++++++++++++++++++ src/utils/dropImport.test.js | 141 +++++++++++++++++++++++++++++ 9 files changed, 515 insertions(+), 13 deletions(-) create mode 100644 src/components/Toast.jsx create mode 100644 src/utils/dropImport.js create mode 100644 src/utils/dropImport.test.js diff --git a/src/Drawd.jsx b/src/Drawd.jsx index b15d3c8..390c7d7 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -25,6 +25,7 @@ import { StickyNoteSidebar } from "./components/StickyNoteSidebar"; import { ScreensPanel } from "./components/ScreensPanel"; import { CanvasArea } from "./components/CanvasArea"; import { ModalsLayer } from "./components/ModalsLayer"; +import { Toast } from "./components/Toast"; import { CollabPresence } from "./components/CollabPresence"; import { CollabBadge } from "./components/CollabBadge"; import { importFlow } from "./utils/importFlow"; @@ -262,12 +263,45 @@ export default function Drawd({ initialRoomCode }) { const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } = useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups }); + // ── Toast notification ───────────────────────────────────────────────────────────── + const [toast, setToast] = useState(null); + const toastTimerRef = useRef(null); + const showToast = useCallback((message, duration = 3000) => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setToast(message); + toastTimerRef.current = setTimeout(() => setToast(null), duration); + }, []); + + // ── Drag-over state (drop zone overlay) ─────────────────────────────────────────── + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dragCounterRef = useRef(0); + const onCanvasDragEnter = useCallback((e) => { + e.preventDefault(); + dragCounterRef.current++; + if (dragCounterRef.current === 1) setIsDraggingOver(true); + }, []); + const onCanvasDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current === 0) setIsDraggingOver(false); + }, []); + // ── Canvas drop (intercepts .drawd files, delegates images) ──────────────────────── const onCanvasDrop = useCallback(async (e) => { e.preventDefault(); + dragCounterRef.current = 0; + setIsDraggingOver(false); + const drawdFile = detectDrawdFile(e.dataTransfer.files); if (!drawdFile) { - handleCanvasDrop(e); + const imageFiles = Array.from(e.dataTransfer.files).filter( + (f) => f.type === "image/png" || f.type === "image/jpeg" + ); + if (imageFiles.length === 0) return; + const rect = canvasRef.current.getBoundingClientRect(); + const worldX = (e.clientX - rect.left - pan.x) / zoom; + const worldY = (e.clientY - rect.top - pan.y) / zoom; + handleCanvasDrop(e, worldX, worldY); + showToast(`Created ${imageFiles.length} screen${imageFiles.length > 1 ? "s" : ""} from dropped images`); return; } @@ -296,7 +330,7 @@ export default function Drawd({ initialRoomCode }) { } catch (err) { alert(err.message); } - }, [screens.length, handleCanvasDrop, applyPayload, setImportConfirm, connectHandle, isFileSystemSupported]); + }, [screens.length, handleCanvasDrop, applyPayload, setImportConfirm, connectHandle, isFileSystemSupported, pan, zoom, showToast]); // ── Keyboard shortcuts ────────────────────────────────────────────────────────────── useKeyboardShortcuts({ @@ -488,6 +522,9 @@ export default function Drawd({ initialRoomCode }) { setGroupContextMenu={setGroupContextMenu} handleImageUpload={handleImageUpload} addScreenAtCenter={addScreenAtCenter} + isDraggingOver={isDraggingOver} + onCanvasDragEnter={onCanvasDragEnter} + onCanvasDragLeave={onCanvasDragLeave} /> {selectedScreenData && ( @@ -574,6 +611,7 @@ export default function Drawd({ initialRoomCode }) { formSummaryScreen={formSummaryScreen} setFormSummaryScreen={setFormSummaryScreen} /> + ); } diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx index 3689349..a88dc31 100644 --- a/src/components/CanvasArea.jsx +++ b/src/components/CanvasArea.jsx @@ -52,6 +52,8 @@ export function CanvasArea({ groupContextMenu, setGroupContextMenu, // ToolBar setActiveTool, handleImageUpload, addScreenAtCenter, + // Drop zone overlay + isDraggingOver, onCanvasDragEnter, onCanvasDragLeave, }) { return (
e.preventDefault()} + onDragEnter={onCanvasDragEnter} + onDragLeave={onCanvasDragLeave} onDrop={onCanvasDrop} onClick={() => { if (groupContextMenu) setGroupContextMenu(null); @@ -86,6 +90,32 @@ export function CanvasArea({ backgroundPosition: `${pan.x}px ${pan.y}px`, }} > + {isDraggingOver && ( +
+ + Drop images to create screens + +
+ )}
({ screenNodeSpy: vi.fn(), @@ -140,6 +140,9 @@ function makeProps(overrides = {}) { setActiveTool: vi.fn(), handleImageUpload: vi.fn(), addScreenAtCenter: vi.fn(), + isDraggingOver: false, + onCanvasDragEnter: vi.fn(), + onCanvasDragLeave: vi.fn(), ...overrides, }; } @@ -149,6 +152,10 @@ describe("CanvasArea", () => { screenNodeSpy.mockClear(); }); + afterEach(() => { + cleanup(); + }); + it("renders screens without throwing when form summary handler is provided", () => { const onFormSummary = vi.fn(); render( @@ -164,4 +171,15 @@ describe("CanvasArea", () => { expect(screenNodeSpy).toHaveBeenCalledTimes(1); expect(screenNodeSpy.mock.calls[0][0].onFormSummary).toBe(onFormSummary); }); + + it("renders drop zone overlay when isDraggingOver is true", () => { + render(); + expect(screen.getByTestId("drop-zone-overlay")).toBeTruthy(); + expect(screen.getByText("Drop images to create screens")).toBeTruthy(); + }); + + it("does not render drop zone overlay when isDraggingOver is false", () => { + render(); + expect(screen.queryByTestId("drop-zone-overlay")).toBeNull(); + }); }); \ No newline at end of file diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..da8e944 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,25 @@ +import { COLORS, FONTS, Z_INDEX } from "../styles/theme"; + +export function Toast({ message }) { + if (!message) return null; + return ( +
+ {message} +
+ ); +} diff --git a/src/constants.js b/src/constants.js index 361d271..466c0f5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -43,6 +43,7 @@ export const GRID_MARGIN = 60; export const PASTE_STAGGER = 30; export const STATE_VARIANT_OFFSET = 250; export const MERGE_GAP = 300; +export const DROP_OVERLAP_MARGIN = 40; export const CENTER_HEIGHT_ESTIMATE = 160; export const VIEWPORT_FALLBACK_WIDTH = 800; export const VIEWPORT_FALLBACK_HEIGHT = 600; diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js index a6a84ba..8741bec 100644 --- a/src/hooks/useScreenManager.js +++ b/src/hooks/useScreenManager.js @@ -1,5 +1,7 @@ import { useState, useRef, useCallback } from "react"; import { generateId } from "../utils/generateId"; +import { screenBounds } from "../utils/canvasMath"; +import { filenameToScreenName, gridPositions, resolveOverlaps } from "../utils/dropImport"; import { DEFAULT_SCREEN_WIDTH, CENTER_HEIGHT_ESTIMATE, @@ -14,6 +16,7 @@ import { VIEWPORT_FALLBACK_HEIGHT, DEFAULT_STATE_NAME, SCREEN_NAME_TEMPLATE, + HEADER_HEIGHT, } from "../constants"; export function useScreenManager(pan, zoom, canvasRef) { @@ -163,6 +166,35 @@ export function useScreenManager(pan, zoom, canvasRef) { setSelectedScreen(newScreen.id); }, [screens, connections, documents, pushHistory, pan, zoom, canvasRef]); + const addScreensBatch = useCallback((screenDefs) => { + if (screenDefs.length === 0) return 0; + pushHistory(screens, connections, documents); + const newScreens = screenDefs.map((def) => ({ + id: generateId(), + name: def.name, + x: def.x, + y: def.y, + width: DEFAULT_SCREEN_WIDTH, + imageData: def.imageData, + description: "", + notes: "", + codeRef: "", + status: "new", + acceptanceCriteria: [], + hotspots: [], + stateGroup: null, + stateName: "", + tbd: false, + tbdNote: "", + roles: [], + figmaSource: null, + })); + setScreens((prev) => [...prev, ...newScreens]); + setSelectedScreen(newScreens[0].id); + screenCounter.current += newScreens.length; + return newScreens.length; + }, [screens, connections, documents, pushHistory]); + const removeScreen = useCallback((id) => { pushHistory(screens, connections, documents); const removedScreen = screens.find((s) => s.id === id); @@ -325,17 +357,59 @@ export function useScreenManager(pan, zoom, canvasRef) { }); }, [addScreenAtCenter, selectedScreen, screens, assignScreenImage]); - const handleCanvasDrop = useCallback((e) => { + const handleCanvasDrop = useCallback((e, worldX, worldY) => { e.preventDefault(); - const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); - files.forEach((file) => { - const reader = new FileReader(); - reader.onload = (ev) => { - addScreen(ev.target.result, file.name.replace(/\.[^.]+$/, "")); - }; - reader.readAsDataURL(file); + const files = Array.from(e.dataTransfer.files).filter( + (f) => f.type === "image/png" || f.type === "image/jpeg" + ); + if (files.length === 0) return; + + Promise.all( + files.map( + (file) => + new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (ev) => { + const dataUrl = ev.target.result; + const img = new Image(); + img.onload = () => { + const renderedHeight = (img.naturalHeight / img.naturalWidth) * DEFAULT_SCREEN_WIDTH; + resolve({ + imageData: dataUrl, + filename: file.name, + imageHeight: renderedHeight, + }); + }; + img.onerror = () => { + resolve({ imageData: dataUrl, filename: file.name, imageHeight: 120 }); + }; + img.src = dataUrl; + }; + reader.readAsDataURL(file); + }) + ) + ).then((results) => { + const itemHeights = results.map((r) => r.imageHeight + HEADER_HEIGHT); + const positions = gridPositions(itemHeights, worldX, worldY); + const candidateRects = positions.map((pos, i) => ({ + x: pos.x, + y: pos.y, + width: DEFAULT_SCREEN_WIDTH, + height: itemHeights[i], + })); + const existingRects = screens.map((s) => screenBounds(s, HEADER_HEIGHT)); + const adjusted = resolveOverlaps(candidateRects, existingRects); + + const screenDefs = results.map((r, i) => ({ + imageData: r.imageData, + name: filenameToScreenName(r.filename), + x: adjusted[i].x, + y: adjusted[i].y, + })); + + addScreensBatch(screenDefs); }); - }, [addScreen]); + }, [screens, addScreensBatch]); const saveHotspot = useCallback((screenId, hotspot) => { pushHistory(screens, connections, documents); @@ -872,6 +946,7 @@ export function useScreenManager(pan, zoom, canvasRef) { fileInputRef, addScreen, addScreenAtCenter, + addScreensBatch, removeScreen, removeScreens, renameScreen, diff --git a/src/hooks/useScreenManager.test.js b/src/hooks/useScreenManager.test.js index 00b8532..7c6399f 100644 --- a/src/hooks/useScreenManager.test.js +++ b/src/hooks/useScreenManager.test.js @@ -961,3 +961,82 @@ describe("document CRUD", () => { expect(result.current.documents).toHaveLength(0); }); }); + +describe("addScreensBatch", () => { + it("creates multiple screens at specified positions", () => { + const { result } = setup(); + + act(() => + result.current.addScreensBatch([ + { imageData: null, name: "A", x: 0, y: 0 }, + { imageData: null, name: "B", x: 300, y: 0 }, + { imageData: null, name: "C", x: 600, y: 0 }, + ]) + ); + + expect(result.current.screens).toHaveLength(3); + expect(result.current.screens[0].name).toBe("A"); + expect(result.current.screens[1].name).toBe("B"); + expect(result.current.screens[2].name).toBe("C"); + expect(result.current.screens[0].x).toBe(0); + expect(result.current.screens[1].x).toBe(300); + expect(result.current.screens[2].x).toBe(600); + }); + + it("single undo removes all screens from the batch", () => { + const { result } = setup(); + + act(() => + result.current.addScreensBatch([ + { imageData: null, name: "A", x: 0, y: 0 }, + { imageData: null, name: "B", x: 300, y: 0 }, + ]) + ); + expect(result.current.screens).toHaveLength(2); + + act(() => result.current.undo()); + expect(result.current.screens).toHaveLength(0); + }); + + it("redo restores all screens from the batch", () => { + const { result } = setup(); + + act(() => + result.current.addScreensBatch([ + { imageData: null, name: "A", x: 0, y: 0 }, + { imageData: null, name: "B", x: 300, y: 0 }, + ]) + ); + + act(() => result.current.undo()); + expect(result.current.screens).toHaveLength(0); + + act(() => result.current.redo()); + expect(result.current.screens).toHaveLength(2); + expect(result.current.screens[0].name).toBe("A"); + expect(result.current.screens[1].name).toBe("B"); + }); + + it("selects the first screen in the batch", () => { + const { result } = setup(); + + act(() => + result.current.addScreensBatch([ + { imageData: null, name: "A", x: 0, y: 0 }, + { imageData: null, name: "B", x: 300, y: 0 }, + ]) + ); + + expect(result.current.selectedScreen).toBe(result.current.screens[0].id); + }); + + it("returns 0 and does not push history for empty batch", () => { + const { result } = setup(); + + let count; + act(() => { count = result.current.addScreensBatch([]); }); + expect(count).toBe(0); + expect(result.current.screens).toHaveLength(0); + expect(result.current.canUndo).toBe(false); + }); +}); diff --git a/src/utils/dropImport.js b/src/utils/dropImport.js new file mode 100644 index 0000000..bdcbd37 --- /dev/null +++ b/src/utils/dropImport.js @@ -0,0 +1,95 @@ +import { GRID_COLUMNS, GRID_COL_WIDTH, GRID_ROW_HEIGHT, DROP_OVERLAP_MARGIN } from "../constants"; +import { rectsIntersect } from "./canvasMath"; + +/** + * Convert a filename to a human-readable screen name. + * "login_page.png" -> "Login Page" + */ +export function filenameToScreenName(filename) { + const withoutExt = filename.replace(/\.[^.]+$/, ""); + return withoutExt + .replace(/[_-]/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/\B\w+/g, (w) => w.toLowerCase()); +} + +/** + * Arrange items in a grid starting at (originX, originY). + * Row height is determined by the tallest item in that row plus rowGap. + * + * @param {number[]} heights - height of each item + * @param {number} originX + * @param {number} originY + * @param {number} rowGap - vertical spacing between rows (default 60) + * @returns {Array<{x, y}>} + */ +export function gridPositions(heights, originX, originY, rowGap = 60) { + const positions = []; + let rowY = originY; + let rowMaxHeight = 0; + + for (let i = 0; i < heights.length; i++) { + const col = i % GRID_COLUMNS; + if (col === 0 && i > 0) { + rowY += rowMaxHeight + rowGap; + rowMaxHeight = 0; + } + positions.push({ + x: originX + col * GRID_COL_WIDTH, + y: rowY, + }); + rowMaxHeight = Math.max(rowMaxHeight, heights[i]); + } + return positions; +} + +/** + * Inflate a rect by margin on all sides. + */ +function inflateRect(rect, margin) { + return { + x: rect.x - margin, + y: rect.y - margin, + width: rect.width + margin * 2, + height: rect.height + margin * 2, + }; +} + +/** + * Check if a candidate rect (inflated by margin) overlaps any rect in the occupied list. + */ +function hasCollision(candidate, occupied, margin) { + const inflated = inflateRect(candidate, margin); + return occupied.some((r) => rectsIntersect(inflated, r)); +} + +/** + * Given candidate rects and existing screen rects, shift candidates to avoid overlaps. + * Returns Array<{x, y}> with adjusted positions. + */ +export function resolveOverlaps(candidates, existing, margin = DROP_OVERLAP_MARGIN) { + const occupied = existing.map((r) => ({ ...r })); + const result = []; + + for (const candidate of candidates) { + let placed = { ...candidate }; + let attempts = 0; + const maxAttempts = GRID_COLUMNS * 20; + + while (hasCollision(placed, occupied, margin) && attempts < maxAttempts) { + placed.x += GRID_COL_WIDTH; + attempts++; + if (attempts % GRID_COLUMNS === 0) { + placed.x = candidate.x; + placed.y += GRID_ROW_HEIGHT; + } + } + + occupied.push(placed); + result.push({ x: placed.x, y: placed.y }); + } + + return result; +} diff --git a/src/utils/dropImport.test.js b/src/utils/dropImport.test.js new file mode 100644 index 0000000..c91a3ba --- /dev/null +++ b/src/utils/dropImport.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { filenameToScreenName, gridPositions, resolveOverlaps } from "./dropImport"; +import { GRID_COLUMNS, GRID_COL_WIDTH } from "../constants"; + +describe("filenameToScreenName", () => { + it("strips extension and title-cases", () => { + expect(filenameToScreenName("login_page.png")).toBe("Login Page"); + }); + + it("handles uppercase filenames", () => { + expect(filenameToScreenName("HOME.jpg")).toBe("Home"); + }); + + it("handles hyphens", () => { + expect(filenameToScreenName("my-dashboard.PNG")).toBe("My Dashboard"); + }); + + it("handles dots in filename", () => { + expect(filenameToScreenName("screenshot.2024.01.15.png")).toBe("Screenshot.2024.01.15"); + }); + + it("handles filename without extension", () => { + expect(filenameToScreenName("noext")).toBe("Noext"); + }); + + it("handles mixed separators", () => { + expect(filenameToScreenName("user_profile-settings.jpg")).toBe("User Profile Settings"); + }); +}); + +describe("gridPositions", () => { + const gap = 60; + + it("returns correct count", () => { + expect(gridPositions([100, 100, 100, 100, 100], 0, 0)).toHaveLength(5); + }); + + it("first position matches origin", () => { + const pos = gridPositions([100], 100, 200); + expect(pos[0]).toEqual({ x: 100, y: 200 }); + }); + + it("arranges in columns", () => { + const pos = gridPositions([100, 100, 100], 0, 0); + expect(pos[0]).toEqual({ x: 0, y: 0 }); + expect(pos[1]).toEqual({ x: GRID_COL_WIDTH, y: 0 }); + expect(pos[2]).toEqual({ x: GRID_COL_WIDTH * 2, y: 0 }); + }); + + it("wraps to next row after GRID_COLUMNS using tallest item height", () => { + // Row of 4 items: heights 100, 200, 150, 100 -> tallest is 200 + const heights = [100, 200, 150, 100, 120]; + const pos = gridPositions(heights, 0, 0, gap); + // Second row starts at tallest(200) + gap(60) = 260 + expect(pos[GRID_COLUMNS]).toEqual({ x: 0, y: 260 }); + }); + + it("uses dynamic row heights based on tallest item per row", () => { + // Row 1: heights [300, 100, 100, 100] -> tallest 300 + // Row 2: heights [50, 200] -> tallest 200 + // Row 3: heights [150] + const heights = [300, 100, 100, 100, 50, 200, 100, 100, 150]; + const pos = gridPositions(heights, 0, 0, gap); + + // All items in row 1 start at y=0 + expect(pos[0].y).toBe(0); + expect(pos[3].y).toBe(0); + + // Row 2 starts at 300 + 60 = 360 + expect(pos[4].y).toBe(360); + expect(pos[7].y).toBe(360); + + // Row 3 starts at 360 + 200 + 60 = 620 + expect(pos[8].y).toBe(620); + }); + + it("offsets from origin", () => { + const heights = [100, 100, 100, 100, 100]; + const pos = gridPositions(heights, 50, 100, gap); + expect(pos[0]).toEqual({ x: 50, y: 100 }); + expect(pos[GRID_COLUMNS]).toEqual({ x: 50, y: 100 + 100 + gap }); + }); +}); + +describe("resolveOverlaps", () => { + const w = 220; + const h = 160; + + it("returns positions unchanged when no existing screens", () => { + const candidates = [ + { x: 0, y: 0, width: w, height: h }, + { x: 300, y: 0, width: w, height: h }, + ]; + const result = resolveOverlaps(candidates, []); + expect(result[0]).toEqual({ x: 0, y: 0 }); + expect(result[1]).toEqual({ x: 300, y: 0 }); + }); + + it("shifts candidate when overlapping an existing screen", () => { + const existing = [{ x: 0, y: 0, width: w, height: h }]; + const candidates = [{ x: 10, y: 10, width: w, height: h }]; + const result = resolveOverlaps(candidates, existing); + expect(result[0].x).toBeGreaterThan(10); + }); + + it("prevents candidates from overlapping each other", () => { + const candidates = [ + { x: 0, y: 0, width: w, height: h }, + { x: 0, y: 0, width: w, height: h }, + ]; + const result = resolveOverlaps(candidates, []); + const rect1 = { x: result[0].x, y: result[0].y, width: w, height: h }; + const rect2 = { x: result[1].x, y: result[1].y, width: w, height: h }; + // They should not overlap (with margin) + const noOverlapX = rect1.x + w + 40 <= rect2.x || rect2.x + w + 40 <= rect1.x; + const noOverlapY = rect1.y + h + 40 <= rect2.y || rect2.y + h + 40 <= rect1.y; + expect(noOverlapX || noOverlapY).toBe(true); + }); + + it("wraps to next row after exhausting columns", () => { + // Fill a row with existing screens + const existing = []; + for (let i = 0; i < GRID_COLUMNS; i++) { + existing.push({ x: i * GRID_COL_WIDTH, y: 0, width: w, height: h }); + } + const candidates = [{ x: 0, y: 0, width: w, height: h }]; + const result = resolveOverlaps(candidates, existing); + expect(result[0].y).toBeGreaterThan(0); + }); + + it("respects custom margin", () => { + const existing = [{ x: 0, y: 0, width: w, height: h }]; + // Place candidate just outside default margin but inside a larger margin + const candidates = [{ x: w + 30, y: 0, width: w, height: h }]; + const resultSmall = resolveOverlaps(candidates, existing, 20); + const resultLarge = resolveOverlaps(candidates, existing, 50); + // With small margin, should stay in place; with large margin, should shift + expect(resultSmall[0].x).toBe(w + 30); + expect(resultLarge[0].x).toBeGreaterThan(w + 30); + }); +});