Grow the flow editor in from the centre, hide the empty brain band
The editor's mount-time fitView animated from React Flow's default viewport, which read as the graph swiping in from the corner on every open. The first fit is instant now, later ones stay animated, and a scaleIn wrapper gives the same entrance the brain view has — with a re-measure on completion so the handle bounds are not stored mid-scale. Home only renders the brain band once some flow has nodes, so a fresh install no longer reserves a screenful of empty space above the flows card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
} from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { scaleIn } from "@/lib/motion"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CanvasTitle } from "./CanvasTitle"
|
||||
@@ -507,9 +509,18 @@ function FlowEditorInner({
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
fitView({ ...FIT_VIEW, duration: 300 })
|
||||
const frame = requestAnimationFrame(() => {
|
||||
fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW)
|
||||
fitted.current = true
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [direction, definitions.length, external.nodes.length, fitView])
|
||||
|
||||
const runMutation = useMutation({
|
||||
@@ -877,68 +888,90 @@ function FlowEditorInner({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={shownNodes}
|
||||
edges={edges}
|
||||
onNodesChange={trackMeasured}
|
||||
onNodesDelete={(deleted) =>
|
||||
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||
{/*
|
||||
* Grows in from the centre rather than arriving mid-pan, the same
|
||||
* entrance the brain view uses. React Flow reads a node's handle bounds
|
||||
* out of the DOM once and never again, and a reading taken mid-scale is
|
||||
* stored a few percent short for good — `LiveEdge` draws off the
|
||||
* `sourceX`/`targetX` that come from those handles, so every edge would
|
||||
* land short of its port forever. Remeasuring once the wrapper is back
|
||||
* at `scale: 1` is what makes the entrance safe.
|
||||
*/}
|
||||
<motion.div
|
||||
variants={scaleIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
onAnimationComplete={() =>
|
||||
updateNodeInternals([
|
||||
...definitions.map((node) => node.id),
|
||||
...external.nodes.map((node) => node.id),
|
||||
])
|
||||
}
|
||||
onNodeClick={(_event, node) => {
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
setFlowPanelOpen(false)
|
||||
setSelectedId(node.id)
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
// Clicking the canvas is how you put a panel away, whichever one it
|
||||
// is: the graph is what you went back to look at.
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(false)
|
||||
setEditorExpanded(false)
|
||||
setInspected(null)
|
||||
}}
|
||||
onEdgeClick={(event, edge) => {
|
||||
const label = (id: string) => {
|
||||
const node = definitions.find((entry) => entry.id === id)
|
||||
return node?.title || node?.id || id
|
||||
}
|
||||
setInspected({
|
||||
message: (edge.data as { message: string }).message,
|
||||
from: label(edge.source),
|
||||
to: label(edge.target),
|
||||
sourceId: edge.source,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW}
|
||||
// Low enough that the fit can always show the whole graph. A phone is
|
||||
// 390px wide and a rank of several nodes is thousands, so a floor of
|
||||
// 0.25 left the fit silently short and the flow running off screen.
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
// The graph places itself. Nothing here is arranged by hand, which is
|
||||
// what keeps a flow small enough to read at a glance.
|
||||
nodesDraggable={false}
|
||||
connectionRadius={30}
|
||||
connectOnClick
|
||||
autoPanOnConnect
|
||||
edgesReconnectable={false}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<CanvasBackground />
|
||||
</ReactFlow>
|
||||
<ReactFlow
|
||||
nodes={shownNodes}
|
||||
edges={edges}
|
||||
onNodesChange={trackMeasured}
|
||||
onNodesDelete={(deleted) =>
|
||||
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||
}
|
||||
onNodeClick={(_event, node) => {
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
setFlowPanelOpen(false)
|
||||
setSelectedId(node.id)
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
// Clicking the canvas is how you put a panel away, whichever one it
|
||||
// is: the graph is what you went back to look at.
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(false)
|
||||
setEditorExpanded(false)
|
||||
setInspected(null)
|
||||
}}
|
||||
onEdgeClick={(event, edge) => {
|
||||
const label = (id: string) => {
|
||||
const node = definitions.find((entry) => entry.id === id)
|
||||
return node?.title || node?.id || id
|
||||
}
|
||||
setInspected({
|
||||
message: (edge.data as { message: string }).message,
|
||||
from: label(edge.source),
|
||||
to: label(edge.target),
|
||||
sourceId: edge.source,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW}
|
||||
// Low enough that the fit can always show the whole graph. A phone is
|
||||
// 390px wide and a rank of several nodes is thousands, so a floor of
|
||||
// 0.25 left the fit silently short and the flow running off screen.
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
// The graph places itself. Nothing here is arranged by hand, which is
|
||||
// what keeps a flow small enough to read at a glance.
|
||||
nodesDraggable={false}
|
||||
connectionRadius={30}
|
||||
connectOnClick
|
||||
autoPanOnConnect
|
||||
edgesReconnectable={false}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<CanvasBackground />
|
||||
</ReactFlow>
|
||||
</motion.div>
|
||||
|
||||
{/*
|
||||
* The bars keep their own lane rather than making way for a panel: they
|
||||
|
||||
@@ -116,7 +116,10 @@ function Dashboard() {
|
||||
// Every section here is free to shrink instead. See DESIGN-GUIDELINES.md
|
||||
// → Responsive.
|
||||
<div className="grid gap-6 [&>*]:min-w-0">
|
||||
<BrainView />
|
||||
{/* Nothing wired up yet means nothing to draw, and the band would still
|
||||
hold a screenful of empty space above the flows card. Node count
|
||||
rather than flow count: a flow made a minute ago has none. */}
|
||||
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
|
||||
|
||||
<Card className="gap-0 py-0">
|
||||
{isPending ? (
|
||||
|
||||
Reference in New Issue
Block a user