diff --git a/craft/src/editor/selection-commands.test.tsx b/craft/src/editor/selection-commands.test.tsx
index e2249f7..e5e2b2d 100644
--- a/craft/src/editor/selection-commands.test.tsx
+++ b/craft/src/editor/selection-commands.test.tsx
@@ -4,7 +4,8 @@ import { SELECTION_STATE } from './selection-fixture';
import {
selectParent, escapeSelection, selectNextSibling, selectPrevSibling, selectFirstChild, selectionCommands,
} from './selection-commands';
-import { __resetCommandsForTests, getCommand } from './commands';
+import React from 'react';
+import { __resetCommandsForTests, getCommand, registerCommands, useCommandShortcuts } from './commands';
let h: EditorHarness;
beforeEach(() => { h = renderEditorHarness({ initialState: SELECTION_STATE }); });
@@ -79,7 +80,7 @@ describe('command definitions', () => {
expect(byId['select.prev-sibling'].shortcut).toBe('shift+tab');
expect(byId['select.first-child'].shortcut).toBeUndefined();
const ctx = (ids: string[]) => ({ selectedIds: ids, query: h.query }) as any;
- for (const c of selectionCommands) {
+ for (const c of selectionCommands.filter((x) => x.id !== 'select.clear')) {
expect(c.group).toBe('select');
expect(c.when!(ctx(['b1']))).toBe(true);
expect(c.when!(ctx([]))).toBe(false);
@@ -94,3 +95,41 @@ describe('command definitions', () => {
expect(getCommand('select.parent')).toBeUndefined();
});
});
+
+describe('Escape on a multi-selection', () => {
+ const Shortcuts: React.FC = () => { useCommandShortcuts(); return null; };
+ const esc = (target: EventTarget = document.body) =>
+ h.act(() => { target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); });
+
+ test('clears the whole selection (and still climbs for a single node)', () => {
+ const off = registerCommands(selectionCommands);
+ try {
+ h.mountChild();
+ run(() => h.actions.selectNode(['b1', 'b2']));
+ expect(sel()).toEqual(['b1', 'b2']);
+ esc();
+ expect(sel()).toEqual([]);
+ run(() => h.actions.selectNode('b1'));
+ esc();
+ expect(sel()).toEqual(['col-0']);
+ } finally {
+ off();
+ }
+ });
+
+ test('not while typing in a text field', () => {
+ const off = registerCommands(selectionCommands);
+ const input = document.createElement('input');
+ try {
+ h.mountChild();
+ run(() => h.actions.selectNode(['b1', 'b2']));
+ document.body.appendChild(input);
+ input.focus();
+ esc(input);
+ expect(sel()).toEqual(['b1', 'b2']);
+ } finally {
+ off();
+ input.remove();
+ }
+ });
+});
diff --git a/craft/src/editor/selection-commands.ts b/craft/src/editor/selection-commands.ts
index b52d672..854ec71 100644
--- a/craft/src/editor/selection-commands.ts
+++ b/craft/src/editor/selection-commands.ts
@@ -94,6 +94,16 @@ export const selectionCommands: Command[] = [
when: oneNonRootSelected,
run: ({ query, actions, selectedIds }) => escapeSelection(query, actions, selectedIds[0]),
},
+ {
+ // Esc on a multi-selection drops the whole selection (select.parent only
+ // claims Esc for a single node).
+ id: 'select.clear',
+ title: 'Clear selection',
+ group: 'select',
+ shortcut: 'escape',
+ when: (ctx) => ctx.selectedIds.length > 1,
+ run: ({ actions }) => actions.clearEvents(),
+ },
{
id: 'select.next-sibling',
title: 'Select next sibling',
diff --git a/craft/src/panels/context-menu/ContextMenu.test.tsx b/craft/src/panels/context-menu/ContextMenu.test.tsx
index 8932e4f..eab096c 100644
--- a/craft/src/panels/context-menu/ContextMenu.test.tsx
+++ b/craft/src/panels/context-menu/ContextMenu.test.tsx
@@ -11,6 +11,7 @@ import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../..
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard';
import { registerCommand } from '../../editor/commands';
+import { registerCoreCommands } from '../../editor/core-commands';
import { isTopLevelSection } from '../../sections/catalog';
let h: EditorHarness;
@@ -166,3 +167,42 @@ describe('ContextMenu section commands (#52 / #58)', () => {
expect(btn('Regenerate this section').disabled).toBe(true);
});
});
+
+describe('ContextMenu on a multi-selection', () => {
+ const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
+
+ test('Duplicate and Delete act on EVERY selected node, not just the first', () => {
+ const off = registerCoreCommands();
+ try {
+ h = renderEditorHarness();
+ const a = add(React.createElement(Heading, {}));
+ const b = add(React.createElement(Heading, {}));
+ h.act(() => { h.actions.selectNode([a, b]); });
+ mountMenu(a);
+ click('Duplicate');
+ expect(rootKids()).toHaveLength(4);
+ h.act(() => { h.actions.selectNode([a, b]); });
+ click('Delete');
+ expect(rootKids()).not.toContain(a);
+ expect(rootKids()).not.toContain(b);
+ expect(rootKids()).toHaveLength(2);
+ } finally {
+ off();
+ }
+ });
+
+ test('a single selection still duplicates just that node', () => {
+ const off = registerCoreCommands();
+ try {
+ h = renderEditorHarness();
+ const a = add(React.createElement(Heading, {}));
+ add(React.createElement(Heading, {}));
+ h.act(() => { h.actions.selectNode(a); });
+ mountMenu(a);
+ click('Duplicate');
+ expect(rootKids()).toHaveLength(3);
+ } finally {
+ off();
+ }
+ });
+});
diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx
index a5abfed..2d92546 100644
--- a/craft/src/panels/context-menu/ContextMenu.tsx
+++ b/craft/src/panels/context-menu/ContextMenu.tsx
@@ -5,7 +5,7 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
-import { getCommand, useCommandState } from '../../editor/commands';
+import { getCommand, useCommandState, useRunCommand } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useNotices } from '../../state/NoticeContext';
@@ -133,10 +133,23 @@ export const ContextMenu: React.FC = ({
const saveAsSection = useCommandState('site.save-as-section');
const regenerateSection = useCommandState('edit.regenerate-section');
+ // On a multi-selection that includes the clicked node, Duplicate/Delete go
+ // through the registry's multi-selection core commands (same as Cmd+D/Del).
+ const runCommand = useRunCommand();
+ const multiSelected = (): boolean => {
+ try {
+ const ids = query.getEvent('selected').all();
+ return ids.length > 1 && !!nodeId && ids.includes(nodeId) && !!getCommand('edit.duplicate');
+ } catch {
+ return false;
+ }
+ };
+
const duplicate = useCallback(() => {
- nodeActions.duplicate();
+ if (multiSelected()) void runCommand('edit.duplicate');
+ else nodeActions.duplicate();
onClose();
- }, [nodeActions, onClose]);
+ }, [nodeActions, onClose]); // eslint-disable-line react-hooks/exhaustive-deps
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
@@ -298,9 +311,10 @@ export const ContextMenu: React.FC = ({
};
const deleteNode = useCallback(() => {
- nodeActions.deleteNode();
+ if (multiSelected() && getCommand('edit.delete')) void runCommand('edit.delete');
+ else nodeActions.deleteNode();
onClose();
- }, [nodeActions, onClose]);
+ }, [nodeActions, onClose]); // eslint-disable-line react-hooks/exhaustive-deps
if (!visible) return null;