Add undo on the canvas, and sharpen the flow chrome
Deleting a node cost its source with no way back. Every mutation already funnels through one commit, so undo/redo is a bounded stack of node snapshots replayed through the same debounced save. Deleting a node only drops it from flow.json — the source file survives — so restoring the id restores the code. Renaming a message now offers to follow the rename across every node still bound to the old name, as one undoable step. Autosave never fired: flush depended on the whole mutation object, which react-query rebuilds every render, so the effect re-ran and its cleanup cancelled the pending timer. Unmounting now flushes rather than drops. The canvas looked blurry zoomed out because Background scales the dot radius by zoom, leaving quarter-pixel dots on a drifting tile; radius and spacing now divide the zoom back out, spacing in octaves so the grid halves. Edge value labels are opaque, the edge popover fits its summary on one row with a trash icon and scrolls names that overflow, and flow settings moved to the flowbar. The flowbar was sized against the viewport rather than the canvas, so its buttons left the screen once enough flows were open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b73ca3645
commit
9cede8dbb5
@@ -7,6 +7,7 @@ import {
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
useUpdateNodeInternals,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
@@ -68,6 +69,91 @@ type Rebind = {
|
||||
dtype: MessageSpec["dtype"]
|
||||
}
|
||||
|
||||
/** A finished message rename, waiting on an answer about the rest of the flow. */
|
||||
type MessageRename = {
|
||||
from: string
|
||||
to: string
|
||||
/** How many other nodes are still bound to the old name. */
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Documents to step back and forward through, newest last. */
|
||||
type History = {
|
||||
past: NodeDef_Input[][]
|
||||
future: NodeDef_Input[][]
|
||||
/** When the last entry was recorded, so a burst of typing stays one edit. */
|
||||
at: number
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 50
|
||||
const TYPING_WINDOW = 500
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function record(
|
||||
history: History,
|
||||
previous: NodeDef_Input[],
|
||||
next: NodeDef_Input[],
|
||||
settled: boolean,
|
||||
) {
|
||||
const sameNodes =
|
||||
previous.length === next.length &&
|
||||
previous.every((node, index) => node.id === next[index].id)
|
||||
const stillTyping =
|
||||
!settled && sameNodes && Date.now() - history.at < TYPING_WINDOW
|
||||
|
||||
if (!stillTyping) {
|
||||
history.past.push(previous)
|
||||
if (history.past.length > HISTORY_LIMIT) history.past.shift()
|
||||
}
|
||||
history.at = settled ? 0 : Date.now()
|
||||
// A new change is a new branch: what was undone is not coming back.
|
||||
history.future = []
|
||||
}
|
||||
|
||||
/** Monaco and every text field keep their own undo, so leave theirs alone. */
|
||||
function isTextEntry(target: EventTarget | null): boolean {
|
||||
return Boolean(
|
||||
(target as Element | null)?.closest?.(
|
||||
"input, textarea, [contenteditable='true'], .monaco-editor",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
data: {},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot grid lives in flow space, so React Flow shrinks it along with the
|
||||
* zoom. Below 1:1 that leaves sub-pixel dots on a fractional grid, which the
|
||||
* canvas can only render as a moiré haze — the whole viewport reads as blurry.
|
||||
* Taking the zoom back out of both the radius and the spacing gives the same
|
||||
* crisp 1.5px dots, 24px apart, whatever the zoom is.
|
||||
*/
|
||||
function CanvasBackground() {
|
||||
const zoom = useStore((state) => state.transform[2])
|
||||
// In octaves, so the grid halves rather than drifting as you zoom.
|
||||
const step = 2 ** Math.round(Math.log2(1 / zoom))
|
||||
return (
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24 * step}
|
||||
size={1.5 / zoom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Step a new node off any node already sitting at that spot. */
|
||||
function freePosition(
|
||||
nodes: NodeDef_Input[],
|
||||
@@ -120,6 +206,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
||||
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
||||
// The editor at full size covers the canvas, so its chrome steps aside.
|
||||
const [editorExpanded, setEditorExpanded] = useState(false)
|
||||
@@ -128,7 +215,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
|
||||
// Keep the latest document in a ref so autosave never captures a stale copy.
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
const commit = useCallback(
|
||||
const history = useRef<History>({ past: [], future: [], at: 0 })
|
||||
|
||||
/** Put a set of nodes on the canvas and on their way to the server. */
|
||||
const apply = useCallback(
|
||||
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
|
||||
const placed = nodes.map((node) => {
|
||||
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
|
||||
@@ -142,6 +232,53 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
[canvasNodes, detail.definition, save],
|
||||
)
|
||||
|
||||
/** Apply a change and make it undoable. */
|
||||
const commit = useCallback(
|
||||
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
|
||||
record(
|
||||
history.current,
|
||||
latest.current.nodes ?? [],
|
||||
nodes,
|
||||
Boolean(positions),
|
||||
)
|
||||
apply(nodes, positions)
|
||||
},
|
||||
[apply],
|
||||
)
|
||||
|
||||
/**
|
||||
* Step through the history. Undo and redo save like any other edit, so the
|
||||
* autosave debounce collapses a run of them into one write.
|
||||
*/
|
||||
const step = useCallback(
|
||||
(back: boolean) => {
|
||||
const { past, future } = history.current
|
||||
const remembered = (back ? past : future).pop()
|
||||
if (!remembered) return
|
||||
;(back ? future : past).push(latest.current.nodes ?? [])
|
||||
history.current.at = 0
|
||||
// xyflow owns the positions, so hand it the remembered ones too.
|
||||
const canvas = toCanvasNodes(remembered)
|
||||
setCanvasNodes(canvas)
|
||||
apply(remembered, canvas)
|
||||
setInspected(null)
|
||||
},
|
||||
[apply, setCanvasNodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Shift makes it a capital Z, so compare on the letter alone.
|
||||
const undoKey =
|
||||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z"
|
||||
if (!undoKey || isTextEntry(event.target)) return
|
||||
event.preventDefault()
|
||||
step(!event.shiftKey)
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [step])
|
||||
|
||||
const typeLabels = useMemo(
|
||||
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
|
||||
[nodeTypeInfo],
|
||||
@@ -418,6 +555,47 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
[commit, definitions, flowName],
|
||||
)
|
||||
|
||||
/**
|
||||
* A message name is shared, not owned: renaming it where it is published
|
||||
* strands every node still reading the old one. Offer to bring them along
|
||||
* rather than deciding for the user.
|
||||
*/
|
||||
const proposeRename = useCallback(
|
||||
(from: string, to: string) => {
|
||||
if (!from || !to) return
|
||||
const message = qualify(flowName, from)
|
||||
const bound = definitions.filter((node) =>
|
||||
[...(node.requires ?? []), ...(node.provides ?? [])].some(
|
||||
(spec) => qualify(flowName, spec.name ?? "") === message,
|
||||
),
|
||||
)
|
||||
if (bound.length) setRenamed({ from, to, count: bound.length })
|
||||
},
|
||||
[definitions, flowName],
|
||||
)
|
||||
|
||||
/** Carry a rename to everything bound to the old name, as one edit. */
|
||||
const applyRename = useCallback(
|
||||
({ from, to }: MessageRename) => {
|
||||
const message = qualify(flowName, from)
|
||||
const follow = (specs: MessageSpec[] | undefined) =>
|
||||
(specs ?? []).map((spec) =>
|
||||
qualify(flowName, spec.name ?? "") === message
|
||||
? { ...spec, name: to, port: "" }
|
||||
: spec,
|
||||
)
|
||||
commit(
|
||||
definitions.map((node) => ({
|
||||
...node,
|
||||
requires: follow(node.requires),
|
||||
provides: follow(node.provides),
|
||||
})),
|
||||
)
|
||||
setRenamed(null)
|
||||
},
|
||||
[commit, definitions, flowName],
|
||||
)
|
||||
|
||||
const focusNode = useCallback(
|
||||
(qualifiedId: string) => {
|
||||
const id = qualifiedId.startsWith(`${flowName}.`)
|
||||
@@ -479,7 +657,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
|
||||
<CanvasBackground />
|
||||
</ReactFlow>
|
||||
|
||||
{editorExpanded ? null : (
|
||||
@@ -487,6 +665,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
onEditFlow={() => {
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -495,10 +677,6 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onEditFlow={() => {
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
onRun={() => {
|
||||
flush()
|
||||
runMutation.mutate()
|
||||
@@ -547,6 +725,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
expanded={editorExpanded}
|
||||
onToggleExpand={() => setEditorExpanded((wide) => !wide)}
|
||||
onChange={updateNode}
|
||||
onRenameMessage={proposeRename}
|
||||
onSaveSource={(code) => {
|
||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||
}}
|
||||
@@ -618,6 +797,33 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(renamed)}
|
||||
onOpenChange={(open) => !open && setRenamed(null)}
|
||||
>
|
||||
<DialogContent data-testid="rename-message">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename this message everywhere?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{renamed?.count === 1
|
||||
? "One other node is"
|
||||
: `${renamed?.count} other nodes are`}{" "}
|
||||
still bound to <span className="font-mono">{renamed?.from}</span>.
|
||||
They keep the old name unless they follow it to{" "}
|
||||
<span className="font-mono">{renamed?.to}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button variant="ghost" onClick={() => setRenamed(null)}>
|
||||
Leave them
|
||||
</Button>
|
||||
<Button onClick={() => renamed && applyRename(renamed)}>
|
||||
Rename everywhere
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -634,14 +840,7 @@ function mergeDragged(
|
||||
|
||||
/** Seed xyflow's own node state once; it owns positions while you drag. */
|
||||
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
||||
return useNodesState<FlowCanvasNode>(
|
||||
definitions.map((node) => ({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
|
||||
data: {},
|
||||
})),
|
||||
)
|
||||
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
||||
}
|
||||
|
||||
export function FlowEditor({ flowName }: { flowName: string }) {
|
||||
|
||||
Reference in New Issue
Block a user