From c596fc70eb234b59b4e054c19a260bdc4b16d416 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 9 Mar 2026 16:22:22 +0100 Subject: [PATCH] shortcuts --- src/App.tsx | 6 + src/components/AppMenubar.tsx | 15 ++- src/components/FlowKeyboardShortcuts.tsx | 158 +++++++++++++++++++++++ src/components/base/NodeMenubar.tsx | 29 ++++- src/lib/flowContext.tsx | 7 + 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/components/FlowKeyboardShortcuts.tsx diff --git a/src/App.tsx b/src/App.tsx index 0506712..c7c0157 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { ContextMenuGroup } from "@/components/ui/context-menu" import { AppMenubar } from '@/components/AppMenubar' +import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts' import { Empty, EmptyContent, @@ -147,6 +148,7 @@ export default function App() { const wrapperRef = React.useRef(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) const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState(null) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) @@ -317,6 +319,7 @@ export default function App() { connectionFrom, setConnectionFrom, isValidConnection, + flowActionsRef, }), [ nodes, @@ -328,6 +331,7 @@ export default function App() { connectionFrom, setConnectionFrom, isValidConnection, + flowActionsRef, ] ) @@ -483,6 +487,7 @@ export default function App() { redo={redo} canUndo={canUndo} canRedo={canRedo} + onFitView={() => flowActionsRef.current?.fitView?.()} /> {projectMessage && (
+ void canUndo: boolean canRedo: boolean + onFitView?: () => void } 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() useEffect(() => { @@ -102,6 +104,17 @@ export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: View + {onFitView && ( + + Fit View + + + ⌘0 + + + + )} + {onFitView && } Theme diff --git a/src/components/FlowKeyboardShortcuts.tsx b/src/components/FlowKeyboardShortcuts.tsx new file mode 100644 index 0000000..66e1296 --- /dev/null +++ b/src/components/FlowKeyboardShortcuts.tsx @@ -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 + 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 = + raw.data != null && typeof raw.data === 'object' + ? { ...raw.data } + : (getDefaultDataForType(raw.type, newId) as Record) + 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).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 +} diff --git a/src/components/base/NodeMenubar.tsx b/src/components/base/NodeMenubar.tsx index 1421d34..d6b4743 100644 --- a/src/components/base/NodeMenubar.tsx +++ b/src/components/base/NodeMenubar.tsx @@ -8,11 +8,13 @@ import { MenubarItem, MenubarMenu, MenubarSeparator, + MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } from '../ui/menubar' +import { Kbd } from '../ui/kbd' const DUPLICATE_OFFSET = { x: 30, y: 30 } @@ -87,24 +89,47 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon ctx?.setRenamingNodeId?.(nodeId) }, [nodeId, ctx]) + const onPaste = useCallback(() => { + ctx?.flowActionsRef?.current?.pasteAtViewportCenter?.() + }, [ctx]) + + const onFitView = useCallback(() => { + ctx?.flowActionsRef?.current?.fitView?.() + }, [ctx]) + return ( Node - + + {/* Edit */} Duplicate + ⌘D Copy + ⌘C + + Paste + ⌘V + + + {/* View */} + + Fit View + ⌘0 + + + {/* Node */} Rename - Reset + Clear {nodeMenuExtraContent} diff --git a/src/lib/flowContext.tsx b/src/lib/flowContext.tsx index 4eaaf15..7afb3c3 100644 --- a/src/lib/flowContext.tsx +++ b/src/lib/flowContext.tsx @@ -4,6 +4,11 @@ import type { AppNode, AppEdge } from '@/lib/nodeTypes' export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null +export type FlowActions = { + pasteAtViewportCenter: () => void + fitView: () => void +} + export type FlowContextValue = { nodes: AppNode[] setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void @@ -15,6 +20,8 @@ export type FlowContextValue = { connectionFrom: ConnectionFrom setConnectionFrom: (v: ConnectionFrom) => void isValidConnection: (connection: Connection) => boolean + /** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */ + flowActionsRef: React.MutableRefObject } const FlowContext = React.createContext(null)