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
@@ -44,14 +44,20 @@ function MessageNameInput({
|
||||
placeholder,
|
||||
autoFocus,
|
||||
onChange,
|
||||
onRenamed,
|
||||
}: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
placeholder: string
|
||||
autoFocus: boolean
|
||||
onChange: (next: string) => void
|
||||
/** The name as it was before this edit, once the field is done with. */
|
||||
onRenamed?: (previous: string, next: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// Every keystroke commits, so a rename is only a rename once the user is
|
||||
// finished with the field.
|
||||
const before = useRef(value)
|
||||
const matches = suggestions.filter(
|
||||
(name) =>
|
||||
name !== value && name.toLowerCase().includes(value.toLowerCase()),
|
||||
@@ -68,8 +74,15 @@ function MessageNameInput({
|
||||
// A port added by hand is meant to be named right away.
|
||||
autoFocus={autoFocus}
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => {
|
||||
before.current = value
|
||||
setOpen(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOpen(false)
|
||||
if (before.current !== value) onRenamed?.(before.current, value)
|
||||
before.current = value
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value)
|
||||
setOpen(true)
|
||||
@@ -122,6 +135,7 @@ function PortList({
|
||||
emptyHint,
|
||||
suggestions,
|
||||
onChange,
|
||||
onRenamed,
|
||||
}: {
|
||||
title: string
|
||||
specs: MessageSpec[]
|
||||
@@ -129,6 +143,7 @@ function PortList({
|
||||
emptyHint: string
|
||||
suggestions: string[]
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
onRenamed?: (previous: string, next: string) => void
|
||||
}) {
|
||||
// The port just added, so its name field can take focus.
|
||||
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
||||
@@ -169,6 +184,7 @@ function PortList({
|
||||
placeholder={`name in ${flow}`}
|
||||
autoFocus={index === freshIndex}
|
||||
onChange={(name) => update(index, { name, port: "" })}
|
||||
onRenamed={onRenamed}
|
||||
/>
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
@@ -212,7 +228,12 @@ function ParamsForm({
|
||||
}) {
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
string,
|
||||
{ type?: string; title?: string; default?: unknown }
|
||||
{
|
||||
type?: string
|
||||
title?: string
|
||||
description?: string
|
||||
default?: unknown
|
||||
}
|
||||
>
|
||||
const entries = Object.entries(properties)
|
||||
if (entries.length === 0) return null
|
||||
@@ -265,6 +286,11 @@ function ParamsForm({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{property.description ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{property.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -279,6 +305,7 @@ function PanelBody({
|
||||
suggestions,
|
||||
expanded,
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
onSaveSource,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
@@ -288,6 +315,7 @@ function PanelBody({
|
||||
suggestions: PortSuggestions
|
||||
expanded: boolean
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onToggleExpand: () => void
|
||||
}) {
|
||||
@@ -341,6 +369,9 @@ function PanelBody({
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
// Only the publishing side names a message; an input is as often
|
||||
// re-pointed at a different one as it is renamed.
|
||||
onRenamed={onRenameMessage}
|
||||
/>
|
||||
<ParamsForm
|
||||
schema={nodeType?.params_schema}
|
||||
@@ -398,6 +429,7 @@ export function NodePanel({
|
||||
suggestions,
|
||||
expanded,
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
onSaveSource,
|
||||
onToggleExpand,
|
||||
onClose,
|
||||
@@ -409,6 +441,7 @@ export function NodePanel({
|
||||
suggestions: PortSuggestions
|
||||
expanded: boolean
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onToggleExpand: () => void
|
||||
onClose: () => void
|
||||
@@ -455,6 +488,7 @@ export function NodePanel({
|
||||
suggestions={suggestions}
|
||||
expanded={expanded}
|
||||
onChange={onChange}
|
||||
onRenameMessage={onRenameMessage}
|
||||
onSaveSource={onSaveSource}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user