shortcuts
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
158
src/components/FlowKeyboardShortcuts.tsx
Normal file
158
src/components/FlowKeyboardShortcuts.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user