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:
1 parent
84bc626ed0
commit
472e5e4d81
4 files changed
+110
-7
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user