diff --git a/frontend/src/app/canvas/CanvasMenubar.tsx b/frontend/src/app/canvas/CanvasMenubar.tsx index 3d8ef4e..1b3edea 100644 --- a/frontend/src/app/canvas/CanvasMenubar.tsx +++ b/frontend/src/app/canvas/CanvasMenubar.tsx @@ -1,8 +1,8 @@ /** - * Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo), View (Fit View, Theme). + * Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Theme). */ -import React, { useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { Menubar, @@ -19,7 +19,8 @@ import { import { Kbd, KbdGroup } from '@/components/ui/kbd' import { useTheme } from '@/lib/themeContext' import { usePlatform } from '@/app/platform/platformContext' -import { ArrowLeft, Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react' +import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Moon, Pencil, Redo2, Sun, Undo2 } from 'lucide-react' +import { Input } from '@/components/ui/input' export type CanvasMenubarProps = { onImport: () => void @@ -28,6 +29,11 @@ export type CanvasMenubarProps = { redo: () => void canUndo: boolean canRedo: boolean + onDuplicate?: () => void + onCopy?: () => void + onPaste?: () => void + canDuplicate?: boolean + canCopy?: boolean onFitView?: () => void } @@ -46,16 +52,58 @@ export function CanvasMenubar({ redo, canUndo, canRedo, + onDuplicate, + onCopy, + onPaste, + canDuplicate = false, + canCopy = false, onFitView, }: CanvasMenubarProps) { const { theme, setTheme } = useTheme() const { projectId } = useParams<{ projectId: string }>() - const { projects } = usePlatform() + const { projects, renameProject } = usePlatform() const projectName = useMemo( () => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null), [projectId, projects] ) + const [isRenamingProject, setIsRenamingProject] = useState(false) + const [renameValue, setRenameValue] = useState('') + const renameInputRef = useRef(null) + const ignoreNextBlurRef = useRef(false) + + useEffect(() => { + if (isRenamingProject) { + setRenameValue(projectName ?? '') + ignoreNextBlurRef.current = true + // Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur) + const t = setTimeout(() => { + renameInputRef.current?.focus() + renameInputRef.current?.select() + }, 100) + return () => clearTimeout(t) + } + }, [isRenamingProject, projectName]) + + const applyRename = useCallback(() => { + if (!projectId || !renameProject) return + const trimmed = renameValue.trim() + if (trimmed) renameProject(projectId, trimmed) + setIsRenamingProject(false) + }, [projectId, renameProject, renameValue]) + + const cancelRename = useCallback(() => { + setIsRenamingProject(false) + }, []) + + const handleRenameBlur = useCallback(() => { + if (ignoreNextBlurRef.current) { + ignoreNextBlurRef.current = false + return + } + applyRename() + }, [applyRename]) + useEffect(() => { const onKeyDown = (ev: KeyboardEvent) => { if (matchKey(ev, UNDO_KEYS)) { @@ -99,6 +147,18 @@ export function CanvasMenubar({ Export… + {projectId && ( + <> + + setIsRenamingProject(true)} + className="gap-2" + > + + Rename + + + )} @@ -122,6 +182,40 @@ export function CanvasMenubar({ + {(onDuplicate != null || onCopy != null || onPaste != null) && } + {onDuplicate != null && ( + + + Duplicate + + + ⌘D + + + + )} + {onCopy != null && ( + + + Copy + + + ⌘C + + + + )} + {onPaste != null && ( + + + Paste + + + ⌘V + + + + )} @@ -162,10 +256,33 @@ export function CanvasMenubar({ - {projectName && ( - - {projectName} - + {projectId && ( +
+ {isRenamingProject ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + applyRename() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelRename() + } + }} + onBlur={handleRenameBlur} + className="h-7 text-sm font-medium text-center" + aria-label="Project name" + /> + ) : ( + + {projectName ?? 'Untitled'} + + )} +
)} ) diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 0f67438..378dbda 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -68,6 +68,7 @@ import { } from '@/app/platform/projectGraphStorage' const SNAP_GRID: [number, number] = [15, 15] +const DUPLICATE_OFFSET = { x: 30, y: 30 } const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], @@ -333,6 +334,59 @@ export function CanvasPage({ projectId }: CanvasPageProps) { [setStateImmediate] ) + const selectedNodes = useMemo( + () => nodes.filter((n) => (n as Node & { selected?: boolean }).selected), + [nodes] + ) + + const handleDuplicate = useCallback(() => { + if (selectedNodes.length === 0 || !setNodes) return + 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) + }) + }, [selectedNodes, setNodes]) + + const handleCopy = useCallback(() => { + if (selectedNodes.length !== 1) return + 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(() => {}) + }, [selectedNodes]) + + const handlePaste = useCallback(() => { + flowActionsRef.current?.pasteAtViewportCenter?.() + }, []) + const flowContextValue = useMemo( () => ({ nodes, @@ -498,6 +552,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) { redo={redo} canUndo={canUndo} canRedo={canRedo} + onDuplicate={handleDuplicate} + onCopy={handleCopy} + onPaste={handlePaste} + canDuplicate={selectedNodes.length > 0} + canCopy={selectedNodes.length === 1} onFitView={() => flowActionsRef.current?.fitView?.()} /> {apiTodosCount !== null && ( diff --git a/frontend/src/components/base/NodeMenubar.tsx b/frontend/src/components/base/NodeMenubar.tsx index 2ed754e..7aa00cd 100644 --- a/frontend/src/components/base/NodeMenubar.tsx +++ b/frontend/src/components/base/NodeMenubar.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useContext } from 'react' import FlowContext from '../../lib/flowContext' -import { getNextNodeId, getResetDataForType } from '../../lib/flowUtils' -import { getDefaultStyle } from '../../lib/nodeRegistry' import { Menubar, MenubarContent, @@ -15,9 +13,6 @@ import { MenubarTrigger, } from '../ui/menubar' import { Kbd } from '../ui/kbd' - -const DUPLICATE_OFFSET = { x: 30, y: 30 } - type Props = { nodeId: string nodeType: string @@ -50,39 +45,6 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon const hasEdit = nodeType === 'config' || nodeType === 'function' const hasConnectedNodes = edges.some((e: any) => e.target === nodeId) - const onDuplicate = useCallback(() => { - if (!setNodes || !node) return - const pos = node.position ?? { x: 0, y: 0 } - setNodes((nds: any[]) => { - const newId = getNextNodeId(nodeType, nds.map((n: any) => n.id)) - const newNode = { - id: newId, - type: node.type, - position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y }, - data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data, - style: getDefaultStyle(nodeType), - } - if (nodeType === 'config' && newNode.data && typeof newNode.data === 'object' && 'title' in newNode.data) { - (newNode.data as { title: string }).title = `${newId}` - } - return nds.concat(newNode) - }) - }, [node, nodeType, setNodes]) - - const onCopy = useCallback(() => { - if (!node) return - const copy = { id: node.id, type: node.type, data: node.data, position: node.position, style: node.style } - navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { }) - }, [node]) - - const onReset = useCallback(() => { - if (!setNodes) return - const resetData = getResetDataForType(nodeType, nodeId) - setNodes((nds: any[]) => - nds.map((n) => (n.id === nodeId ? { ...n, data: resetData } : n)) - ) - }, [nodeId, nodeType, setNodes]) - const onDelete = useCallback(() => { if (!setNodes || !setEdges) return setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId)) @@ -93,14 +55,6 @@ 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 ( @@ -108,37 +62,14 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon Node - {/* Edit */} - - Duplicate - ⌘D - - - Copy - ⌘C - - - Paste - ⌘V - - - {/* View */} - - Fit View - ⌘0 - - - {/* Node */} Rename - - Clear - {nodeMenuExtraContent} Delete +