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 && (
+
({
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);
+ });
+});