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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user