diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1acf369..afca1b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -213,12 +213,8 @@ export default function App() { // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) const redoRef = useRef(redo) - const copyRef = useRef(copySelectedNodes) - const pasteRef = useRef(pasteNodes) useEffect(() => { undoRef.current = undo }, [undo]) useEffect(() => { redoRef.current = redo }, [redo]) - useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes]) - useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes]) // Global keyboard shortcuts useEffect(() => { @@ -232,8 +228,8 @@ export default function App() { if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } - if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return } - if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return } + // Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place + // nodes under the cursor / viewport center. if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } } window.addEventListener('keydown', handler) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index be30641..b789680 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ReactFlow, Background, @@ -40,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o onNodesChange, onEdgesChange, setSelectedNode, snapshotHistory, fitViewPending, clearFitViewPending, + copySelectedNodes, pasteNodes, } = useCanvasStore() - const { fitView } = useReactFlow() + const { fitView, screenToFlowPosition } = useReactFlow() + + // Track the last cursor position over the canvas so paste lands under it. + const cursorRef = useRef<{ x: number; y: number } | null>(null) + const onMouseMove = useCallback((e: React.MouseEvent) => { + cursorRef.current = { x: e.clientX, y: e.clientY } + }, []) + + // Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste + // can project the cursor / viewport center into flow coordinates. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey)) return + const el = e.target as HTMLElement + const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable + if (isInput) return + if (e.key === 'c') { + copySelectedNodes() + } else if (e.key === 'v') { + const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 } + pasteNodes(screenToFlowPosition(screen)) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [copySelectedNodes, pasteNodes, screenToFlowPosition]) // Fit view after canvas loads (fitViewPending is set by loadCanvas) useEffect(() => { @@ -100,7 +126,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() return ( -
+
{ editingTextId: null, past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, }) }) @@ -690,25 +690,112 @@ describe('canvasStore', () => { }) useCanvasStore.getState().copySelectedNodes() const { clipboard } = useCanvasStore.getState() - expect(clipboard).toHaveLength(1) - expect(clipboard[0].id).toBe('a') + expect(clipboard.nodes).toHaveLength(1) + expect(clipboard.nodes[0].id).toBe('a') }) - it('pasteNodes creates new nodes with new IDs and offset position', () => { - const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true } - useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] }) + it('copySelectedNodes captures edges whose endpoints are both selected', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('a'), selected: true }, + { ...makeNode('b'), selected: true }, + { ...makeNode('c'), selected: false }, + ], + edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')], + }) + useCanvasStore.getState().copySelectedNodes() + const { clipboard } = useCanvasStore.getState() + expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b']) + expect(clipboard.edges).toHaveLength(1) + expect(clipboard.edges[0].id).toBe('e-ab') + }) + + it('copySelectedNodes pulls in children of a selected group', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('g', { type: 'group' }), type: 'group', selected: true }, + { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false }, + ], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g']) + }) + + it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => { + const node = { ...makeNode('src'), position: { x: 100, y: 100 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) useCanvasStore.getState().pasteNodes() const { nodes } = useCanvasStore.getState() - expect(nodes).toHaveLength(2) - const pasted = nodes.find((n) => n.id !== 'src')! - expect(pasted).toBeDefined() - expect(pasted.position.x).toBe(150) - expect(pasted.position.y).toBe(150) - expect(pasted.selected).toBe(false) + expect(nodes).toHaveLength(1) + const pasted = nodes[0] + expect(pasted.id).not.toBe('src') + expect(pasted.position).toEqual({ x: 150, y: 150 }) + expect(pasted.selected).toBe(true) + }) + + it('pasteNodes centers the pasted bounding box on the target point', () => { + const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) + useCanvasStore.getState().pasteNodes({ x: 500, y: 300 }) + const pasted = useCanvasStore.getState().nodes[0] + // bbox center (50,50) shifted onto (500,300) → top-left at (450,250) + expect(pasted.position).toEqual({ x: 450, y: 250 }) + }) + + it('pasteNodes remaps edge endpoints to the new node IDs', () => { + const a = { ...makeNode('a') } + const b = { ...makeNode('b') } + useCanvasStore.setState({ + nodes: [], + edges: [], + clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] }, + }) + useCanvasStore.getState().pasteNodes() + const { nodes, edges } = useCanvasStore.getState() + expect(edges).toHaveLength(1) + const ids = nodes.map((n) => n.id) + expect(ids).toContain(edges[0].source) + expect(ids).toContain(edges[0].target) + expect(edges[0].source).not.toBe('a') + expect(edges[0].id).not.toBe('e-ab') + }) + + it('pasteNodes preserves parent-child relationship under remapped IDs', () => { + const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } } + const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } }) + useCanvasStore.getState().pasteNodes() + const { nodes } = useCanvasStore.getState() + const newGroup = nodes.find((n) => n.data.type === 'group')! + const newChild = nodes.find((n) => n.id !== newGroup.id)! + expect(newChild.parentId).toBe(newGroup.id) + expect(newChild.data.parent_id).toBe(newGroup.id) + // Child keeps its parent-relative position (no offset applied to children) + expect(newChild.position).toEqual({ x: 20, y: 30 }) + // Group (the root) precedes its child in the array + expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan( + nodes.findIndex((n) => n.id === newChild.id), + ) + }) + + it('clipboard survives loadCanvas so nodes can be pasted into another design', () => { + useCanvasStore.setState({ + nodes: [{ ...makeNode('a'), selected: true }], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + // Switch to another design: loadCanvas replaces nodes/edges. + useCanvasStore.getState().loadCanvas([makeNode('other')], []) + expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1) + useCanvasStore.getState().pasteNodes() + const ids = useCanvasStore.getState().nodes.map((n) => n.id) + expect(ids).toContain('other') + expect(ids).toHaveLength(2) }) it('pasteNodes does nothing when clipboard is empty', () => { - useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] }) + useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } }) useCanvasStore.getState().pasteNodes() expect(useCanvasStore.getState().nodes).toHaveLength(1) }) @@ -867,7 +954,7 @@ describe('canvasStore — custom style apply', () => { editingTextId: null, past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index ef28809..a58c08a 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -15,6 +15,10 @@ import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } +type Clipboard = { nodes: Node[]; edges: Edge[] } + +/** Resolve a node's effective parent id from either the RF field or domain data. */ +const parentIdOf = (n: Node): string | undefined => n.parentId ?? n.data.parent_id ?? undefined interface CanvasState { nodes: Node[] @@ -31,10 +35,12 @@ interface CanvasState { undo: () => void redo: () => void - // Clipboard - clipboard: Node[] + // Clipboard — survives design switches so nodes can be pasted into another design + clipboard: Clipboard copySelectedNodes: () => void - pasteNodes: () => void + /** Paste clipboard into the current canvas. `center` (flow coords) lands the + * pasted bounding-box center under the cursor / viewport center. */ + pasteNodes: (center?: { x: number; y: number }) => void onNodesChange: (changes: NodeChange>[]) => void onEdgesChange: (changes: EdgeChange>[]) => void @@ -82,7 +88,7 @@ export const useCanvasStore = create((set) => ({ past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, snapshotHistory: () => set((state) => ({ @@ -117,24 +123,100 @@ export const useCanvasStore = create((set) => ({ }), copySelectedNodes: () => - set((state) => ({ - clipboard: state.nodes.filter((n) => n.selected), - })), - - pasteNodes: () => set((state) => { - if (state.clipboard.length === 0) return state - const newNodes = state.clipboard.map((n) => ({ - ...n, + // Start from explicitly selected nodes, then pull in all descendants so a + // copied group / container brings its children along. + const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id)) + if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } } + let grew = true + while (grew) { + grew = false + for (const n of state.nodes) { + const pid = parentIdOf(n) + if (pid && ids.has(pid) && !ids.has(n.id)) { + ids.add(n.id) + grew = true + } + } + } + const nodes = state.nodes.filter((n) => ids.has(n.id)) + // Keep only edges whose both endpoints are inside the copied set. + const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) + return { clipboard: { nodes, edges } } + }), + + pasteNodes: (center) => + set((state) => { + const clip = state.clipboard + if (clip.nodes.length === 0) return state + + // Fresh ids for every copied node; edges/parent links are remapped through it. + const idMap = new Map() + clip.nodes.forEach((n) => idMap.set(n.id, generateUUID())) + + // A "root" is a copied node whose parent was not also copied — these carry + // absolute positions and receive the paste offset; children move with them. + const isRoot = (n: Node) => { + const pid = parentIdOf(n) + return !pid || !idMap.has(pid) + } + const roots = clip.nodes.filter(isRoot) + + // Default cascade offset; when a target center is given, shift the root + // bounding-box center onto it instead. + let offsetX = 50 + let offsetY = 50 + if (center && roots.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const n of roots) { + const w = n.width ?? n.measured?.width ?? 200 + const h = n.height ?? n.measured?.height ?? 80 + minX = Math.min(minX, n.position.x) + minY = Math.min(minY, n.position.y) + maxX = Math.max(maxX, n.position.x + w) + maxY = Math.max(maxY, n.position.y + h) + } + offsetX = center.x - (minX + maxX) / 2 + offsetY = center.y - (minY + maxY) / 2 + } + + const pasted = clip.nodes.map((n) => { + const root = isRoot(n) + const newParentId = root ? undefined : idMap.get(parentIdOf(n)!) + return { + ...n, + id: idMap.get(n.id)!, + position: root + ? { x: n.position.x + offsetX, y: n.position.y + offsetY } + : { ...n.position }, + selected: true, + parentId: newParentId, + extent: newParentId ? ('parent' as const) : undefined, + data: { ...n.data, parent_id: newParentId }, + } + }) + + const pastedEdges = clip.edges.map((e) => ({ + ...e, id: generateUUID(), - position: { x: n.position.x + 50, y: n.position.y + 50 }, + source: idMap.get(e.source)!, + target: idMap.get(e.target)!, selected: false, - parentId: undefined, - extent: undefined, - data: { ...n.data, parent_id: undefined }, })) + + // React Flow requires parents before children within the appended block. + const parents = pasted.filter((n) => !n.parentId) + const children = pasted.filter((n) => !!n.parentId) + const pastedNodes = [...parents, ...children] + + // Deselect everything already on the canvas so only the paste is selected. + const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)) + return { - nodes: [...state.nodes, ...newNodes], + nodes: [...existing, ...pastedNodes], + edges: [...state.edges, ...pastedEdges], + selectedNodeId: null, + selectedNodeIds: pastedNodes.map((n) => n.id), past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], hasUnsavedChanges: true, @@ -503,7 +585,9 @@ export const useCanvasStore = create((set) => ({ // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) - set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true }) + // NOTE: clipboard is intentionally preserved here so nodes copied in one + // design can be pasted after switching to another design. + set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true }) }, clearFitViewPending: () => set({ fitViewPending: false }),