From 408b160e3948c3da62dbba234fa259ae856d6ea8 Mon Sep 17 00:00:00 2001 From: JackUait Date: Sun, 16 Nov 2025 01:00:11 +0300 Subject: [PATCH] fix: partially replace deprecated APIs with the modern ones --- docs/api.md | 3 - .../block-tunes/block-tune-move-down.ts | 4 +- .../block-tunes/block-tune-move-up.ts | 4 +- src/components/block/index.ts | 4 +- src/components/blocks.ts | 23 -- src/components/dom.ts | 24 -- .../inline-tools/inline-tool-bold.ts | 4 +- src/components/modules/api/blocks.ts | 18 -- src/components/modules/blockEvents.ts | 94 ++++++-- src/components/modules/blockManager.ts | 15 -- src/components/modules/blockSelection.ts | 3 +- test/unit/components/blocks.test.ts | 30 --- test/unit/components/dom.test.ts | 15 -- .../inline-tools/inline-tool-bold.test.ts | 186 +++++++++++++++ .../components/modules/api/blocks.test.ts | 18 -- .../components/modules/blockEvents.test.ts | 31 ++- .../components/modules/blockManager.test.ts | 222 +++++++++++++++++- types/api/blocks.d.ts | 8 - types/tools/tool-settings.d.ts | 12 - 19 files changed, 523 insertions(+), 195 deletions(-) create mode 100644 test/unit/components/inline-tools/inline-tool-bold.test.ts diff --git a/docs/api.md b/docs/api.md index bfde79da..95de75dc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,9 +59,6 @@ Methods that working with Blocks `renderFromHTML(data)` - parse and render passed HTML string (*not for production use*) -`swap(fromIndex, toIndex)` - swaps two Blocks by their positions (deprecated: -use 'move' instead) - `move(toIndex, fromIndex)` - moves block from one index to another position. `fromIndex` will be the current block's index by default. diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts index a06308c8..9a979289 100644 --- a/src/components/block-tunes/block-tune-move-down.ts +++ b/src/components/block-tunes/block-tune-move-down.ts @@ -6,7 +6,7 @@ import type { API, BlockTune } from '../../../types'; import { IconChevronDown } from '@codexteam/icons'; -import type { TunesMenuConfig } from '../../../types/tools'; +import type { MenuConfig } from '../../../types/tools'; /** @@ -44,7 +44,7 @@ export default class MoveDownTune implements BlockTune { /** * Tune's appearance in block settings menu */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: IconChevronDown, title: this.api.i18n.t('Move down'), diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index e007fbb9..d72b490b 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -5,7 +5,7 @@ */ import type { API, BlockTune } from '../../../types'; import { IconChevronUp } from '@codexteam/icons'; -import type { TunesMenuConfig } from '../../../types/tools'; +import type { MenuConfig } from '../../../types/tools'; /** * @@ -42,7 +42,7 @@ export default class MoveUpTune implements BlockTune { /** * Tune's appearance in block settings menu */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: IconChevronUp, title: this.api.i18n.t('Move up'), diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 7f567f17..2a191978 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -21,7 +21,7 @@ import type BlockTuneAdapter from '../tools/tune'; import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import type ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import type { TunesMenuConfigItem } from '../../../types/tools'; +import type { MenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import type { EditorEventMap } from '../events'; import { FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; @@ -386,7 +386,7 @@ export default class Block extends EventsDispatcher { const toolTunesPopoverParams: PopoverItemParams[] = []; const commonTunesPopoverParams: PopoverItemParams[] = []; const pushTuneConfig = ( - tuneConfig: TunesMenuConfigItem | TunesMenuConfigItem[] | HTMLElement | undefined, + tuneConfig: MenuConfigItem | MenuConfigItem[] | HTMLElement | undefined, target: PopoverItemParams[] ): void => { if (!tuneConfig) { diff --git a/src/components/blocks.ts b/src/components/blocks.ts index 69cb8e51..10132aaf 100644 --- a/src/components/blocks.ts +++ b/src/components/blocks.ts @@ -1,5 +1,4 @@ import * as _ from './utils'; -import $ from './dom'; import type Block from './block'; import { BlockToolAPI } from './block'; import type { MoveEvent } from '../../types/tools'; @@ -119,28 +118,6 @@ export default class Blocks { this.insertToDOM(block); } - /** - * Swaps blocks with indexes first and second - * - * @param {number} first - first block index - * @param {number} second - second block index - * @deprecated — use 'move' instead - */ - public swap(first: number, second: number): void { - const secondBlock = this.blocks[second]; - - /** - * Change in DOM - */ - $.swap(this.blocks[first].holder, secondBlock.holder); - - /** - * Change in array - */ - this.blocks[second] = this.blocks[first]; - this.blocks[first] = secondBlock; - } - /** * Move a block from one to another index * diff --git a/src/components/dom.ts b/src/components/dom.ts index 24b7af21..50a827a2 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -130,30 +130,6 @@ export default class Dom { } } - /** - * Swap two elements in parent - * - * @param {HTMLElement} el1 - from - * @param {HTMLElement} el2 - to - * @deprecated - */ - public static swap(el1: HTMLElement, el2: HTMLElement): void { - // create marker element and insert it where el1 is - const temp = document.createElement('div'); - const parent = el1.parentNode; - - parent?.insertBefore(temp, el1); - - // move el1 to right before el2 - parent?.insertBefore(el1, el2); - - // move el2 to right before where el1 used to be - parent?.insertBefore(el2, temp); - - // remove temporary marker node - parent?.removeChild(temp); - } - /** * Selector Decorator * diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 890d52b0..444a0290 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -1575,8 +1575,8 @@ export default class BoldInlineTool implements InlineTool { * @param event - The keyboard event to check */ private static isBoldShortcut(event: KeyboardEvent): boolean { - const platform = typeof navigator !== 'undefined' ? navigator.platform : ''; - const isMac = platform.toUpperCase().includes('MAC'); + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''; + const isMac = userAgent.includes('mac'); const primaryModifier = isMac ? event.metaKey : event.ctrlKey; if (!primaryModifier || event.altKey) { diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 01c8338e..f96d46ef 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -23,7 +23,6 @@ export default class BlocksAPI extends Module { render: (data: OutputData): Promise => this.render(data), renderFromHTML: (data: string): Promise => this.renderFromHTML(data), delete: (index?: number): void => this.delete(index), - swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex), move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex), getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index), getById: (id: string): BlockAPIInterface | null => this.getById(id), @@ -127,23 +126,6 @@ export default class BlocksAPI extends Module { return new BlockAPI(block); } - /** - * Call Block Manager method that swap Blocks - * - * @param {number} fromIndex - position of first Block - * @param {number} toIndex - position of second Block - * @deprecated — use 'move' instead - */ - public swap(fromIndex: number, toIndex: number): void { - _.log( - '`blocks.swap()` method is deprecated and will be removed in the next major release. ' + - 'Use `block.move()` method instead', - 'info' - ); - - this.Editor.BlockManager.swap(fromIndex, toIndex); - } - /** * Move block from one index to another * diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 22218ba1..ac89f9d1 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -10,6 +10,19 @@ import { areBlocksMergeable } from '../utils/blocks'; import * as caretUtils from '../utils/caret'; import { focus } from '@editorjs/caret'; +const KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP: Record = { + Backspace: _.keyCodes.BACKSPACE, + Delete: _.keyCodes.DELETE, + Enter: _.keyCodes.ENTER, + Tab: _.keyCodes.TAB, + ArrowDown: _.keyCodes.DOWN, + ArrowRight: _.keyCodes.RIGHT, + ArrowUp: _.keyCodes.UP, + ArrowLeft: _.keyCodes.LEFT, +}; + +const PRINTABLE_SPECIAL_KEYS = new Set(['Enter', 'Process', 'Spacebar', 'Space', 'Dead']); + /** * */ @@ -29,10 +42,12 @@ export default class BlockEvents extends Module { return; } + const keyCode = this.getKeyCode(event); + /** - * Fire keydown processor by event.keyCode + * Fire keydown processor by normalized keyboard code */ - switch (event.keyCode) { + switch (keyCode) { case _.keyCodes.BACKSPACE: this.backspace(event); break; @@ -87,7 +102,7 @@ export default class BlockEvents extends Module { */ private handleSelectedBlocksDeletion(event: KeyboardEvent): boolean { const { BlockSelection, BlockManager, Caret } = this.Editor; - const isRemoveKey = event.keyCode === _.keyCodes.BACKSPACE || event.keyCode === _.keyCodes.DELETE; + const isRemoveKey = event.key === 'Backspace' || event.key === 'Delete'; const selectionExists = SelectionUtils.isSelectionExists; const selectionCollapsed = SelectionUtils.isCollapsed === true; const shouldHandleSelectionDeletion = isRemoveKey && @@ -133,7 +148,7 @@ export default class BlockEvents extends Module { * - close Toolbar * - clear block highlighting */ - if (!_.isPrintableKey(event.keyCode)) { + if (!this.isPrintableKeyEvent(event)) { return; } @@ -601,8 +616,14 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keyboard event */ private arrowRightAndDown(event: KeyboardEvent): void { - const isFlipperCombination = Flipper.usedKeys.includes(event.keyCode) && - (!event.shiftKey || event.keyCode === _.keyCodes.TAB); + const keyCode = this.getKeyCode(event); + + if (keyCode === null) { + return; + } + + const isFlipperCombination = Flipper.usedKeys.includes(keyCode) && + (!event.shiftKey || keyCode === _.keyCodes.TAB); /** * Arrows might be handled on toolbars by flipper @@ -624,7 +645,7 @@ export default class BlockEvents extends Module { const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined; const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected; - const isShiftDownKey = event.shiftKey && event.keyCode === _.keyCodes.DOWN; + const isShiftDownKey = event.shiftKey && keyCode === _.keyCodes.DOWN; if (isShiftDownKey && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(); @@ -636,7 +657,7 @@ export default class BlockEvents extends Module { void this.Editor.InlineToolbar.tryToShow(); } - const navigateNext = event.keyCode === _.keyCodes.DOWN || (event.keyCode === _.keyCodes.RIGHT && !this.isRtl); + const navigateNext = keyCode === _.keyCodes.DOWN || (keyCode === _.keyCodes.RIGHT && !this.isRtl); const isNavigated = navigateNext ? this.Editor.Caret.navigateNext() : this.Editor.Caret.navigatePrevious(); if (isNavigated) { @@ -677,7 +698,13 @@ export default class BlockEvents extends Module { */ const toolbarOpened = this.Editor.UI.someToolbarOpened; - if (toolbarOpened && Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) { + const keyCode = this.getKeyCode(event); + + if (keyCode === null) { + return; + } + + if (toolbarOpened && Flipper.usedKeys.includes(keyCode) && (!event.shiftKey || keyCode === _.keyCodes.TAB)) { return; } @@ -696,7 +723,7 @@ export default class BlockEvents extends Module { const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined; const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected; - const isShiftUpKey = event.shiftKey && event.keyCode === _.keyCodes.UP; + const isShiftUpKey = event.shiftKey && keyCode === _.keyCodes.UP; if (isShiftUpKey && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(false); @@ -708,7 +735,7 @@ export default class BlockEvents extends Module { void this.Editor.InlineToolbar.tryToShow(); } - const navigatePrevious = event.keyCode === _.keyCodes.UP || (event.keyCode === _.keyCodes.LEFT && !this.isRtl); + const navigatePrevious = keyCode === _.keyCodes.UP || (keyCode === _.keyCodes.LEFT && !this.isRtl); const isNavigated = navigatePrevious ? this.Editor.Caret.navigatePrevious() : this.Editor.Caret.navigateNext(); if (isNavigated) { @@ -743,10 +770,13 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keyboard event */ private needToolbarClosing(event: KeyboardEvent): boolean { - const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened); - const blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened); - const inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened); - const flippingToolbarItems = event.keyCode === _.keyCodes.TAB; + const keyCode = this.getKeyCode(event); + const isEnter = keyCode === _.keyCodes.ENTER; + const isTab = keyCode === _.keyCodes.TAB; + const toolboxItemSelected = (isEnter && this.Editor.Toolbar.toolbox.opened); + const blockSettingsItemSelected = (isEnter && this.Editor.BlockSettings.opened); + const inlineToolbarItemSelected = (isEnter && this.Editor.InlineToolbar.opened); + const flippingToolbarItems = isTab; /** * Do not close Toolbar in cases: @@ -798,4 +828,38 @@ export default class BlockEvents extends Module { }); } } + + /** + * Convert KeyboardEvent.key or code to the legacy numeric keyCode + * + * @param event - keyboard event + */ + private getKeyCode(event: KeyboardEvent): number | null { + const keyFromEvent = event.key && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.key]; + + if (keyFromEvent !== undefined && typeof keyFromEvent === 'number') { + return keyFromEvent; + } + + const codeFromEvent = event.code && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.code]; + + if (codeFromEvent !== undefined && typeof codeFromEvent === 'number') { + return codeFromEvent; + } + + return null; + } + + /** + * Detect whether KeyDown should be treated as printable input + * + * @param event - keyboard event + */ + private isPrintableKeyEvent(event: KeyboardEvent): boolean { + if (!event.key) { + return false; + } + + return event.key.length === 1 || PRINTABLE_SPECIAL_KEYS.has(event.key); + } } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 965f5d64..0775711a 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -865,21 +865,6 @@ export default class BlockManager extends Module { return this.blocks.find((block) => block.holder === firstLevelBlock); } - /** - * Swap Blocks Position - * - * @param {number} fromIndex - index of first block - * @param {number} toIndex - index of second block - * @deprecated — use 'move' instead - */ - public swap(fromIndex: number, toIndex: number): void { - /** Move up current Block */ - this.blocksStore.swap(fromIndex, toIndex); - - /** Now actual block moved up so that current block index decreased */ - this.currentBlockIndex = toIndex; - } - /** * Move a block to a new index * diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index ae67afb5..752e42dd 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -238,7 +238,8 @@ export default class BlockSelection extends Module { this.readyToBlockSelection = false; const isKeyboard = reason && (reason instanceof KeyboardEvent); - const isPrintableKey = isKeyboard && _.isPrintableKey((reason as KeyboardEvent).keyCode); + const keyboardEvent = reason as KeyboardEvent; + const isPrintableKey = isKeyboard && keyboardEvent.key && keyboardEvent.key.length === 1; /** * If reason caused clear of the selection was printable key and any block is selected, diff --git a/test/unit/components/blocks.test.ts b/test/unit/components/blocks.test.ts index 0a319086..832405ad 100644 --- a/test/unit/components/blocks.test.ts +++ b/test/unit/components/blocks.test.ts @@ -233,36 +233,6 @@ describe('Blocks', () => { }); }); - describe('swap', () => { - it('should swap two blocks', () => { - const blocks = createBlocks(); - const block1 = createMockBlock('block-1'); - const block2 = createMockBlock('block-2'); - - blocks.push(block1); - blocks.push(block2); - - blocks.swap(0, 1); - - expect(blocks.blocks[0]).toBe(block2); - expect(blocks.blocks[1]).toBe(block1); - }); - - it('should swap blocks in DOM', () => { - const blocks = createBlocks(); - const block1 = createMockBlock('block-1'); - const block2 = createMockBlock('block-2'); - - blocks.push(block1); - blocks.push(block2); - - blocks.swap(0, 1); - - expect(workingArea.children[0]).toBe(block2.holder); - expect(workingArea.children[1]).toBe(block1.holder); - }); - }); - describe('move', () => { it('should move block from one index to another', () => { const blocks = createBlocks(); diff --git a/test/unit/components/dom.test.ts b/test/unit/components/dom.test.ts index 18af3451..f06443ba 100644 --- a/test/unit/components/dom.test.ts +++ b/test/unit/components/dom.test.ts @@ -63,21 +63,6 @@ describe('Dom helper utilities', () => { expect(Array.from(parent.children)).toEqual([second, first, initial]); }); - - it('swaps elements in place via swap()', () => { - const parent = document.createElement('div'); - const first = document.createElement('span'); - const second = document.createElement('span'); - - first.id = 'first'; - second.id = 'second'; - - parent.append(first, second); - - Dom.swap(first, second); - - expect(Array.from(parent.children).map((el) => el.id)).toEqual(['second', 'first']); - }); }); describe('selectors', () => { diff --git a/test/unit/components/inline-tools/inline-tool-bold.test.ts b/test/unit/components/inline-tools/inline-tool-bold.test.ts new file mode 100644 index 00000000..7affb0d3 --- /dev/null +++ b/test/unit/components/inline-tools/inline-tool-bold.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import BoldInlineTool from '../../../../src/components/inline-tools/inline-tool-bold'; +import SelectionUtils from '../../../../src/components/selection'; +import type { PopoverItemDefaultBaseParams } from '../../../../types/utils/popover'; + +type BoldInlineToolInternals = { + shortcutListenerRegistered: boolean; + selectionListenerRegistered: boolean; + inputListenerRegistered: boolean; + instances: Set; + handleShortcut: EventListener; + handleGlobalSelectionChange: EventListener; + handleGlobalInput: EventListener; +}; + +const getInternals = (): BoldInlineToolInternals => { + return BoldInlineTool as unknown as BoldInlineToolInternals; +}; + +const clearSelection = (): void => { + const selection = window.getSelection(); + + selection?.removeAllRanges(); +}; + +const resetBoldInlineTool = (): void => { + const internals = getInternals(); + + document.removeEventListener('keydown', internals.handleShortcut, true); + document.removeEventListener('selectionchange', internals.handleGlobalSelectionChange, true); + document.removeEventListener('input', internals.handleGlobalInput, true); + + internals.shortcutListenerRegistered = false; + internals.selectionListenerRegistered = false; + internals.inputListenerRegistered = false; + internals.instances.clear(); +}; + +const setupEditor = (html: string): { block: HTMLElement } => { + const wrapper = document.createElement('div'); + + wrapper.className = SelectionUtils.CSS.editorWrapper; + + const block = document.createElement('div'); + + block.dataset.blockTool = 'paragraph'; + block.contentEditable = 'true'; + block.innerHTML = html; + wrapper.appendChild(block); + + const toolbar = document.createElement('div'); + + toolbar.dataset.cy = 'inline-toolbar'; + + const button = document.createElement('button'); + + button.dataset.itemName = 'bold'; + toolbar.appendChild(button); + wrapper.appendChild(toolbar); + + document.body.appendChild(wrapper); + + return { block }; +}; + +const setRange = (node: Text, start: number, end?: number): void => { + const selection = window.getSelection(); + const range = document.createRange(); + + range.setStart(node, start); + + if (typeof end === 'number') { + range.setEnd(node, end); + } else { + range.collapse(true); + } + + selection?.removeAllRanges(); + selection?.addRange(range); +}; + +describe('BoldInlineTool', () => { + beforeEach(() => { + document.body.innerHTML = ''; + clearSelection(); + resetBoldInlineTool(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + clearSelection(); + resetBoldInlineTool(); + }); + + it('describes the tool metadata', () => { + expect(BoldInlineTool.isInline).toBe(true); + expect(BoldInlineTool.title).toBe('Bold'); + expect(BoldInlineTool.sanitize).toEqual({ + strong: {}, + b: {}, + }); + + const tool = new BoldInlineTool(); + + expect(tool.shortcut).toBe('CMD+B'); + }); + + it('wraps selected text and reports bold state', () => { + const { block } = setupEditor('Hello world'); + const textNode = block.firstChild as Text; + + setRange(textNode, 0, 5); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strong = block.querySelector('strong'); + + expect(strong?.textContent).toBe('Hello'); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(true); + }); + + it('unwraps existing bold text when toggled again', () => { + const { block } = setupEditor('Hello world'); + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + + const textNode = strong?.firstChild as Text; + + setRange(textNode, 0, textNode.textContent?.length ?? 0); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + expect(block.querySelector('strong')).toBeNull(); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(false); + }); + + it('starts a collapsed bold segment when caret is not inside bold', () => { + const { block } = setupEditor('Hello'); + const textNode = block.firstChild as Text; + + setRange(textNode, 0); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + expect(strong?.getAttribute('data-bold-collapsed-active')).toBe('true'); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(true); + expect(window.getSelection()?.anchorNode).toBe(strong?.firstChild ?? null); + }); + + it('exits collapsed bold when caret is inside bold content', () => { + const { block } = setupEditor('BOLD text'); + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + + const textNode = strong?.firstChild as Text; + const length = textNode.textContent?.length ?? 0; + + setRange(textNode, length); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strongAfter = block.querySelector('strong'); + + expect(strongAfter?.getAttribute('data-bold-collapsed-length')).toBe(length.toString()); + expect(strongAfter?.getAttribute('data-bold-collapsed-active')).toBeNull(); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(false); + expect(window.getSelection()?.anchorNode?.parentNode).toBe(block); + }); +}); diff --git a/test/unit/components/modules/api/blocks.test.ts b/test/unit/components/modules/api/blocks.test.ts index 664be123..645cd81c 100644 --- a/test/unit/components/modules/api/blocks.test.ts +++ b/test/unit/components/modules/api/blocks.test.ts @@ -69,7 +69,6 @@ type BlockManagerMock = { getBlockById: ReturnType; getBlockIndex: ReturnType; getBlock: ReturnType; - swap: ReturnType; move: ReturnType; removeBlock: ReturnType; insert: ReturnType; @@ -97,7 +96,6 @@ const createBlockManagerMock = (initialBlocks: BlockStub[] = [ createBlockStub() getBlock: vi.fn((element: HTMLElement) => { return blockManager.blocks.find((block) => block.holder === element); }) as ReturnType, - swap: vi.fn() as ReturnType, move: vi.fn() as ReturnType, removeBlock: vi.fn((block?: BlockStub) => { if (!block) { @@ -437,22 +435,6 @@ describe('BlocksAPI', () => { }); describe('block ordering', () => { - it('delegates swap to BlockManager and logs deprecation notice', () => { - const { blocksApi, blockManager } = createBlocksApi(); - const logSpy = vi.spyOn(utils, 'log').mockImplementation(() => {}); - - blocksApi.swap(1, 2); - - expect(blockManager.swap).toHaveBeenCalledWith(1, 2); - expect(logSpy).toHaveBeenCalledWith( - '`blocks.swap()` method is deprecated and will be removed in the next major release. ' + - 'Use `block.move()` method instead', - 'info' - ); - - logSpy.mockRestore(); - }); - it('delegates move to BlockManager', () => { const { blocksApi, blockManager } = createBlocksApi(); diff --git a/test/unit/components/modules/blockEvents.test.ts b/test/unit/components/modules/blockEvents.test.ts index 7b67f518..b97fb7e0 100644 --- a/test/unit/components/modules/blockEvents.test.ts +++ b/test/unit/components/modules/blockEvents.test.ts @@ -16,6 +16,18 @@ 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 = { + [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 = {}): BlockEvents => { const blockEvents = new BlockEvents({ config: {} as EditorConfig, @@ -109,10 +121,22 @@ const createBlockEvents = (overrides: Partial = {}): BlockEvents }; const createKeyboardEvent = (options: Partial): 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: 0, - key: '', - code: '', + keyCode: derivedKeyCode, + key: derivedKey, + code: derivedCode, ctrlKey: false, metaKey: false, altKey: false, @@ -1113,4 +1137,3 @@ describe('BlockEvents', () => { }); }); }); - diff --git a/test/unit/components/modules/blockManager.test.ts b/test/unit/components/modules/blockManager.test.ts index cffe8a12..208bbe2b 100644 --- a/test/unit/components/modules/blockManager.test.ts +++ b/test/unit/components/modules/blockManager.test.ts @@ -5,8 +5,9 @@ import BlockManager from '../../../../src/components/modules/blockManager'; import EventsDispatcher from '../../../../src/components/utils/events'; import type { EditorConfig } from '../../../../types'; import type { EditorModules } from '../../../../src/types-internal/editor-modules'; +import { BlockChanged } from '../../../../src/components/events'; import type { EditorEventMap } from '../../../../src/components/events'; -import type Block from '../../../../src/components/block'; +import Block from '../../../../src/components/block'; import { BlockAddedMutationType } from '../../../../types/events/block/BlockAdded'; import { BlockRemovedMutationType } from '../../../../types/events/block/BlockRemoved'; import { BlockMovedMutationType } from '../../../../types/events/block/BlockMoved'; @@ -40,6 +41,8 @@ const createBlockStub = (options: { tunes?: Record; } = {}): Block => { const holder = document.createElement('div'); + + holder.classList.add(Block.CSS.wrapper); const inputs = [ document.createElement('div') ]; const data = options.data ?? {}; @@ -392,4 +395,221 @@ describe('BlockManager', () => { expect(blocksStub.replace).not.toHaveBeenCalled(); expect(blockDidMutatedSpy).not.toHaveBeenCalled(); }); + + it('inserts default block at provided index and shifts current index when not focusing it', () => { + const existingBlock = createBlockStub({ id: 'existing' }); + const { blockManager, blocksStub } = createBlockManager({ + initialBlocks: [ existingBlock ], + }); + + blockManager.currentBlockIndex = 0; + const defaultBlock = createBlockStub({ id: 'default' }); + const composeBlockSpy = getComposeBlockSpy(blockManager).mockReturnValue(defaultBlock); + const blockDidMutatedSpy = getBlockDidMutatedSpy(blockManager); + + const result = blockManager.insertDefaultBlockAtIndex(0); + + expect(result).toBe(defaultBlock); + expect(blocksStub.blocks[0]).toBe(defaultBlock); + expect(blockManager.currentBlockIndex).toBe(1); + expect(composeBlockSpy).toHaveBeenCalledWith({ tool: 'paragraph' }); + expect(blockDidMutatedSpy).toHaveBeenCalledWith( + BlockAddedMutationType, + defaultBlock, + expect.objectContaining({ index: 0 }) + ); + }); + + it('focuses inserted default block when needToFocus is true', () => { + const { blockManager, blocksStub } = createBlockManager({ + initialBlocks: [createBlockStub({ id: 'first' }), createBlockStub({ id: 'second' })], + }); + + blockManager.currentBlockIndex = 1; + const defaultBlock = createBlockStub({ id: 'focus-me' }); + + getComposeBlockSpy(blockManager).mockReturnValue(defaultBlock); + + blockManager.insertDefaultBlockAtIndex(1, true); + + expect(blocksStub.blocks[1]).toBe(defaultBlock); + expect(blockManager.currentBlockIndex).toBe(1); + }); + + it('throws a descriptive error when default block tool is missing', () => { + const { blockManager } = createBlockManager(); + + (blockManager as unknown as { config: EditorConfig }).config.defaultBlock = undefined; + + expect(() => blockManager.insertDefaultBlockAtIndex(0)).toThrow('Could not insert default Block. Default block tool is not defined in the configuration.'); + }); + + it('removes only selected blocks and returns the first removed index', () => { + const blocks = [ + createBlockStub({ id: 'first' }), + createBlockStub({ id: 'second' }), + createBlockStub({ id: 'third' }), + ]; + + blocks[1].selected = true; + blocks[2].selected = true; + + const { blockManager } = createBlockManager({ + initialBlocks: blocks, + }); + + const removeSpy = vi + .spyOn(blockManager as unknown as { removeBlock: BlockManager['removeBlock'] }, 'removeBlock') + .mockResolvedValue(); + + const firstRemovedIndex = blockManager.removeSelectedBlocks(); + + expect(removeSpy).toHaveBeenNthCalledWith(1, blocks[2], false); + expect(removeSpy).toHaveBeenNthCalledWith(2, blocks[1], false); + expect(firstRemovedIndex).toBe(1); + }); + + it('splits the current block using caret fragment contents', () => { + const fragment = document.createDocumentFragment(); + + fragment.appendChild(document.createTextNode('Split content')); + + const caretStub = { + extractFragmentFromCaretPosition: vi.fn().mockReturnValue(fragment), + } as unknown as EditorModules['Caret']; + + const { blockManager } = createBlockManager({ + initialBlocks: [ createBlockStub({ id: 'origin' }) ], + editorOverrides: { + Caret: caretStub, + }, + }); + + const insertedBlock = createBlockStub({ id: 'split' }); + const insertSpy = vi.spyOn(blockManager as unknown as { insert: BlockManager['insert'] }, 'insert').mockReturnValue(insertedBlock); + + const result = blockManager.split(); + + expect(caretStub.extractFragmentFromCaretPosition).toHaveBeenCalledTimes(1); + expect(insertSpy).toHaveBeenCalledWith({ data: { text: 'Split content' } }); + expect(result).toBe(insertedBlock); + }); + + it('converts a block using the target tool conversion config', async () => { + const blockToConvert = createBlockStub({ id: 'paragraph', + name: 'paragraph' }); + + (blockToConvert.save as Mock).mockResolvedValue({ data: { text: 'Old' } }); + (blockToConvert.exportDataAsString as Mock).mockResolvedValue('

Converted

'); + + const replacingTool = { + name: 'header', + sanitizeConfig: { p: true }, + conversionConfig: { + import: (text: string, settings?: { level?: number }) => ({ + text: text.toUpperCase(), + level: settings?.level ?? 1, + }), + }, + settings: { level: 2 }, + }; + + const { blockManager } = createBlockManager({ + initialBlocks: [ blockToConvert ], + editorOverrides: { + Tools: { + blockTools: new Map([ [replacingTool.name, replacingTool] ]), + } as unknown as EditorModules['Tools'], + }, + }); + + const replacedBlock = createBlockStub({ id: 'header', + name: 'header' }); + const replaceSpy = vi.spyOn(blockManager as unknown as { replace: BlockManager['replace'] }, 'replace').mockReturnValue(replacedBlock); + + const result = await blockManager.convert(blockToConvert, 'header', { level: 4 }); + + expect(replaceSpy).toHaveBeenCalledWith(blockToConvert, 'header', { + text: '

CONVERTED

', + level: 4, + }); + expect(result).toBe(replacedBlock); + }); + + it('sets current block by a child node that belongs to the current editor instance', () => { + const blocks = [ + createBlockStub({ id: 'first' }), + createBlockStub({ id: 'second' }), + ]; + const { blockManager } = createBlockManager({ + initialBlocks: blocks, + }); + + const ui = (blockManager as unknown as { Editor: EditorModules }).Editor.UI; + + ui.nodes.wrapper.classList.add(ui.CSS.editorWrapper); + ui.nodes.wrapper.appendChild(blocks[0].holder); + ui.nodes.wrapper.appendChild(blocks[1].holder); + document.body.appendChild(ui.nodes.wrapper); + + const childNode = document.createElement('span'); + + blocks[1].holder.appendChild(childNode); + + const current = blockManager.setCurrentBlockByChildNode(childNode); + + expect(current).toBe(blocks[1]); + expect(blockManager.currentBlockIndex).toBe(1); + expect(blocks[1].updateCurrentInput).toHaveBeenCalledTimes(1); + + const alienWrapper = document.createElement('div'); + + alienWrapper.classList.add(ui.CSS.editorWrapper); + const alienBlock = createBlockStub({ id: 'alien' }); + + alienWrapper.appendChild(alienBlock.holder); + const alienChild = document.createElement('span'); + + alienBlock.holder.appendChild(alienChild); + + expect(blockManager.setCurrentBlockByChildNode(alienChild)).toBeUndefined(); + + ui.nodes.wrapper.remove(); + }); + + it('emits enumerable events when blockDidMutated is invoked', () => { + const block = createBlockStub({ id: 'block-1' }); + const { blockManager } = createBlockManager({ + initialBlocks: [ block ], + }); + + const emitSpy = vi.spyOn( + (blockManager as unknown as { eventsDispatcher: EventsDispatcher }).eventsDispatcher, + 'emit' + ); + + const detail = { index: 0 }; + const result = (blockManager as unknown as { blockDidMutated: BlockDidMutated }).blockDidMutated( + BlockAddedMutationType, + block, + detail + ); + + expect(result).toBe(block); + expect(emitSpy).toHaveBeenCalledTimes(1); + + const [eventName, payload] = emitSpy.mock.calls[0] as [string, { event: CustomEvent }]; + + expect(eventName).toBe(BlockChanged); + expect(payload.event).toBeInstanceOf(CustomEvent); + expect(payload.event.type).toBe(BlockAddedMutationType); + expect(payload.event.detail).toEqual(expect.objectContaining(detail)); + expect(payload.event.detail.target).toEqual(expect.objectContaining({ + id: block.id, + name: block.name, + })); + + expect(Object.prototype.propertyIsEnumerable.call(payload.event, 'type')).toBe(true); + expect(Object.prototype.propertyIsEnumerable.call(payload.event, 'detail')).toBe(true); + }); }); diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 06baafb6..f10027cf 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -34,14 +34,6 @@ export interface Blocks { */ delete(index?: number): void; - /** - * Swaps two Blocks - * @param {number} fromIndex - block to swap - * @param {number} toIndex - block to swap with - * @deprecated — use 'move' instead - */ - swap(fromIndex: number, toIndex: number): void; - /** * Moves a block to a new index * @param {number} toIndex - index where the block is moved to diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index 1658ee9a..3dfa5e0f 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -68,18 +68,6 @@ export interface ExternalToolSettings { toolbox?: ToolboxConfig | false; } -/** - * Tool's tunes configuration. - * @deprecated use {@link MenuConfig} type instead - */ -export type TunesMenuConfig = MenuConfig; - -/** - * Single Tunes Menu Config item - * @deprecated use {@link MenuConfigItem} type instead - */ -export type TunesMenuConfigItem = MenuConfigItem; - /** * For internal Tools 'class' property is optional */