diff --git a/package-lock.json b/package-lock.json
index 1cac420..2f25f3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "drawd",
"version": "1.0.0",
"dependencies": {
+ "@grida/refig": "^0.0.4",
"@supabase/supabase-js": "^2.99.2",
"@vercel/analytics": "^2.0.1",
"react": "^19.0.0",
@@ -1131,6 +1132,25 @@
}
}
},
+ "node_modules/@grida/canvas-wasm": {
+ "version": "0.91.0-canary.3",
+ "resolved": "https://registry.npmjs.org/@grida/canvas-wasm/-/canvas-wasm-0.91.0-canary.3.tgz",
+ "integrity": "sha512-lh7wtS6NvCZIZajCVGwHUUt47kh/qhAlKniO69C89gI4G3b720rtahDLL8QGtAhiJVold90kmLIsd06NfIZz1Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@grida/refig": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/@grida/refig/-/refig-0.0.4.tgz",
+ "integrity": "sha512-0rXge0vWDRZmVsxzbQAOoL77iCVPVXeNh1xnlL8XxPySnzyhmssIBeHe/yNpMzYSNHlW2Eo7McAiFDi1xnDK/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@grida/canvas-wasm": "0.90.0-canary.8",
+ "commander": "^12.1.0"
+ },
+ "bin": {
+ "refig": "dist/cli.mjs"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2495,6 +2515,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
diff --git a/package.json b/package.json
index 673342d..c17ccc6 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,17 @@
"test": "vitest run"
},
"dependencies": {
+ "@grida/refig": "^0.0.4",
"@supabase/supabase-js": "^2.99.2",
"@vercel/analytics": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
+ "overrides": {
+ "@grida/refig": {
+ "@grida/canvas-wasm": "0.91.0-canary.3"
+ }
+ },
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/react": "^16.3.2",
diff --git a/public/grida_canvas_wasm.wasm b/public/grida_canvas_wasm.wasm
new file mode 100644
index 0000000..f063b3a
Binary files /dev/null and b/public/grida_canvas_wasm.wasm differ
diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index b726966..fa97aa2 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -1,8 +1,6 @@
-import { useState, useCallback, useEffect, useRef } from "react";
-import { COLORS, FONTS, FONT_LINK, Z_INDEX } from "./styles/theme";
-import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT, FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants";
-import { generateInstructionFiles } from "./utils/generateInstructionFiles";
-import { validateInstructions } from "./utils/validateInstructions";
+import { useState, useCallback, useRef, useEffect } from "react";
+import { COLORS, FONTS, FONT_LINK } from "./styles/theme";
+import { FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants";
import { useCanvas } from "./hooks/useCanvas";
import { useScreenManager } from "./hooks/useScreenManager";
import { useFilePersistence } from "./hooks/useFilePersistence";
@@ -12,44 +10,32 @@ import { useCanvasMouseHandlers } from "./hooks/useCanvasMouseHandlers";
import { useImportExport } from "./hooks/useImportExport";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useCanvasSelection } from "./hooks/useCanvasSelection";
-import { useCollaboration } from "./hooks/useCollaboration";
-import { ScreenNode } from "./components/ScreenNode";
-import { ConnectionLines } from "./components/ConnectionLines";
-import { HotspotModal } from "./components/HotspotModal";
-import { ConnectionEditModal } from "./components/ConnectionEditModal";
-import { InstructionsPanel } from "./components/InstructionsPanel";
-import { DocumentsPanel } from "./components/DocumentsPanel";
-import { DataModelsPanel } from "./components/DataModelsPanel";
-import { RenameModal } from "./components/RenameModal";
-import { ImportConfirmModal } from "./components/ImportConfirmModal";
+import { useStickyNotes } from "./hooks/useStickyNotes";
+import { useScreenGroups } from "./hooks/useScreenGroups";
+import { useDataModels } from "./hooks/useDataModels";
+import { useFigmaPaste } from "./hooks/useFigmaPaste";
+import { useInstructionGeneration } from "./hooks/useInstructionGeneration";
+import { useFileActions } from "./hooks/useFileActions";
+import { useCollabSync } from "./hooks/useCollabSync";
+import { useInteractionCallbacks } from "./hooks/useInteractionCallbacks";
+import { useDerivedCanvasState } from "./hooks/useDerivedCanvasState";
import { TopBar } from "./components/TopBar";
import { Sidebar } from "./components/Sidebar";
-import { EmptyState } from "./components/EmptyState";
-import { ConditionalPrompt } from "./components/ConditionalPrompt";
-import { InlineConditionLabels } from "./components/InlineConditionLabels";
-import { ShortcutsPanel } from "./components/ShortcutsPanel";
-import { ScreensPanel } from "./components/ScreensPanel";
-import { SelectionOverlay } from "./components/SelectionOverlay";
-import { ToolBar } from "./components/ToolBar";
-import { StickyNote } from "./components/StickyNote";
import { StickyNoteSidebar } from "./components/StickyNoteSidebar";
-import { ScreenGroup } from "./components/ScreenGroup";
-import { importFlow } from "./utils/importFlow";
-import { detectDrawdFile } from "./utils/detectDrawdFile";
-import { generateId } from "./utils/generateId";
-import { ShareModal } from "./components/ShareModal";
+import { ScreensPanel } from "./components/ScreensPanel";
+import { CanvasArea } from "./components/CanvasArea";
+import { ModalsLayer } from "./components/ModalsLayer";
import { CollabPresence } from "./components/CollabPresence";
import { CollabBadge } from "./components/CollabBadge";
-import { RemoteCursors } from "./components/RemoteCursors";
-import { HostLeftModal } from "./components/HostLeftModal";
-import { ParticipantsPanel } from "./components/ParticipantsPanel";
+import { importFlow } from "./utils/importFlow";
+import { detectDrawdFile } from "./utils/detectDrawdFile";
export default function Drawd({ initialRoomCode }) {
// ── Active tool ──────────────────────────────────────────────────────────
const [activeTool, setActiveTool] = useState("select");
- // ── Core hooks ──────────────────────────────────────────────
+ // ── Core hooks ──────────────────────────────────────────
const {
pan, setPan, zoom, setZoom, isPanning, dragging, multiDragging, canvasRef,
isSpaceHeld, spaceHeld, handleDragStart, handleMultiDragStart, handleMouseMove, handleMouseUp, handleCanvasMouseDown,
@@ -83,138 +69,51 @@ export default function Drawd({ initialRoomCode }) {
const [techStack, setTechStack] = useState({});
const [scopeRoot, setScopeRoot] = useState(null);
- // ── Data models ───────────────────────────────────────────────────────────
- const [dataModels, setDataModels] = useState([]);
- const [showDataModels, setShowDataModels] = useState(false);
-
- // ── Sticky notes ──────────────────────────────────────────────────────────
- const [stickyNotes, setStickyNotes] = useState([]);
- const [selectedStickyNote, setSelectedStickyNote] = useState(null);
-
- // ── Screen groups ─────────────────────────────────────────────────────────
- const [screenGroups, setScreenGroups] = useState([]);
- const [selectedScreenGroup, setSelectedScreenGroup] = useState(null);
-
- // ── Screen group callbacks ────────────────────────────────────────────────
- const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008) => {
- const group = { id: generateId(), name, screenIds, color, folderHint: "" };
- setScreenGroups((prev) => [...prev, group]);
- return group.id;
- }, []);
-
- const updateScreenGroup = useCallback((id, patch) => {
- setScreenGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g));
- }, []);
-
- const deleteScreenGroup = useCallback((id) => {
- setScreenGroups((prev) => prev.filter((g) => g.id !== id));
- }, []);
-
- const addScreenToGroup = useCallback((groupId, screenId) => {
- setScreenGroups((prev) => prev.map((g) =>
- g.id === groupId && !g.screenIds.includes(screenId)
- ? { ...g, screenIds: [...g.screenIds, screenId] }
- : g
- ));
- }, []);
-
- const removeScreenFromGroup = useCallback((screenId) => {
- setScreenGroups((prev) => prev.map((g) => ({
- ...g,
- screenIds: g.screenIds.filter((id) => id !== screenId),
- })));
- }, []);
-
- const addStickyNote = useCallback((x, y) => {
- const note = { id: generateId(), x, y, width: DEFAULT_SCREEN_WIDTH, content: "", color: "yellow", author: "" };
- setStickyNotes((prev) => [...prev, note]);
- }, []);
-
- const updateStickyNote = useCallback((id, patch) => {
- setStickyNotes((prev) => prev.map((n) => n.id === id ? { ...n, ...patch } : n));
- }, []);
+ // ── Extracted CRUD hooks ────────────────────────────────────────────────
+ const {
+ stickyNotes, setStickyNotes,
+ selectedStickyNote, setSelectedStickyNote,
+ addStickyNote, updateStickyNote, deleteStickyNote,
+ } = useStickyNotes();
- const deleteStickyNote = useCallback((id) => {
- setStickyNotes((prev) => prev.filter((n) => n.id !== id));
- }, []);
+ const {
+ screenGroups, setScreenGroups,
+ selectedScreenGroup, setSelectedScreenGroup,
+ addScreenGroup, updateScreenGroup, deleteScreenGroup,
+ addScreenToGroup, removeScreenFromGroup,
+ } = useScreenGroups();
- const addDataModel = useCallback((name, schema) => {
- const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
- setDataModels((prev) => [...prev, { id, name, schema, createdAt: new Date().toISOString() }]);
- return id;
- }, []);
- const updateDataModel = useCallback((id, patch) => {
- setDataModels((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)));
- }, []);
- const deleteDataModel = useCallback((id) => {
- setDataModels((prev) => prev.filter((m) => m.id !== id));
- }, []);
+ const {
+ dataModels, setDataModels,
+ showDataModels, setShowDataModels,
+ addDataModel, updateDataModel, deleteDataModel,
+ } = useDataModels();
// ── Collaboration ──────────────────────────────────────────────────────────
- const [showShareModal, setShowShareModal] = useState(!!initialRoomCode);
- const [showParticipants, setShowParticipants] = useState(false);
- const pendingRemoteStateRef = useRef(null);
-
- const screensRef = useRef(screens);
- useEffect(() => { screensRef.current = screens; }, [screens]);
-
- const applyRemotePayload = useCallback((payload) => {
- const incomingScreens = payload.screens || [];
- // Preserve existing imageData for screens that arrive without it.
- // buildCollabPayload strips imageData to stay under Supabase's size limit,
- // so we merge with the guest's current images to avoid flicker.
- const currentScreens = screensRef.current;
- const merged = incomingScreens.map((s) => {
- if (!s.imageData) {
- const existing = currentScreens.find((e) => e.id === s.id);
- if (existing?.imageData) {
- return { ...s, imageData: existing.imageData };
- }
- }
- return s;
- });
- replaceAll(
- merged,
- payload.connections || [],
- merged.length + 1,
- payload.documents || [],
- );
- if (payload.featureBrief !== undefined) setFeatureBrief(payload.featureBrief);
- if (payload.taskLink !== undefined) setTaskLink(payload.taskLink);
- if (payload.techStack !== undefined) setTechStack(payload.techStack);
- if (payload.dataModels !== undefined) setDataModels(payload.dataModels);
- if (payload.stickyNotes !== undefined) setStickyNotes(payload.stickyNotes);
- if (payload.screenGroups !== undefined) setScreenGroups(payload.screenGroups);
- }, [replaceAll]);
+ const draggingRef = useRef(false);
+ draggingRef.current = dragging;
+ const hotspotInteractionRef = useRef(null);
- const applyPendingRemoteState = useCallback((payload) => {
- applyRemotePayload(payload);
- }, [applyRemotePayload]);
-
- const collab = useCollaboration({
+ const {
+ collab, isReadOnly,
+ showShareModal, setShowShareModal,
+ showParticipants, setShowParticipants,
+ pendingRemoteStateRef, applyPendingRemoteState,
+ } = useCollabSync({
screens, connections, documents,
featureBrief, taskLink, techStack,
dataModels, stickyNotes, screenGroups,
- applyRemoteState: (payload) => {
- // If user is mid-drag, queue the update
- if (dragging || hotspotInteraction?.mode === "draw" || hotspotInteraction?.mode === "reposition" || hotspotInteraction?.mode === "resize") {
- pendingRemoteStateRef.current = payload;
- return;
- }
- applyRemotePayload(payload);
- },
- applyRemoteImage: patchScreenImage,
- canvasRef, pan, zoom,
+ replaceAll, setFeatureBrief, setTaskLink, setTechStack,
+ setDataModels, setStickyNotes, setScreenGroups,
+ draggingRef, hotspotInteractionRef, patchScreenImage,
+ canvasRef, pan, zoom, initialRoomCode,
});
- const isReadOnly = collab.isReadOnly;
-
- // Auto-close participants panel when disconnecting
useEffect(() => {
if (!collab.isConnected) setShowParticipants(false);
}, [collab.isConnected]);
- // BFS forward from scopeRoot following connections
+ // ── Scope computation ───────────────────────────────────────────────────
const scopeScreenIds = scopeRoot ? (() => {
const visited = new Set([scopeRoot]);
const queue = [scopeRoot];
@@ -230,63 +129,40 @@ export default function Drawd({ initialRoomCode }) {
return visited;
})() : null;
+ // ── File persistence ────────────────────────────────────────────────────
const {
connectedFileName, saveStatus, isFileSystemSupported,
openFile, saveAs, saveNow, disconnect,
} = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups);
// ── File actions ───────────────────────────────────────────────────
- const applyPayload = useCallback((payload) => {
- replaceAll(payload.screens, payload.connections, payload.screens.length + 1, payload.documents || []);
- if (payload.viewport) { setPan(payload.viewport.pan); setZoom(payload.viewport.zoom); }
- setFeatureBrief(payload.metadata?.featureBrief || "");
- setTaskLink(payload.metadata?.taskLink || "");
- setTechStack(payload.metadata?.techStack || {});
- setDataModels(payload.dataModels || []);
- setStickyNotes(payload.stickyNotes || []);
- setScreenGroups(payload.screenGroups || []);
- setScopeRoot(null);
- }, [replaceAll, setPan, setZoom]);
-
- const onOpen = useCallback(async () => {
- try {
- const payload = await openFile();
- if (!payload) return;
- applyPayload(payload);
- } catch (err) { alert(err.message); }
- }, [openFile, applyPayload]);
-
- const onSaveAs = useCallback(async () => {
- try { await saveAs(); } catch (err) { alert("Save failed: " + err.message); }
- }, [saveAs]);
-
- const onNew = useCallback(() => {
- if (screens.length > 0) {
- if (!window.confirm("You have unsaved changes. Start a new flow?")) return;
- }
- replaceAll([], [], 1, []);
- setPan({ x: 0, y: 0 });
- setZoom(1);
- setFeatureBrief("");
- setTaskLink("");
- setTechStack({});
- setDataModels([]);
- setStickyNotes([]);
- setScreenGroups([]);
- setScopeRoot(null);
- disconnect();
- }, [screens.length, replaceAll, setPan, setZoom, disconnect]);
+ const { applyPayload, onOpen, onSaveAs, onNew } = useFileActions({
+ screens, replaceAll, setPan, setZoom,
+ setFeatureBrief, setTaskLink, setTechStack,
+ setDataModels, setStickyNotes, setScreenGroups,
+ setScopeRoot, openFile, saveAs, disconnect,
+ });
// ── Modal state ────────────────────────────────────────────────────────
const [hotspotModal, setHotspotModal] = useState(null);
const [connectionEditModal, setConnectionEditModal] = useState(null);
const [showDocuments, setShowDocuments] = useState(false);
- const [showInstructions, setShowInstructions] = useState(false);
- const [groupContextMenu, setGroupContextMenu] = useState(null); // { screenId, x, y }
- const [instructions, setInstructions] = useState(null);
+ const [groupContextMenu, setGroupContextMenu] = useState(null);
const [renameModal, setRenameModal] = useState(null);
const [showShortcuts, setShowShortcuts] = useState(false);
+ // ── Instruction generation ─────────────────────────────────────────────
+ const { instructions, showInstructions, setShowInstructions, onGenerate, buildInstructionResult } =
+ useInstructionGeneration({
+ screens, connections, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, screenGroups, scopeScreenIds,
+ });
+
+ // ── Figma paste ────────────────────────────────────────────────────────
+ const { figmaProcessing, figmaError, setFigmaError } =
+ useFigmaPaste({ handlePaste, addScreenAtCenter });
+
// ── Interaction hooks ────────────────────────────────────────────────────────
const connInteraction = useConnectionInteraction({
screens, connections, canvasRef, pan, zoom,
@@ -320,81 +196,29 @@ export default function Drawd({ initialRoomCode }) {
onEndpointMouseDown, onScreenDimensions, handleDropImage,
} = hsInteraction;
- // ── Cross-concern callbacks ──────────────────────────────────────────────────────────
- const onConnectionClick = useCallback((connId) => {
- setSelectedConnection(connId);
- setHotspotInteraction(null);
- }, [setSelectedConnection, setHotspotInteraction]);
-
- const onConnectionDoubleClick = useCallback((connId) => {
- const conn = connections.find((c) => c.id === connId);
- if (!conn) return;
- const screen = screens.find((s) => s.id === conn.fromScreenId);
- if (!screen) return;
- if (conn.hotspotId) {
- const hotspot = screen.hotspots.find((h) => h.id === conn.hotspotId);
- if (hotspot) setHotspotModal({ screen, hotspot, connection: conn });
- } else {
- const groupConns = conn.conditionGroupId
- ? connections.filter((c) => c.conditionGroupId === conn.conditionGroupId)
- : [conn];
- setConnectionEditModal({ connection: conn, groupConnections: groupConns, fromScreen: screen });
- }
- }, [connections, screens]);
-
- const onConnectComplete = useCallback((targetScreenId) => {
- // Handle hotspot-drag connect
- if (hotspotInteraction?.mode === "hotspot-drag") {
- if (targetScreenId !== hotspotInteraction.screenId) {
- quickConnectHotspot(hotspotInteraction.screenId, hotspotInteraction.hotspotId, targetScreenId);
- }
- setHotspotInteraction({ mode: "selected", screenId: hotspotInteraction.screenId, hotspotId: hotspotInteraction.hotspotId });
- setHoverTarget(null);
- return;
- }
-
- if (!connecting) return;
- const fromId = connecting.fromScreenId;
- if (targetScreenId === fromId) { cancelConnecting(); return; }
-
- const existingPlain = connections.filter((c) => c.fromScreenId === fromId && !c.hotspotId);
-
- // Scenario 2: existing conditional group — auto-join
- const existingGroup = existingPlain.find((c) => c.conditionGroupId);
- if (existingGroup) {
- addToConditionalGroup(fromId, targetScreenId, existingGroup.conditionGroupId);
- setEditingConditionGroup(existingGroup.conditionGroupId);
- cancelConnecting();
- return;
- }
-
- // Scenario 1: existing non-grouped connection — show prompt
- if (existingPlain.length > 0) {
- const isDuplicate = existingPlain.some((c) => c.toScreenId === targetScreenId);
- if (isDuplicate) { cancelConnecting(); return; }
- const fromScreen = screens.find((s) => s.id === fromId);
- const promptX = fromScreen ? fromScreen.x + (fromScreen.width || DEFAULT_SCREEN_WIDTH) + 20 : 0;
- const promptY = fromScreen ? fromScreen.y : 0;
- setConditionalPrompt({ fromId, targetScreenId, existingConnId: existingPlain[0].id, x: promptX, y: promptY });
- cancelConnecting();
- return;
- }
-
- addConnection(fromId, targetScreenId);
- cancelConnecting();
- }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, addConnection, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt]);
+ // Keep collab sync refs up to date
+ hotspotInteractionRef.current = hotspotInteraction;
- // Open hotspot modal when a draw gesture completes
- useEffect(() => {
- if (hotspotInteraction?.mode === "draw-complete") {
- const screen = screens.find((s) => s.id === hotspotInteraction.screenId);
- if (screen) {
- const { x, y, w, h } = hotspotInteraction.drawRect;
- setHotspotModal({ screen, hotspot: null, prefilledRect: { x, y, w, h } });
- }
- setHotspotInteraction(null);
- }
- }, [hotspotInteraction, screens, setHotspotInteraction]);
+ // ── Cross-concern callbacks ──────────────────────────────────────────────────────────
+ const {
+ onConnectionClick, onConnectionDoubleClick, onConnectComplete,
+ onDragStart, onMultiDragStart,
+ addHotspot, onHotspotDoubleClick, addHotspotViaConnect,
+ onScreensPanelClick,
+ } = useInteractionCallbacks({
+ screens, connections, stickyNotes,
+ connecting, cancelConnecting,
+ hotspotInteraction, setHotspotInteraction,
+ setSelectedConnection, setHoverTarget,
+ setConditionalPrompt, setEditingConditionGroup,
+ setHotspotModal, setConnectionEditModal,
+ quickConnectHotspot, addConnection, addToConditionalGroup,
+ onStartConnect,
+ activeTool, captureDragSnapshot,
+ handleDragStart, handleMultiDragStart,
+ canvasSelection, clearSelection,
+ setSelectedScreen, setPan, zoom, canvasRef,
+ });
// ── Canvas event handlers ──────────────────────────────────────────────────────────
const { onCanvasMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, canvasCursor } =
@@ -470,133 +294,12 @@ export default function Drawd({ initialRoomCode }) {
isReadOnly,
});
- // ── Paste handler ───────────────────────────────────────────────────────────────
- useEffect(() => {
- const onPaste = (e) => {
- const tag = document.activeElement?.tagName;
- if (tag === "INPUT" || tag === "TEXTAREA") return;
- handlePaste(e);
- };
- document.addEventListener("paste", onPaste);
- return () => document.removeEventListener("paste", onPaste);
- }, [handlePaste]);
-
- // ── Misc callbacks ──────────────────────────────────────────────────────────────────
- const onDragStart = useCallback((e, screenId) => {
- if (activeTool === "pan") return;
- captureDragSnapshot();
- handleDragStart(e, screenId, screens);
- }, [handleDragStart, screens, captureDragSnapshot, activeTool]);
-
- const onMultiDragStart = useCallback((e) => {
- if (activeTool === "pan") return;
- captureDragSnapshot();
- handleMultiDragStart(e, canvasSelection, screens, stickyNotes);
- }, [activeTool, captureDragSnapshot, handleMultiDragStart, canvasSelection, screens, stickyNotes]);
-
- const addHotspot = useCallback((screenId) => {
- const screen = screens.find((s) => s.id === screenId);
- setHotspotModal({ screen, hotspot: null });
- }, [screens]);
-
- const onHotspotDoubleClick = useCallback((_e, screenId, hotspotId) => {
- const screen = screens.find((s) => s.id === screenId);
- if (!screen) return;
- const hotspot = screen.hotspots.find((h) => h.id === hotspotId);
- if (!hotspot) return;
- setHotspotInteraction(null);
- setHotspotModal({ screen, hotspot });
- }, [screens, setHotspotInteraction]);
-
- const addHotspotViaConnect = useCallback((screenId) => {
- onStartConnect(screenId);
- }, [onStartConnect]);
-
- const buildInstructionResult = useCallback((warnings) => {
- const scopedScreens = scopeScreenIds
- ? screens.filter((s) => scopeScreenIds.has(s.id))
- : screens;
- return generateInstructionFiles(scopedScreens, connections, {
- platform: "auto",
- documents,
- featureBrief,
- taskLink,
- techStack,
- dataModels,
- screenGroups,
- scopeScreenIds,
- allScreens: screens,
- warnings,
- });
- }, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups]);
-
- const onGenerate = useCallback(() => {
- if (screens.length === 0) return;
- const scopedScreens = scopeScreenIds
- ? screens.filter((s) => scopeScreenIds.has(s.id))
- : screens;
- const warnings = validateInstructions(scopedScreens, connections, { documents });
- const errors = warnings.filter((w) => w.level === "error");
- if (
- errors.length > 0 &&
- !window.confirm(
- `Found ${errors.length} issue(s) that may affect generated output:\n\n${errors.map((e) => `\u2022 ${e.message}`).join("\n")}\n\nGenerate anyway?`
- )
- ) return;
- const result = buildInstructionResult(warnings);
- setInstructions(result);
- setShowInstructions(true);
- }, [screens, connections, documents, scopeScreenIds, buildInstructionResult]);
-
- const onScreensPanelClick = useCallback((screenId) => {
- clearSelection();
- setSelectedScreen(screenId);
- const screen = screens.find((s) => s.id === screenId);
- if (!screen || !canvasRef.current) return;
- const vw = canvasRef.current.clientWidth;
- const vh = canvasRef.current.clientHeight;
- const screenW = screen.width || DEFAULT_SCREEN_WIDTH;
- const screenH = screen.imageHeight ? screen.imageHeight + HEADER_HEIGHT : DEFAULT_SCREEN_HEIGHT;
- const centerX = screen.x + screenW / 2;
- const centerY = screen.y + screenH / 2;
- setPan({ x: vw / 2 - centerX * zoom, y: vh / 2 - centerY * zoom });
- }, [screens, zoom, canvasRef, setPan, setSelectedScreen, clearSelection]);
-
// ── Derived values ──────────────────────────────────────────────────────────────────
const selectedScreenData = screens.find((s) => s.id === selectedScreen);
const selectedStickyNoteData = stickyNotes.find((n) => n.id === selectedStickyNote);
- const previewLine = connecting
- ? { fromScreenId: connecting.fromScreenId, toX: connecting.mouseX, toY: connecting.mouseY }
- : null;
-
- const hotspotPreviewLine = hotspotInteraction?.mode === "hotspot-drag"
- ? { fromScreenId: hotspotInteraction.screenId, hotspotId: hotspotInteraction.hotspotId, toX: hotspotInteraction.mouseX, toY: hotspotInteraction.mouseY }
- : null;
-
- const endpointDragPreview = hotspotInteraction?.mode === "conn-endpoint-drag"
- ? { connectionId: hotspotInteraction.connectionId, endpoint: hotspotInteraction.endpoint, mouseX: hotspotInteraction.mouseX, mouseY: hotspotInteraction.mouseY }
- : null;
-
- const selectedHotspotId = hotspotInteraction?.hotspotId || null;
- const drawRect = hotspotInteraction?.mode === "draw" ? hotspotInteraction.drawRect : null;
-
- // Ghost preview showing where the hotspot will land during reposition drag
- const repositionGhost = (() => {
- if (hotspotInteraction?.mode !== "reposition" || hotspotInteraction.worldX == null) return null;
- const srcScreen = screens.find((s) => s.id === hotspotInteraction.screenId);
- if (!srcScreen) return null;
- const hs = srcScreen.hotspots.find((h) => h.id === hotspotInteraction.hotspotId);
- if (!hs) return null;
- const pixelW = (hs.w / 100) * (srcScreen.width || DEFAULT_SCREEN_WIDTH);
- const pixelH = (hs.h / 100) * (srcScreen.imageHeight || DEFAULT_SCREEN_HEIGHT);
- return {
- x: hotspotInteraction.worldX - pixelW / 2,
- y: hotspotInteraction.worldY - pixelH / 2,
- width: pixelW,
- height: pixelH,
- };
- })();
+ const { previewLine, hotspotPreviewLine, endpointDragPreview, selectedHotspotId, drawRect, repositionGhost } =
+ useDerivedCanvasState({ connecting, hotspotInteraction, screens });
// ── Render ────────────────────────────────────────────────────────────────────────────
return (
@@ -673,324 +376,88 @@ export default function Drawd({ initialRoomCode }) {
isReadOnly={isReadOnly}
/>
- {/* Canvas */}
-
e.preventDefault()}
- onDrop={onCanvasDrop}
- onClick={() => { if (groupContextMenu) setGroupContextMenu(null); }}
- onDoubleClick={(e) => {
- if (e.target !== canvasRef.current) return;
- const rect = canvasRef.current.getBoundingClientRect();
- const worldX = (e.clientX - rect.left - pan.x) / zoom;
- const worldY = (e.clientY - rect.top - pan.y) / zoom;
- addStickyNote(worldX, worldY);
- }}
- style={{
- flex: 1,
- position: "relative",
- overflow: "hidden",
- background: COLORS.canvasBg,
- cursor: canvasCursor,
- backgroundImage: `radial-gradient(circle, ${COLORS.canvasDot} 1px, transparent 1px)`,
- backgroundSize: `${24 * zoom}px ${24 * zoom}px`,
- backgroundPosition: `${pan.x}px ${pan.y}px`,
- }}
- >
-
- {screenGroups.map((group) => (
-
{
- setSelectedScreenGroup(id);
- setSelectedStickyNote(null);
- setSelectedScreen(null);
- setSelectedConnection(null);
- setHotspotInteraction(null);
- }}
- />
- ))}
- {screens.map((screen) => (
- { clearSelection(); setSelectedScreen(id); setSelectedStickyNote(null); }}
- onDragStart={onDragStart}
- isSpaceHeld={isSpaceHeld}
- onAddHotspot={addHotspotViaConnect}
- onRemoveScreen={removeScreen}
- onDotDragStart={onDotDragStart}
- onConnectTarget={onConnectComplete}
- onHoverTarget={setHoverTarget}
- isConnectHoverTarget={hoverTarget === screen.id}
- isConnecting={!!connecting}
- selectedHotspotId={hotspotInteraction?.screenId === screen.id ? selectedHotspotId : null}
- selectedHotspotIds={selectedHotspots.length > 0 && selectedHotspots[0].screenId === screen.id
- ? new Set(selectedHotspots.map((h) => h.hotspotId))
- : null}
- onHotspotMouseDown={onHotspotMouseDown}
- onHotspotDoubleClick={onHotspotDoubleClick}
- onImageAreaMouseDown={onImageAreaMouseDown}
- onHotspotDragHandleMouseDown={onHotspotDragHandleMouseDown}
- onResizeHandleMouseDown={onResizeHandleMouseDown}
- onScreenDimensions={onScreenDimensions}
- drawRect={drawRect}
- isHotspotDragging={hotspotInteraction?.mode === "hotspot-drag"}
- onUpdateDescription={updateScreenDescription}
- onAddState={addState}
- onDropImage={handleDropImage}
- activeTool={activeTool}
- scopeRoot={scopeRoot}
- isInScope={scopeScreenIds ? scopeScreenIds.has(screen.id) : undefined}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- const rect = canvasRef.current?.getBoundingClientRect();
- setGroupContextMenu({ screenId: screen.id, x: e.clientX - (rect?.left || 0), y: e.clientY - (rect?.top || 0) });
- }}
- isMultiSelected={canvasSelection.some((i) => i.type === "screen" && i.id === screen.id)}
- onToggleSelect={toggleSelection}
- onMultiDragStart={onMultiDragStart}
- isReadOnly={isReadOnly}
- />
- ))}
- {stickyNotes.map((note) => (
- {
- setSelectedStickyNote(id);
- setSelectedScreen(null);
- setSelectedConnection(null);
- setHotspotInteraction(null);
- setSelectedScreenGroup(null);
- }}
- isMultiSelected={canvasSelection.some((i) => i.type === "sticky" && i.id === note.id)}
- onToggleSelect={toggleSelection}
- onMultiDragStart={onMultiDragStart}
- onDragStart={(e, id) => {
- e.stopPropagation();
- const startX = e.clientX;
- const startY = e.clientY;
- const origX = note.x;
- const origY = note.y;
- const onMove = (me) => {
- const dx = (me.clientX - startX) / zoom;
- const dy = (me.clientY - startY) / zoom;
- updateStickyNote(id, { x: origX + dx, y: origY + dy });
- };
- const onUp = () => {
- window.removeEventListener("mousemove", onMove);
- window.removeEventListener("mouseup", onUp);
- };
- window.addEventListener("mousemove", onMove);
- window.addEventListener("mouseup", onUp);
- }}
- />
- ))}
-
-
- {repositionGhost && (
-
- )}
- {conditionalPrompt && (
-
- )}
- {collab.isConnected && }
- {editingConditionGroup && (
- setEditingConditionGroup(null)}
- />
- )}
-
-
- {screens.length === 0 &&
}
-
- {/* Zoom indicator */}
-
- {Math.round(zoom * 100)}%
-
-
- {/* Screen group context menu */}
- {groupContextMenu && (
-
setGroupContextMenu(null)}
- >
-
- Add to Group
-
- {screenGroups.map((g) => (
-
- ))}
-
-
-
-
- )}
-
- {/* Tool switcher */}
-
addScreenAtCenter()}
- isReadOnly={isReadOnly}
- onAddStickyNote={() => {
- if (!canvasRef.current) return;
- const rect = canvasRef.current.getBoundingClientRect();
- const worldX = (rect.width / 2 - pan.x) / zoom;
- const worldY = (rect.height / 2 - pan.y) / zoom;
- addStickyNote(worldX, worldY);
- }}
- />
-
+
{selectedScreenData && (
- {hotspotModal && (
- {
- saveHotspot(hotspotModal.screen.id, hs);
- if (hotspotModal.connection) {
- updateConnection(hotspotModal.connection.id, {
- transitionType: hs.transitionType ?? "",
- transitionLabel: hs.transitionLabel ?? "",
- });
- }
- setHotspotModal(null);
- }}
- onDelete={(id) => { deleteHotspot(hotspotModal.screen.id, id); setHotspotModal(null); }}
- onClose={() => setHotspotModal(null)}
- />
- )}
-
- {connectionEditModal && (
- {
- saveConnectionGroup(connectionEditModal.connection.id, payload);
- setConnectionEditModal(null);
- setSelectedConnection(null);
- }}
- onDelete={() => {
- const conn = connectionEditModal.connection;
- if (conn.conditionGroupId) {
- deleteConnectionGroup(conn.conditionGroupId);
- } else {
- deleteConnection(conn.id);
- }
- setConnectionEditModal(null);
- setSelectedConnection(null);
- }}
- onClose={() => setConnectionEditModal(null)}
- />
- )}
-
- {showDocuments && (
- setShowDocuments(false)}
- />
- )}
-
- {showDataModels && (
- setShowDataModels(false)}
- />
- )}
-
- {showInstructions && (
- setShowInstructions(false)}
- />
- )}
-
- {renameModal && (
- { renameScreen(renameModal.id, name); setRenameModal(null); }}
- onClose={() => setRenameModal(null)}
- />
- )}
-
- {importConfirm && (
- setImportConfirm(null)}
- />
- )}
-
- {showParticipants && collab.isConnected && (
- setShowParticipants(false)}
- />
- )}
-
- {showShortcuts && setShowShortcuts(false)} />}
-
- {showShareModal && (
- {
- collab.createRoom(name, color);
- setShowShareModal(false);
- }}
- onJoinRoom={(code, name, color) => {
- collab.joinRoom(code, name, color);
- setShowShareModal(false);
- }}
- onClose={() => setShowShareModal(false)}
- />
- )}
-
- {collab.hostLeft && (
- {
- collab.dismissHostLeft();
- collab.leaveRoom();
- }}
- onLeave={() => {
- collab.dismissHostLeft();
- collab.leaveRoom();
- }}
- />
- )}
+
);
}
diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx
new file mode 100644
index 0000000..696ee65
--- /dev/null
+++ b/src/components/CanvasArea.jsx
@@ -0,0 +1,372 @@
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
+import { ScreenNode } from "./ScreenNode";
+import { ConnectionLines } from "./ConnectionLines";
+import { ConditionalPrompt } from "./ConditionalPrompt";
+import { InlineConditionLabels } from "./InlineConditionLabels";
+import { SelectionOverlay } from "./SelectionOverlay";
+import { EmptyState } from "./EmptyState";
+import { ToolBar } from "./ToolBar";
+import { StickyNote } from "./StickyNote";
+import { ScreenGroup } from "./ScreenGroup";
+import { RemoteCursors } from "./RemoteCursors";
+
+export function CanvasArea({
+ // Canvas state
+ canvasRef, pan, zoom, canvasCursor,
+ // Mouse handlers
+ onCanvasMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onCanvasDrop,
+ // Screen groups
+ screenGroups, selectedScreenGroup, updateScreenGroup, deleteScreenGroup,
+ addScreenToGroup, removeScreenFromGroup, addScreenGroup,
+ setSelectedScreenGroup, setSelectedStickyNote, setSelectedScreen, setSelectedConnection, setHotspotInteraction,
+ // Screens
+ screens, selectedScreen, clearSelection,
+ onDragStart, isSpaceHeld, addHotspotViaConnect, removeScreen,
+ onDotDragStart, onConnectComplete, setHoverTarget, hoverTarget, connecting,
+ hotspotInteraction, selectedHotspotId, selectedHotspots,
+ onHotspotMouseDown, onHotspotDoubleClick, onImageAreaMouseDown,
+ onHotspotDragHandleMouseDown, onResizeHandleMouseDown, onScreenDimensions,
+ drawRect, updateScreenDescription, addState, handleDropImage, activeTool,
+ scopeRoot, scopeScreenIds, canvasSelection, toggleSelection, onMultiDragStart,
+ isReadOnly,
+ // Sticky notes
+ stickyNotes, selectedStickyNote, updateStickyNote, deleteStickyNote, addStickyNote,
+ // Selection overlay
+ rubberBandRect,
+ // Connection lines
+ connections, previewLine, hotspotPreviewLine, selectedConnection,
+ onConnectionClick, onConnectionDoubleClick, onEndpointMouseDown, endpointDragPreview,
+ // Reposition ghost
+ repositionGhost,
+ // Conditional prompt
+ conditionalPrompt, onConditionalPromptConfirm, onConditionalPromptCancel,
+ // Collaboration
+ collab,
+ // Inline condition labels
+ editingConditionGroup, updateConnection, setEditingConditionGroup,
+ // Group context menu
+ groupContextMenu, setGroupContextMenu,
+ // ToolBar
+ setActiveTool, handleImageUpload, addScreenAtCenter,
+}) {
+ return (
+ e.preventDefault()}
+ onDrop={onCanvasDrop}
+ onClick={() => { if (groupContextMenu) setGroupContextMenu(null); }}
+ onDoubleClick={(e) => {
+ if (e.target !== canvasRef.current) return;
+ const rect = canvasRef.current.getBoundingClientRect();
+ const worldX = (e.clientX - rect.left - pan.x) / zoom;
+ const worldY = (e.clientY - rect.top - pan.y) / zoom;
+ addStickyNote(worldX, worldY);
+ }}
+ style={{
+ flex: 1,
+ position: "relative",
+ overflow: "hidden",
+ background: COLORS.canvasBg,
+ cursor: canvasCursor,
+ backgroundImage: `radial-gradient(circle, ${COLORS.canvasDot} 1px, transparent 1px)`,
+ backgroundSize: `${24 * zoom}px ${24 * zoom}px`,
+ backgroundPosition: `${pan.x}px ${pan.y}px`,
+ }}
+ >
+
+ {screenGroups.map((group) => (
+
{
+ setSelectedScreenGroup(id);
+ setSelectedStickyNote(null);
+ setSelectedScreen(null);
+ setSelectedConnection(null);
+ setHotspotInteraction(null);
+ }}
+ />
+ ))}
+ {screens.map((screen) => (
+ { clearSelection(); setSelectedScreen(id); setSelectedStickyNote(null); }}
+ onDragStart={onDragStart}
+ isSpaceHeld={isSpaceHeld}
+ onAddHotspot={addHotspotViaConnect}
+ onRemoveScreen={removeScreen}
+ onDotDragStart={onDotDragStart}
+ onConnectTarget={onConnectComplete}
+ onHoverTarget={setHoverTarget}
+ isConnectHoverTarget={hoverTarget === screen.id}
+ isConnecting={!!connecting}
+ selectedHotspotId={hotspotInteraction?.screenId === screen.id ? selectedHotspotId : null}
+ selectedHotspotIds={selectedHotspots.length > 0 && selectedHotspots[0].screenId === screen.id
+ ? new Set(selectedHotspots.map((h) => h.hotspotId))
+ : null}
+ onHotspotMouseDown={onHotspotMouseDown}
+ onHotspotDoubleClick={onHotspotDoubleClick}
+ onImageAreaMouseDown={onImageAreaMouseDown}
+ onHotspotDragHandleMouseDown={onHotspotDragHandleMouseDown}
+ onResizeHandleMouseDown={onResizeHandleMouseDown}
+ onScreenDimensions={onScreenDimensions}
+ drawRect={drawRect}
+ isHotspotDragging={hotspotInteraction?.mode === "hotspot-drag"}
+ onUpdateDescription={updateScreenDescription}
+ onAddState={addState}
+ onDropImage={handleDropImage}
+ activeTool={activeTool}
+ scopeRoot={scopeRoot}
+ isInScope={scopeScreenIds ? scopeScreenIds.has(screen.id) : undefined}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = canvasRef.current?.getBoundingClientRect();
+ setGroupContextMenu({ screenId: screen.id, x: e.clientX - (rect?.left || 0), y: e.clientY - (rect?.top || 0) });
+ }}
+ isMultiSelected={canvasSelection.some((i) => i.type === "screen" && i.id === screen.id)}
+ onToggleSelect={toggleSelection}
+ onMultiDragStart={onMultiDragStart}
+ isReadOnly={isReadOnly}
+ />
+ ))}
+ {stickyNotes.map((note) => (
+ {
+ setSelectedStickyNote(id);
+ setSelectedScreen(null);
+ setSelectedConnection(null);
+ setHotspotInteraction(null);
+ setSelectedScreenGroup(null);
+ }}
+ isMultiSelected={canvasSelection.some((i) => i.type === "sticky" && i.id === note.id)}
+ onToggleSelect={toggleSelection}
+ onMultiDragStart={onMultiDragStart}
+ onDragStart={(e, id) => {
+ e.stopPropagation();
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const origX = note.x;
+ const origY = note.y;
+ const onMove = (me) => {
+ const dx = (me.clientX - startX) / zoom;
+ const dy = (me.clientY - startY) / zoom;
+ updateStickyNote(id, { x: origX + dx, y: origY + dy });
+ };
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ }}
+ />
+ ))}
+
+
+ {repositionGhost && (
+
+ )}
+ {conditionalPrompt && (
+
+ )}
+ {collab.isConnected && }
+ {editingConditionGroup && (
+ setEditingConditionGroup(null)}
+ />
+ )}
+
+
+ {screens.length === 0 &&
}
+
+ {/* Zoom indicator */}
+
+ {Math.round(zoom * 100)}%
+
+
+ {/* Screen group context menu */}
+ {groupContextMenu && (
+
setGroupContextMenu(null)}
+ >
+
+ Add to Group
+
+ {screenGroups.map((g) => (
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Tool switcher */}
+
addScreenAtCenter()}
+ isReadOnly={isReadOnly}
+ onAddStickyNote={() => {
+ if (!canvasRef.current) return;
+ const rect = canvasRef.current.getBoundingClientRect();
+ const worldX = (rect.width / 2 - pan.x) / zoom;
+ const worldY = (rect.height / 2 - pan.y) / zoom;
+ addStickyNote(worldX, worldY);
+ }}
+ />
+
+ );
+}
diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx
new file mode 100644
index 0000000..57c4cd8
--- /dev/null
+++ b/src/components/ModalsLayer.jsx
@@ -0,0 +1,213 @@
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+import { HotspotModal } from "./HotspotModal";
+import { ConnectionEditModal } from "./ConnectionEditModal";
+import { DocumentsPanel } from "./DocumentsPanel";
+import { DataModelsPanel } from "./DataModelsPanel";
+import { InstructionsPanel } from "./InstructionsPanel";
+import { RenameModal } from "./RenameModal";
+import { ImportConfirmModal } from "./ImportConfirmModal";
+import { ParticipantsPanel } from "./ParticipantsPanel";
+import { ShortcutsPanel } from "./ShortcutsPanel";
+import { ShareModal } from "./ShareModal";
+import { HostLeftModal } from "./HostLeftModal";
+
+export function ModalsLayer({
+ // Hotspot modal
+ hotspotModal, setHotspotModal,
+ screens, documents, addDocument,
+ saveHotspot, deleteHotspot, updateConnection,
+ // Connection edit modal
+ connectionEditModal, setConnectionEditModal,
+ saveConnectionGroup, deleteConnectionGroup, deleteConnection,
+ setSelectedConnection,
+ // Documents
+ showDocuments, setShowDocuments,
+ updateDocument, deleteDocument,
+ // Data models
+ showDataModels, setShowDataModels,
+ dataModels, addDataModel, updateDataModel, deleteDataModel,
+ // Instructions
+ showInstructions, setShowInstructions, instructions,
+ // Rename
+ renameModal, setRenameModal, renameScreen,
+ // Import
+ importConfirm, onImportReplace, onImportMerge, setImportConfirm,
+ // Participants
+ showParticipants, setShowParticipants, collab,
+ // Shortcuts
+ showShortcuts, setShowShortcuts,
+ // Share
+ showShareModal, setShowShareModal, initialRoomCode,
+ // Figma
+ figmaProcessing, figmaError, setFigmaError,
+}) {
+ return (
+ <>
+ {hotspotModal && (
+ {
+ saveHotspot(hotspotModal.screen.id, hs);
+ if (hotspotModal.connection) {
+ updateConnection(hotspotModal.connection.id, {
+ transitionType: hs.transitionType ?? "",
+ transitionLabel: hs.transitionLabel ?? "",
+ });
+ }
+ setHotspotModal(null);
+ }}
+ onDelete={(id) => { deleteHotspot(hotspotModal.screen.id, id); setHotspotModal(null); }}
+ onClose={() => setHotspotModal(null)}
+ />
+ )}
+
+ {connectionEditModal && (
+ {
+ saveConnectionGroup(connectionEditModal.connection.id, payload);
+ setConnectionEditModal(null);
+ setSelectedConnection(null);
+ }}
+ onDelete={() => {
+ const conn = connectionEditModal.connection;
+ if (conn.conditionGroupId) {
+ deleteConnectionGroup(conn.conditionGroupId);
+ } else {
+ deleteConnection(conn.id);
+ }
+ setConnectionEditModal(null);
+ setSelectedConnection(null);
+ }}
+ onClose={() => setConnectionEditModal(null)}
+ />
+ )}
+
+ {showDocuments && (
+ setShowDocuments(false)}
+ />
+ )}
+
+ {showDataModels && (
+ setShowDataModels(false)}
+ />
+ )}
+
+ {showInstructions && (
+ setShowInstructions(false)}
+ />
+ )}
+
+ {renameModal && (
+ { renameScreen(renameModal.id, name); setRenameModal(null); }}
+ onClose={() => setRenameModal(null)}
+ />
+ )}
+
+ {importConfirm && (
+ setImportConfirm(null)}
+ />
+ )}
+
+ {showParticipants && collab.isConnected && (
+ setShowParticipants(false)}
+ />
+ )}
+
+ {showShortcuts && setShowShortcuts(false)} />}
+
+ {showShareModal && (
+ {
+ collab.createRoom(name, color);
+ setShowShareModal(false);
+ }}
+ onJoinRoom={(code, name, color) => {
+ collab.joinRoom(code, name, color);
+ setShowShareModal(false);
+ }}
+ onClose={() => setShowShareModal(false)}
+ />
+ )}
+
+ {collab.hostLeft && (
+ {
+ collab.dismissHostLeft();
+ collab.leaveRoom();
+ }}
+ onLeave={() => {
+ collab.dismissHostLeft();
+ collab.leaveRoom();
+ }}
+ />
+ )}
+
+ {figmaProcessing && (
+
+
+
Rendering Figma frame...
+
This may take a moment on first use while the rendering engine loads.
+
+
+ )}
+
+ {figmaError && (
+ setFigmaError(null)}>
+ Figma paste failed: {figmaError}
+
+ )}
+ >
+ );
+}
diff --git a/src/hooks/useCollabSync.js b/src/hooks/useCollabSync.js
new file mode 100644
index 0000000..424b475
--- /dev/null
+++ b/src/hooks/useCollabSync.js
@@ -0,0 +1,68 @@
+import { useState, useCallback, useEffect, useRef } from "react";
+import { useCollaboration } from "./useCollaboration";
+
+export function useCollabSync({
+ screens, connections, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, stickyNotes, screenGroups,
+ replaceAll, setFeatureBrief, setTaskLink, setTechStack,
+ setDataModels, setStickyNotes, setScreenGroups,
+ draggingRef, hotspotInteractionRef, patchScreenImage,
+ canvasRef, pan, zoom, initialRoomCode,
+}) {
+ const [showShareModal, setShowShareModal] = useState(!!initialRoomCode);
+ const [showParticipants, setShowParticipants] = useState(false);
+ const pendingRemoteStateRef = useRef(null);
+
+ const screensRef = useRef(screens);
+ useEffect(() => { screensRef.current = screens; }, [screens]);
+
+ const applyRemotePayload = useCallback((payload) => {
+ const incomingScreens = payload.screens || [];
+ const currentScreens = screensRef.current;
+ const merged = incomingScreens.map((s) => {
+ if (!s.imageData) {
+ const existing = currentScreens.find((e) => e.id === s.id);
+ if (existing?.imageData) return { ...s, imageData: existing.imageData };
+ }
+ return s;
+ });
+ replaceAll(merged, payload.connections || [], merged.length + 1, payload.documents || []);
+ if (payload.featureBrief !== undefined) setFeatureBrief(payload.featureBrief);
+ if (payload.taskLink !== undefined) setTaskLink(payload.taskLink);
+ if (payload.techStack !== undefined) setTechStack(payload.techStack);
+ if (payload.dataModels !== undefined) setDataModels(payload.dataModels);
+ if (payload.stickyNotes !== undefined) setStickyNotes(payload.stickyNotes);
+ if (payload.screenGroups !== undefined) setScreenGroups(payload.screenGroups);
+ }, [replaceAll, setFeatureBrief, setTaskLink, setTechStack, setDataModels, setStickyNotes, setScreenGroups]);
+
+ const applyPendingRemoteState = useCallback((payload) => {
+ applyRemotePayload(payload);
+ }, [applyRemotePayload]);
+
+ const collab = useCollaboration({
+ screens, connections, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, stickyNotes, screenGroups,
+ applyRemoteState: (payload) => {
+ const dragging = draggingRef.current;
+ const hsMode = hotspotInteractionRef.current?.mode;
+ if (dragging || hsMode === "draw" || hsMode === "reposition" || hsMode === "resize") {
+ pendingRemoteStateRef.current = payload;
+ return;
+ }
+ applyRemotePayload(payload);
+ },
+ applyRemoteImage: patchScreenImage,
+ canvasRef, pan, zoom,
+ });
+
+ const isReadOnly = collab.isReadOnly;
+
+ return {
+ collab, isReadOnly,
+ showShareModal, setShowShareModal,
+ showParticipants, setShowParticipants,
+ pendingRemoteStateRef, applyPendingRemoteState,
+ };
+}
diff --git a/src/hooks/useDataModels.js b/src/hooks/useDataModels.js
new file mode 100644
index 0000000..bd84c69
--- /dev/null
+++ b/src/hooks/useDataModels.js
@@ -0,0 +1,26 @@
+import { useState, useCallback } from "react";
+
+export function useDataModels() {
+ const [dataModels, setDataModels] = useState([]);
+ const [showDataModels, setShowDataModels] = useState(false);
+
+ const addDataModel = useCallback((name, schema) => {
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
+ setDataModels((prev) => [...prev, { id, name, schema, createdAt: new Date().toISOString() }]);
+ return id;
+ }, []);
+
+ const updateDataModel = useCallback((id, patch) => {
+ setDataModels((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)));
+ }, []);
+
+ const deleteDataModel = useCallback((id) => {
+ setDataModels((prev) => prev.filter((m) => m.id !== id));
+ }, []);
+
+ return {
+ dataModels, setDataModels,
+ showDataModels, setShowDataModels,
+ addDataModel, updateDataModel, deleteDataModel,
+ };
+}
diff --git a/src/hooks/useDerivedCanvasState.js b/src/hooks/useDerivedCanvasState.js
new file mode 100644
index 0000000..bda51fe
--- /dev/null
+++ b/src/hooks/useDerivedCanvasState.js
@@ -0,0 +1,36 @@
+import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
+
+export function useDerivedCanvasState({ connecting, hotspotInteraction, screens }) {
+ const previewLine = connecting
+ ? { fromScreenId: connecting.fromScreenId, toX: connecting.mouseX, toY: connecting.mouseY }
+ : null;
+
+ const hotspotPreviewLine = hotspotInteraction?.mode === "hotspot-drag"
+ ? { fromScreenId: hotspotInteraction.screenId, hotspotId: hotspotInteraction.hotspotId, toX: hotspotInteraction.mouseX, toY: hotspotInteraction.mouseY }
+ : null;
+
+ const endpointDragPreview = hotspotInteraction?.mode === "conn-endpoint-drag"
+ ? { connectionId: hotspotInteraction.connectionId, endpoint: hotspotInteraction.endpoint, mouseX: hotspotInteraction.mouseX, mouseY: hotspotInteraction.mouseY }
+ : null;
+
+ const selectedHotspotId = hotspotInteraction?.hotspotId || null;
+ const drawRect = hotspotInteraction?.mode === "draw" ? hotspotInteraction.drawRect : null;
+
+ const repositionGhost = (() => {
+ if (hotspotInteraction?.mode !== "reposition" || hotspotInteraction.worldX == null) return null;
+ const srcScreen = screens.find((s) => s.id === hotspotInteraction.screenId);
+ if (!srcScreen) return null;
+ const hs = srcScreen.hotspots.find((h) => h.id === hotspotInteraction.hotspotId);
+ if (!hs) return null;
+ const pixelW = (hs.w / 100) * (srcScreen.width || DEFAULT_SCREEN_WIDTH);
+ const pixelH = (hs.h / 100) * (srcScreen.imageHeight || DEFAULT_SCREEN_HEIGHT);
+ return {
+ x: hotspotInteraction.worldX - pixelW / 2,
+ y: hotspotInteraction.worldY - pixelH / 2,
+ width: pixelW,
+ height: pixelH,
+ };
+ })();
+
+ return { previewLine, hotspotPreviewLine, endpointDragPreview, selectedHotspotId, drawRect, repositionGhost };
+}
diff --git a/src/hooks/useFigmaPaste.js b/src/hooks/useFigmaPaste.js
new file mode 100644
index 0000000..471b507
--- /dev/null
+++ b/src/hooks/useFigmaPaste.js
@@ -0,0 +1,131 @@
+import { useState, useEffect, useRef } from "react";
+import { isFigmaClipboard, extractFigmaData, parseFigmaFrames, renderFigmaBuffer } from "../utils/parseFigmaClipboard";
+
+// Time window (ms) to apply stashed Figma metadata to a regular image paste.
+// After detecting Figma clipboard and prompting "Copy as PNG", the user re-copies
+// with Shift+Cmd+C and pastes. The stashed frame name is applied to that paste.
+const FIGMA_STASH_TTL = 30000;
+
+export function useFigmaPaste({ handlePaste, addScreenAtCenter }) {
+ const [figmaProcessing, setFigmaProcessing] = useState(false);
+ const [figmaError, setFigmaError] = useState(null);
+
+ // Stash Figma metadata so the next regular image paste gets the frame name
+ const figmaStashRef = useRef(null);
+
+ // Global paste listener: intercepts Figma clipboard before regular image paste
+ useEffect(() => {
+ const onPaste = async (e) => {
+ const tag = document.activeElement?.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
+
+ if (e.clipboardData && isFigmaClipboard(e.clipboardData)) {
+ const html = e.clipboardData.getData("text/html");
+ const figmaData = extractFigmaData(html);
+ if (figmaData) {
+ const items = Array.from(e.clipboardData.items || []);
+
+ // Check for native PNG rendered by Figma's own engine (pixel-perfect)
+ const pngItem = items.find((item) => item.kind === "file" && item.type === "image/png");
+
+ e.preventDefault();
+ setFigmaError(null);
+
+ // Extract frame name from binary data (lightweight kiwi parse, no WASM)
+ let frameName = "Figma Frame";
+ let frameCount = 1;
+ try {
+ const { frames } = parseFigmaFrames(figmaData.buffer);
+ if (frames.length > 0) frameName = frames[0].name;
+ frameCount = frames.length;
+ } catch {
+ // Use default name
+ }
+
+ const figmaSource = {
+ fileKey: figmaData.meta.fileKey,
+ frameName,
+ importedAt: new Date().toISOString(),
+ };
+
+ if (pngItem) {
+ // Fast path: native clipboard PNG (Figma Desktop)
+ const blob = pngItem.getAsFile();
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (frameCount > 1) {
+ alert("Multiple frames detected. Only the first frame was imported. Please copy and paste one frame at a time for best results.");
+ }
+ addScreenAtCenter(reader.result, frameName, 0, { figmaSource });
+ };
+ reader.onerror = () => {
+ setFigmaError("Failed to read Figma clipboard image");
+ };
+ reader.readAsDataURL(blob);
+ } else {
+ // Figma Web: no native PNG. Render via WASM with IMAGE fills
+ // stripped (they have no pixel data and would show checker patterns).
+ // Stash metadata so a follow-up Shift+Cmd+C paste inherits the name.
+ figmaStashRef.current = { frameName, figmaSource, stashedAt: Date.now() };
+ setFigmaProcessing(true);
+
+ try {
+ const rendered = await renderFigmaBuffer(figmaData.buffer);
+ if (rendered.frameCount > 1) {
+ alert("Multiple frames detected. Only the first frame was imported. Please copy and paste one frame at a time for best results.");
+ }
+ addScreenAtCenter(rendered.imageDataUrl, rendered.frameName, 0, { figmaSource });
+ setFigmaError(
+ "Tip: For pixel-perfect results, use Shift+Cmd+C in Figma to copy as PNG, then paste here.",
+ );
+ } catch (err) {
+ if (import.meta.env.DEV) console.error("[Figma] WASM render failed:", err);
+ setFigmaError(
+ `Figma frame "${frameName}" detected but rendering failed. ` +
+ "Try Shift+Cmd+C in Figma to copy as PNG, then paste here.",
+ );
+ } finally {
+ setFigmaProcessing(false);
+ }
+ }
+ return;
+ }
+ }
+
+ // Check if this is a regular image paste that should inherit stashed Figma metadata
+ const stash = figmaStashRef.current;
+ if (stash && Date.now() - stash.stashedAt < FIGMA_STASH_TTL) {
+ const items = Array.from(e.clipboardData?.items || []);
+ const imgItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
+ if (imgItem) {
+ e.preventDefault();
+ setFigmaError(null);
+ const blob = imgItem.getAsFile();
+ const reader = new FileReader();
+ reader.onload = () => {
+ addScreenAtCenter(reader.result, stash.frameName, 0, { figmaSource: stash.figmaSource });
+ figmaStashRef.current = null;
+ };
+ reader.onerror = () => {
+ setFigmaError("Failed to read clipboard image");
+ };
+ reader.readAsDataURL(blob);
+ return;
+ }
+ }
+
+ handlePaste(e);
+ };
+ document.addEventListener("paste", onPaste);
+ return () => document.removeEventListener("paste", onPaste);
+ }, [handlePaste, addScreenAtCenter]);
+
+ // Auto-dismiss Figma error after 10 seconds (longer for actionable guidance)
+ useEffect(() => {
+ if (!figmaError) return;
+ const timer = setTimeout(() => setFigmaError(null), 10000);
+ return () => clearTimeout(timer);
+ }, [figmaError]);
+
+ return { figmaProcessing, figmaError, setFigmaError };
+}
diff --git a/src/hooks/useFileActions.js b/src/hooks/useFileActions.js
new file mode 100644
index 0000000..0c789d6
--- /dev/null
+++ b/src/hooks/useFileActions.js
@@ -0,0 +1,51 @@
+import { useCallback } from "react";
+
+export function useFileActions({
+ screens, replaceAll, setPan, setZoom,
+ setFeatureBrief, setTaskLink, setTechStack,
+ setDataModels, setStickyNotes, setScreenGroups,
+ setScopeRoot, openFile, saveAs, disconnect,
+}) {
+ const applyPayload = useCallback((payload) => {
+ replaceAll(payload.screens, payload.connections, payload.screens.length + 1, payload.documents || []);
+ if (payload.viewport) { setPan(payload.viewport.pan); setZoom(payload.viewport.zoom); }
+ setFeatureBrief(payload.metadata?.featureBrief || "");
+ setTaskLink(payload.metadata?.taskLink || "");
+ setTechStack(payload.metadata?.techStack || {});
+ setDataModels(payload.dataModels || []);
+ setStickyNotes(payload.stickyNotes || []);
+ setScreenGroups(payload.screenGroups || []);
+ setScopeRoot(null);
+ }, [replaceAll, setPan, setZoom, setFeatureBrief, setTaskLink, setTechStack, setDataModels, setStickyNotes, setScreenGroups, setScopeRoot]);
+
+ const onOpen = useCallback(async () => {
+ try {
+ const payload = await openFile();
+ if (!payload) return;
+ applyPayload(payload);
+ } catch (err) { alert(err.message); }
+ }, [openFile, applyPayload]);
+
+ const onSaveAs = useCallback(async () => {
+ try { await saveAs(); } catch (err) { alert("Save failed: " + err.message); }
+ }, [saveAs]);
+
+ const onNew = useCallback(() => {
+ if (screens.length > 0) {
+ if (!window.confirm("You have unsaved changes. Start a new flow?")) return;
+ }
+ replaceAll([], [], 1, []);
+ setPan({ x: 0, y: 0 });
+ setZoom(1);
+ setFeatureBrief("");
+ setTaskLink("");
+ setTechStack({});
+ setDataModels([]);
+ setStickyNotes([]);
+ setScreenGroups([]);
+ setScopeRoot(null);
+ disconnect();
+ }, [screens.length, replaceAll, setPan, setZoom, setFeatureBrief, setTaskLink, setTechStack, setDataModels, setStickyNotes, setScreenGroups, setScopeRoot, disconnect]);
+
+ return { applyPayload, onOpen, onSaveAs, onNew };
+}
diff --git a/src/hooks/useInstructionGeneration.js b/src/hooks/useInstructionGeneration.js
new file mode 100644
index 0000000..77cec72
--- /dev/null
+++ b/src/hooks/useInstructionGeneration.js
@@ -0,0 +1,50 @@
+import { useState, useCallback } from "react";
+import { generateInstructionFiles } from "../utils/generateInstructionFiles";
+import { validateInstructions } from "../utils/validateInstructions";
+
+export function useInstructionGeneration({
+ screens, connections, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, screenGroups, scopeScreenIds,
+}) {
+ const [instructions, setInstructions] = useState(null);
+ const [showInstructions, setShowInstructions] = useState(false);
+
+ const buildInstructionResult = useCallback((warnings) => {
+ const scopedScreens = scopeScreenIds
+ ? screens.filter((s) => scopeScreenIds.has(s.id))
+ : screens;
+ return generateInstructionFiles(scopedScreens, connections, {
+ platform: "auto",
+ documents,
+ featureBrief,
+ taskLink,
+ techStack,
+ dataModels,
+ screenGroups,
+ scopeScreenIds,
+ allScreens: screens,
+ warnings,
+ });
+ }, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups]);
+
+ const onGenerate = useCallback(() => {
+ if (screens.length === 0) return;
+ const scopedScreens = scopeScreenIds
+ ? screens.filter((s) => scopeScreenIds.has(s.id))
+ : screens;
+ const warnings = validateInstructions(scopedScreens, connections, { documents });
+ const errors = warnings.filter((w) => w.level === "error");
+ if (
+ errors.length > 0 &&
+ !window.confirm(
+ `Found ${errors.length} issue(s) that may affect generated output:\n\n${errors.map((e) => `\u2022 ${e.message}`).join("\n")}\n\nGenerate anyway?`
+ )
+ ) return;
+ const result = buildInstructionResult(warnings);
+ setInstructions(result);
+ setShowInstructions(true);
+ }, [screens, connections, documents, scopeScreenIds, buildInstructionResult]);
+
+ return { instructions, showInstructions, setShowInstructions, onGenerate, buildInstructionResult };
+}
diff --git a/src/hooks/useInteractionCallbacks.js b/src/hooks/useInteractionCallbacks.js
new file mode 100644
index 0000000..66281f1
--- /dev/null
+++ b/src/hooks/useInteractionCallbacks.js
@@ -0,0 +1,140 @@
+import { useCallback, useEffect } from "react";
+import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
+
+export function useInteractionCallbacks({
+ screens, connections, stickyNotes,
+ connecting, cancelConnecting,
+ hotspotInteraction, setHotspotInteraction,
+ setSelectedConnection, setHoverTarget,
+ setConditionalPrompt, setEditingConditionGroup,
+ setHotspotModal, setConnectionEditModal,
+ quickConnectHotspot, addConnection, addToConditionalGroup,
+ onStartConnect,
+ activeTool, captureDragSnapshot,
+ handleDragStart, handleMultiDragStart,
+ canvasSelection, clearSelection,
+ setSelectedScreen, setPan, zoom, canvasRef,
+}) {
+ const onConnectionClick = useCallback((connId) => {
+ setSelectedConnection(connId);
+ setHotspotInteraction(null);
+ }, [setSelectedConnection, setHotspotInteraction]);
+
+ const onConnectionDoubleClick = useCallback((connId) => {
+ const conn = connections.find((c) => c.id === connId);
+ if (!conn) return;
+ const screen = screens.find((s) => s.id === conn.fromScreenId);
+ if (!screen) return;
+ if (conn.hotspotId) {
+ const hotspot = screen.hotspots.find((h) => h.id === conn.hotspotId);
+ if (hotspot) setHotspotModal({ screen, hotspot, connection: conn });
+ } else {
+ const groupConns = conn.conditionGroupId
+ ? connections.filter((c) => c.conditionGroupId === conn.conditionGroupId)
+ : [conn];
+ setConnectionEditModal({ connection: conn, groupConnections: groupConns, fromScreen: screen });
+ }
+ }, [connections, screens, setHotspotModal, setConnectionEditModal]);
+
+ const onConnectComplete = useCallback((targetScreenId) => {
+ if (hotspotInteraction?.mode === "hotspot-drag") {
+ if (targetScreenId !== hotspotInteraction.screenId) {
+ quickConnectHotspot(hotspotInteraction.screenId, hotspotInteraction.hotspotId, targetScreenId);
+ }
+ setHotspotInteraction({ mode: "selected", screenId: hotspotInteraction.screenId, hotspotId: hotspotInteraction.hotspotId });
+ setHoverTarget(null);
+ return;
+ }
+
+ if (!connecting) return;
+ const fromId = connecting.fromScreenId;
+ if (targetScreenId === fromId) { cancelConnecting(); return; }
+
+ const existingPlain = connections.filter((c) => c.fromScreenId === fromId && !c.hotspotId);
+
+ const existingGroup = existingPlain.find((c) => c.conditionGroupId);
+ if (existingGroup) {
+ addToConditionalGroup(fromId, targetScreenId, existingGroup.conditionGroupId);
+ setEditingConditionGroup(existingGroup.conditionGroupId);
+ cancelConnecting();
+ return;
+ }
+
+ if (existingPlain.length > 0) {
+ const isDuplicate = existingPlain.some((c) => c.toScreenId === targetScreenId);
+ if (isDuplicate) { cancelConnecting(); return; }
+ const fromScreen = screens.find((s) => s.id === fromId);
+ const promptX = fromScreen ? fromScreen.x + (fromScreen.width || DEFAULT_SCREEN_WIDTH) + 20 : 0;
+ const promptY = fromScreen ? fromScreen.y : 0;
+ setConditionalPrompt({ fromId, targetScreenId, existingConnId: existingPlain[0].id, x: promptX, y: promptY });
+ cancelConnecting();
+ return;
+ }
+
+ addConnection(fromId, targetScreenId);
+ cancelConnecting();
+ }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, addConnection, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt]);
+
+ // Open hotspot modal when a draw gesture completes
+ useEffect(() => {
+ if (hotspotInteraction?.mode === "draw-complete") {
+ const screen = screens.find((s) => s.id === hotspotInteraction.screenId);
+ if (screen) {
+ const { x, y, w, h } = hotspotInteraction.drawRect;
+ setHotspotModal({ screen, hotspot: null, prefilledRect: { x, y, w, h } });
+ }
+ setHotspotInteraction(null);
+ }
+ }, [hotspotInteraction, screens, setHotspotInteraction, setHotspotModal]);
+
+ const onDragStart = useCallback((e, screenId) => {
+ if (activeTool === "pan") return;
+ captureDragSnapshot();
+ handleDragStart(e, screenId, screens);
+ }, [handleDragStart, screens, captureDragSnapshot, activeTool]);
+
+ const onMultiDragStart = useCallback((e) => {
+ if (activeTool === "pan") return;
+ captureDragSnapshot();
+ handleMultiDragStart(e, canvasSelection, screens, stickyNotes);
+ }, [activeTool, captureDragSnapshot, handleMultiDragStart, canvasSelection, screens, stickyNotes]);
+
+ const addHotspot = useCallback((screenId) => {
+ const screen = screens.find((s) => s.id === screenId);
+ setHotspotModal({ screen, hotspot: null });
+ }, [screens, setHotspotModal]);
+
+ const onHotspotDoubleClick = useCallback((_e, screenId, hotspotId) => {
+ const screen = screens.find((s) => s.id === screenId);
+ if (!screen) return;
+ const hotspot = screen.hotspots.find((h) => h.id === hotspotId);
+ if (!hotspot) return;
+ setHotspotInteraction(null);
+ setHotspotModal({ screen, hotspot });
+ }, [screens, setHotspotInteraction, setHotspotModal]);
+
+ const addHotspotViaConnect = useCallback((screenId) => {
+ onStartConnect(screenId);
+ }, [onStartConnect]);
+
+ const onScreensPanelClick = useCallback((screenId) => {
+ clearSelection();
+ setSelectedScreen(screenId);
+ const screen = screens.find((s) => s.id === screenId);
+ if (!screen || !canvasRef.current) return;
+ const vw = canvasRef.current.clientWidth;
+ const vh = canvasRef.current.clientHeight;
+ const screenW = screen.width || DEFAULT_SCREEN_WIDTH;
+ const screenH = screen.imageHeight ? screen.imageHeight + HEADER_HEIGHT : DEFAULT_SCREEN_HEIGHT;
+ const centerX = screen.x + screenW / 2;
+ const centerY = screen.y + screenH / 2;
+ setPan({ x: vw / 2 - centerX * zoom, y: vh / 2 - centerY * zoom });
+ }, [screens, zoom, canvasRef, setPan, setSelectedScreen, clearSelection]);
+
+ return {
+ onConnectionClick, onConnectionDoubleClick, onConnectComplete,
+ onDragStart, onMultiDragStart,
+ addHotspot, onHotspotDoubleClick, addHotspotViaConnect,
+ onScreensPanelClick,
+ };
+}
diff --git a/src/hooks/useScreenGroups.js b/src/hooks/useScreenGroups.js
new file mode 100644
index 0000000..2eb6352
--- /dev/null
+++ b/src/hooks/useScreenGroups.js
@@ -0,0 +1,44 @@
+import { useState, useCallback } from "react";
+import { generateId } from "../utils/generateId";
+import { COLORS } from "../styles/theme";
+
+export function useScreenGroups() {
+ const [screenGroups, setScreenGroups] = useState([]);
+ const [selectedScreenGroup, setSelectedScreenGroup] = useState(null);
+
+ const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008) => {
+ const group = { id: generateId(), name, screenIds, color, folderHint: "" };
+ setScreenGroups((prev) => [...prev, group]);
+ return group.id;
+ }, []);
+
+ const updateScreenGroup = useCallback((id, patch) => {
+ setScreenGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g));
+ }, []);
+
+ const deleteScreenGroup = useCallback((id) => {
+ setScreenGroups((prev) => prev.filter((g) => g.id !== id));
+ }, []);
+
+ const addScreenToGroup = useCallback((groupId, screenId) => {
+ setScreenGroups((prev) => prev.map((g) =>
+ g.id === groupId && !g.screenIds.includes(screenId)
+ ? { ...g, screenIds: [...g.screenIds, screenId] }
+ : g
+ ));
+ }, []);
+
+ const removeScreenFromGroup = useCallback((screenId) => {
+ setScreenGroups((prev) => prev.map((g) => ({
+ ...g,
+ screenIds: g.screenIds.filter((id) => id !== screenId),
+ })));
+ }, []);
+
+ return {
+ screenGroups, setScreenGroups,
+ selectedScreenGroup, setSelectedScreenGroup,
+ addScreenGroup, updateScreenGroup, deleteScreenGroup,
+ addScreenToGroup, removeScreenFromGroup,
+ };
+}
diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js
index 1a5f758..2275ca8 100644
--- a/src/hooks/useScreenManager.js
+++ b/src/hooks/useScreenManager.js
@@ -124,12 +124,13 @@ export function useScreenManager(pan, zoom, canvasRef) {
tbd: false,
tbdNote: "",
roles: [],
+ figmaSource: null,
};
setScreens((prev) => [...prev, newScreen]);
setSelectedScreen(newScreen.id);
}, [screens, connections, documents, pushHistory, pan, zoom]);
- const addScreenAtCenter = useCallback((imageData = null, name = null, offset = 0) => {
+ const addScreenAtCenter = useCallback((imageData = null, name = null, offset = 0, meta = {}) => {
pushHistory(screens, connections, documents);
const count = screenCounter.current++;
const el = canvasRef?.current;
@@ -155,6 +156,8 @@ export function useScreenManager(pan, zoom, canvasRef) {
tbd: false,
tbdNote: "",
roles: [],
+ figmaSource: null,
+ ...meta,
};
setScreens((prev) => [...prev, newScreen]);
setSelectedScreen(newScreen.id);
@@ -771,6 +774,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
tbd: false,
tbdNote: "",
roles: [],
+ figmaSource: null,
};
setScreens((prev) => [...prev, newScreen]);
setSelectedScreen(newScreen.id);
diff --git a/src/hooks/useStickyNotes.js b/src/hooks/useStickyNotes.js
new file mode 100644
index 0000000..3b5c92d
--- /dev/null
+++ b/src/hooks/useStickyNotes.js
@@ -0,0 +1,27 @@
+import { useState, useCallback } from "react";
+import { generateId } from "../utils/generateId";
+import { DEFAULT_SCREEN_WIDTH } from "../constants";
+
+export function useStickyNotes() {
+ const [stickyNotes, setStickyNotes] = useState([]);
+ const [selectedStickyNote, setSelectedStickyNote] = useState(null);
+
+ const addStickyNote = useCallback((x, y) => {
+ const note = { id: generateId(), x, y, width: DEFAULT_SCREEN_WIDTH, content: "", color: "yellow", author: "" };
+ setStickyNotes((prev) => [...prev, note]);
+ }, []);
+
+ const updateStickyNote = useCallback((id, patch) => {
+ setStickyNotes((prev) => prev.map((n) => n.id === id ? { ...n, ...patch } : n));
+ }, []);
+
+ const deleteStickyNote = useCallback((id) => {
+ setStickyNotes((prev) => prev.filter((n) => n.id !== id));
+ }, []);
+
+ return {
+ stickyNotes, setStickyNotes,
+ selectedStickyNote, setSelectedStickyNote,
+ addStickyNote, updateStickyNote, deleteStickyNote,
+ };
+}
diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md
index 2194f0e..b05445d 100644
--- a/src/pages/docs/userGuide.md
+++ b/src/pages/docs/userGuide.md
@@ -25,12 +25,24 @@ Screens are the foundation of every Drawd project. Each screen represents a sing
- Floating toolbar — Click the Upload icon (`U`) in the bottom toolbar to open a file picker. Select one or more image files (PNG, JPG, WebP).
- Drag and drop — Drag image files from Finder or Explorer directly onto the canvas.
- Paste from clipboard — Copy an image (e.g. a screenshot) and press Cmd/Ctrl+V to paste it onto the canvas.
+- Paste from Figma — Use `Shift+Cmd+C` in Figma to copy a frame as PNG, then paste into Drawd with `Cmd/Ctrl+V`. The frame appears as a pixel-perfect screen image with its Figma name.
- Add blank screen — Click the Blank Screen icon (`B`) in the bottom toolbar to create a placeholder screen without an image.
### Replacing an image
You can replace the image on an existing screen by pasting from the clipboard or dragging a new image file directly onto it. The screen's hotspots, connections, name, position, and notes are all preserved — only the image is swapped out.
+### Paste from Figma
+
+For pixel-perfect results, use `Shift+Cmd+C` (Copy as PNG) in Figma, then paste into Drawd with `Cmd/Ctrl+V`. Drawd automatically detects the Figma frame name and applies it to the screen.
+
+If you use regular `Cmd+C` in Figma, Drawd renders a layout preview using the clipboard scene graph. Text, shapes, gradients, vectors, and shared library components are rendered at their correct positions, while raster images (photos, backgrounds) appear as transparent areas since the clipboard does not include image pixel data. A loading overlay is shown during rendering.
+
+After the layout preview is added, a tip appears suggesting `Shift+Cmd+C` for pixel-perfect results. If you follow the tip and paste within 30 seconds, the frame name and Figma metadata are automatically applied to the new paste.
+
+> [!TIP]
+> For best results, copy one frame at a time. Use `Shift+Cmd+C` in Figma (not regular `Cmd+C`) to get a pixel-perfect image with all raster content included.
+
### Naming screens
Screens are named from their filename by default. To rename a screen, click the pencil icon on the screen card or in the left panel, or press `F2` when a screen is selected.
diff --git a/src/stubs/assert.js b/src/stubs/assert.js
new file mode 100644
index 0000000..05ef5c4
--- /dev/null
+++ b/src/stubs/assert.js
@@ -0,0 +1,3 @@
+// Browser stub for Node "assert" builtin.
+// Used by @grida/refig internals; not exercised in browser code paths.
+export default function assert() {}
diff --git a/src/stubs/crypto.js b/src/stubs/crypto.js
new file mode 100644
index 0000000..e054116
--- /dev/null
+++ b/src/stubs/crypto.js
@@ -0,0 +1,4 @@
+// Browser stub for Node "node:crypto" builtin.
+// Used by @grida/canvas-wasm; browser path uses Web Crypto API instead.
+export default {};
+export const randomBytes = (n) => new Uint8Array(n);
diff --git a/src/stubs/fs.js b/src/stubs/fs.js
new file mode 100644
index 0000000..d5c774d
--- /dev/null
+++ b/src/stubs/fs.js
@@ -0,0 +1,4 @@
+// Browser stub for Node "node:fs" builtin.
+// Used by @grida/canvas-wasm for WASM loading fallback; browser path uses fetch instead.
+export default {};
+export const readFileSync = () => "";
diff --git a/src/stubs/module.js b/src/stubs/module.js
new file mode 100644
index 0000000..141ec0f
--- /dev/null
+++ b/src/stubs/module.js
@@ -0,0 +1,5 @@
+// Browser stub for Node "module" builtin.
+// Used by fflate (bundled in @grida/refig) to optionally load worker_threads.
+export function createRequire() {
+ return () => ({});
+}
diff --git a/src/utils/importFlow.js b/src/utils/importFlow.js
index 016b36b..117e013 100644
--- a/src/utils/importFlow.js
+++ b/src/utils/importFlow.js
@@ -43,6 +43,7 @@ export function importFlow(fileText) {
if (screen.tbd === undefined) screen.tbd = false;
if (screen.tbdNote === undefined) screen.tbdNote = "";
if (!Array.isArray(screen.roles)) screen.roles = [];
+ if (screen.figmaSource === undefined) screen.figmaSource = null;
if (Array.isArray(screen.hotspots)) {
for (const hs of screen.hotspots) {
if (!hs.elementType) hs.elementType = "button";
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
new file mode 100644
index 0000000..11df6b6
--- /dev/null
+++ b/src/utils/parseFigmaClipboard.js
@@ -0,0 +1,334 @@
+import { FigmaDocument, FigmaRenderer } from "@grida/refig/browser";
+// @grida/refig v0.0.4 — chunk hash is version-specific.
+// iofigma is exported from the chunk but not re-exported from @grida/refig/browser.
+// Pin the dependency version to keep this import stable.
+import { iofigma } from "@grida/refig/dist/chunk-INJ5F2RK.mjs";
+
+// ---------------------------------------------------------------------------
+// Shared-component capture: monkey-patch factory.node to intercept
+// derivedSymbolData from INSTANCE nodeChanges during kiwi parsing.
+// The patch is scoped — only active while captureState is non-null.
+// ---------------------------------------------------------------------------
+const origFactoryNode = iofigma.kiwi.factory.node;
+let captureState = null;
+
+iofigma.kiwi.factory.node = function (nc, message) {
+ if (captureState) {
+ captureState.message = message;
+ if (nc.derivedSymbolData?.length && nc.guid) {
+ captureState.derived.set(iofigma.kiwi.guid(nc.guid), nc);
+ }
+ }
+ return origFactoryNode.call(this, nc, message);
+};
+
+function beginCapture() {
+ captureState = { derived: new Map(), message: null };
+}
+
+function endCapture() {
+ const result = captureState;
+ captureState = null;
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Shared-component resolution: process captured derivedSymbolData, build
+// parent-child trees from the derived NodeChanges, and patch INSTANCE nodes
+// in _figFile so the renderer sees the full subtree.
+// ---------------------------------------------------------------------------
+function findNodeRecursive(nodes, targetId) {
+ for (const node of nodes || []) {
+ if (node.id === targetId) return node;
+ if (node.children) {
+ const found = findNodeRecursive(node.children, targetId);
+ if (found) return found;
+ }
+ }
+ return null;
+}
+
+function findNodeInFigFile(figFile, nodeId) {
+ for (const page of figFile?.pages || []) {
+ const found = findNodeRecursive(page.rootNodes, nodeId);
+ if (found) return found;
+ }
+ return null;
+}
+
+function resolveSharedComponents(doc, captured) {
+ if (!captured?.derived?.size) return 0;
+
+ const { derived, message } = captured;
+ let patchCount = 0;
+
+ for (const [instanceGuid, kiwiInstance] of derived) {
+ const derivedNCs = kiwiInstance.derivedSymbolData;
+
+ // Convert derived nodeChanges to REST-like nodes using the same factory
+ const nodes = derivedNCs
+ .map((nc) => origFactoryNode(nc, message))
+ .filter(Boolean);
+ if (nodes.length === 0) continue;
+
+ // Build lookup maps: guid → REST-like node, guid → raw kiwi nodeChange
+ const guidToNode = new Map();
+ nodes.forEach((n) => guidToNode.set(n.id, n));
+
+ const guidToKiwi = new Map();
+ derivedNCs.forEach((nc) => {
+ if (nc.guid) guidToKiwi.set(iofigma.kiwi.guid(nc.guid), nc);
+ });
+
+ // Build parent-child relationships (mirrors buildChildrenRelationsInPlace)
+ nodes.forEach((node) => {
+ const kiwi = guidToKiwi.get(node.id);
+ if (!kiwi?.parentIndex?.guid) return;
+ const parentGuid = iofigma.kiwi.guid(kiwi.parentIndex.guid);
+ const parent = guidToNode.get(parentGuid);
+ if (parent && "children" in parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(node);
+ }
+ });
+
+ // Sort children by fractional position index
+ guidToNode.forEach((parent) => {
+ if (!parent.children?.length) return;
+ parent.children.sort((a, b) => {
+ const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
+ const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
+ return aPos.localeCompare(bPos);
+ });
+ });
+
+ // Find root children — direct children of the INSTANCE node
+ const rootChildren = nodes.filter((node) => {
+ const kiwi = guidToKiwi.get(node.id);
+ if (!kiwi?.parentIndex?.guid) return false;
+ return iofigma.kiwi.guid(kiwi.parentIndex.guid) === instanceGuid;
+ });
+
+ if (rootChildren.length === 0) continue;
+
+ // Patch the INSTANCE node in _figFile with resolved children
+ const instanceNode = findNodeInFigFile(doc._figFile, instanceGuid);
+ if (instanceNode) {
+ instanceNode.children = rootChildren;
+ patchCount++;
+ }
+ }
+
+ // Clear scene cache so _resolve() regenerates from patched _figFile
+ if (patchCount > 0) {
+ doc._sceneCache.clear();
+ }
+
+ return patchCount;
+}
+
+/**
+ * Detect Figma content in clipboard HTML.
+ * Figma puts hidden spans with `(figmeta)` and `(figma)` markers
+ * in `text/html`. Figma Desktop may also include an `image/png` blob;
+ * Figma Web typically does not.
+ *
+ * @param {DataTransfer} clipboardData
+ * @returns {boolean}
+ */
+export function isFigmaClipboard(clipboardData) {
+ const html = clipboardData.getData("text/html");
+ if (!html) return false;
+ return html.includes("(figmeta)") && html.includes("(figma)");
+}
+
+/**
+ * Extract figmeta JSON and binary buffer from clipboard HTML.
+ *
+ * Clipboard HTML structure:
+ *
+ *
+ *
+ * @param {string} html - clipboard text/html content
+ * @returns {{ meta: { fileKey: string, pasteID: string }, buffer: Uint8Array } | null}
+ */
+export function extractFigmaData(html) {
+ try {
+ const metaMatch = html.match(/\(figmeta\)([\s\S]*?)\(\/figmeta\)/);
+ const bufferMatch = html.match(/\(figma\)([\s\S]*?)\(\/figma\)/);
+
+ if (!metaMatch || !bufferMatch) return null;
+
+ const metaJson = JSON.parse(atob(metaMatch[1].trim()));
+ const binaryStr = atob(bufferMatch[1].trim());
+ const buffer = new Uint8Array(binaryStr.length);
+ for (let i = 0; i < binaryStr.length; i++) {
+ buffer[i] = binaryStr.charCodeAt(i);
+ }
+
+ return {
+ meta: {
+ fileKey: metaJson.fileKey || null,
+ pasteID: metaJson.pasteID || null,
+ },
+ buffer,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Parse a Figma binary buffer to extract frame metadata without WASM rendering.
+ * Uses FigmaDocument's internal kiwi parser to get frame names and IDs.
+ *
+ * Note: the clipboard binary contains the full scene graph (positions, fills,
+ * text, fonts) but NOT the raster image bytes referenced by IMAGE fills,
+ * and system fonts like SF Pro cannot be loaded from the browser.
+ * This function is used for lightweight metadata extraction only.
+ *
+ * @param {Uint8Array} buffer - decoded fig-kiwi binary
+ * @returns {{ frames: Array<{ id: string, name: string }>, document: FigmaDocument }}
+ */
+export function parseFigmaFrames(buffer) {
+ const doc = new FigmaDocument(buffer);
+
+ // Capture derivedSymbolData from INSTANCE nodeChanges during parsing.
+ // This data contains shared/library component definitions that are not
+ // included in the top-level nodeChanges array.
+ beginCapture();
+
+ // Trigger the internal kiwi parse so _figFile is populated.
+ // _resolve() converts to Grida IR (flat node map, no tree hierarchy),
+ // but _figFile retains the original Figma page/frame structure.
+ doc._resolve();
+
+ const captured = endCapture();
+
+ // Resolve shared/library component instances by injecting their children
+ // from derivedSymbolData into the _figFile tree. Wrapped in try/catch so
+ // failures degrade gracefully to the current behavior (empty instances).
+ try {
+ const patchCount = resolveSharedComponents(doc, captured);
+ if (patchCount > 0 && import.meta.env.DEV) {
+ console.log(
+ `[Figma] Resolved ${patchCount} shared component instance(s)`,
+ );
+ }
+ } catch (err) {
+ if (import.meta.env.DEV) {
+ console.warn("[Figma] Shared component resolution failed:", err);
+ }
+ }
+
+ const figFile = doc._figFile;
+ const frames = [];
+
+ if (figFile?.pages) {
+ for (const page of figFile.pages) {
+ const rootNodes = page.rootNodes || [];
+ for (const node of rootNodes) {
+ if (node.id && node.name) {
+ frames.push({
+ id: node.id,
+ name: node.name,
+ width: node.size?.x ?? null,
+ height: node.size?.y ?? null,
+ });
+ }
+ }
+ }
+ }
+
+ if (import.meta.env.DEV) {
+ console.log(
+ `[Figma] Parsed ${frames.length} frame(s)`,
+ frames.map((f) => `${f.name} (${f.width}x${f.height})`),
+ );
+ }
+
+ return { frames, document: doc };
+}
+
+/**
+ * Convert a Uint8Array to a base64 string.
+ * Uses chunked btoa to avoid call-stack overflow on large buffers.
+ */
+function uint8ArrayToBase64(bytes) {
+ const CHUNK = 0x8000;
+ const parts = [];
+ for (let i = 0; i < bytes.length; i += CHUNK) {
+ parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
+ }
+ return btoa(parts.join(""));
+}
+
+/**
+ * Walk a Figma scene JSON and remove all IMAGE-type fills.
+ * Clipboard IMAGE fills contain no pixel data (just CDN references)
+ * and render as checker patterns. Stripping them yields transparent
+ * areas, preserving the rest of the layout (vectors, text, gradients).
+ */
+function stripImageFills(sceneJsonStr) {
+ const scene = JSON.parse(sceneJsonStr);
+ const nodes = scene.document?.nodes;
+ if (nodes) {
+ for (const node of Object.values(nodes)) {
+ if (Array.isArray(node.fills)) {
+ node.fills = node.fills.filter((f) => f.type !== "image");
+ }
+ }
+ }
+ return JSON.stringify(scene);
+}
+
+/**
+ * Render a single Figma frame with IMAGE fills stripped.
+ *
+ * Strategy: resolve the scene (populates internal cache), mutate the
+ * cached sceneJson to remove image fills, then render normally.
+ * The renderer reads the same cache reference, so no subclassing needed.
+ *
+ * @param {FigmaDocument} doc - parsed Figma document
+ * @param {string} nodeId - frame node ID to render
+ * @param {{ width: number, height: number }} [dimensions] - optional size override
+ * @returns {Promise} data:image/png;base64,... URL
+ */
+async function renderFigmaFrame(doc, nodeId, dimensions) {
+ const resolved = doc._resolve(nodeId);
+ resolved.sceneJson = stripImageFills(resolved.sceneJson);
+
+ const renderer = new FigmaRenderer(doc, { loadFigmaDefaultFonts: true });
+ try {
+ const renderOpts = { format: "png", scale: 2 };
+ if (dimensions?.width && dimensions?.height) {
+ renderOpts.width = Math.ceil(dimensions.width);
+ renderOpts.height = Math.ceil(dimensions.height);
+ }
+ const result = await renderer.render(nodeId, renderOpts);
+ const base64 = uint8ArrayToBase64(result.data);
+ return `data:image/png;base64,${base64}`;
+ } finally {
+ renderer.dispose();
+ }
+}
+
+/**
+ * High-level entry point: parse a Figma clipboard buffer and render
+ * the first frame with IMAGE fills stripped (layout-only preview).
+ *
+ * @param {Uint8Array} buffer - decoded fig-kiwi binary from clipboard
+ * @returns {Promise<{ frameName: string, imageDataUrl: string, frameCount: number }>}
+ */
+export async function renderFigmaBuffer(buffer) {
+ const { frames, document: doc } = parseFigmaFrames(buffer);
+ if (frames.length === 0) throw new Error("No frames found in Figma clipboard data");
+
+ const firstFrame = frames[0];
+ const imageDataUrl = await renderFigmaFrame(doc, firstFrame.id, {
+ width: firstFrame.width,
+ height: firstFrame.height,
+ });
+
+ return { frameName: firstFrame.name, imageDataUrl, frameCount: frames.length };
+}
diff --git a/src/utils/parseFigmaClipboard.test.js b/src/utils/parseFigmaClipboard.test.js
new file mode 100644
index 0000000..0252c65
--- /dev/null
+++ b/src/utils/parseFigmaClipboard.test.js
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { isFigmaClipboard, extractFigmaData } from "./parseFigmaClipboard.js";
+
+// Helper to build a mock DataTransfer with getData
+function mockClipboardData(htmlContent) {
+ return {
+ getData(type) {
+ if (type === "text/html") return htmlContent || "";
+ return "";
+ },
+ };
+}
+
+// Build clipboard HTML that mirrors Figma's actual format
+function buildFigmaHtml(meta = { fileKey: "abc123", pasteID: "p1", dataType: "scene" }, bufferBytes = [1, 2, 3]) {
+ const metaBase64 = btoa(JSON.stringify(meta));
+ const bufferBase64 = btoa(String.fromCharCode(...bufferBytes));
+ return ``;
+}
+
+describe("isFigmaClipboard", () => {
+ it("returns true for HTML with figmeta and figma markers", () => {
+ const clip = mockClipboardData(buildFigmaHtml());
+ expect(isFigmaClipboard(clip)).toBe(true);
+ });
+
+ it("returns false for regular HTML without markers", () => {
+ const clip = mockClipboardData("hello world
");
+ expect(isFigmaClipboard(clip)).toBe(false);
+ });
+
+ it("returns false for empty HTML", () => {
+ const clip = mockClipboardData("");
+ expect(isFigmaClipboard(clip)).toBe(false);
+ });
+
+ it("returns false when no text/html is available", () => {
+ const clip = mockClipboardData(null);
+ expect(isFigmaClipboard(clip)).toBe(false);
+ });
+
+ it("returns false when only figmeta is present (no figma buffer)", () => {
+ const metaBase64 = btoa(JSON.stringify({ fileKey: "abc" }));
+ const clip = mockClipboardData(``);
+ expect(isFigmaClipboard(clip)).toBe(false);
+ });
+});
+
+describe("extractFigmaData", () => {
+ it("extracts meta and buffer from valid Figma HTML", () => {
+ const html = buildFigmaHtml({ fileKey: "xyz789", pasteID: "paste1" }, [10, 20, 30]);
+ const result = extractFigmaData(html);
+ expect(result).not.toBeNull();
+ expect(result.meta.fileKey).toBe("xyz789");
+ expect(result.meta.pasteID).toBe("paste1");
+ expect(result.buffer).toBeInstanceOf(Uint8Array);
+ expect(result.buffer.length).toBe(3);
+ expect(result.buffer[0]).toBe(10);
+ expect(result.buffer[1]).toBe(20);
+ expect(result.buffer[2]).toBe(30);
+ });
+
+ it("returns null for HTML without figma markers", () => {
+ expect(extractFigmaData("not figma
")).toBeNull();
+ });
+
+ it("returns null for malformed base64 in figmeta", () => {
+ const html = ``;
+ expect(extractFigmaData(html)).toBeNull();
+ });
+
+ it("returns null for missing buffer marker", () => {
+ const metaBase64 = btoa(JSON.stringify({ fileKey: "abc" }));
+ const html = ``;
+ expect(extractFigmaData(html)).toBeNull();
+ });
+
+ it("handles meta with missing fields gracefully", () => {
+ const html = buildFigmaHtml({}, [1]);
+ const result = extractFigmaData(html);
+ expect(result).not.toBeNull();
+ expect(result.meta.fileKey).toBeNull();
+ expect(result.meta.pasteID).toBeNull();
+ });
+});
diff --git a/vite.config.js b/vite.config.js
index af19f71..b1a7997 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,8 +1,83 @@
import { defineConfig } from "vite";
+import { resolve } from "path";
+import { readFileSync, existsSync } from "fs";
import react from "@vitejs/plugin-react";
+const stubDir = resolve("src/stubs");
+
+const NODE_STUBS = {
+ module: resolve(stubDir, "module.js"),
+ assert: resolve(stubDir, "assert.js"),
+ "node:fs": resolve(stubDir, "fs.js"),
+ "node:crypto": resolve(stubDir, "crypto.js"),
+};
+
+// @grida/refig bundles Node builtins (assert, module, node:fs, node:crypto)
+// in its shared chunk. These are only used in Node code paths and are safely
+// wrapped in try/catch at runtime. We stub them for the browser build.
+const nodeStubPlugin = {
+ name: "node-stub",
+ enforce: "pre",
+ resolveId(id) {
+ if (id in NODE_STUBS) return NODE_STUBS[id];
+ },
+};
+
+// esbuild plugin for dev dependency pre-bundling
+const esbuildNodeStubPlugin = {
+ name: "node-stub",
+ setup(build) {
+ const filter = /^(module|assert|node:fs|node:crypto)$/;
+ build.onResolve({ filter }, (args) => ({
+ path: NODE_STUBS[args.path],
+ }));
+ },
+};
+
+// Serve the Skia WASM binary from @grida/canvas-wasm with correct MIME type.
+// Vite's pre-bundler changes the script URL so Emscripten's locateFile()
+// can't find the .wasm file relative to the original package. This middleware
+// intercepts any request ending in "grida_canvas_wasm.wasm" and serves the
+// actual binary from node_modules. With npm overrides the package may be nested
+// under @grida/refig instead of hoisted to the top level.
+const wasmCandidates = [
+ resolve("node_modules/@grida/canvas-wasm/dist/grida_canvas_wasm.wasm"),
+ resolve("node_modules/@grida/refig/node_modules/@grida/canvas-wasm/dist/grida_canvas_wasm.wasm"),
+];
+const wasmPath = wasmCandidates.find((p) => existsSync(p));
+const serveWasmPlugin = {
+ name: "serve-wasm",
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.url?.endsWith("grida_canvas_wasm.wasm") && wasmPath) {
+ res.setHeader("Content-Type", "application/wasm");
+ res.end(readFileSync(wasmPath));
+ return;
+ }
+ next();
+ });
+ },
+};
+
export default defineConfig({
- plugins: [react()],
+ plugins: [nodeStubPlugin, serveWasmPlugin, react()],
+ resolve: {
+ alias: {
+ ...NODE_STUBS,
+ // @grida/refig v0.0.4 exports map only lists "." and "./browser".
+ // The shared chunk exports iofigma (kiwi parser utilities) needed
+ // for shared-component resolution. This alias bypasses the exports
+ // restriction so Vite can resolve the deep import.
+ "@grida/refig/dist/chunk-INJ5F2RK.mjs": resolve(
+ "node_modules/@grida/refig/dist/chunk-INJ5F2RK.mjs",
+ ),
+ },
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ plugins: [esbuildNodeStubPlugin],
+ },
+ },
test: {
environment: "jsdom",
},