Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/Drawd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,16 +174,19 @@ export default function Drawd({ initialRoomCode }) {
const connInteraction = useConnectionInteraction({
screens, connections, canvasRef, pan, zoom,
addConnection, addToConditionalGroup, convertToConditionalGroup,
linkAsState,
});

const {
connecting, setConnecting, cancelConnecting,
hoverTarget, setHoverTarget,
selectedConnection, setSelectedConnection,
conditionalPrompt, setConditionalPrompt,
connectionTypePrompt, setConnectionTypePrompt,
editingConditionGroup, setEditingConditionGroup,
onDotDragStart, onStartConnect,
onConditionalPromptConfirm, onConditionalPromptCancel,
onConnectionTypeNavigate, onConnectionTypeStateVariant,
} = connInteraction;

const hsInteraction = useHotspotInteraction({
Expand Down Expand Up @@ -218,6 +221,7 @@ export default function Drawd({ initialRoomCode }) {
hotspotInteraction, setHotspotInteraction,
setSelectedConnection, setHoverTarget,
setConditionalPrompt, setEditingConditionGroup,
setConnectionTypePrompt,
setHotspotModal, setConnectionEditModal,
quickConnectHotspot, addConnection, addToConditionalGroup,
onStartConnect,
Expand Down Expand Up @@ -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}
Expand Down
20 changes: 18 additions & 2 deletions src/components/CanvasArea.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,6 +42,8 @@ export function CanvasArea({
repositionGhost,
// Conditional prompt
conditionalPrompt, onConditionalPromptConfirm, onConditionalPromptCancel,
// Connection type prompt
connectionTypePrompt, onConnectionTypeNavigate, onConnectionTypeStateVariant,
// Collaboration
collab,
// Inline condition labels
Expand All @@ -53,13 +56,18 @@ export function CanvasArea({
return (
<div
ref={canvasRef}
onMouseDown={onCanvasMouseDown}
onMouseDown={(e) => {
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();
Expand Down Expand Up @@ -224,6 +232,14 @@ export function CanvasArea({
onCancel={onConditionalPromptCancel}
/>
)}
{connectionTypePrompt && (
<ConnectionTypePrompt
x={connectionTypePrompt.x}
y={connectionTypePrompt.y}
onNavigate={onConnectionTypeNavigate}
onStateVariant={onConnectionTypeStateVariant}
/>
)}
{collab.isConnected && <RemoteCursors cursors={collab.remoteCursors} />}
{editingConditionGroup && (
<InlineConditionLabels
Expand Down
61 changes: 61 additions & 0 deletions src/components/ConnectionTypePrompt.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { COLORS, FONTS, styles, Z_INDEX } from "../styles/theme";

export function ConnectionTypePrompt({ x, y, onNavigate, onStateVariant }) {
return (
<div
style={{
position: "absolute",
left: x,
top: y,
background: COLORS.surface,
border: `1px solid ${COLORS.accent}`,
borderRadius: 10,
padding: "14px 18px",
boxShadow: `0 4px 20px rgba(0,0,0,0.5), 0 0 12px ${COLORS.accent02}`,
zIndex: Z_INDEX.canvasPrompt,
minWidth: 200,
pointerEvents: "all",
}}
onMouseDown={(e) => e.stopPropagation()}
>
<div
style={{
color: COLORS.text,
fontSize: 13,
fontFamily: FONTS.ui,
fontWeight: 500,
marginBottom: 12,
}}
>
Connection type?
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={onNavigate}
style={{
...styles.btnPrimary,
flex: 1,
padding: "7px 0",
borderRadius: 6,
fontSize: 12,
}}
>
Navigate
</button>
<button
onClick={onStateVariant}
style={{
...styles.btnPrimary,
flex: 1,
padding: "7px 0",
background: COLORS.accent,
borderRadius: 6,
fontSize: 12,
}}
>
State Variant
</button>
</div>
</div>
);
}
17 changes: 17 additions & 0 deletions src/hooks/useConnectionInteraction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
};
}
10 changes: 7 additions & 3 deletions src/hooks/useInteractionCallbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down
26 changes: 26 additions & 0 deletions src/hooks/useScreenManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -873,6 +898,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
saveConnectionGroup,
deleteConnectionGroup,
addState,
linkAsState,
updateStateName,
addDocument,
updateDocument,
Expand Down
96 changes: 96 additions & 0 deletions src/hooks/useScreenManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion src/pages/docs/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading