import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { useKeyboardShortcuts } from './useKeyboardShortcuts'; import { getClipboardTree, setClipboardTree } from './clipboard'; import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; import { SELECTION_STATE } from '../editor/selection-fixture'; import { registerSelectionCommands } from '../editor/selection-commands'; import { __resetCommandsForTests, registerCommand } from '../editor/commands'; import { DeviceProvider, useDevice } from '../state/DeviceContext'; import { ShortcutsModalHost } from '../panels/topbar/ShortcutsModal'; /** * Keyboard layer on the REAL editor: every key goes through the one registry * dispatcher (useCommandShortcuts), nothing is mocked in @craftjs/core. * Fixture: ROOT > sec > sec-in > cols > col-0 (b1, b2) / col-1, plus sec2. */ let h: EditorHarness; let device = 'desktop'; const Probe: React.FC = () => { useKeyboardShortcuts(); device = useDevice().device; return null; }; beforeEach(() => { __resetCommandsForTests(); setClipboardTree(null); device = 'desktop'; h = renderEditorHarness({ initialState: SELECTION_STATE, wrap: (tree) => {tree}, }); registerSelectionCommands(); h.mountChild(<>); }); afterEach(() => { h.unmount(); __resetCommandsForTests(); setClipboardTree(null); vi.restoreAllMocks(); (document.activeElement as HTMLElement | null)?.blur?.(); }); const sel = () => h.query.getEvent('selected').all() as string[]; const kids = (id: string) => h.query.node(id).get().data.nodes as string[]; const key = (k: string, init: KeyboardEventInit = {}) => { const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }); h.act(() => { document.dispatchEvent(ev); }); return ev; }; const mod = { ctrlKey: true }; function withFocus(el: T, fn: () => void) { document.body.appendChild(el); el.focus(); try { fn(); } finally { el.remove(); } } /** jsdom never reports isContentEditable, so stub the active element. */ function withEditable(fn: () => void) { const spy = vi.spyOn(document, 'activeElement', 'get') .mockReturnValue({ tagName: 'H1', isContentEditable: true, closest: () => null } as unknown as Element); try { fn(); } finally { spy.mockRestore(); } } describe('single dispatcher', () => { test('mounting the shell hooks adds exactly one document keydown listener', () => { h.unmount(); __resetCommandsForTests(); const spy = vi.spyOn(document, 'addEventListener'); h = renderEditorHarness({ initialState: SELECTION_STATE }); h.mountChild(); expect(spy.mock.calls.filter(([t]) => t === 'keydown')).toHaveLength(1); }); }); describe('duplicate (mod+d)', () => { test('inserts the copy right AFTER the source and selects it', () => { h.act(() => h.actions.selectNode('b1')); const ev = key('d', mod); expect(ev.defaultPrevented).toBe(true); const order = kids('col-0'); expect(order).toHaveLength(3); expect(order[0]).toBe('b1'); expect(order[2]).toBe('b2'); expect(sel()).toEqual([order[1]]); }); }); describe('move among siblings (alt+arrow)', () => { test('alt+arrowdown moves b1 below b2; alt+arrowup moves it back', () => { h.act(() => h.actions.selectNode('b1')); key('ArrowDown', { altKey: true }); expect(kids('col-0')).toEqual(['b2', 'b1']); key('ArrowUp', { altKey: true }); expect(kids('col-0')).toEqual(['b1', 'b2']); }); test('at the edge it is a no-op', () => { h.act(() => h.actions.selectNode('b1')); key('ArrowUp', { altKey: true }); expect(kids('col-0')).toEqual(['b1', 'b2']); }); }); describe('device keys 1/2/3', () => { test('switch the shared device', () => { key('2'); expect(device).toBe('tablet'); key('3'); expect(device).toBe('mobile'); key('1'); expect(device).toBe('desktop'); }); test('are ignored while typing in an input', () => { withFocus(document.createElement('input'), () => { key('3'); expect(device).toBe('desktop'); }); }); test('are ignored while a contentEditable heading is focused', () => { withEditable(() => { key('3'); expect(device).toBe('desktop'); }); }); test('are not hijacked by modifier combos', () => { key('3', mod); expect(device).toBe('desktop'); }); // #39 follow-up: bare keys never fire while any dialog/modal is open. test.each([ ['an aria-modal dialog', { role: 'dialog', 'aria-modal': 'true' }], ['a non-modal role=dialog (e.g. a popover)', { role: 'dialog' }], ['an alertdialog', { role: 'alertdialog' }], ['a Modal backdrop without a role', { class: 'modal-backdrop' }], ])('are ignored while %s is open, even with focus on the body', (_label, attrs) => { const d = document.createElement('div'); for (const [k, v] of Object.entries(attrs)) d.setAttribute(k, v); document.body.appendChild(d); try { key('3'); expect(device).toBe('desktop'); } finally { d.remove(); } key('3'); expect(device).toBe('mobile'); }); test('are ignored while focus is inside a dialog', () => { const d = document.createElement('div'); d.setAttribute('role', 'dialog'); const btn = document.createElement('button'); d.appendChild(btn); document.body.appendChild(d); btn.focus(); try { key('2'); expect(device).toBe('desktop'); } finally { d.remove(); } }); test('a role=dialog inside the canvas frame (page content) does not block them', () => { const frame = document.createElement('div'); frame.className = 'canvas-device-frame'; const d = document.createElement('div'); d.setAttribute('role', 'dialog'); d.setAttribute('aria-modal', 'true'); frame.appendChild(d); document.body.appendChild(frame); try { key('2'); expect(device).toBe('tablet'); } finally { frame.remove(); } }); }); describe('site commands (mod+s)', () => { test('runs the registered site.save and prevents the browser Save dialog', () => { const run = vi.fn(); registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run }); const ev = key('s', mod); expect(run).toHaveBeenCalledTimes(1); expect(ev.defaultPrevented).toBe(true); }); test('still fires with a text field focused', () => { const run = vi.fn(); registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run }); withFocus(document.createElement('input'), () => { key('s', mod); }); expect(run).toHaveBeenCalledTimes(1); }); }); describe('cheat sheet (mod+/)', () => { const dialog = () => document.body.querySelector('[role="dialog"]'); test('mod+/ opens it, listing the live commands; Escape closes it', () => { expect(dialog()).toBeNull(); key('/', mod); expect(dialog()).not.toBeNull(); expect(dialog()!.textContent).toContain('Keyboard shortcuts'); expect(dialog()!.textContent).toContain('Duplicate'); key('Escape'); expect(dialog()).toBeNull(); }); test('mod+shift+? opens it too', () => { key('?', { ...mod, shiftKey: true }); expect(dialog()).not.toBeNull(); }); test('Esc closing the dialog does not also climb the canvas selection', () => { h.act(() => h.actions.selectNode('b1')); key('/', mod); key('Escape'); expect(sel()).toEqual(['b1']); }); }); describe('copy / paste (mod+c / mod+v)', () => { test('copy stores a detached tree snapshot of the selection', () => { h.act(() => h.actions.selectNode('b1')); key('c', mod); const clip = getClipboardTree(); expect(clip!.rootNodeId).toBe('b1'); }); test('paste lands right after the selected node with FRESH ids, and selects it', () => { h.act(() => h.actions.selectNode('b1')); key('c', mod); key('v', mod); const order = kids('col-0'); expect(order).toHaveLength(3); expect(order[0]).toBe('b1'); expect(order[1]).not.toBe('b1'); expect(new Set(order).size).toBe(3); expect(sel()).toEqual([order[1]]); }); test('paste with nothing selected appends to ROOT', () => { h.act(() => h.actions.selectNode('b1')); key('c', mod); h.act(() => h.actions.clearEvents()); key('v', mod); const rootKids = kids('ROOT'); expect(rootKids).toHaveLength(3); expect(rootKids.slice(0, 2)).toEqual(['sec', 'sec2']); }); test('paste with an empty clipboard is a no-op and not prevented', () => { h.act(() => h.actions.selectNode('b1')); const ev = key('v', mod); expect(kids('col-0')).toEqual(['b1', 'b2']); expect(ev.defaultPrevented).toBe(false); }); test('ignored while focus is in an input', () => { h.act(() => h.actions.selectNode('b1')); withFocus(document.createElement('input'), () => { key('c', mod); }); expect(getClipboardTree()).toBeNull(); }); test('ignored while a contentEditable is focused', () => { h.act(() => h.actions.selectNode('b1')); withEditable(() => { key('c', mod); }); expect(getClipboardTree()).toBeNull(); }); }); describe('delete / undo / redo', () => { test('Delete removes the selection; mod+z restores; mod+y redoes', () => { h.act(() => h.actions.selectNode('b1')); key('Delete'); expect(kids('col-0')).toEqual(['b2']); key('z', mod); expect(kids('col-0')).toEqual(['b1', 'b2']); key('y', mod); expect(kids('col-0')).toEqual(['b2']); key('z', mod); key('z', { ...mod, shiftKey: true }); expect(kids('col-0')).toEqual(['b2']); }); test('Backspace in an input does not delete the node', () => { h.act(() => h.actions.selectNode('b1')); withFocus(document.createElement('input'), () => { key('Backspace'); }); expect(kids('col-0')).toEqual(['b1', 'b2']); }); }); describe('selection navigation', () => { test('Esc climbs button -> column -> columns -> section, then clears; never ROOT', () => { h.act(() => h.actions.selectNode('b1')); const seen: string[][] = []; for (let i = 0; i < 4; i++) { key('Escape'); seen.push(sel()); } expect(seen).toEqual([['col-0'], ['cols'], ['sec'], []]); key('Escape'); expect(sel()).not.toContain('ROOT'); }); test('Tab cycles siblings and wraps; Shift+Tab reverses; default prevented', () => { h.act(() => h.actions.selectNode('b1')); expect(key('Tab').defaultPrevented).toBe(true); expect(sel()).toEqual(['b2']); key('Tab'); expect(sel()).toEqual(['b1']); key('Tab', { shiftKey: true }); expect(sel()).toEqual(['b2']); }); test('Tab is not intercepted with an focused', () => { h.act(() => h.actions.selectNode('b1')); withFocus(document.createElement('input'), () => { expect(key('Tab').defaultPrevented).toBe(false); }); expect(sel()).toEqual(['b1']); }); test('Tab is not intercepted while a