shortcuts

This commit is contained in:
2026-03-09 16:22:22 +01:00
parent 40379161e0
commit c596fc70eb
5 changed files with 212 additions and 3 deletions

View File

@@ -33,6 +33,7 @@ import {
ContextMenuGroup ContextMenuGroup
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { AppMenubar } from '@/components/AppMenubar' import { AppMenubar } from '@/components/AppMenubar'
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts'
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -147,6 +148,7 @@ export default function App() {
const wrapperRef = React.useRef<HTMLDivElement | null>(null) const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
const flowActionsRef = React.useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null) const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null) const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null) const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
@@ -317,6 +319,7 @@ export default function App() {
connectionFrom, connectionFrom,
setConnectionFrom, setConnectionFrom,
isValidConnection, isValidConnection,
flowActionsRef,
}), }),
[ [
nodes, nodes,
@@ -328,6 +331,7 @@ export default function App() {
connectionFrom, connectionFrom,
setConnectionFrom, setConnectionFrom,
isValidConnection, isValidConnection,
flowActionsRef,
] ]
) )
@@ -483,6 +487,7 @@ export default function App() {
redo={redo} redo={redo}
canUndo={canUndo} canUndo={canUndo}
canRedo={canRedo} canRedo={canRedo}
onFitView={() => flowActionsRef.current?.fitView?.()}
/> />
{projectMessage && ( {projectMessage && (
<div <div
@@ -536,6 +541,7 @@ export default function App() {
)} )}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView> <ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad /> <FlowFitViewOnLoad />
<FlowKeyboardShortcuts />
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}

View File

@@ -4,6 +4,7 @@ import {
MenubarContent, MenubarContent,
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator,
MenubarTrigger, MenubarTrigger,
MenubarSub, MenubarSub,
MenubarSubTrigger, MenubarSubTrigger,
@@ -21,6 +22,7 @@ type AppMenubarProps = {
redo: () => void redo: () => void
canUndo: boolean canUndo: boolean
canRedo: boolean canRedo: boolean
onFitView?: () => void
} }
const UNDO_KEYS = { key: 'z', shiftKey: false } const UNDO_KEYS = { key: 'z', shiftKey: false }
@@ -35,7 +37,7 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
) )
} }
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: AppMenubarProps) { export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo, onFitView }: AppMenubarProps) {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
useEffect(() => { useEffect(() => {
@@ -102,6 +104,17 @@ export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }:
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="font-medium">View</MenubarTrigger> <MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarContent> <MenubarContent>
{onFitView && (
<MenubarItem onClick={onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{onFitView && <MenubarSeparator />}
<MenubarSub> <MenubarSub>
<MenubarSubTrigger>Theme</MenubarSubTrigger> <MenubarSubTrigger>Theme</MenubarSubTrigger>
<MenubarSubContent> <MenubarSubContent>

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useContext, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react'
import type { Node } from '@xyflow/react'
import FlowContext from '@/lib/flowContext'
import { getNextNodeId, getDefaultDataForType } from '@/lib/flowUtils'
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/nodeRegistry'
const DUPLICATE_OFFSET = { x: 30, y: 30 }
function isMod(ev: KeyboardEvent) {
return ev.ctrlKey || ev.metaKey
}
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
export function FlowKeyboardShortcuts() {
const { fitView, screenToFlowPosition } = useReactFlow()
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setConnectionFrom = ctx?.setConnectionFrom
const flowActionsRef = ctx?.flowActionsRef
const pasteAtViewportCenter = useCallback(async () => {
if (!setNodes || !screenToFlowPosition) return
try {
const text = await navigator.clipboard?.readText()
if (!text) return
const raw = JSON.parse(text) as {
id?: string
type?: string
data?: Record<string, unknown>
position?: { x: number; y: number }
style?: unknown
}
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
const pane = document.querySelector('.react-flow__viewport')
const rect = pane?.getBoundingClientRect()
const center = rect
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
const position = screenToFlowPosition(center)
setNodes((nds: Node[]) => {
const newId = getNextNodeId(raw.type, nds.map((n) => n.id))
const data: Record<string, unknown> =
raw.data != null && typeof raw.data === 'object'
? { ...raw.data }
: (getDefaultDataForType(raw.type, newId) as Record<string, unknown>)
if (raw.type === 'config') data.title = `config-${newId}`
const style = getDefaultStyle(raw.type)
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
} catch {
// Invalid clipboard or not a copied node
}
}, [setNodes, screenToFlowPosition])
const doFitView = useCallback(() => {
fitView?.({ duration: 200 })
}, [fitView])
useEffect(() => {
if (flowActionsRef) {
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
return () => {
flowActionsRef.current = null
}
}
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
setConnectionFrom?.(null)
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
ev.preventDefault()
return
}
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length === 1) {
const node = selectedNodes[0] as Node & { selected?: boolean }
const copy = {
id: node.id,
type: node.type,
data: node.data,
position: node.position,
style: node.style,
}
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => {})
ev.preventDefault()
}
return
}
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
pasteAtViewportCenter()
ev.preventDefault()
return
}
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length > 0 && setNodes) {
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id)
const toAdd: Node[] = []
for (const node of selectedNodes) {
const n = node as Node & { selected?: boolean }
const pos = n.position ?? { x: 0, y: 0 }
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
existingIds.push(newId)
const newNode: Node = {
id: newId,
type: n.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
style: getDefaultStyle(String(n.type)),
}
if (
newNode.data &&
typeof newNode.data === 'object' &&
'title' in newNode.data &&
String(n.type) === 'config'
) {
;(newNode.data as Record<string, unknown>).title = `${newId}`
}
toAdd.push(newNode)
}
return nds.concat(toAdd)
})
ev.preventDefault()
}
return
}
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
doFitView()
ev.preventDefault()
return
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [
nodes,
setNodes,
setConnectionFrom,
pasteAtViewportCenter,
doFitView,
])
return null
}

