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;