Copy nodes, bind the keys, and keep publish within reach

The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.

Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.

A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:43:41 +02:00
co-authored by Claude Fable 5
parent 929bb56662
commit fa12ffd323
9 changed files with 477 additions and 222 deletions
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react"
/**
* Window-level keyboard shortcuts.
*
* A chord reads as `mod+shift+z`, where `mod` is ⌘ on a Mac and Ctrl
* everywhere else. Bindings are plain data, so a view declares the whole set
* it answers to in one place.
*/
export type Shortcuts = Record<string, (event: KeyboardEvent) => void>
/** Text fields and the code editor keep their own bindings and their own undo. */
export function isTextEntry(target: EventTarget | null): boolean {
return Boolean(
(target as Element | null)?.closest?.(
"input, textarea, [contenteditable='true'], .monaco-editor",
),
)
}
/** Focus is inside the embedded code editor. */
export function inCodeEditor(target: EventTarget | null): boolean {
return Boolean((target as Element | null)?.closest?.(".monaco-editor"))
}
function chordOf(event: KeyboardEvent): string {
const parts: string[] = []
if (event.metaKey || event.ctrlKey) parts.push("mod")
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
// Shift makes it a capital Z, so compare on the letter alone.
parts.push(event.key.toLowerCase())
return parts.join("+")
}
/**
* Bind chords for as long as the component is mounted.
*
* A chord listed in `inTextEntry` fires even while a field or the code editor
* has focus; every other one steps aside, because typing there means typing.
*/
export function useShortcuts(bindings: Shortcuts, inTextEntry: string[] = []) {
// Read through a ref, so a fresh handler on every render never re-binds.
const latest = useRef({ bindings, inTextEntry })
latest.current = { bindings, inTextEntry }
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const chord = chordOf(event)
const run = latest.current.bindings[chord]
if (!run) return
if (
isTextEntry(event.target) &&
!latest.current.inTextEntry.includes(chord)
) {
return
}
event.preventDefault()
run(event)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
}