Skip to content

Commit ba41004

Browse files
authored
feat: drag-and-drop multi-image import onto canvas (#21)
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. Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent d052773 commit ba41004

10 files changed

Lines changed: 516 additions & 13 deletions

File tree

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const browserGlobals = {
2929
KeyboardEvent: "readonly",
3030
MouseEvent: "readonly",
3131
CustomEvent: "readonly",
32+
Image: "readonly",
3233
};
3334

3435
export default [

src/Drawd.jsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { StickyNoteSidebar } from "./components/StickyNoteSidebar";
2626
import { ScreensPanel } from "./components/ScreensPanel";
2727
import { CanvasArea } from "./components/CanvasArea";
2828
import { ModalsLayer } from "./components/ModalsLayer";
29+
import { Toast } from "./components/Toast";
2930
import { CollabPresence } from "./components/CollabPresence";
3031
import { CollabBadge } from "./components/CollabBadge";
3132
import { importFlow } from "./utils/importFlow";
@@ -275,12 +276,45 @@ export default function Drawd({ initialRoomCode }) {
275276
const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } =
276277
useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups });
277278

279+
// ── Toast notification ─────────────────────────────────────────────────────────────
280+
const [toast, setToast] = useState(null);
281+
const toastTimerRef = useRef(null);
282+
const showToast = useCallback((message, duration = 3000) => {
283+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
284+
setToast(message);
285+
toastTimerRef.current = setTimeout(() => setToast(null), duration);
286+
}, []);
287+
288+
// ── Drag-over state (drop zone overlay) ───────────────────────────────────────────
289+
const [isDraggingOver, setIsDraggingOver] = useState(false);
290+
const dragCounterRef = useRef(0);
291+
const onCanvasDragEnter = useCallback((e) => {
292+
e.preventDefault();
293+
dragCounterRef.current++;
294+
if (dragCounterRef.current === 1) setIsDraggingOver(true);
295+
}, []);
296+
const onCanvasDragLeave = useCallback(() => {
297+
dragCounterRef.current--;
298+
if (dragCounterRef.current === 0) setIsDraggingOver(false);
299+
}, []);
300+
278301
// ── Canvas drop (intercepts .drawd files, delegates images) ────────────────────────
279302
const onCanvasDrop = useCallback(async (e) => {
280303
e.preventDefault();
304+
dragCounterRef.current = 0;
305+
setIsDraggingOver(false);
306+
281307
const drawdFile = detectDrawdFile(e.dataTransfer.files);
282308
if (!drawdFile) {
283-
handleCanvasDrop(e);
309+
const imageFiles = Array.from(e.dataTransfer.files).filter(
310+
(f) => f.type === "image/png" || f.type === "image/jpeg"
311+
);
312+
if (imageFiles.length === 0) return;
313+
const rect = canvasRef.current.getBoundingClientRect();
314+
const worldX = (e.clientX - rect.left - pan.x) / zoom;
315+
const worldY = (e.clientY - rect.top - pan.y) / zoom;
316+
handleCanvasDrop(e, worldX, worldY);
317+
showToast(`Created ${imageFiles.length} screen${imageFiles.length > 1 ? "s" : ""} from dropped images`);
284318
return;
285319
}
286320

@@ -309,7 +343,7 @@ export default function Drawd({ initialRoomCode }) {
309343
} catch (err) {
310344
alert(err.message);
311345
}
312-
}, [screens.length, handleCanvasDrop, applyPayload, setImportConfirm, connectHandle, isFileSystemSupported]);
346+
}, [screens.length, handleCanvasDrop, applyPayload, setImportConfirm, connectHandle, isFileSystemSupported, pan, zoom, showToast]);
313347

