From c15e590d65dc17274e7071e3a58ac00bb3c8a6df Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 30 Mar 2026 04:27:07 +0700 Subject: [PATCH] feat: link existing screens as state variants via drag connection When dragging a connection from one screen to another, a popup now asks the user to choose between "Navigate" (creates a navigation connection) and "State Variant" (links the target screen into the source's state group). This enables users to assign existing screens as state variants without having to create new blank screens. --- src/Drawd.jsx | 9 ++- src/components/CanvasArea.jsx | 20 +++++- src/components/ConnectionTypePrompt.jsx | 61 ++++++++++++++++ src/hooks/useConnectionInteraction.js | 17 +++++ src/hooks/useInteractionCallbacks.js | 10 ++- src/hooks/useScreenManager.js | 26 +++++++ src/hooks/useScreenManager.test.js | 96 +++++++++++++++++++++++++ src/pages/docs/userGuide.md | 6 +- 8 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/components/ConnectionTypePrompt.jsx diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 06f416f..dc5810c 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -49,7 +49,7 @@ export default function Drawd({ initialRoomCode }) { updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, patchScreenImage, quickConnectHotspot, updateConnection, deleteConnection, addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup, - addState, updateStateName, addDocument, updateDocument, deleteDocument, + addState, linkAsState, updateStateName, addDocument, updateDocument, deleteDocument, replaceAll, mergeAll, canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot, updateScreenStatus, markAllExisting, @@ -174,6 +174,7 @@ export default function Drawd({ initialRoomCode }) { const connInteraction = useConnectionInteraction({ screens, connections, canvasRef, pan, zoom, addConnection, addToConditionalGroup, convertToConditionalGroup, + linkAsState, }); const { @@ -181,9 +182,11 @@ export default function Drawd({ initialRoomCode }) { hoverTarget, setHoverTarget, selectedConnection, setSelectedConnection, conditionalPrompt, setConditionalPrompt, + connectionTypePrompt, setConnectionTypePrompt, editingConditionGroup, setEditingConditionGroup, onDotDragStart, onStartConnect, onConditionalPromptConfirm, onConditionalPromptCancel, + onConnectionTypeNavigate, onConnectionTypeStateVariant, } = connInteraction; const hsInteraction = useHotspotInteraction({ @@ -218,6 +221,7 @@ export default function Drawd({ initialRoomCode }) { hotspotInteraction, setHotspotInteraction, setSelectedConnection, setHoverTarget, setConditionalPrompt, setEditingConditionGroup, + setConnectionTypePrompt, setHotspotModal, setConnectionEditModal, quickConnectHotspot, addConnection, addToConditionalGroup, onStartConnect, @@ -468,6 +472,9 @@ export default function Drawd({ initialRoomCode }) { conditionalPrompt={conditionalPrompt} onConditionalPromptConfirm={onConditionalPromptConfirm} onConditionalPromptCancel={onConditionalPromptCancel} + connectionTypePrompt={connectionTypePrompt} + onConnectionTypeNavigate={onConnectionTypeNavigate} + onConnectionTypeStateVariant={onConnectionTypeStateVariant} collab={collab} editingConditionGroup={editingConditionGroup} updateConnection={updateConnection} diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx index 696ee65..5adf948 100644 --- a/src/components/CanvasArea.jsx +++ b/src/components/CanvasArea.jsx @@ -3,6 +3,7 @@ import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants"; import { ScreenNode } from "./ScreenNode"; import { ConnectionLines } from "./ConnectionLines"; import { ConditionalPrompt } from "./ConditionalPrompt"; +import { ConnectionTypePrompt } from "./ConnectionTypePrompt"; import { InlineConditionLabels } from "./InlineConditionLabels"; import { SelectionOverlay } from "./SelectionOverlay"; import { EmptyState } from "./EmptyState"; @@ -41,6 +42,8 @@ export function CanvasArea({ repositionGhost, // Conditional prompt conditionalPrompt, onConditionalPromptConfirm, onConditionalPromptCancel, + // Connection type prompt + connectionTypePrompt, onConnectionTypeNavigate, onConnectionTypeStateVariant, // Collaboration collab, // Inline condition labels @@ -53,13 +56,18 @@ export function CanvasArea({ return (
{ + if (connectionTypePrompt) { onConnectionTypeNavigate(); return; } + onCanvasMouseDown(e); + }} onMouseMove={onCanvasMouseMove} onMouseUp={onCanvasMouseUp} onMouseLeave={onCanvasMouseLeave} onDragOver={(e) => e.preventDefault()} onDrop={onCanvasDrop} - onClick={() => { if (groupContextMenu) setGroupContextMenu(null); }} + onClick={() => { + if (groupContextMenu) setGroupContextMenu(null); + }} onDoubleClick={(e) => { if (e.target !== canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); @@ -224,6 +232,14 @@ export function CanvasArea({ onCancel={onConditionalPromptCancel} /> )} + {connectionTypePrompt && ( + + )} {collab.isConnected && } {editingConditionGroup && ( e.stopPropagation()} + > +
+ Connection type? +
+
+ + +
+
+ ); +} diff --git a/src/hooks/useConnectionInteraction.js b/src/hooks/useConnectionInteraction.js index 039b5d6..53e5c95 100644 --- a/src/hooks/useConnectionInteraction.js +++ b/src/hooks/useConnectionInteraction.js @@ -10,11 +10,13 @@ export function useConnectionInteraction({ addConnection, addToConditionalGroup: _addToConditionalGroup, convertToConditionalGroup, + linkAsState, }) { const [connecting, setConnecting] = useState(null); const [hoverTarget, setHoverTarget] = useState(null); const [selectedConnection, setSelectedConnection] = useState(null); const [conditionalPrompt, setConditionalPrompt] = useState(null); + const [connectionTypePrompt, setConnectionTypePrompt] = useState(null); const [editingConditionGroup, setEditingConditionGroup] = useState(null); const cancelConnecting = useCallback(() => { @@ -55,16 +57,31 @@ export function useConnectionInteraction({ setConditionalPrompt(null); }, [conditionalPrompt, addConnection]); + const onConnectionTypeNavigate = useCallback(() => { + if (!connectionTypePrompt) return; + addConnection(connectionTypePrompt.fromId, connectionTypePrompt.targetScreenId); + setConnectionTypePrompt(null); + }, [connectionTypePrompt, addConnection]); + + const onConnectionTypeStateVariant = useCallback(() => { + if (!connectionTypePrompt) return; + linkAsState(connectionTypePrompt.targetScreenId, connectionTypePrompt.fromId); + setConnectionTypePrompt(null); + }, [connectionTypePrompt, linkAsState]); + return { connecting, setConnecting, hoverTarget, setHoverTarget, selectedConnection, setSelectedConnection, conditionalPrompt, setConditionalPrompt, + connectionTypePrompt, setConnectionTypePrompt, editingConditionGroup, setEditingConditionGroup, cancelConnecting, onDotDragStart, onStartConnect, onConditionalPromptConfirm, onConditionalPromptCancel, + onConnectionTypeNavigate, + onConnectionTypeStateVariant, }; } diff --git a/src/hooks/useInteractionCallbacks.js b/src/hooks/useInteractionCallbacks.js index 66281f1..4bcd01a 100644 --- a/src/hooks/useInteractionCallbacks.js +++ b/src/hooks/useInteractionCallbacks.js @@ -7,8 +7,9 @@ export function useInteractionCallbacks({ hotspotInteraction, setHotspotInteraction, setSelectedConnection, setHoverTarget, setConditionalPrompt, setEditingConditionGroup, + setConnectionTypePrompt, setHotspotModal, setConnectionEditModal, - quickConnectHotspot, addConnection, addToConditionalGroup, + quickConnectHotspot, addConnection: _addConnection, addToConditionalGroup, onStartConnect, activeTool, captureDragSnapshot, handleDragStart, handleMultiDragStart, @@ -71,9 +72,12 @@ export function useInteractionCallbacks({ return; } - addConnection(fromId, targetScreenId); + 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; + setConnectionTypePrompt({ fromId, targetScreenId, x: promptX, y: promptY }); cancelConnecting(); - }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, addConnection, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt]); + }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt, setConnectionTypePrompt]); // Open hotspot modal when a draw gesture completes useEffect(() => { diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js index 2275ca8..918fbe6 100644 --- a/src/hooks/useScreenManager.js +++ b/src/hooks/useScreenManager.js @@ -780,6 +780,31 @@ export function useScreenManager(pan, zoom, canvasRef) { setSelectedScreen(newScreen.id); }, [screens, connections, documents, pushHistory]); + const linkAsState = useCallback((screenId, parentScreenId) => { + pushHistory(screens, connections, documents); + const parent = screens.find((s) => s.id === parentScreenId); + const target = screens.find((s) => s.id === screenId); + if (!parent || !target) return; + if (screenId === parentScreenId) return; + if (parent.stateGroup && parent.stateGroup === target.stateGroup) return; + + const groupId = parent.stateGroup || generateId(); + const siblings = screens.filter((s) => s.stateGroup === groupId); + const stateNumber = siblings.length + (parent.stateGroup ? 1 : 2); + + setScreens((prev) => + prev.map((s) => { + if (s.id === parentScreenId && !s.stateGroup) { + return { ...s, stateGroup: groupId, stateName: DEFAULT_STATE_NAME }; + } + if (s.id === screenId) { + return { ...s, stateGroup: groupId, stateName: s.stateName || `State ${stateNumber - 1}` }; + } + return s; + }) + ); + }, [screens, connections, documents, pushHistory]); + const updateStateName = useCallback((screenId, stateName) => { pushHistory(screens, connections, documents); setScreens((prev) => prev.map((s) => (s.id === screenId ? { ...s, stateName } : s))); @@ -873,6 +898,7 @@ export function useScreenManager(pan, zoom, canvasRef) { saveConnectionGroup, deleteConnectionGroup, addState, + linkAsState, updateStateName, addDocument, updateDocument, diff --git a/src/hooks/useScreenManager.test.js b/src/hooks/useScreenManager.test.js index f371983..00b8532 100644 --- a/src/hooks/useScreenManager.test.js +++ b/src/hooks/useScreenManager.test.js @@ -510,6 +510,102 @@ describe("addState", () => { }); }); +describe("linkAsState", () => { + it("links two screens into the same stateGroup", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[0].stateGroup).toBeTruthy(); + expect(result.current.screens[1].stateGroup).toBe(result.current.screens[0].stateGroup); + }); + + it("parent gets stateName='Default', target gets 'State 1'", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[0].stateName).toBe("Default"); + expect(result.current.screens[1].stateName).toBe("State 1"); + }); + + it("does nothing when linking a screen to itself", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + const idA = result.current.screens[0].id; + + act(() => result.current.linkAsState(idA, idA)); + expect(result.current.screens[0].stateGroup).toBeNull(); + }); + + it("does nothing when both screens are already in the same group", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + + act(() => result.current.linkAsState(idB, idA)); + const groupId = result.current.screens[0].stateGroup; + + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[0].stateGroup).toBe(groupId); + expect(result.current.screens[1].stateGroup).toBe(groupId); + }); + + it("reuses existing stateGroup when parent already has one", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + act(() => result.current.addScreen(null, "C")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + const idC = result.current.screens[2].id; + + act(() => result.current.addState(idA)); + const groupId = result.current.screens[0].stateGroup; + + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[1].stateGroup).toBe(groupId); + + act(() => result.current.linkAsState(idC, idA)); + expect(result.current.screens[2].stateGroup).toBe(groupId); + }); + + it("is undoable", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[0].stateGroup).toBeTruthy(); + + act(() => result.current.undo()); + expect(result.current.screens[0].stateGroup).toBeNull(); + expect(result.current.screens[1].stateGroup).toBeNull(); + }); + + it("preserves existing stateName on target screen", () => { + const { result } = setup(); + act(() => result.current.addScreen(null, "A")); + act(() => result.current.addScreen(null, "B")); + const idA = result.current.screens[0].id; + const idB = result.current.screens[1].id; + + act(() => result.current.updateStateName(idB, "Loading")); + act(() => result.current.linkAsState(idB, idA)); + expect(result.current.screens[1].stateName).toBe("Loading"); + }); +}); + describe("saveConnectionGroup", () => { it("navigate mode saves connection with fromScreenId, toScreenId, label", () => { const { result } = setup(); diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index 137c8de..26134ab 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -184,10 +184,14 @@ After creating a conditional group, inline label inputs appear along each connec Screen states let you model different visual variants of the same logical screen — for example, a loading state, an error state, or a logged-in vs. logged-out view. -### Adding a state +### Adding a new state screen Select a screen, then click "Add State" in the right sidebar. A new variant screen is created 250px to the right of the original, sharing a state group with it. The original screen is automatically labeled "Default". +### Linking an existing screen as a state variant + +Drag a connection from one screen to another. A popup appears asking you to choose between **Navigate** (creates a normal navigation link) and **State Variant** (links the target screen into the source screen's state group). Choosing "State Variant" groups both screens together — a dashed connector line appears between them, and they are treated as a single logical screen in the generated instructions. + ### Naming states Each state has a name field visible in the sidebar (e.g., "Loading", "Error", "Empty"). State names appear in the screen header on the canvas and in the generated instructions.