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.