314348
// ── Keyboard shortcuts ──────────────────────────────────────────────────────────────
315349
useKeyboardShortcuts({
@@ -503,6 +537,9 @@ export default function Drawd({ initialRoomCode }) {
503537
setGroupContextMenu={setGroupContextMenu}
504538
handleImageUpload={handleImageUpload}
505539
addScreenAtCenter={addScreenAtCenter}
540+
isDraggingOver={isDraggingOver}
541+
onCanvasDragEnter={onCanvasDragEnter}
542+
onCanvasDragLeave={onCanvasDragLeave}
506543
onTemplates={onTemplates}
507544
/>
508545

@@ -593,6 +630,7 @@ export default function Drawd({ initialRoomCode }) {
593630
setShowTemplateBrowser={setShowTemplateBrowser}
594631
onInsertTemplate={onInsertTemplate}
595632
/>
633+
<Toast message={toast} />
596634
</div>
597635
);
598636
}

src/components/CanvasArea.jsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export function CanvasArea({
5252
groupContextMenu, setGroupContextMenu,
5353
// ToolBar
5454
setActiveTool, handleImageUpload, addScreenAtCenter,
55+
// Drop zone overlay
56+
isDraggingOver, onCanvasDragEnter, onCanvasDragLeave,
5557
// Templates
5658
onTemplates,
5759
}) {
@@ -66,6 +68,8 @@ export function CanvasArea({
6668
onMouseUp={onCanvasMouseUp}
6769
onMouseLeave={onCanvasMouseLeave}
6870
onDragOver={(e) => e.preventDefault()}
71+
onDragEnter={onCanvasDragEnter}
72+
onDragLeave={onCanvasDragLeave}
6973
onDrop={onCanvasDrop}
7074
onClick={() => {
7175
if (groupContextMenu) setGroupContextMenu(null);
@@ -88,6 +92,32 @@ export function CanvasArea({
8892
backgroundPosition: `${pan.x}px ${pan.y}px`,
8993
}}
9094
>
95+
{isDraggingOver && (
96+
<div
97+
data-testid="drop-zone-overlay"
98+
style={{
99+
position: "absolute",
100+
inset: 0,
101+
background: "rgba(97,175,239,0.08)",
102+
border: `2px dashed ${COLORS.accent}`,
103+
borderRadius: 12,
104+
display: "flex",
105+
alignItems: "center",
106+
justifyContent: "center",
107+
zIndex: Z_INDEX.contextMenu,
108+
pointerEvents: "none",
109+
}}
110+
>
111+
<span style={{
112+
color: COLORS.accent,
113+
fontFamily: FONTS.mono,
114+
fontSize: 16,
115+
fontWeight: 600,
116+
}}>
117+
Drop images to create screens
118+
</span>
119+
</div>
120+
)}
91121
<div
92122
className="canvas-inner"
93123
style={{

src/components/CanvasArea.test.jsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { render, screen } from "@testing-library/react";
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, cleanup } from "@testing-library/react";
33

44
const { screenNodeSpy } = vi.hoisted(() => ({
55
screenNodeSpy: vi.fn(),
@@ -140,6 +140,9 @@ function makeProps(overrides = {}) {
140140
setActiveTool: vi.fn(),
141141
handleImageUpload: vi.fn(),
142142
addScreenAtCenter: vi.fn(),
143+
isDraggingOver: false,
144+
onCanvasDragEnter: vi.fn(),
145+
onCanvasDragLeave: vi.fn(),
143146
...overrides,
144147
};
145148
}
@@ -149,6 +152,10 @@ describe("CanvasArea", () => {
149152
screenNodeSpy.mockClear();
150153
});
151154

155+
afterEach(() => {
156+
cleanup();
157+
});
158+
152159
it("renders screens without throwing when form summary handler is provided", () => {
153160
const onFormSummary = vi.fn();
154161
render(
@@ -164,4 +171,15 @@ describe("CanvasArea", () => {
164171
expect(screenNodeSpy).toHaveBeenCalledTimes(1);
165172
expect(screenNodeSpy.mock.calls[0][0].onFormSummary).toBe(onFormSummary);
166173
});
174+
175+
it("renders drop zone overlay when isDraggingOver is true", () => {
176+
render(<CanvasArea {...makeProps({ isDraggingOver: true })} />);
177+
expect(screen.getByTestId("drop-zone-overlay")).toBeTruthy();
178+
expect(screen.getByText("Drop images to create screens")).toBeTruthy();
179+
});
180+
181+
it("does not render drop zone overlay when isDraggingOver is false", () => {
182+
render(<CanvasArea {...makeProps({ isDraggingOver: false })} />);
183+
expect(screen.queryByTestId("drop-zone-overlay")).toBeNull();
184+
});
167185
});

src/components/Toast.jsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
2+
3+
export function Toast({ message }) {
4+
if (!message) return null;
5+
return (
6+
<div style={{
7+
position: "fixed",
8+
bottom: 40,
9+
left: "50%",
10+
transform: "translateX(-50%)",
11+
background: COLORS.surface,
12+
border: `1px solid ${COLORS.accent}`,
13+
borderRadius: 8,
14+
padding: "10px 20px",
15+
color: COLORS.text,
16+
fontFamily: FONTS.mono,
17+
fontSize: 13,
18+
zIndex: Z_INDEX.contextMenu + 1,
19+
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
20+
pointerEvents: "none",
21+
}}>
22+
{message}
23+
</div>
24+
);
25+
}

src/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const GRID_MARGIN = 60;
4343
export const PASTE_STAGGER = 30;
4444
export const STATE_VARIANT_OFFSET = 250;
4545
export const MERGE_GAP = 300;
46+
export const DROP_OVERLAP_MARGIN = 40;
4647
export const CENTER_HEIGHT_ESTIMATE = 160;
4748
export const VIEWPORT_FALLBACK_WIDTH = 800;
4849
export const VIEWPORT_FALLBACK_HEIGHT = 600;

src/hooks/useScreenManager.js

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useState, useRef, useCallback } from "react";
22
import { generateId } from "../utils/generateId";
3+
import { screenBounds } from "../utils/canvasMath";
4+
import { filenameToScreenName, gridPositions, resolveOverlaps } from "../utils/dropImport";
35
import {
46
DEFAULT_SCREEN_WIDTH,
57
CENTER_HEIGHT_ESTIMATE,
@@ -14,6 +16,7 @@ import {
1416
VIEWPORT_FALLBACK_HEIGHT,
1517
DEFAULT_STATE_NAME,
1618
SCREEN_NAME_TEMPLATE,
19+
HEADER_HEIGHT,
1720
} from "../constants";
1821

1922
export function useScreenManager(pan, zoom, canvasRef) {
@@ -163,6 +166,35 @@ export function useScreenManager(pan, zoom, canvasRef) {
163166
setSelectedScreen(newScreen.id);
164167
}, [screens, connections, documents, pushHistory, pan, zoom, canvasRef]);
165168

169+
const addScreensBatch = useCallback((screenDefs) => {
170+
if (screenDefs.length === 0) return 0;
171+
pushHistory(screens, connections, documents);
172+
const newScreens = screenDefs.map((def) => ({
173+
id: generateId(),
174+
name: def.name,
175+
x: def.x,
176+
y: def.y,
177+
width: DEFAULT_SCREEN_WIDTH,
178+
imageData: def.imageData,
179+
description: "",
180+
notes: "",
181+
codeRef: "",
182+
status: "new",
183+
acceptanceCriteria: [],
184+
hotspots: [],
185+
stateGroup: null,
186+
stateName: "",
187+
tbd: false,
188+
tbdNote: "",
189+
roles: [],
190+
figmaSource: null,
191+
}));
192+
setScreens((prev) => [...prev, ...newScreens]);
193+
setSelectedScreen(newScreens[0].id);
194+
screenCounter.current += newScreens.length;
195+
return newScreens.length;
196+
}, [screens, connections, documents, pushHistory]);
197+
166198
const removeScreen = useCallback((id) => {
167199
pushHistory(screens, connections, documents);
168200
const removedScreen = screens.find((s) => s.id === id);
@@ -325,17 +357,59 @@ export function useScreenManager(pan, zoom, canvasRef) {
325357
});
326358
}, [addScreenAtCenter, selectedScreen, screens, assignScreenImage]);
327359

328-
const handleCanvasDrop = useCallback((e) => {
360+
const handleCanvasDrop = useCallback((e, worldX, worldY) => {
329361
e.preventDefault();
330-
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
331-
files.forEach((file) => {
332-
const reader = new FileReader();
333-
reader.onload = (ev) => {
334-
addScreen(ev.target.result, file.name.replace(/\.[^.]+$/, ""));
335-
};
336-
reader.readAsDataURL(file);
362+
const files = Array.from(e.dataTransfer.files).filter(
363+
(f) => f.type === "image/png" || f.type === "image/jpeg"
364+
);
365+
if (files.length === 0) return;
366+
367+
Promise.all(
368+
files.map(
369+
(file) =>
370+
new Promise((resolve) => {
371+
const reader = new FileReader();
372+
reader.onload = (ev) => {
373+
const dataUrl = ev.target.result;
374+
const img = new Image();
375+
img.onload = () => {
376+
const renderedHeight = (img.naturalHeight / img.naturalWidth) * DEFAULT_SCREEN_WIDTH;
377+
resolve({
378+
imageData: dataUrl,
379+
filename: file.name,
380+
imageHeight: renderedHeight,
381+
});
382+
};
383+
img.onerror = () => {
384+
resolve({ imageData: dataUrl, filename: file.name, imageHeight: 120 });
385+
};
386+
img.src = dataUrl;
387+
};
388+
reader.readAsDataURL(file);
389+
})
390+
)
391+
).then((results) => {
392+
const itemHeights = results.map((r) => r.imageHeight + HEADER_HEIGHT);
393+
const positions = gridPositions(itemHeights, worldX, worldY);
394+
const candidateRects = positions.map((pos, i) => ({
395+
x: pos.x,
396+
y: pos.y,
397+
width: DEFAULT_SCREEN_WIDTH,
398+
height: itemHeights[i],
399+
}));
400+
const existingRects = screens.map((s) => screenBounds(s, HEADER_HEIGHT));
401+
const adjusted = resolveOverlaps(candidateRects, existingRects);
402+
403+
const screenDefs = results.map((r, i) => ({
404+
imageData: r.imageData,
405+
name: filenameToScreenName(r.filename),
406+
x: adjusted[i].x,
407+
y: adjusted[i].y,
408+
}));
409+
410+
addScreensBatch(screenDefs);
337411
});
338-
}, [addScreen]);
412+
}, [screens, addScreensBatch]);
339413

340414
const saveHotspot = useCallback((screenId, hotspot) => {
341415
pushHistory(screens, connections, documents);
@@ -872,6 +946,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
872946
fileInputRef,
873947
addScreen,
874948
addScreenAtCenter,
949+
addScreensBatch,
875950
removeScreen,
876951
removeScreens,
877952
renameScreen,

0 commit comments

Comments
 (0)