Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CanvasTitle } from "./CanvasTitle"
|
||||
@@ -46,17 +47,12 @@ import { CommandPalette } from "./CommandPalette"
|
||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
import { EndpointNode } from "./EndpointNode"
|
||||
import {
|
||||
deriveEndpoints,
|
||||
ENDPOINT_TYPE,
|
||||
isEndpointNode,
|
||||
placementsFor,
|
||||
rememberPlacement,
|
||||
} from "./endpoints"
|
||||
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||
import { FlowPanel } from "./FlowPanel"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
import { type Direction, layoutGraph } from "./layout"
|
||||
import { NodePanel } from "./NodePanel"
|
||||
import "./flow.css"
|
||||
import { liveStore, useFlowPaused } from "./liveStore"
|
||||
@@ -119,8 +115,8 @@ const CLIPBOARD_KEY = "fluksio.nodeClipboard"
|
||||
* Remember the document as it was before a change.
|
||||
*
|
||||
* Fields commit on every keystroke, so consecutive edits that leave the same
|
||||
* nodes in place fold into the entry already on the stack. Anything carrying
|
||||
* positions — a drag, a new node — is a finished action and starts its own.
|
||||
* nodes in place fold into the entry already on the stack. Anything that adds
|
||||
* or removes a node is a finished action and starts its own.
|
||||
*/
|
||||
function record(
|
||||
history: History,
|
||||
@@ -145,11 +141,12 @@ function record(
|
||||
history.future = []
|
||||
}
|
||||
|
||||
/** Positions come from the layout, so xyflow's own state only tracks identity. */
|
||||
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
|
||||
return definitions.map((node) => ({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
}))
|
||||
}
|
||||
@@ -174,26 +171,6 @@ function CanvasBackground() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Step a new node off any node already sitting at that spot. */
|
||||
function freePosition(
|
||||
nodes: NodeDef_Input[],
|
||||
start: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const position = { ...start }
|
||||
// Roughly a node's footprint, so a nudged node clears the one below it.
|
||||
const occupied = () =>
|
||||
nodes.some(
|
||||
(node) =>
|
||||
Math.abs((node.position?.x ?? 0) - position.x) < 220 &&
|
||||
Math.abs((node.position?.y ?? 0) - position.y) < 80,
|
||||
)
|
||||
while (occupied()) {
|
||||
position.x += 48
|
||||
position.y += 96
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
/** 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))
|
||||
@@ -213,7 +190,7 @@ function FlowEditorInner({
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast, showSuccessToast } = useCustomToast()
|
||||
const { screenToFlowPosition, fitView } = useReactFlow()
|
||||
const { fitView } = useReactFlow()
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
||||
@@ -243,6 +220,9 @@ function FlowEditorInner({
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
||||
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
||||
// Throwing an edit away is offered from the dock, so its confirmation lives
|
||||
// here rather than inside the settings panel.
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
// The editor at full size takes the width the canvas chrome does not need.
|
||||
const [editorExpanded, setEditorExpanded] = useState(false)
|
||||
// The dock hosts the logs, but a failing node opens them too, at its own
|
||||
@@ -278,14 +258,10 @@ function FlowEditorInner({
|
||||
|
||||
/** The same, for the changes that only touch the nodes. */
|
||||
const commit = useCallback(
|
||||
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
|
||||
const placed = nodes.map((node) => {
|
||||
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
|
||||
return canvas ? { ...node, position: canvas.position } : node
|
||||
})
|
||||
commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
|
||||
(nodes: NodeDef_Input[]) => {
|
||||
commitDoc({ ...latest.current, nodes })
|
||||
},
|
||||
[canvasNodes, commitDoc],
|
||||
[commitDoc],
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -383,8 +359,8 @@ function FlowEditorInner({
|
||||
],
|
||||
)
|
||||
|
||||
// A cheap fingerprint of the wiring: it changes when a name does, but not
|
||||
// when a node merely moves.
|
||||
// A cheap fingerprint of the wiring, and the only thing the layout depends
|
||||
// on: what the graph looks like follows from what is wired to what.
|
||||
const key = bindingsKey(definitions)
|
||||
|
||||
// Offer the names already in play: everything published is worth reading,
|
||||
@@ -407,31 +383,6 @@ function FlowEditorInner({
|
||||
}
|
||||
}, [key])
|
||||
|
||||
// Endpoints are movable but are not the flow's to store, so where they were
|
||||
// put lives in the browser rather than in flow.json.
|
||||
const [moved, setMoved] = useState<Record<string, { x: number; y: number }>>(
|
||||
() => placementsFor(flowName),
|
||||
)
|
||||
|
||||
// React Flow measures a node once and keeps the size on it. Endpoints are
|
||||
// rebuilt on every drag frame, so unless the measurement is carried over
|
||||
// they arrive unmeasured and React Flow drops the edges attached to them
|
||||
// until it has measured again — remounting those edges, which makes them
|
||||
// pulse as if a value had just landed. Their own drag lit up the canvas.
|
||||
const measured = useRef(new Map<string, { width: number; height: number }>())
|
||||
const trackMeasured = useCallback(
|
||||
(changes: NodeChange<FlowCanvasNode>[]) => {
|
||||
for (const change of changes) {
|
||||
if (change.type !== "dimensions" || !change.dimensions) continue
|
||||
if (isEndpointNode({ id: change.id })) {
|
||||
measured.current.set(change.id, change.dimensions)
|
||||
}
|
||||
}
|
||||
onNodesChange(changes)
|
||||
},
|
||||
[onNodesChange],
|
||||
)
|
||||
|
||||
/** Where clicking an endpoint takes you: the thing it stands for. */
|
||||
const openEndpoint = useCallback(
|
||||
(id: string) => {
|
||||
@@ -451,18 +402,31 @@ function FlowEditorInner({
|
||||
[navigate],
|
||||
)
|
||||
|
||||
// React Flow measures a node once and keeps the size on it. An endpoint is
|
||||
// not in `canvasNodes`, so the measurement it reports back has nowhere to
|
||||
// land: without carrying it over by hand the endpoint arrives unmeasured on
|
||||
// the next render, and React Flow draws an unmeasured node hidden, taking
|
||||
// the edges attached to it with it.
|
||||
const measured = useRef(new Map<string, { width: number; height: number }>())
|
||||
const trackMeasured = useCallback(
|
||||
(changes: NodeChange<FlowCanvasNode>[]) => {
|
||||
for (const change of changes) {
|
||||
if (change.type !== "dimensions" || !change.dimensions) continue
|
||||
if (isEndpointNode({ id: change.id })) {
|
||||
measured.current.set(change.id, change.dimensions)
|
||||
}
|
||||
}
|
||||
onNodesChange(changes)
|
||||
},
|
||||
[onNodesChange],
|
||||
)
|
||||
|
||||
// Dashboards and other flows wired into this one. They are drawn but never
|
||||
// stored: they join at render, after everything that reads or writes
|
||||
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on.
|
||||
const external = useMemo(() => {
|
||||
const built = deriveEndpoints(
|
||||
detail.endpoints ?? [],
|
||||
definitions,
|
||||
flowName,
|
||||
new Map(canvasNodes.map((node) => [node.id, node.position])),
|
||||
moved,
|
||||
)
|
||||
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName)
|
||||
return {
|
||||
...built,
|
||||
nodes: built.nodes.map((node) => {
|
||||
@@ -470,30 +434,83 @@ function FlowEditorInner({
|
||||
return size ? { ...node, measured: size, ...size } : node
|
||||
}),
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
||||
}, [detail.endpoints, key, flowName, moved])
|
||||
}, [detail.endpoints, key, flowName])
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
// Kept off `external` deliberately: an endpoint's edges depend on which
|
||||
// messages it touches, never on where it sits, so dragging one must not
|
||||
// rebuild the edge array on every frame.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render.
|
||||
const edges = useMemo(
|
||||
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
||||
[key, flowName, detail.endpoints],
|
||||
)
|
||||
|
||||
const shownNodes = useMemo(
|
||||
() => [...renderedNodes, ...external.nodes],
|
||||
[renderedNodes, external],
|
||||
// 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"
|
||||
|
||||
/**
|
||||
* Nobody places a node here — the graph lays itself out, endpoints included,
|
||||
* so a producer lands upstream of what it feeds without a lane of its own.
|
||||
*
|
||||
* Keyed on which nodes exist and how they are wired, never on the node
|
||||
* objects: React Flow writes measurements back through `onNodesChange`, so
|
||||
* their identity changes constantly and the layout would run on every frame.
|
||||
*/
|
||||
const ids = [
|
||||
...canvasNodes.map((node) => node.id),
|
||||
...external.nodes.map((node) => node.id),
|
||||
]
|
||||
const shapeKey = `${direction}|${key}|${ids.join(",")}`
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
|
||||
const positions = useMemo(
|
||||
() => layoutGraph(ids, edges, direction),
|
||||
[shapeKey, edges],
|
||||
)
|
||||
|
||||
// Editing ports adds and removes handles. React Flow measures those once, so
|
||||
// it has to be told, or an edge to a brand-new handle never gets drawn.
|
||||
/**
|
||||
* The endpoints, placed.
|
||||
*
|
||||
* Memoised rather than mapped at render: React Flow keeps a node's
|
||||
* measurement against the object it measured, and an endpoint is not in
|
||||
* `canvasNodes`, so handing over a fresh one every render would leave it
|
||||
* permanently unmeasured — which React Flow draws as hidden.
|
||||
*/
|
||||
const externalNodes = useMemo(
|
||||
() =>
|
||||
external.nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
[external, positions],
|
||||
)
|
||||
|
||||
const shownNodes = useMemo(
|
||||
() => [
|
||||
...renderedNodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
...externalNodes,
|
||||
],
|
||||
[renderedNodes, externalNodes, positions],
|
||||
)
|
||||
|
||||
// Editing ports adds and removes handles, and flipping direction moves them
|
||||
// to the other side. React Flow measures those once, so it has to be told,
|
||||
// or an edge to a brand-new handle never gets drawn.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
|
||||
useEffect(() => {
|
||||
updateNodeInternals(definitions.map((node) => node.id))
|
||||
}, [key, updateNodeInternals])
|
||||
updateNodeInternals([
|
||||
...definitions.map((node) => node.id),
|
||||
...external.nodes.map((node) => node.id),
|
||||
])
|
||||
}, [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.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
|
||||
useEffect(() => {
|
||||
fitView({ ...FIT_VIEW, duration: 300 })
|
||||
}, [direction, definitions.length, external.nodes.length, fitView])
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -565,18 +582,9 @@ function FlowEditorInner({
|
||||
const addNode = useCallback(
|
||||
(type: string, sourceRef?: string) => {
|
||||
const id = uniqueNodeId(definitions, sourceRef ?? type)
|
||||
// Drop it where the user is looking, but never on top of another node.
|
||||
const position = freePosition(
|
||||
definitions,
|
||||
screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
}),
|
||||
)
|
||||
const node: NodeDef_Input = {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
params: {},
|
||||
requires: [],
|
||||
provides: [],
|
||||
@@ -584,16 +592,15 @@ function FlowEditorInner({
|
||||
// flow's own.
|
||||
...(sourceRef ? { source_ref: sourceRef } : {}),
|
||||
}
|
||||
const nextDefinitions = [...definitions, node]
|
||||
const nextCanvas = [
|
||||
// Unwired, so the layout puts it in a rank of its own until it is bound.
|
||||
setCanvasNodes([
|
||||
...canvasNodes,
|
||||
{ id, type: "flow", position, data: {} } as FlowCanvasNode,
|
||||
]
|
||||
setCanvasNodes(nextCanvas)
|
||||
commit(nextDefinitions, nextCanvas)
|
||||
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
|
||||
])
|
||||
commit([...definitions, node])
|
||||
setSelectedId(id)
|
||||
},
|
||||
[canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes],
|
||||
[canvasNodes, commit, definitions, setCanvasNodes],
|
||||
)
|
||||
|
||||
const updateNode = useCallback(
|
||||
@@ -819,31 +826,22 @@ function FlowEditorInner({
|
||||
let pool = definitions
|
||||
const pasted: NodeDef_Input[] = []
|
||||
for (const node of clipboard.nodes ?? []) {
|
||||
const position = freePosition(pool, {
|
||||
// Offset, so a copy of a node in this flow is visibly its own.
|
||||
x: (node.position?.x ?? 0) + 48,
|
||||
y: (node.position?.y ?? 0) + 48,
|
||||
})
|
||||
const copy = { ...node, id: uniqueNodeId(pool, node.id), position }
|
||||
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
|
||||
pool = [...pool, copy]
|
||||
pasted.push(copy)
|
||||
}
|
||||
if (!pasted.length) return
|
||||
|
||||
const nextCanvas = [
|
||||
setCanvasNodes([
|
||||
...canvasNodes,
|
||||
...pasted.map(
|
||||
(node) =>
|
||||
({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: node.position,
|
||||
data: {},
|
||||
}) as FlowCanvasNode,
|
||||
),
|
||||
]
|
||||
setCanvasNodes(nextCanvas)
|
||||
commit(pool, nextCanvas)
|
||||
...pasted.map((node) => ({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
})),
|
||||
])
|
||||
commit(pool)
|
||||
setSelectedId(pasted[pasted.length - 1].id)
|
||||
|
||||
clipboard.nodes.forEach((node, index) => {
|
||||
@@ -883,25 +881,6 @@ function FlowEditorInner({
|
||||
nodes={shownNodes}
|
||||
edges={edges}
|
||||
onNodesChange={trackMeasured}
|
||||
onNodeDrag={(_event, _node, dragged) => {
|
||||
// An endpoint's position is ours, not React Flow's, so it only
|
||||
// follows the pointer if we move it every frame.
|
||||
const endpoints = dragged.filter(isEndpointNode)
|
||||
if (!endpoints.length) return
|
||||
setMoved((current) => {
|
||||
const next = { ...current }
|
||||
for (const node of endpoints) next[node.id] = node.position
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onNodeDragStop={(_event, _node, dragged) => {
|
||||
for (const node of dragged.filter(isEndpointNode)) {
|
||||
// Written once at the end; every frame would be a write per pixel.
|
||||
rememberPlacement(flowName, node.id, node.position)
|
||||
}
|
||||
const own = dragged.filter(isDocumentNode)
|
||||
if (own.length) commit(definitions, mergeDragged(canvasNodes, own))
|
||||
}}
|
||||
onNodesDelete={(deleted) =>
|
||||
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||
}
|
||||
@@ -943,9 +922,14 @@ function FlowEditorInner({
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW}
|
||||
minZoom={0.25}
|
||||
// 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}
|
||||
nodeDragThreshold={5}
|
||||
// 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
|
||||
@@ -987,6 +971,7 @@ function FlowEditorInner({
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
onPublish={() => void publishFlow()}
|
||||
onDiscard={() => setDiscardOpen(true)}
|
||||
logs={{
|
||||
open: logsOpen,
|
||||
node: logsNode,
|
||||
@@ -1038,17 +1023,6 @@ function FlowEditorInner({
|
||||
toggling={enableMutation.isPending}
|
||||
onToggleEnabled={(next) => enableMutation.mutate(next)}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
discarding={discard.isPending}
|
||||
onDiscardDraft={() => {
|
||||
discard.mutate(undefined, {
|
||||
// The published document replaces what is on the canvas, and the
|
||||
// version counter goes back with it.
|
||||
onSuccess: () => {
|
||||
setFlowPanelOpen(false)
|
||||
onReload()
|
||||
},
|
||||
})
|
||||
}}
|
||||
onClose={() => setFlowPanelOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -1164,6 +1138,42 @@ function FlowEditorInner({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
||||
<DialogDescription>
|
||||
The canvas goes back to the version the engine is running. What
|
||||
you edited since is dropped, though the flow store's git history
|
||||
keeps it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={discard.isPending}
|
||||
onClick={() => {
|
||||
setDiscardOpen(false)
|
||||
discard.mutate(undefined, {
|
||||
// The published document replaces what is on the canvas, and
|
||||
// the version counter goes back with it.
|
||||
onSuccess: () => {
|
||||
setFlowPanelOpen(false)
|
||||
onReload()
|
||||
},
|
||||
})
|
||||
}}
|
||||
data-testid="confirm-discard-draft"
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/*
|
||||
* Not dismissable: until one version wins, every further save fails, so
|
||||
* there is nothing useful to go back to.
|
||||
@@ -1203,17 +1213,10 @@ function FlowEditorInner({
|
||||
)
|
||||
}
|
||||
|
||||
function mergeDragged(
|
||||
nodes: FlowCanvasNode[],
|
||||
dragged: FlowCanvasNode[],
|
||||
): FlowCanvasNode[] {
|
||||
const moved = new Map(dragged.map((node) => [node.id, node.position]))
|
||||
return nodes.map((node) =>
|
||||
moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node,
|
||||
)
|
||||
}
|
||||
|
||||
/** Seed xyflow's own node state once; it owns positions while you drag. */
|
||||
/**
|
||||
* Seed xyflow's own node state once. It tracks which nodes exist and which are
|
||||
* selected; the positions on it are placeholders the layout replaces at render.
|
||||
*/
|
||||
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
||||
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user