From 6421306849b00b96a000e04b530a975562bf4549 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 15:01:30 -0700 Subject: [PATCH] feat(builder): implement Ctrl+C/V copy-paste shortcuts The context menu advertised Ctrl+C/Ctrl+V hints but useKeyboardShortcuts only handled undo/redo/delete/duplicate/escape -- pressing them did nothing. Add a tiny shared module-level clipboard (src/hooks/clipboard.ts) used by both the keyboard hook and the context menu so copying via one entry point and pasting via the other stay consistent. Ctrl/Cmd+C stores the selected node id (skipping ROOT). Ctrl/Cmd+V inserts a copy as a sibling of the current selection via regenerateTreeIds, mirroring the existing context-menu paste behavior. Both respect the existing "disabled while typing" guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/clipboard.test.ts | 29 ++++++++++++++ craft/src/hooks/clipboard.ts | 23 +++++++++++ craft/src/hooks/useKeyboardShortcuts.ts | 40 +++++++++++++++++++ craft/src/panels/context-menu/ContextMenu.tsx | 8 ++-- 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 craft/src/hooks/clipboard.test.ts create mode 100644 craft/src/hooks/clipboard.ts diff --git a/craft/src/hooks/clipboard.test.ts b/craft/src/hooks/clipboard.test.ts new file mode 100644 index 0000000..03dd7f6 --- /dev/null +++ b/craft/src/hooks/clipboard.test.ts @@ -0,0 +1,29 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; + +describe('clipboard', () => { + afterEach(() => { + setClipboardNodeId(null); + }); + + test('starts empty', () => { + expect(getClipboardNodeId()).toBeNull(); + }); + + test('set then get returns the stored node id', () => { + setClipboardNodeId('node-123'); + expect(getClipboardNodeId()).toBe('node-123'); + }); + + test('is a shared module-level store -- overwriting replaces the previous value', () => { + setClipboardNodeId('first'); + setClipboardNodeId('second'); + expect(getClipboardNodeId()).toBe('second'); + }); + + test('can be cleared back to null', () => { + setClipboardNodeId('node-123'); + setClipboardNodeId(null); + expect(getClipboardNodeId()).toBeNull(); + }); +}); diff --git a/craft/src/hooks/clipboard.ts b/craft/src/hooks/clipboard.ts new file mode 100644 index 0000000..25f7fc3 --- /dev/null +++ b/craft/src/hooks/clipboard.ts @@ -0,0 +1,23 @@ +/** + * Tiny shared clipboard for canvas node copy/paste. + * + * Both the context menu (right-click Copy/Paste) and the keyboard shortcuts + * hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level + * store, so copying a node via one entry point and pasting via the other + * behaves consistently instead of each maintaining its own clipboard. + * + * Deliberately not React state -- nothing in the UI needs to re-render + * reactively when the clipboard changes; consumers just read the current + * value at the moment they need it (on paste, or when a menu opens). + */ +let clipboardNodeId: string | null = null; + +/** Returns the id of the node currently on the clipboard, or null if empty. */ +export function getClipboardNodeId(): string | null { + return clipboardNodeId; +} + +/** Sets (or clears, with `null`) the node id on the clipboard. */ +export function setClipboardNodeId(nodeId: string | null): void { + clipboardNodeId = nodeId; +} diff --git a/craft/src/hooks/useKeyboardShortcuts.ts b/craft/src/hooks/useKeyboardShortcuts.ts index 78a14e9..284a5cf 100644 --- a/craft/src/hooks/useKeyboardShortcuts.ts +++ b/craft/src/hooks/useKeyboardShortcuts.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useEditor } from '@craftjs/core'; import { findDeletableTarget } from '../utils/craft-helpers'; import { regenerateTreeIds } from '../utils/craft-tree'; +import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; function isInputFocused(): boolean { const el = document.activeElement; @@ -85,6 +86,45 @@ export function useKeyboardShortcuts() { return; } + // Ctrl+C: copy selected node id to the shared clipboard + if (ctrl && (e.key === 'c' || e.key === 'C')) { + e.preventDefault(); + try { + const selected = query.getEvent('selected').all(); + if (selected.length > 0 && selected[0] !== 'ROOT') { + setClipboardNodeId(selected[0]); + } + } catch (err) { + console.error('Copy failed:', err); + } + return; + } + + // Ctrl+V: paste the clipboard node as a sibling of the current selection + if (ctrl && (e.key === 'v' || e.key === 'V')) { + e.preventDefault(); + try { + const sourceId = getClipboardNodeId(); + if (!sourceId || !query.node(sourceId).get()) return; + + const selected = query.getEvent('selected').all(); + if (selected.length === 0) return; + const selectedId = selected[0]; + + let targetParent = 'ROOT'; + if (selectedId !== 'ROOT') { + const node = query.node(selectedId).get(); + targetParent = node?.data?.parent || 'ROOT'; + } + + const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); + actions.addNodeTree(tree, targetParent); + } catch (err) { + console.error('Paste failed:', err); + } + return; + } + // Escape: deselect all if (e.key === 'Escape') { e.preventDefault(); diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index ef55d77..b1eb0b8 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -4,6 +4,7 @@ import { findDeletableTarget } from '../../utils/craft-helpers'; import { useSitesmithModal } from '../../state/SitesmithContext'; import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { regenerateTreeIds } from '../../utils/craft-tree'; +import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; interface ContextMenuProps { visible: boolean; @@ -32,7 +33,6 @@ export const ContextMenu: React.FC = ({ const { actions, query } = useEditor(); const { open: openSitesmith } = useSitesmithModal(); const menuRef = useRef(null); - const clipboardRef = useRef(null); // Close on click outside useEffect(() => { @@ -80,7 +80,7 @@ export const ContextMenu: React.FC = ({ const copyNode = useCallback(() => { if (!nodeId || nodeId === 'ROOT') return; try { - clipboardRef.current = nodeId; + setClipboardNodeId(nodeId); } catch (e) { console.error('Copy failed:', e); } @@ -88,7 +88,7 @@ export const ContextMenu: React.FC = ({ }, [nodeId, onClose]); const pasteNode = useCallback(() => { - const sourceId = clipboardRef.current; + const sourceId = getClipboardNodeId(); if (!sourceId) { onClose(); return; @@ -210,7 +210,7 @@ export const ContextMenu: React.FC = ({ label: 'Paste', shortcut: 'Ctrl+V', action: pasteNode, - disabled: !clipboardRef.current, + disabled: !getClipboardNodeId(), dividerAfter: true, }, {