fix(selection): Esc clears a multi-selection; context menu acts on all selected

Esc was only claimed by select.parent for a single node, so a multi-selection
could not be dropped from the keyboard. Add select.clear (Esc, >1 selected)
through the existing dispatcher, which already skips text fields and open
dialogs. The context menu's Duplicate/Delete now run the multi-selection core
commands (edit.duplicate / edit.delete) when the clicked node is part of a
multi-selection, instead of acting on the first node only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:41:22 -07:00
1 parent 84bc626ed0
commit 472e5e4d81
4 files changed
+110 -7

No files matched your search

+41 -2
View File
@@ -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(<Shortcuts />);
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(<Shortcuts />);
run(() => h.actions.selectNode(['b1', 'b2']));
document.body.appendChild(input);
input.focus();
esc(input);
expect(sel()).toEqual(['b1', 'b2']);
} finally {
off();
input.remove();
}
});
});
+10
View File
@@ -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',
@@ -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();
}
});
});
+19 -5
View File
@@ -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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
};
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;