mirror of
https://github.com/codex-team/editor.js
synced 2026-03-18 08:29:52 +01:00
refactor: simplify read-only tests by removing drag-and-drop functionality test: update sanitisation tests to check for safe attributes in HTML test: enhance configuration tests to ensure proper toolbox popover visibility test: adjust keyboard shortcuts tests for inline tool handling and modifier key mapping test: modify toolbox tests to use more specific selectors for paragraph blocks test: improve UI module tests to utilize block selection methods directly chore: remove deprecated drag-and-drop tests and related code fix: update paste module error messages for clarity on configuration issues
1101 lines
37 KiB
TypeScript
1101 lines
37 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@editorjs/caret', () => ({
|
|
focus: vi.fn(),
|
|
}));
|
|
|
|
import { focus } from '@editorjs/caret';
|
|
import BlockEvents from '../../../../src/components/modules/blockEvents';
|
|
import EventsDispatcher from '../../../../src/components/utils/events';
|
|
import type { EditorModules } from '../../../../src/types-internal/editor-modules';
|
|
import type { EditorConfig } from '../../../../types';
|
|
import type { EditorEventMap } from '../../../../src/components/events';
|
|
import type Block from '../../../../src/components/block';
|
|
import SelectionUtils from '../../../../src/components/selection';
|
|
import * as caretUtils from '../../../../src/components/utils/caret';
|
|
import * as blocksUtils from '../../../../src/components/utils/blocks';
|
|
import { keyCodes } from '../../../../src/components/utils';
|
|
|
|
const KEY_CODE_TO_KEY_MAP: Record<number, string> = {
|
|
[keyCodes.BACKSPACE]: 'Backspace',
|
|
[keyCodes.DELETE]: 'Delete',
|
|
[keyCodes.DOWN]: 'ArrowDown',
|
|
[keyCodes.ENTER]: 'Enter',
|
|
[keyCodes.LEFT]: 'ArrowLeft',
|
|
[keyCodes.RIGHT]: 'ArrowRight',
|
|
[keyCodes.SLASH]: '/',
|
|
[keyCodes.TAB]: 'Tab',
|
|
[keyCodes.UP]: 'ArrowUp',
|
|
};
|
|
|
|
const createBlockEvents = (overrides: Partial<EditorModules> = {}): BlockEvents => {
|
|
const blockEvents = new BlockEvents({
|
|
config: {} as EditorConfig,
|
|
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
|
|
});
|
|
|
|
const wrapper = document.createElement('div');
|
|
|
|
const defaults: Partial<EditorModules> = {
|
|
Toolbar: {
|
|
opened: false,
|
|
close: vi.fn(),
|
|
moveAndOpen: vi.fn(),
|
|
toolbox: {
|
|
open: vi.fn(),
|
|
},
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSelection: {
|
|
anyBlockSelected: false,
|
|
clearSelection: vi.fn(),
|
|
copySelectedBlocks: vi.fn(() => Promise.resolve()),
|
|
selectedBlocks: [],
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
InlineToolbar: {
|
|
opened: false,
|
|
tryToShow: vi.fn(async () => undefined),
|
|
} as unknown as EditorModules['InlineToolbar'],
|
|
BlockManager: {
|
|
currentBlock: undefined,
|
|
currentBlockIndex: 0,
|
|
previousBlock: null,
|
|
nextBlock: null,
|
|
getBlockByChildNode: vi.fn(),
|
|
insertDefaultBlockAtIndex: vi.fn(),
|
|
removeBlock: vi.fn(),
|
|
removeSelectedBlocks: vi.fn(),
|
|
split: vi.fn(),
|
|
} as unknown as EditorModules['BlockManager'],
|
|
Caret: {
|
|
navigateNext: vi.fn(() => false),
|
|
navigatePrevious: vi.fn(() => false),
|
|
setToBlock: vi.fn(),
|
|
insertContentAtCaretPosition: vi.fn(),
|
|
positions: {
|
|
START: 'start-position',
|
|
END: 'end-position',
|
|
},
|
|
} as unknown as EditorModules['Caret'],
|
|
UI: {
|
|
nodes: {
|
|
wrapper,
|
|
},
|
|
someToolbarOpened: false,
|
|
someFlipperButtonFocused: false,
|
|
checkEmptiness: vi.fn(),
|
|
closeAllToolbars: vi.fn(),
|
|
} as unknown as EditorModules['UI'],
|
|
BlockSettings: {
|
|
opened: false,
|
|
open: vi.fn(),
|
|
} as unknown as EditorModules['BlockSettings'],
|
|
CrossBlockSelection: {
|
|
toggleBlockSelectedState: vi.fn(),
|
|
} as unknown as EditorModules['CrossBlockSelection'],
|
|
};
|
|
|
|
const mergedState: Partial<EditorModules> = { ...defaults };
|
|
|
|
for (const [moduleName, moduleOverrides] of Object.entries(overrides) as Array<[keyof EditorModules, unknown]>) {
|
|
const defaultModule = defaults[moduleName];
|
|
|
|
if (
|
|
defaultModule !== undefined &&
|
|
defaultModule !== null &&
|
|
typeof defaultModule === 'object' &&
|
|
moduleOverrides !== null &&
|
|
typeof moduleOverrides === 'object'
|
|
) {
|
|
(mergedState as Record<keyof EditorModules, EditorModules[keyof EditorModules]>)[moduleName] = {
|
|
...(defaultModule as object),
|
|
...(moduleOverrides as object),
|
|
} as EditorModules[typeof moduleName];
|
|
} else if (moduleOverrides !== undefined) {
|
|
(mergedState as Record<keyof EditorModules, EditorModules[keyof EditorModules]>)[moduleName] = moduleOverrides as EditorModules[typeof moduleName];
|
|
}
|
|
}
|
|
|
|
blockEvents.state = mergedState as EditorModules;
|
|
|
|
return blockEvents;
|
|
};
|
|
|
|
const createKeyboardEvent = (options: Partial<KeyboardEvent>): KeyboardEvent => {
|
|
let derivedKey: string;
|
|
|
|
if (options.key !== undefined) {
|
|
derivedKey = options.key;
|
|
} else if (options.keyCode !== undefined) {
|
|
derivedKey = KEY_CODE_TO_KEY_MAP[options.keyCode] ?? String.fromCharCode(options.keyCode);
|
|
} else {
|
|
derivedKey = '';
|
|
}
|
|
const derivedCode = options.code !== undefined ? options.code : derivedKey;
|
|
const derivedKeyCode = options.keyCode ?? 0;
|
|
|
|
return {
|
|
keyCode: derivedKeyCode,
|
|
key: derivedKey,
|
|
code: derivedCode,
|
|
ctrlKey: false,
|
|
metaKey: false,
|
|
altKey: false,
|
|
shiftKey: false,
|
|
preventDefault: vi.fn(),
|
|
target: document.createElement('div'),
|
|
...options,
|
|
} as KeyboardEvent;
|
|
};
|
|
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('BlockEvents', () => {
|
|
describe('keyup', () => {
|
|
it('calls UI.checkEmptiness when Shift is not pressed', () => {
|
|
const checkEmptiness = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
UI: {
|
|
checkEmptiness,
|
|
} as unknown as EditorModules['UI'],
|
|
});
|
|
|
|
blockEvents.keyup(createKeyboardEvent({ shiftKey: false }));
|
|
|
|
expect(checkEmptiness).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips UI.checkEmptiness when Shift is pressed', () => {
|
|
const checkEmptiness = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
UI: {
|
|
checkEmptiness,
|
|
} as unknown as EditorModules['UI'],
|
|
});
|
|
|
|
blockEvents.keyup(createKeyboardEvent({ shiftKey: true }));
|
|
|
|
expect(checkEmptiness).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
|
|
describe('handleCommandC', () => {
|
|
it('copies selected blocks when any block is selected', () => {
|
|
const copySelectedBlocks = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: true,
|
|
copySelectedBlocks,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const event = new Event('copy') as ClipboardEvent;
|
|
|
|
blockEvents.handleCommandC(event);
|
|
|
|
expect(copySelectedBlocks).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it('does nothing when no blocks are selected', () => {
|
|
const copySelectedBlocks = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: false,
|
|
copySelectedBlocks,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
|
|
blockEvents.handleCommandC(new Event('copy') as ClipboardEvent);
|
|
|
|
expect(copySelectedBlocks).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handleCommandX', () => {
|
|
it('cuts selected blocks and restores caret position', async () => {
|
|
const copySelectedBlocks = vi.fn().mockResolvedValue(undefined);
|
|
const removeSelectedBlocks = vi.fn().mockReturnValue(3);
|
|
const insertDefaultBlockAtIndex = vi.fn().mockReturnValue({} as Block);
|
|
const clearSelection = vi.fn();
|
|
const setToBlock = vi.fn();
|
|
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: true,
|
|
copySelectedBlocks,
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
BlockManager: {
|
|
removeSelectedBlocks,
|
|
insertDefaultBlockAtIndex,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
Caret: {
|
|
setToBlock,
|
|
positions: {
|
|
START: 'start-position',
|
|
},
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = new Event('cut') as ClipboardEvent;
|
|
|
|
blockEvents.handleCommandX(event);
|
|
await copySelectedBlocks.mock.results[0]!.value;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(copySelectedBlocks).toHaveBeenCalledWith(event);
|
|
expect(removeSelectedBlocks).toHaveBeenCalledTimes(1);
|
|
expect(insertDefaultBlockAtIndex).toHaveBeenCalledWith(3, true);
|
|
expect(setToBlock).toHaveBeenCalledWith(insertDefaultBlockAtIndex.mock.results[0]!.value, 'start-position');
|
|
expect(clearSelection).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it('does nothing when there is no block selection', () => {
|
|
const copySelectedBlocks = vi.fn().mockResolvedValue(undefined);
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: false,
|
|
copySelectedBlocks,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
|
|
blockEvents.handleCommandX(new Event('cut') as ClipboardEvent);
|
|
|
|
expect(copySelectedBlocks).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('keydown', () => {
|
|
const handlers: Array<{ keyCode: number; handler: 'backspace' | 'delete' | 'enter' | 'arrowRightAndDown' | 'arrowLeftAndUp' | 'tabPressed' }> = [
|
|
{ keyCode: keyCodes.BACKSPACE,
|
|
handler: 'backspace' },
|
|
{ keyCode: keyCodes.DELETE,
|
|
handler: 'delete' },
|
|
{ keyCode: keyCodes.ENTER,
|
|
handler: 'enter' },
|
|
{ keyCode: keyCodes.DOWN,
|
|
handler: 'arrowRightAndDown' },
|
|
{ keyCode: keyCodes.RIGHT,
|
|
handler: 'arrowRightAndDown' },
|
|
{ keyCode: keyCodes.UP,
|
|
handler: 'arrowLeftAndUp' },
|
|
{ keyCode: keyCodes.LEFT,
|
|
handler: 'arrowLeftAndUp' },
|
|
{ keyCode: keyCodes.TAB,
|
|
handler: 'tabPressed' },
|
|
];
|
|
|
|
it.each(handlers)('delegates keyCode %s to %s', ({ keyCode, handler }) => {
|
|
const blockEvents = createBlockEvents();
|
|
const spy = vi
|
|
.spyOn(blockEvents as unknown as Record<typeof handler, (event: KeyboardEvent) => void>, handler)
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ keyCode });
|
|
|
|
blockEvents.keydown(event);
|
|
|
|
expect(spy).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it('calls slashPressed when "/" is typed without modifiers', () => {
|
|
const blockEvents = createBlockEvents();
|
|
const slashSpy = vi
|
|
.spyOn(blockEvents as unknown as { slashPressed: (event: KeyboardEvent) => void }, 'slashPressed')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.SLASH,
|
|
key: '/' });
|
|
|
|
blockEvents.keydown(event);
|
|
|
|
expect(slashSpy).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it('skips slashPressed when modifier key is pressed', () => {
|
|
const blockEvents = createBlockEvents();
|
|
const slashSpy = vi
|
|
.spyOn(blockEvents as unknown as { slashPressed: (event: KeyboardEvent) => void }, 'slashPressed')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.SLASH,
|
|
key: '/',
|
|
ctrlKey: true });
|
|
|
|
blockEvents.keydown(event);
|
|
|
|
expect(slashSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('activates command slash when Slash code is used with control modifier', () => {
|
|
const blockEvents = createBlockEvents();
|
|
const commandSpy = vi
|
|
.spyOn(blockEvents as unknown as { commandSlashPressed: () => void }, 'commandSlashPressed')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ code: 'Slash',
|
|
ctrlKey: true });
|
|
|
|
blockEvents.keydown(event);
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(commandSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('tabPressed', () => {
|
|
it('skips navigation when inline toolbar is open', () => {
|
|
const navigateNext = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
InlineToolbar: {
|
|
opened: true,
|
|
} as unknown as EditorModules['InlineToolbar'],
|
|
Caret: {
|
|
navigateNext,
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.TAB });
|
|
|
|
(blockEvents as unknown as { tabPressed: (event: KeyboardEvent) => void }).tabPressed(event);
|
|
|
|
expect(navigateNext).not.toHaveBeenCalled();
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prevents default when navigation succeeds', () => {
|
|
const navigateNext = vi.fn().mockReturnValue(true);
|
|
const blockEvents = createBlockEvents({
|
|
Caret: {
|
|
navigateNext,
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.TAB });
|
|
|
|
(blockEvents as unknown as { tabPressed: (event: KeyboardEvent) => void }).tabPressed(event);
|
|
|
|
expect(navigateNext).toHaveBeenCalledWith(true);
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('navigates to previous input on Shift+Tab', () => {
|
|
const navigatePrevious = vi.fn().mockReturnValue(true);
|
|
const blockEvents = createBlockEvents({
|
|
Caret: {
|
|
navigatePrevious,
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.TAB,
|
|
shiftKey: true });
|
|
|
|
(blockEvents as unknown as { tabPressed: (event: KeyboardEvent) => void }).tabPressed(event);
|
|
|
|
expect(navigatePrevious).toHaveBeenCalledWith(true);
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('slashPressed', () => {
|
|
it('ignores events fired outside the editor wrapper', () => {
|
|
const insertContentAtCaretPosition = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Caret: {
|
|
insertContentAtCaretPosition,
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = createKeyboardEvent({
|
|
keyCode: keyCodes.SLASH,
|
|
key: '/',
|
|
target: document.createElement('div'),
|
|
});
|
|
|
|
(blockEvents as unknown as { slashPressed: (event: KeyboardEvent) => void }).slashPressed(event);
|
|
|
|
expect(insertContentAtCaretPosition).not.toHaveBeenCalled();
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens toolbox for empty block inside editor', () => {
|
|
const insertContentAtCaretPosition = vi.fn();
|
|
const currentBlock = {
|
|
isEmpty: true,
|
|
} as unknown as Block;
|
|
const wrapper = document.createElement('div');
|
|
const target = document.createElement('div');
|
|
|
|
wrapper.appendChild(target);
|
|
document.body.appendChild(wrapper);
|
|
const blockEvents = createBlockEvents({
|
|
Caret: {
|
|
insertContentAtCaretPosition,
|
|
} as unknown as EditorModules['Caret'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
UI: {
|
|
nodes: {
|
|
wrapper,
|
|
},
|
|
} as unknown as EditorModules['UI'],
|
|
});
|
|
const activateSpy = vi
|
|
.spyOn(blockEvents as unknown as { activateToolbox: () => void }, 'activateToolbox')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({
|
|
keyCode: keyCodes.SLASH,
|
|
key: '/',
|
|
target,
|
|
});
|
|
|
|
(blockEvents as unknown as { slashPressed: (event: KeyboardEvent) => void }).slashPressed(event);
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(insertContentAtCaretPosition).toHaveBeenCalledWith('/');
|
|
expect(activateSpy).toHaveBeenCalledTimes(1);
|
|
|
|
wrapper.remove();
|
|
});
|
|
|
|
it('does nothing when current block is not empty', () => {
|
|
const insertContentAtCaretPosition = vi.fn();
|
|
const currentBlock = {
|
|
isEmpty: false,
|
|
} as unknown as Block;
|
|
const wrapper = document.createElement('div');
|
|
const target = document.createElement('div');
|
|
|
|
wrapper.appendChild(target);
|
|
document.body.appendChild(wrapper);
|
|
const blockEvents = createBlockEvents({
|
|
Caret: {
|
|
insertContentAtCaretPosition,
|
|
} as unknown as EditorModules['Caret'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
UI: {
|
|
nodes: {
|
|
wrapper,
|
|
},
|
|
} as unknown as EditorModules['UI'],
|
|
});
|
|
const activateSpy = vi
|
|
.spyOn(blockEvents as unknown as { activateToolbox: () => void }, 'activateToolbox')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({
|
|
keyCode: keyCodes.SLASH,
|
|
key: '/',
|
|
target,
|
|
});
|
|
|
|
(blockEvents as unknown as { slashPressed: (event: KeyboardEvent) => void }).slashPressed(event);
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
expect(insertContentAtCaretPosition).not.toHaveBeenCalled();
|
|
expect(activateSpy).not.toHaveBeenCalled();
|
|
|
|
wrapper.remove();
|
|
});
|
|
});
|
|
|
|
describe('enter', () => {
|
|
it('returns early when line breaks are enabled', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const currentBlock = {
|
|
tool: {
|
|
isLineBreaksEnabled: true,
|
|
},
|
|
} as unknown as Block;
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
moveAndOpen,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.ENTER });
|
|
|
|
(blockEvents as unknown as { enter: (event: KeyboardEvent) => void }).enter(event);
|
|
|
|
expect(moveAndOpen).not.toHaveBeenCalled();
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('inserts a new block when caret is at the end of the block', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const setToBlock = vi.fn();
|
|
const insertDefaultBlockAtIndex = vi.fn().mockReturnValue({ id: 'inserted' } as unknown as Block);
|
|
const currentInput = document.createElement('div');
|
|
const currentBlock = {
|
|
tool: {
|
|
isLineBreaksEnabled: false,
|
|
},
|
|
currentInput,
|
|
hasMedia: false,
|
|
} as unknown as Block;
|
|
|
|
vi.spyOn(caretUtils, 'isCaretAtStartOfInput').mockReturnValue(false);
|
|
vi.spyOn(caretUtils, 'isCaretAtEndOfInput').mockReturnValue(true);
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
moveAndOpen,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
currentBlockIndex: 2,
|
|
insertDefaultBlockAtIndex,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
Caret: {
|
|
setToBlock,
|
|
positions: {
|
|
START: 'start-position',
|
|
END: 'end-position',
|
|
},
|
|
} as unknown as EditorModules['Caret'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.ENTER });
|
|
|
|
(blockEvents as unknown as { enter: (event: KeyboardEvent) => void }).enter(event);
|
|
|
|
expect(insertDefaultBlockAtIndex).toHaveBeenCalledWith(3);
|
|
expect(setToBlock).toHaveBeenCalledWith(insertDefaultBlockAtIndex.mock.results[0]!.value);
|
|
expect(moveAndOpen).toHaveBeenCalledWith(insertDefaultBlockAtIndex.mock.results[0]!.value);
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('backspace', () => {
|
|
it('returns early when selection is not collapsed', () => {
|
|
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(false);
|
|
const blockEvents = createBlockEvents();
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.BACKSPACE });
|
|
|
|
(blockEvents as unknown as { backspace: (event: KeyboardEvent) => void }).backspace(event);
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('merges blocks when previous block is mergeable', () => {
|
|
const toolbarClose = vi.fn();
|
|
const mergeableInput = document.createElement('div');
|
|
const currentBlock = {
|
|
currentInput: mergeableInput,
|
|
firstInput: mergeableInput,
|
|
isEmpty: false,
|
|
hasMedia: false,
|
|
} as unknown as Block;
|
|
const previousBlock = {
|
|
isEmpty: false,
|
|
} as unknown as Block;
|
|
|
|
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(true);
|
|
vi.spyOn(caretUtils, 'isCaretAtStartOfInput').mockReturnValue(true);
|
|
vi.spyOn(blocksUtils, 'areBlocksMergeable').mockReturnValue(true);
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close: toolbarClose,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
previousBlock,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
});
|
|
const mergeSpy = vi
|
|
.spyOn(blockEvents as unknown as { mergeBlocks: (target: Block, source: Block) => void }, 'mergeBlocks')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.BACKSPACE });
|
|
|
|
(blockEvents as unknown as { backspace: (event: KeyboardEvent) => void }).backspace(event);
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(toolbarClose).toHaveBeenCalledTimes(1);
|
|
expect(mergeSpy).toHaveBeenCalledWith(previousBlock, currentBlock);
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('merges with next block when mergeable', () => {
|
|
const toolbarClose = vi.fn();
|
|
const mergeableInput = document.createElement('div');
|
|
const currentBlock = {
|
|
currentInput: mergeableInput,
|
|
lastInput: mergeableInput,
|
|
isEmpty: false,
|
|
} as unknown as Block;
|
|
const nextBlock = {
|
|
isEmpty: false,
|
|
} as unknown as Block;
|
|
|
|
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(true);
|
|
vi.spyOn(caretUtils, 'isCaretAtEndOfInput').mockReturnValue(true);
|
|
vi.spyOn(blocksUtils, 'areBlocksMergeable').mockReturnValue(true);
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close: toolbarClose,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockManager: {
|
|
currentBlock,
|
|
nextBlock,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
});
|
|
const mergeSpy = vi
|
|
.spyOn(blockEvents as unknown as { mergeBlocks: (target: Block, source: Block) => void }, 'mergeBlocks')
|
|
.mockImplementation(() => undefined);
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.DELETE });
|
|
|
|
(blockEvents as unknown as { delete: (event: KeyboardEvent) => void }).delete(event);
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(toolbarClose).toHaveBeenCalledTimes(1);
|
|
expect(mergeSpy).toHaveBeenCalledWith(currentBlock, nextBlock);
|
|
});
|
|
});
|
|
|
|
describe('arrowRightAndDown', () => {
|
|
it('returns early when toolbar handles the key combination', () => {
|
|
const toolbarClose = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
UI: {
|
|
someToolbarOpened: true,
|
|
} as unknown as EditorModules['UI'],
|
|
Toolbar: {
|
|
close: toolbarClose,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.DOWN });
|
|
|
|
(blockEvents as unknown as { arrowRightAndDown: (event: KeyboardEvent) => void }).arrowRightAndDown(event);
|
|
|
|
expect(toolbarClose).not.toHaveBeenCalled();
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('toggles cross-block selection when extending selection with Shift+Down', () => {
|
|
const toggleBlockSelectedState = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: true,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
CrossBlockSelection: {
|
|
toggleBlockSelectedState,
|
|
} as unknown as EditorModules['CrossBlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.DOWN,
|
|
shiftKey: true });
|
|
|
|
(blockEvents as unknown as { arrowRightAndDown: (event: KeyboardEvent) => void }).arrowRightAndDown(event);
|
|
|
|
expect(toggleBlockSelectedState).toHaveBeenCalledTimes(1);
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prevents default when caret navigation succeeds', () => {
|
|
const toolbarClose = vi.fn();
|
|
const navigateNext = vi.fn().mockReturnValue(true);
|
|
const clearSelection = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close: toolbarClose,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
Caret: {
|
|
navigateNext,
|
|
} as unknown as EditorModules['Caret'],
|
|
BlockSelection: {
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.DOWN });
|
|
|
|
(blockEvents as unknown as { arrowRightAndDown: (event: KeyboardEvent) => void }).arrowRightAndDown(event);
|
|
|
|
expect(toolbarClose).toHaveBeenCalledTimes(1);
|
|
expect(navigateNext).toHaveBeenCalledTimes(1);
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(clearSelection).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('arrowLeftAndUp', () => {
|
|
it('returns early when toolbar is opened and handles the key', () => {
|
|
const closeAllToolbars = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
UI: {
|
|
someToolbarOpened: true,
|
|
closeAllToolbars,
|
|
} as unknown as EditorModules['UI'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.LEFT });
|
|
|
|
(blockEvents as unknown as { arrowLeftAndUp: (event: KeyboardEvent) => void }).arrowLeftAndUp(event);
|
|
|
|
expect(closeAllToolbars).not.toHaveBeenCalled();
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('toggles cross-block selection when extending selection with Shift+Up', () => {
|
|
const toggleBlockSelectedState = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
anyBlockSelected: true,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
CrossBlockSelection: {
|
|
toggleBlockSelectedState,
|
|
} as unknown as EditorModules['CrossBlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.UP,
|
|
shiftKey: true });
|
|
|
|
(blockEvents as unknown as { arrowLeftAndUp: (event: KeyboardEvent) => void }).arrowLeftAndUp(event);
|
|
|
|
expect(toggleBlockSelectedState).toHaveBeenCalledWith(false);
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prevents default when navigating to previous element succeeds', () => {
|
|
const toolbarClose = vi.fn();
|
|
const navigatePrevious = vi.fn().mockReturnValue(true);
|
|
const clearSelection = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close: toolbarClose,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
Caret: {
|
|
navigatePrevious,
|
|
} as unknown as EditorModules['Caret'],
|
|
BlockSelection: {
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: keyCodes.UP });
|
|
|
|
(blockEvents as unknown as { arrowLeftAndUp: (event: KeyboardEvent) => void }).arrowLeftAndUp(event);
|
|
|
|
expect(toolbarClose).toHaveBeenCalledTimes(1);
|
|
expect(navigatePrevious).toHaveBeenCalledTimes(1);
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(clearSelection).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('commandSlashPressed', () => {
|
|
it('activates block settings when a single block is selected', () => {
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
selectedBlocks: [ {} ],
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const activateSpy = vi
|
|
.spyOn(blockEvents as unknown as { activateBlockSettings: () => void }, 'activateBlockSettings')
|
|
.mockImplementation(() => undefined);
|
|
|
|
(blockEvents as unknown as { commandSlashPressed: () => void }).commandSlashPressed();
|
|
|
|
expect(activateSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips activation when multiple blocks are selected', () => {
|
|
const blockEvents = createBlockEvents({
|
|
BlockSelection: {
|
|
selectedBlocks: [ {}, {} ],
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const activateSpy = vi
|
|
.spyOn(blockEvents as unknown as { activateBlockSettings: () => void }, 'activateBlockSettings')
|
|
.mockImplementation(() => undefined);
|
|
|
|
(blockEvents as unknown as { commandSlashPressed: () => void }).commandSlashPressed();
|
|
|
|
expect(activateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('needToolbarClosing', () => {
|
|
const createToolbarMocks = (): Partial<EditorModules> => ({
|
|
Toolbar: {
|
|
toolbox: {
|
|
opened: false,
|
|
},
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSettings: {
|
|
opened: false,
|
|
} as unknown as EditorModules['BlockSettings'],
|
|
InlineToolbar: {
|
|
opened: false,
|
|
} as unknown as EditorModules['InlineToolbar'],
|
|
});
|
|
|
|
it('returns false when Shift is pressed', () => {
|
|
const blockEvents = createBlockEvents(createToolbarMocks() as Partial<EditorModules>);
|
|
const event = createKeyboardEvent({ shiftKey: true });
|
|
|
|
const result = (blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean })
|
|
.needToolbarClosing(event);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false when Tab is pressed', () => {
|
|
const blockEvents = createBlockEvents(createToolbarMocks() as Partial<EditorModules>);
|
|
const event = createKeyboardEvent({ keyCode: 9 });
|
|
|
|
const result = (blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean })
|
|
.needToolbarClosing(event);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false when toolbox item is selected and Enter pressed', () => {
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
toolbox: {
|
|
opened: true,
|
|
},
|
|
},
|
|
BlockSettings: {
|
|
opened: false,
|
|
},
|
|
InlineToolbar: {
|
|
opened: false,
|
|
},
|
|
} as Partial<EditorModules>);
|
|
const event = createKeyboardEvent({ keyCode: 13 });
|
|
|
|
const result = (blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean })
|
|
.needToolbarClosing(event);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns true when none of the skip conditions met', () => {
|
|
const blockEvents = createBlockEvents(createToolbarMocks() as Partial<EditorModules>);
|
|
const event = createKeyboardEvent({ keyCode: 65 });
|
|
|
|
const result = (blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean })
|
|
.needToolbarClosing(event);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('beforeKeydownProcessing', () => {
|
|
it('closes toolbar and clears selection for printable keys without modifiers', () => {
|
|
const close = vi.fn();
|
|
const clearSelection = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSelection: {
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: 65 });
|
|
|
|
vi.spyOn(blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean }, 'needToolbarClosing')
|
|
.mockReturnValue(true);
|
|
|
|
blockEvents.beforeKeydownProcessing(event);
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(clearSelection).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it('keeps selection when shortcut modifier is pressed', () => {
|
|
const close = vi.fn();
|
|
const clearSelection = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSelection: {
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
const event = createKeyboardEvent({ keyCode: 65,
|
|
ctrlKey: true });
|
|
|
|
vi.spyOn(blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean }, 'needToolbarClosing')
|
|
.mockReturnValue(true);
|
|
|
|
blockEvents.beforeKeydownProcessing(event);
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(clearSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips processing when toolbar should not be closed', () => {
|
|
const close = vi.fn();
|
|
const clearSelection = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
close,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSelection: {
|
|
clearSelection,
|
|
} as unknown as EditorModules['BlockSelection'],
|
|
});
|
|
|
|
vi.spyOn(blockEvents as unknown as { needToolbarClosing: (event: KeyboardEvent) => boolean }, 'needToolbarClosing')
|
|
.mockReturnValue(false);
|
|
|
|
blockEvents.beforeKeydownProcessing(createKeyboardEvent({ keyCode: 65 }));
|
|
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(clearSelection).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('activateToolbox', () => {
|
|
it('opens toolbar when it is closed', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const open = vi.fn();
|
|
const toolbar = {
|
|
opened: false,
|
|
moveAndOpen,
|
|
toolbox: {
|
|
open,
|
|
},
|
|
};
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: toolbar as unknown as EditorModules['Toolbar'],
|
|
});
|
|
|
|
(blockEvents as unknown as { activateToolbox: () => void }).activateToolbox();
|
|
|
|
expect(moveAndOpen).toHaveBeenCalledTimes(1);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips moveAndOpen when toolbar already opened', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const open = vi.fn();
|
|
const toolbar = {
|
|
opened: true,
|
|
moveAndOpen,
|
|
toolbox: {
|
|
open,
|
|
},
|
|
};
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: toolbar as unknown as EditorModules['Toolbar'],
|
|
});
|
|
|
|
(blockEvents as unknown as { activateToolbox: () => void }).activateToolbox();
|
|
|
|
expect(moveAndOpen).not.toHaveBeenCalled();
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('activateBlockSettings', () => {
|
|
it('opens toolbar and block settings when closed', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const open = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
opened: false,
|
|
moveAndOpen,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSettings: {
|
|
opened: false,
|
|
open,
|
|
} as unknown as EditorModules['BlockSettings'],
|
|
});
|
|
|
|
(blockEvents as unknown as { activateBlockSettings: () => void }).activateBlockSettings();
|
|
|
|
expect(moveAndOpen).toHaveBeenCalledTimes(1);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips reopening toolbar when it is already opened', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const open = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
opened: true,
|
|
moveAndOpen,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSettings: {
|
|
opened: false,
|
|
open,
|
|
} as unknown as EditorModules['BlockSettings'],
|
|
});
|
|
|
|
(blockEvents as unknown as { activateBlockSettings: () => void }).activateBlockSettings();
|
|
|
|
expect(moveAndOpen).not.toHaveBeenCalled();
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not reopen block settings when already opened', () => {
|
|
const moveAndOpen = vi.fn();
|
|
const open = vi.fn();
|
|
const blockEvents = createBlockEvents({
|
|
Toolbar: {
|
|
opened: true,
|
|
moveAndOpen,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
BlockSettings: {
|
|
opened: true,
|
|
open,
|
|
} as unknown as EditorModules['BlockSettings'],
|
|
});
|
|
|
|
(blockEvents as unknown as { activateBlockSettings: () => void }).activateBlockSettings();
|
|
|
|
expect(open).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('mergeBlocks', () => {
|
|
it('focuses the target block, merges blocks and closes toolbar', async () => {
|
|
const mergeBlocksFn = vi.fn().mockResolvedValue(undefined);
|
|
const closeToolbar = vi.fn();
|
|
const targetBlock = {
|
|
lastInput: document.createElement('div'),
|
|
} as unknown as Block;
|
|
const blockToMerge = {} as Block;
|
|
const blockEvents = createBlockEvents({
|
|
BlockManager: {
|
|
mergeBlocks: mergeBlocksFn,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
Toolbar: {
|
|
close: closeToolbar,
|
|
} as unknown as EditorModules['Toolbar'],
|
|
});
|
|
|
|
(blockEvents as unknown as { mergeBlocks: (target: Block, source: Block) => void }).mergeBlocks(
|
|
targetBlock,
|
|
blockToMerge
|
|
);
|
|
await mergeBlocksFn.mock.results[0]!.value;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(focus).toHaveBeenCalledWith(targetBlock.lastInput, false);
|
|
expect(mergeBlocksFn).toHaveBeenCalledWith(targetBlock, blockToMerge);
|
|
expect(closeToolbar).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns early when target block has no lastInput', () => {
|
|
const mergeBlocksFn = vi.fn().mockResolvedValue(undefined);
|
|
const blockEvents = createBlockEvents({
|
|
BlockManager: {
|
|
mergeBlocks: mergeBlocksFn,
|
|
} as unknown as EditorModules['BlockManager'],
|
|
Toolbar: {
|
|
close: vi.fn(),
|
|
} as unknown as EditorModules['Toolbar'],
|
|
});
|
|
|
|
(blockEvents as unknown as { mergeBlocks: (target: Block, source: Block) => void }).mergeBlocks(
|
|
{} as Block,
|
|
{} as Block
|
|
);
|
|
|
|
expect(mergeBlocksFn).not.toHaveBeenCalled();
|
|
expect(focus).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|