Following a flow: endpoint lands on the label leading back, selected, centred and with its edges highlighted
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user