Let one effect own the flow canvas viewport
Three things moved the viewport independently — the shape-fit effect, focusNode, and React Flow's own fitView prop — so a fourth for "centre the node I just selected" would have been a fourth party to the argument. There is one effect now, and which branch it takes is decided by what changed rather than by what is true: selecting a node brings that node into the lane the panel leaves, and every other change — new wiring, a new endpoint, a panel opening — re-fits the whole flow into the same lane. A selection centres once, so the port edits that follow re-fit around it, which is what makes a new edge's far end visible. The refit triggers on the edge count, not the bindings key: that key changes on every keystroke in a message-name field, and refitting per character is not what "an edge was created" means. renderedNodes overwrote xyflow's own `selected` flag, so a box-selection of several nodes was invisible even though delete and copy acted on all of them. The logs panel was a popover anchored on its own button, which is why it sat off centre, hugged the button and closed on any outside click. It is a plain surface above the dock now, and the button is stateful. Escape still closes it. Expanding a node's editor gives the panel the whole inset and puts the code on the left with the settings beside it, while the toolbar and the flow name translate off screen. Narrowing the window past `md` gives the room back — the sheet it becomes has no second column to hold. The zoom buttons are gone: there is a mouse, or there is a pinch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
@@ -57,7 +57,7 @@ import {
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
import { EndpointNode } from "./EndpointNode"
|
||||
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
||||
import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||
import { FlowPanel } from "./FlowPanel"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
@@ -352,7 +352,7 @@ function FlowEditorInner({
|
||||
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
|
||||
return {
|
||||
...node,
|
||||
selected: node.id === selectedId,
|
||||
selected: node.selected || node.id === selectedId,
|
||||
data: {
|
||||
definition: definition ?? { id: node.id },
|
||||
flow: flowName,
|
||||
@@ -484,9 +484,17 @@ function FlowEditorInner({
|
||||
[key, flowName, detail.endpoints],
|
||||
)
|
||||
|
||||
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.
|
||||
const direction: Direction = useIsMobile() ? "TB" : "LR"
|
||||
const direction: Direction = isMobile ? "TB" : "LR"
|
||||
|
||||
// Expanding is a desktop affordance, so a window narrowed past `md` gives the
|
||||
// room back: the sheet it becomes has no second column to hold, and its body
|
||||
// only scrolls while the editor is its normal size.
|
||||
useEffect(() => {
|
||||
if (isMobile) setEditorExpanded(false)
|
||||
}, [isMobile])
|
||||
|
||||
/**
|
||||
* Nobody places a node here — the graph lays itself out, endpoints included,
|
||||
@@ -554,21 +562,53 @@ function FlowEditorInner({
|
||||
])
|
||||
}, [key, direction, external, updateNodeInternals])
|
||||
|
||||
// A relayout can put a new node outside the viewport, and turning the graph
|
||||
// on its side moves everything. Both want the whole flow back in view.
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
// A panel is the view you are working in, so it takes the room — but never
|
||||
// the lanes the bars sit in: publishing is most wanted right after editing.
|
||||
const panelOpen = Boolean(selected) || flowPanelOpen
|
||||
|
||||
// One effect owns the viewport, so nothing fights over it. Selecting a node
|
||||
// brings that node into the lane the panel leaves; every other change to the
|
||||
// graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow
|
||||
// into the same lane. Which of the two runs is decided by what changed, not by
|
||||
// what is true: a selection centres once, and the port edits that follow it
|
||||
// re-fit around it, because a new edge's far end is what wants to be seen.
|
||||
// The first fit is instant: an animated one travels from React Flow's
|
||||
// default viewport to the content, which is the whole flow visibly sliding
|
||||
// in from the corner every time one is opened. Later fits move from
|
||||
// somewhere the user was already looking, so those stay animated.
|
||||
const fitted = useRef(false)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
|
||||
const centred = useRef<string | null>(null)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render.
|
||||
useEffect(() => {
|
||||
const focus =
|
||||
selectedId && selectedId !== centred.current ? selectedId : null
|
||||
centred.current = selectedId
|
||||
// A phone's sheet covers the canvas outright, and so does the expanded
|
||||
// editor: there is no viewport to aim.
|
||||
if (editorExpanded) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW)
|
||||
const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
|
||||
const duration = fitted.current ? 300 : 0
|
||||
fitView(
|
||||
focus
|
||||
? { ...view, nodes: [{ id: focus }], duration }
|
||||
: { ...view, duration },
|
||||
)
|
||||
fitted.current = true
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [direction, definitions.length, external.nodes.length, fitView])
|
||||
}, [
|
||||
direction,
|
||||
definitions.length,
|
||||
external.nodes.length,
|
||||
edges.length,
|
||||
selectedId,
|
||||
panelOpen,
|
||||
editorExpanded,
|
||||
isMobile,
|
||||
fitView,
|
||||
])
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (inputs: Record<string, unknown> = {}) =>
|
||||
@@ -832,10 +872,9 @@ function FlowEditorInner({
|
||||
const id = qualifiedId.startsWith(`${flowName}.`)
|
||||
? qualifiedId.slice(flowName.length + 1)
|
||||
: qualifiedId
|
||||
fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 })
|
||||
setSelectedId(id)
|
||||
},
|
||||
[fitView, flowName],
|
||||
[flowName],
|
||||
)
|
||||
|
||||
/** Put the stored draft live. Publishing what is queued means saving first. */
|
||||
@@ -941,11 +980,6 @@ function FlowEditorInner({
|
||||
["mod+s", "mod+k"],
|
||||
)
|
||||
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
// A panel is the view you are working in, so it takes the room — but never
|
||||
// the lanes the bars sit in: publishing is most wanted right after editing.
|
||||
const panelOpen = Boolean(selected) || flowPanelOpen
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
@@ -1045,13 +1079,22 @@ function FlowEditorInner({
|
||||
panelOpen && !editorExpanded && "md:right-[27rem]",
|
||||
)}
|
||||
>
|
||||
<CanvasTitle>
|
||||
<CanvasTitle
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
editorExpanded && "-translate-y-[calc(100%+1rem)]",
|
||||
)}
|
||||
>
|
||||
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
||||
{flowDoc.title || flowName}
|
||||
</span>
|
||||
</CanvasTitle>
|
||||
|
||||
<FlowDock
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
editorExpanded && "translate-y-[calc(100%+1rem)]",
|
||||
)}
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
|
||||
Reference in New Issue
Block a user