Following a flow: endpoint lands on the label leading back, selected, centred and with its edges highlighted

This commit is contained in:
2026-10-15 16:35:30 +02:00
parent 2f780ad570
commit 6d0fd53d08
2 changed files with 61 additions and 15 deletions
+2 -1
View File
@@ -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.
+59 -14
View File
@@ -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<string | null>(
focus && !isEndpointNode({ id: focus }) ? focus : null,
focus && landsOn(focus) ? focus : null,
)
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(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)