View File

@@ -8,11 +8,13 @@ import {
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator, MenubarSeparator,
MenubarShortcut,
MenubarSub, MenubarSub,
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
MenubarTrigger, MenubarTrigger,
} from '../ui/menubar' } from '../ui/menubar'
import { Kbd } from '../ui/kbd'
const DUPLICATE_OFFSET = { x: 30, y: 30 } const DUPLICATE_OFFSET = { x: 30, y: 30 }
@@ -87,24 +89,47 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
ctx?.setRenamingNodeId?.(nodeId) ctx?.setRenamingNodeId?.(nodeId)
}, [nodeId, ctx]) }, [nodeId, ctx])
const onPaste = useCallback(() => {
ctx?.flowActionsRef?.current?.pasteAtViewportCenter?.()
}, [ctx])
const onFitView = useCallback(() => {
ctx?.flowActionsRef?.current?.fitView?.()
}, [ctx])
return ( return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground"> <Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs"> <MenubarTrigger className="px-1.5 py-0 text-xs">
Node Node
</MenubarTrigger> </MenubarTrigger>
<MenubarContent className="min-w-[10rem]"> <MenubarContent className="min-w-[12rem]">
{/* Edit */}
<MenubarItem className="text-xs" onClick={onDuplicate}> <MenubarItem className="text-xs" onClick={onDuplicate}>
Duplicate Duplicate
<MenubarShortcut className="ml-auto pl-4"><Kbd>D</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem className="text-xs" onClick={onCopy}> <MenubarItem className="text-xs" onClick={onCopy}>
Copy Copy
<MenubarShortcut className="ml-auto pl-4"><Kbd>C</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem className="text-xs" onClick={onPaste}>
Paste
<MenubarShortcut className="ml-auto pl-4"><Kbd>V</Kbd></MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
{/* View */}
<MenubarItem className="text-xs" onClick={onFitView}>
Fit View
<MenubarShortcut className="ml-auto pl-4"><Kbd>0</Kbd></MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
{/* Node */}
<MenubarItem className="text-xs" onClick={onRename}> <MenubarItem className="text-xs" onClick={onRename}>
Rename Rename
</MenubarItem> </MenubarItem>
<MenubarItem className="text-xs" onClick={onReset}> <MenubarItem className="text-xs" onClick={onReset}>
Reset Clear
</MenubarItem> </MenubarItem>
{nodeMenuExtraContent} {nodeMenuExtraContent}
<MenubarSeparator /> <MenubarSeparator />

View File

@@ -4,6 +4,11 @@ import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type FlowActions = {
pasteAtViewportCenter: () => void
fitView: () => void
}
export type FlowContextValue = { export type FlowContextValue = {
nodes: AppNode[] nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
@@ -15,6 +20,8 @@ export type FlowContextValue = {
connectionFrom: ConnectionFrom connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean isValidConnection: (connection: Connection) => boolean
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
flowActionsRef: React.MutableRefObject<FlowActions | null>
} }
const FlowContext = React.createContext<FlowContextValue | null>(null) const FlowContext = React.createContext<FlowContextValue | null>(null)