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
65 lines
2.1 KiB
TypeScript
65 lines
2.1 KiB
TypeScript
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)
|
|
}, [])
|
|
}
|