Skip to content

Commit 75d021d

Browse files
authored
feat: link existing screens as state variants via drag connection (#16)
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. Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent bb1b2cb commit 75d021d

8 files changed

Lines changed: 238 additions & 7 deletions

File tree

src/Drawd.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export default function Drawd({ initialRoomCode }) {
4949
updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, patchScreenImage, quickConnectHotspot,
5050
updateConnection, deleteConnection,
5151
addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup,
52-
addState, updateStateName, addDocument, updateDocument, deleteDocument,
52+
addState, linkAsState, updateStateName, addDocument, updateDocument, deleteDocument,
5353
replaceAll, mergeAll,
5454
canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot,
5555
updateScreenStatus, markAllExisting,
@@ -174,16 +174,19 @@ export default function Drawd({ initialRoomCode }) {
174174
const connInteraction = useConnectionInteraction({
175175
screens, connections, canvasRef, pan, zoom,
176176
addConnection, addToConditionalGroup, convertToConditionalGroup,
177+
linkAsState,
177178
});
178179

179180
const {
180181
connecting, setConnecting, cancelConnecting,
181182
hoverTarget, setHoverTarget,
182183
selectedConnection, setSelectedConnection,
183184
conditionalPrompt, setConditionalPrompt,
185+
connectionTypePrompt, setConnectionTypePrompt,
184186
editingConditionGroup, setEditingConditionGroup,
185187
onDotDragStart, onStartConnect,
186188
onConditionalPromptConfirm, onConditionalPromptCancel,
189+
onConnectionTypeNavigate, onConnectionTypeStateVariant,
187190
} = connInteraction;
188191

189192
const hsInteraction = useHotspotInteraction({
@@ -218,6 +221,7 @@ export default function Drawd({ initialRoomCode }) {
218221
hotspotInteraction, setHotspotInteraction,
219222
setSelectedConnection, setHoverTarget,
220223
setConditionalPrompt, setEditingConditionGroup,
224+
setConnectionTypePrompt,
221225
setHotspotModal, setConnectionEditModal,
222226
quickConnectHotspot, addConnection, addToConditionalGroup,
223227
onStartConnect,
@@ -468,6 +472,9 @@ export default function Drawd({ initialRoomCode }) {
468472
conditionalPrompt={conditionalPrompt}
469473
onConditionalPromptConfirm={onConditionalPromptConfirm}
470474
onConditionalPromptCancel={onConditionalPromptCancel}
475+
connectionTypePrompt={connectionTypePrompt}
476+
onConnectionTypeNavigate={onConnectionTypeNavigate}
477+
onConnectionTypeStateVariant={onConnectionTypeStateVariant}
471478
collab={collab}
472479
editingConditionGroup={editingConditionGroup}
473480
updateConnection={updateConnection}

src/components/CanvasArea.jsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
33
import { ScreenNode } from "./ScreenNode";
44
import { ConnectionLines } from "./ConnectionLines";
55
import { ConditionalPrompt } from "./ConditionalPrompt";
6+
import { ConnectionTypePrompt } from "./ConnectionTypePrompt";
67
import { InlineConditionLabels } from "./InlineConditionLabels";
78
import { SelectionOverlay } from "./SelectionOverlay";
89
import { EmptyState } from "./EmptyState";
@@ -41,6 +42,8 @@ export function CanvasArea({
4142
repositionGhost,
4243
// Conditional prompt
4344
conditionalPrompt, onConditionalPromptConfirm, onConditionalPromptCancel,
45+
// Connection type prompt
46+
connectionTypePrompt, onConnectionTypeNavigate, onConnectionTypeStateVariant,
4447
// Collaboration
4548
collab,
4649
// Inline condition labels
@@ -53,13 +56,18 @@ export function CanvasArea({
5356
return (
5457
<div
5558
ref={canvasRef}
56-
onMouseDown={onCanvasMouseDown}
59+
onMouseDown={(e) => {
60+
if (connectionTypePrompt) { onConnectionTypeNavigate(); return; }
61+
onCanvasMouseDown(e);
62+
}}
5763
onMouseMove={onCanvasMouseMove}
5864
onMouseUp={onCanvasMouseUp}
5965
onMouseLeave={onCanvasMouseLeave}
6066
onDragOver={(e) => e.preventDefault()}
6167
onDrop={onCanvasDrop}
62-
onClick={() => { if (groupContextMenu) setGroupContextMenu(null); }}
68+
onClick={() => {
69+
if (groupContextMenu) setGroupContextMenu(null);
70+
}}
6371
onDoubleClick={(e) => {
6472
if (e.target !== canvasRef.current) return;
6573
const rect = canvasRef.current.getBoundingClientRect();
@@ -224,6 +232,14 @@ export function CanvasArea({
224232
onCancel={onConditionalPromptCancel}
225233
/>
226234
)}
235+
{connectionTypePrompt && (
236+
<ConnectionTypePrompt
237+
x={connectionTypePrompt.x}
238+
y={connectionTypePrompt.y}
239+
onNavigate={onConnectionTypeNavigate}
240+
onStateVariant={onConnectionTypeStateVariant}
241+
/>
242+
)}
227243
{collab.isConnected && <RemoteCursors cursors={collab.remoteCursors} />}
228244
{editingConditionGroup && (
229245
<InlineConditionLabels
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { COLORS, FONTS, styles, Z_INDEX } from "../styles/theme";
2+
3+
export function ConnectionTypePrompt({ x, y, onNavigate, onStateVariant }) {
4+
return (
5+
<div
6+
style={{
7+
position: "absolute",
8+
left: x,
9+
top: y,
10+
background: COLORS.surface,
11+
border: `1px solid ${COLORS.accent}`,
12+
borderRadius: 10,
13+
padding: "14px 18px",
14+
boxShadow: `0 4px 20px rgba(0,0,0,0.5), 0 0 12px ${COLORS.accent02}`,
15+
zIndex: Z_INDEX.canvasPrompt,
16+
minWidth: 200,
17+
pointerEvents: "all",
18+
}}
19+
onMouseDown={(e) => e.stopPropagation()}
20+
>
21+
<div
22+
style={{
23+
color: COLORS.text,
24+
fontSize: 13,
25+
fontFamily: FONTS.ui,
26+
fontWeight: 500,
27+
marginBottom: 12,
28+
}}
29+
>
30+
Connection type?
31+
</div>
32+
<div style={{ display: "flex", gap: 8 }}>
33+
<button
34+
onClick={onNavigate}
35+
style={{
36+
...styles.btnPrimary,
37+
flex: 1,
38+
padding: "7px 0",
39+
borderRadius: 6,
40+
fontSize: 12,
41+
}}
42+
>
43+
Navigate
44+
</button>
45+
<button
46+
onClick={onStateVariant}
47+
style={{
48+
...styles.btnPrimary,
49+
flex: 1,
50+
padding: "7px 0",
51+
background: COLORS.accent,
52+
borderRadius: 6,
53+
fontSize: 12,
54+
}}
55+
>
56+
State Variant
57+
</button>
58+
</div>
59+
</div>
60+
);
61+
}

src/hooks/useConnectionInteraction.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ export function useConnectionInteraction({
1010
addConnection,
1111
addToConditionalGroup: _addToConditionalGroup,
1212
convertToConditionalGroup,
13+
linkAsState,
1314
}) {
1415
const [connecting, setConnecting] = useState(null);
1516
const [hoverTarget, setHoverTarget] = useState(null);
1617
const [selectedConnection, setSelectedConnection] = useState(null);
1718
const [conditionalPrompt, setConditionalPrompt] = useState(null);
19+
const [connectionTypePrompt, setConnectionTypePrompt] = useState(null);
1820
const [editingConditionGroup, setEditingConditionGroup] = useState(null);
1921

2022
const cancelConnecting = useCallback(() => {
@@ -55,16 +57,31 @@ export function useConnectionInteraction({
5557
setConditionalPrompt(null);
5658
}, [conditionalPrompt, addConnection]);
5759

60+
const onConnectionTypeNavigate = useCallback(() => {
61+
if (!connectionTypePrompt) return;
62+
addConnection(connectionTypePrompt.fromId, connectionTypePrompt.targetScreenId);
63+
setConnectionTypePrompt(null);
64+
}, [connectionTypePrompt, addConnection]);
65+
66+
const onConnectionTypeStateVariant = useCallback(() => {
67+
if (!connectionTypePrompt) return;
68+
linkAsState(connectionTypePrompt.targetScreenId, connectionTypePrompt.fromId);
69+
setConnectionTypePrompt(null);
70+
}, [connectionTypePrompt, linkAsState]);
71+
5872
return {
5973
connecting, setConnecting,
6074
hoverTarget, setHoverTarget,
6175
selectedConnection, setSelectedConnection,
6276
conditionalPrompt, setConditionalPrompt,
77+
connectionTypePrompt, setConnectionTypePrompt,
6378
editingConditionGroup, setEditingConditionGroup,
6479
cancelConnecting,
6580
onDotDragStart,
6681
onStartConnect,
6782
onConditionalPromptConfirm,
6883
onConditionalPromptCancel,
84+
onConnectionTypeNavigate,
85+
onConnectionTypeStateVariant,
6986
};
7087
}

src/hooks/useInteractionCallbacks.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ export function useInteractionCallbacks({
77
hotspotInteraction, setHotspotInteraction,
88
setSelectedConnection, setHoverTarget,
99
setConditionalPrompt, setEditingConditionGroup,
10+
setConnectionTypePrompt,
1011
setHotspotModal, setConnectionEditModal,
11-
quickConnectHotspot, addConnection, addToConditionalGroup,
12+
quickConnectHotspot, addConnection: _addConnection, addToConditionalGroup,
1213
onStartConnect,
1314
activeTool, captureDragSnapshot,
1415
handleDragStart, handleMultiDragStart,
@@ -71,9 +72,12 @@ export function useInteractionCallbacks({
7172
return;
7273
}
7374

74-
addConnection(fromId, targetScreenId);
75+
const fromScreen = screens.find((s) => s.id === fromId);
76+
const promptX = fromScreen ? fromScreen.x + (fromScreen.width || DEFAULT_SCREEN_WIDTH) + 20 : 0;
77+
const promptY = fromScreen ? fromScreen.y : 0;
78+
setConnectionTypePrompt({ fromId, targetScreenId, x: promptX, y: promptY });
7579
cancelConnecting();
76-
}, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, addConnection, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt]);
80+
}, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt, setConnectionTypePrompt]);
7781

7882
// Open hotspot modal when a draw gesture completes
7983
useEffect(() => {

src/hooks/useScreenManager.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,31 @@ export function useScreenManager(pan, zoom, canvasRef) {
780780
setSelectedScreen(newScreen.id);
781781
}, [screens, connections, documents, pushHistory]);
782782

783+
const linkAsState = useCallback((screenId, parentScreenId) => {
784+
pushHistory(screens, connections, documents);
785+
const parent = screens.find((s) => s.id === parentScreenId);
786+
const target = screens.find((s) => s.id === screenId);
787+
if (!parent || !target) return;
788+
if (screenId === parentScreenId) return;
789+
if (parent.stateGroup && parent.stateGroup === target.stateGroup) return;
790+
791+
const groupId = parent.stateGroup || generateId();
792+
const siblings = screens.filter((s) => s.stateGroup === groupId);
793+
const stateNumber = siblings.length + (parent.stateGroup ? 1 : 2);
794+
795+
setScreens((prev) =>
796+
prev.map((s) => {
797+
if (s.id === parentScreenId && !s.stateGroup) {
798+
return { ...s, stateGroup: groupId, stateName: DEFAULT_STATE_NAME };
799+
}
800+
if (s.id === screenId) {
801+
return { ...s, stateGroup: groupId, stateName: s.stateName || `State ${stateNumber - 1}` };
802+
}
803+
return s;
804+
})
805+
);
806+
}, [screens, connections, documents, pushHistory]);
807+
783808
const updateStateName = useCallback((screenId, stateName) => {
784809
pushHistory(screens, connections, documents);
785810
setScreens((prev) => prev.map((s) => (s.id === screenId ? { ...s, stateName } : s)));
@@ -873,6 +898,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
873898
saveConnectionGroup,
874899
deleteConnectionGroup,
875900
addState,
901+
linkAsState,
876902
updateStateName,
877903
addDocument,
878904
updateDocument,

src/hooks/useScreenManager.test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,102 @@ describe("addState", () => {
510510
});
511511
});
512512

513+
describe("linkAsState", () => {
514+
it("links two screens into the same stateGroup", () => {
515+
const { result } = setup();
516+
act(() => result.current.addScreen(null, "A"));
517+
act(() => result.current.addScreen(null, "B"));
518+
const idA = result.current.screens[0].id;
519+
const idB = result.current.screens[1].id;
520+
521+
act(() => result.current.linkAsState(idB, idA));
522+
expect(result.current.screens[0].stateGroup).toBeTruthy();
523+
expect(result.current.screens[1].stateGroup).toBe(result.current.screens[0].stateGroup);
524+
});
525+
526+
it("parent gets stateName='Default', target gets 'State 1'", () => {
527+
const { result } = setup();
528+
act(() => result.current.addScreen(null, "A"));
529+
act(() => result.current.addScreen(null, "B"));
530+
const idA = result.current.screens[0].id;
531+
const idB = result.current.screens[1].id;
532+
533+
act(() => result.current.linkAsState(idB, idA));
534+
expect(result.current.screens[0].stateName).toBe("Default");
535+
expect(result.current.screens[1].stateName).toBe("State 1");
536+
});
537+
538+
it("does nothing when linking a screen to itself", () => {
539+
const { result } = setup();
540+
act(() => result.current.addScreen(null, "A"));
541+
const idA = result.current.screens[0].id;
542+
543+
act(() => result.current.linkAsState(idA, idA));
544+
expect(result.current.screens[0].stateGroup).toBeNull();
545+
});
546+
547+
it("does nothing when both screens are already in the same group", () => {
548+
const { result } = setup();
549+
act(() => result.current.addScreen(null, "A"));
550+
act(() => result.current.addScreen(null, "B"));
551+
const idA = result.current.screens[0].id;
552+
const idB = result.current.screens[1].id;
553+
554+
act(() => result.current.linkAsState(idB, idA));
555+
const groupId = result.current.screens[0].stateGroup;
556+
557+
act(() => result.current.linkAsState(idB, idA));
558+
expect(result.current.screens[0].stateGroup).toBe(groupId);
559+
expect(result.current.screens[1].stateGroup).toBe(groupId);
560+
});
561+
562+
it("reuses existing stateGroup when parent already has one", () => {
563+
const { result } = setup();
564+
act(() => result.current.addScreen(null, "A"));
565+
act(() => result.current.addScreen(null, "B"));
566+
act(() => result.current.addScreen(null, "C"));
567+
const idA = result.current.screens[0].id;
568+
const idB = result.current.screens[1].id;
569+
const idC = result.current.screens[2].id;
570+
571+
act(() => result.current.addState(idA));
572+
const groupId = result.current.screens[0].stateGroup;
573+
574+
act(() => result.current.linkAsState(idB, idA));
575+
expect(result.current.screens[1].stateGroup).toBe(groupId);
576+
577+
act(() => result.current.linkAsState(idC, idA));
578+
expect(result.current.screens[2].stateGroup).toBe(groupId);
579+
});
580+
581+
it("is undoable", () => {
582+
const { result } = setup();
583+
act(() => result.current.addScreen(null, "A"));
584+
act(() => result.current.addScreen(null, "B"));
585+
const idA = result.current.screens[0].id;
586+
const idB = result.current.screens[1].id;
587+
588+
act(() => result.current.linkAsState(idB, idA));
589+
expect(result.current.screens[0].stateGroup).toBeTruthy();
590+
591+
act(() => result.current.undo());
592+
expect(result.current.screens[0].stateGroup).toBeNull();
593+
expect(result.current.screens[1].stateGroup).toBeNull();
594+
});
595+
596+
it("preserves existing stateName on target screen", () => {
597+
const { result } = setup();
598+
act(() => result.current.addScreen(null, "A"));
599+
act(() => result.current.addScreen(null, "B"));
600+
const idA = result.current.screens[0].id;
601+
const idB = result.current.screens[1].id;
602+
603+
act(() => result.current.updateStateName(idB, "Loading"));
604+
act(() => result.current.linkAsState(idB, idA));
605+
expect(result.current.screens[1].stateName).toBe("Loading");
606+
});
607+
});
608+
513609
describe("saveConnectionGroup", () => {
514610
it("navigate mode saves connection with fromScreenId, toScreenId, label", () => {
515611
const { result } = setup();

src/pages/docs/userGuide.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,14 @@ After creating a conditional group, inline label inputs appear along each connec
184184

185185
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.
186186

187-
### Adding a state
187+
### Adding a new state screen
188188

189189
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".
190190

191+
### Linking an existing screen as a state variant
192+
193+
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.
194+
191195
### Naming states
192196

193197
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.

0 commit comments

Comments
 (0)