diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 4bbb2e0..1cb2d9a 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -13,7 +13,8 @@ The graph runs left to right on desktop and top to bottom on phones. in other flows that exchange messages with this flow. A value passing lights one up in a sweep: left to right when it sends into the flow, right to left when it reads out of it. Click a panel port to open its panel in the Panels - dialog. + dialog. Click a node in another flow to open that flow on the label leading + back here, with its edges highlighted. Values update over a websocket. A connection banner tells you when the editor cannot reach the engine. diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 59e338a..c6906b6 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -58,6 +58,7 @@ import { bindingsKey, boundaryKey, deriveEdges, + flowOf, portOf, qualify, } from "./deriveEdges" @@ -228,6 +229,15 @@ function CanvasBackground() { ) } +/** + * Whether arriving on this id selects it. A node does, and so does another + * flow's label, which is where a link from that flow lands; every other + * endpoint opens what it stands for instead. + */ +function landsOn(id: string): boolean { + return !isEndpointNode({ id }) || id.startsWith("flow:") +} + /** A name that does not collide with the nodes already on the canvas. */ function uniqueNodeId(existing: NodeDef_Input[], type: string): string { const taken = new Set(existing.map((node) => node.id)) @@ -276,8 +286,10 @@ function FlowEditorInner({ detail.definition.nodes ?? [], ) // An endpoint is never selected; arriving on one opens it instead (below). + // Another flow's label is the exception: it is where a link from that flow + // lands, so it is selected like a node. const [selectedId, setSelectedId] = useState( - focus && !isEndpointNode({ id: focus }) ? focus : null, + focus && landsOn(focus) ? focus : null, ) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(null) @@ -474,9 +486,14 @@ function FlowEditorInner({ } }, [key]) - /** Where clicking an endpoint takes you: the thing it stands for. */ + /** + * Where clicking an endpoint takes you: the thing it stands for. + * + * `via` is the node here it is wired to. The other flow draws that node as + * a label of its own, which is where following the link lands. + */ const openEndpoint = useCallback( - (id: string) => { + (id: string, via?: string) => { const [kind, rest] = id.split(":", 2) if (kind === "input" || kind === "output") { // This flow's own boundary, declared in its settings. @@ -497,10 +514,11 @@ function FlowEditorInner({ navigate({ to: "/flows/$flowName", params: { flowName: (rest ?? "").split(".")[0] }, + search: { node: `flow:${via ? `${flowName}.${via}` : flowName}` }, }) } }, - [navigate], + [navigate, flowName], ) // React Flow measures a node once and keeps the size on it. An endpoint is @@ -563,6 +581,17 @@ function FlowEditorInner({ [key, flowName, detail.endpoints], ) + // Arrived from another flow: the label standing for the node the link left + // from, or failing that any label of that flow. Its edges are lit with it, + // so the connection that was followed is the one that reads first. + const landing = useMemo(() => { + if (!selectedId?.startsWith("flow:")) return null + const ids = external.nodes.map((node) => node.id) + if (ids.includes(selectedId)) return selectedId + const origin = flowOf(selectedId.slice("flow:".length)) + return ids.find((id) => id.startsWith(`flow:${origin}.`)) ?? null + }, [selectedId, external]) + const isMobile = useIsMobile() // Which way the graph runs. A phone has height to spare and no width, so it // reads top to bottom; everything else reads left to right. @@ -630,17 +659,24 @@ function FlowEditorInner({ .filter((node) => positions?.has(node.id)) .map((node) => ({ ...node, + selected: node.id === landing, position: positions?.get(node.id) ?? node.position, })), - [external, positions], + [external, positions, landing], ) // Marked after the layout has had the derived array, so selecting an edge // never sends the graph through the layout again. const shownEdges = useMemo( () => - edges.map((edge) => ({ ...edge, selected: selectedEdges.has(edge.id) })), - [edges, selectedEdges], + edges.map((edge) => ({ + ...edge, + selected: + selectedEdges.has(edge.id) || + edge.source === landing || + edge.target === landing, + })), + [edges, selectedEdges, landing], ) const shownNodes = useMemo( @@ -672,14 +708,15 @@ function FlowEditorInner({ // only changes the address. Seeded above for the first arrival, set here for // the ones after it. An endpoint — an input or output found by the search — // does what clicking its label does, which also covers one the canvas does - // not draw because a dashboard already accounts for it. + // not draw because a dashboard already accounts for it. Another flow's label + // is landed on instead, being where a link from that flow arrives. useEffect(() => { if (!focus) return - if (isEndpointNode({ id: focus })) { - openEndpoint(focus) - } else { + if (landsOn(focus)) { setFlowPanelOpen(false) setSelectedId(focus) + } else { + openEndpoint(focus) } }, [focus, openEndpoint]) @@ -705,8 +742,9 @@ function FlowEditorInner({ // Recorded as centred only once the fit below has run: arriving from // another screen selects before the layout has answered, and spending the // selection on the early returns is what fitted the whole flow instead. - const focus = - selectedId && selectedId !== centred.current ? selectedId : null + // Another flow's label is centred once it has been found on this canvas. + const target = selectedId?.startsWith("flow:") ? landing : selectedId + const focus = target && selectedId !== centred.current ? target : null // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return @@ -732,6 +770,7 @@ function FlowEditorInner({ edges.length, positions, selectedId, + landing, panelOpen, editorExpanded, isMobile, @@ -1186,7 +1225,13 @@ function FlowEditorInner({ // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. if (!isDocumentNode(node)) { - openEndpoint(node.id) + const wire = edges.find( + (edge) => edge.source === node.id || edge.target === node.id, + ) + openEndpoint( + node.id, + wire && (wire.source === node.id ? wire.target : wire.source), + ) return } setFlowPanelOpen(false)