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:
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user