diff --git a/eslint.config.mjs b/eslint.config.mjs index b6fbeeb4..472bd6d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -239,6 +239,7 @@ export default [ 'jsdoc/require-returns-type': 'off', '@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/member-ordering': 'off', + '@typescript-eslint/naming-convention': 'off', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/consistent-type-exports': 'error', 'prefer-arrow-callback': 'error', diff --git a/src/components/core.ts b/src/components/core.ts index 128ee5a1..2a9c7d79 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -40,46 +40,40 @@ export default class Core { /** * Ready promise. Resolved if Editor.js is ready to work, rejected otherwise */ - let onReady: (value?: void | PromiseLike) => void; - let onFail: (reason?: unknown) => void; - - this.isReady = new Promise((resolve, reject) => { - onReady = resolve; - onFail = reject; - }); - // Initialize config to satisfy TypeScript's definite assignment check // The setter will properly assign and process the config this.config = {}; - Promise.resolve() - .then(async () => { - this.configuration = config; + this.isReady = new Promise((resolve, reject) => { + Promise.resolve() + .then(async () => { + this.configuration = config; - this.validate(); - this.init(); - await this.start(); - await this.render(); + this.validate(); + this.init(); + await this.start(); + await this.render(); - const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances; + const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances; - UI.checkEmptiness(); - ModificationsObserver.enable(); + UI.checkEmptiness(); + ModificationsObserver.enable(); - if ((this.configuration as EditorConfig).autofocus === true && this.configuration.readOnly !== true) { - Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START); - } + if ((this.configuration as EditorConfig).autofocus === true && this.configuration.readOnly !== true) { + Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START); + } - onReady(); - }) - .catch((error) => { - _.log(`Editor.js is not ready because of ${error}`, 'error'); + resolve(); + }) + .catch((error) => { + _.log(`Editor.js is not ready because of ${error}`, 'error'); - /** - * Reject this.isReady promise - */ - onFail(error); - }); + /** + * Reject this.isReady promise + */ + reject(error); + }); + }); } /** diff --git a/src/components/dom.ts b/src/components/dom.ts index ac07fb77..7e66ca90 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -116,8 +116,9 @@ export default class Dom { */ public static prepend(parent: Element, elements: Element | Element[]): void { if (Array.isArray(elements)) { - elements = elements.reverse(); - elements.forEach((el) => parent.prepend(el)); + const reversedElements = [ ...elements ].reverse(); + + reversedElements.forEach((el) => parent.prepend(el)); } else { parent.prepend(elements); } @@ -231,8 +232,8 @@ export default class Dom { * * @type {string} */ - const child = atLast ? 'lastChild' : 'firstChild'; - const sibling = atLast ? 'previousSibling' : 'nextSibling'; + const child: 'lastChild' | 'firstChild' = atLast ? 'lastChild' : 'firstChild'; + const sibling: 'previousSibling' | 'nextSibling' = atLast ? 'previousSibling' : 'nextSibling'; if (node === null || node.nodeType !== Node.ELEMENT_NODE) { return node; @@ -244,40 +245,28 @@ export default class Dom { return node; } - let nodeChild = nodeChildProperty as Node; - - /** - * special case when child is single tag that can't contain any content - */ - if ( - Dom.isSingleTag(nodeChild as HTMLElement) && + const nodeChild = nodeChildProperty as Node; + const shouldSkipChild = Dom.isSingleTag(nodeChild as HTMLElement) && !Dom.isNativeInput(nodeChild) && - !Dom.isLineBreakTag(nodeChild as HTMLElement) - ) { - /** - * 1) We need to check the next sibling. If it is Node Element then continue searching for deepest - * from sibling - * - * 2) If single tag's next sibling is null, then go back to parent and check his sibling - * In case of Node Element continue searching - * - * 3) If none of conditions above happened return parent Node Element - */ - const siblingNode = nodeChild[sibling]; + !Dom.isLineBreakTag(nodeChild as HTMLElement); - if (siblingNode) { - nodeChild = siblingNode; - } else { - const parentSiblingNode = nodeChild.parentNode?.[sibling]; - - if (!parentSiblingNode) { - return nodeChild.parentNode; - } - nodeChild = parentSiblingNode; - } + if (!shouldSkipChild) { + return this.getDeepestNode(nodeChild, atLast); } - return this.getDeepestNode(nodeChild, atLast); + const siblingNode = nodeChild[sibling]; + + if (siblingNode) { + return this.getDeepestNode(siblingNode, atLast); + } + + const parentSiblingNode = nodeChild.parentNode?.[sibling]; + + if (parentSiblingNode) { + return this.getDeepestNode(parentSiblingNode, atLast); + } + + return nodeChild.parentNode; } /** @@ -343,26 +332,22 @@ export default class Dom { * @returns {boolean} */ public static canSetCaret(target: HTMLElement): boolean { - let result = true; - if (Dom.isNativeInput(target)) { - switch (target.type) { - case 'file': - case 'checkbox': - case 'radio': - case 'hidden': - case 'submit': - case 'button': - case 'image': - case 'reset': - result = false; - break; - } - } else { - result = Dom.isContentEditable(target); + const disallowedTypes = new Set([ + 'file', + 'checkbox', + 'radio', + 'hidden', + 'submit', + 'button', + 'image', + 'reset', + ]); + + return !disallowedTypes.has(target.type); } - return result; + return Dom.isContentEditable(target); } /** @@ -375,23 +360,18 @@ export default class Dom { * @returns {boolean} true if it is empty */ public static isNodeEmpty(node: Node, ignoreChars?: string): boolean { - let nodeText: string | undefined; - if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) { return false; } - if (this.isElement(node) && this.isNativeInput(node)) { - nodeText = (node as HTMLInputElement).value; - } else { - nodeText = node.textContent?.replace('\u200B', ''); - } + const baseText = this.isElement(node) && this.isNativeInput(node) + ? (node as HTMLInputElement).value + : node.textContent?.replace('\u200B', ''); + const normalizedText = ignoreChars + ? baseText?.replace(new RegExp(ignoreChars, 'g'), '') + : baseText; - if (ignoreChars) { - nodeText = nodeText?.replace(new RegExp(ignoreChars, 'g'), ''); - } - - return (nodeText?.length ?? 0) === 0; + return (normalizedText?.length ?? 0) === 0; } /** @@ -427,14 +407,12 @@ export default class Dom { continue; } - node = currentNode; - - if (this.isLeaf(node) && !this.isNodeEmpty(node, ignoreChars)) { + if (this.isLeaf(currentNode) && !this.isNodeEmpty(currentNode, ignoreChars)) { return false; } - if (node.childNodes) { - treeWalker.push(...Array.from(node.childNodes)); + if (currentNode.childNodes) { + treeWalker.push(...Array.from(currentNode.childNodes)); } } @@ -529,14 +507,15 @@ export default class Dom { * @returns {boolean} */ public static containsOnlyInlineElements(data: string | HTMLElement): boolean { - let wrapper: HTMLElement; + const wrapper = _.isString(data) + ? (() => { + const container = document.createElement('div'); - if (_.isString(data)) { - wrapper = document.createElement('div'); - wrapper.innerHTML = data; - } else { - wrapper = data; - } + container.innerHTML = data; + + return container; + })() + : data; const check = (element: Element): boolean => { return !Dom.blockElements.includes(element.tagName.toLowerCase()) && @@ -622,58 +601,77 @@ export default class Dom { * @returns {{node: Node | null, offset: number}} - node and offset inside node */ public static getNodeByOffset(root: Node, totalOffset: number): {node: Node | null; offset: number} { - let currentOffset = 0; - let lastTextNode: Node | null = null; - const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, null ); - let node: Node | null = walker.nextNode(); + const findNode = ( + nextNode: Node | null, + accumulatedOffset: number, + previousNode: Node | null, + previousNodeLength: number + ): { node: Node | null; offset: number } => { + if (!nextNode) { + if (!previousNode) { + return { + node: null, + offset: 0, + }; + } - while (node) { - const textContent = node.textContent; - const nodeLength = textContent === null ? 0 : textContent.length; + const baseOffset = accumulatedOffset - previousNodeLength; + const safeTotalOffset = Math.max(totalOffset - baseOffset, 0); + const offsetInsidePrevious = Math.min(safeTotalOffset, previousNodeLength); - lastTextNode = node; - - if (currentOffset + nodeLength >= totalOffset) { - break; + return { + node: previousNode, + offset: offsetInsidePrevious, + }; } - currentOffset += nodeLength; - node = walker.nextNode(); - } + const textContent = nextNode.textContent ?? ''; + const nodeLength = textContent.length; + const hasReachedOffset = accumulatedOffset + nodeLength >= totalOffset; - /** - * If no node found or last node is empty, return null - */ - if (!lastTextNode) { + if (hasReachedOffset) { + return { + node: nextNode, + offset: Math.min(totalOffset - accumulatedOffset, nodeLength), + }; + } + + return findNode( + walker.nextNode(), + accumulatedOffset + nodeLength, + nextNode, + nodeLength + ); + }; + + const initialNode = walker.nextNode(); + const { node, offset } = findNode(initialNode, 0, null, 0); + + if (!node) { return { node: null, offset: 0, }; } - const textContent = lastTextNode.textContent; + const textContent = node.textContent; - if (textContent === null || textContent.length === 0) { + if (!textContent || textContent.length === 0) { return { node: null, offset: 0, }; } - /** - * Calculate offset inside found node - */ - const nodeOffset = Math.min(totalOffset - currentOffset, textContent.length); - return { - node: lastTextNode, - offset: nodeOffset, + node, + offset, }; } } @@ -754,5 +752,7 @@ export const calculateBaseline = (element: Element): number => { * @param element - The element to toggle the [data-empty] attribute on */ export const toggleEmptyMark = (element: HTMLElement): void => { - element.dataset.empty = Dom.isEmpty(element) ? 'true' : 'false'; + const { dataset } = element; + + dataset.empty = Dom.isEmpty(element) ? 'true' : 'false'; }; diff --git a/test/unit/components/core.test.ts b/test/unit/components/core.test.ts new file mode 100644 index 00000000..883eb0c7 --- /dev/null +++ b/test/unit/components/core.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { EditorConfig } from '../../../types'; +import type { EditorModules } from '../../../src/types-internal/editor-modules'; +import { CriticalError } from '../../../src/components/errors/critical'; + +const mockRegistry = vi.hoisted(() => ({ + dom: { + get: vi.fn(), + isElement: vi.fn(), + }, + utils: { + isObject: vi.fn(), + isString: vi.fn(), + isEmpty: vi.fn(), + setLogLevel: vi.fn(), + log: vi.fn(), + deprecationAssert: vi.fn(), + }, + i18n: { + setDictionary: vi.fn(), + }, + modules: { + toolsPrepare: vi.fn(), + uiPrepare: vi.fn(), + uiCheckEmptiness: vi.fn(), + blockManagerPrepare: vi.fn(), + pastePrepare: vi.fn(), + blockSelectionPrepare: vi.fn(), + rectangleSelectionPrepare: vi.fn(), + crossBlockSelectionPrepare: vi.fn(), + readOnlyPrepare: vi.fn(), + rendererPrepare: vi.fn(), + rendererRender: vi.fn(() => Promise.resolve()), + modificationsObserverPrepare: vi.fn(), + modificationsObserverEnable: vi.fn(), + caretPrepare: vi.fn(), + caretSetToBlock: vi.fn(), + }, +})); + +vi.mock('../../../src/components/dom', () => ({ + __esModule: true, + default: { + get: mockRegistry.dom.get, + isElement: mockRegistry.dom.isElement, + }, +})); + +vi.mock('../../../src/components/utils', () => ({ + __esModule: true, + isObject: mockRegistry.utils.isObject, + isString: mockRegistry.utils.isString, + isEmpty: mockRegistry.utils.isEmpty, + setLogLevel: mockRegistry.utils.setLogLevel, + log: mockRegistry.utils.log, + deprecationAssert: mockRegistry.utils.deprecationAssert, + LogLevels: { + VERBOSE: 'VERBOSE', + INFO: 'INFO', + }, +})); + +vi.mock('../../../src/components/i18n', () => ({ + __esModule: true, + default: { + setDictionary: mockRegistry.i18n.setDictionary, + }, +})); + +vi.mock('../../../src/components/modules', () => { + /** + * Minimal Tools module stub used in Core tests. + */ + class MockTools { + public state?: EditorModules; + public prepare = mockRegistry.modules.toolsPrepare; + } + + /** + * Minimal UI module stub used in Core tests. + */ + class MockUI { + public state?: EditorModules; + public prepare = mockRegistry.modules.uiPrepare; + public checkEmptiness = mockRegistry.modules.uiCheckEmptiness; + } + + /** + * Minimal BlockManager module stub used in Core tests. + */ + class MockBlockManager { + public state?: EditorModules; + public prepare = mockRegistry.modules.blockManagerPrepare; + public blocks = [ { id: 'block-1' } ]; + } + + /** + * Minimal Paste module stub used in Core tests. + */ + class MockPaste { + public state?: EditorModules; + public prepare = mockRegistry.modules.pastePrepare; + } + + /** + * Minimal BlockSelection module stub used in Core tests. + */ + class MockBlockSelection { + public state?: EditorModules; + public prepare = mockRegistry.modules.blockSelectionPrepare; + } + + /** + * Minimal RectangleSelection module stub used in Core tests. + */ + class MockRectangleSelection { + public state?: EditorModules; + public prepare = mockRegistry.modules.rectangleSelectionPrepare; + } + + /** + * Minimal CrossBlockSelection module stub used in Core tests. + */ + class MockCrossBlockSelection { + public state?: EditorModules; + public prepare = mockRegistry.modules.crossBlockSelectionPrepare; + } + + /** + * Minimal ReadOnly module stub used in Core tests. + */ + class MockReadOnly { + public state?: EditorModules; + public prepare = mockRegistry.modules.readOnlyPrepare; + } + + /** + * Minimal Renderer module stub used in Core tests. + */ + class MockRenderer { + public state?: EditorModules; + public prepare = mockRegistry.modules.rendererPrepare; + public render = mockRegistry.modules.rendererRender; + } + + /** + * Minimal ModificationsObserver module stub used in Core tests. + */ + class MockModificationsObserver { + public state?: EditorModules; + public prepare = mockRegistry.modules.modificationsObserverPrepare; + public enable = mockRegistry.modules.modificationsObserverEnable; + } + + /** + * Minimal Caret module stub used in Core tests. + */ + class MockCaret { + public state?: EditorModules; + public prepare = mockRegistry.modules.caretPrepare; + public setToBlock = mockRegistry.modules.caretSetToBlock; + + /** + * Provides the caret positions map required by Core. + */ + public get positions(): { START: string } { + return { + START: 'start', + }; + } + } + + return { + __esModule: true, + default: { + Tools: MockTools, + UI: MockUI, + BlockManager: MockBlockManager, + Paste: MockPaste, + BlockSelection: MockBlockSelection, + RectangleSelection: MockRectangleSelection, + CrossBlockSelection: MockCrossBlockSelection, + ReadOnly: MockReadOnly, + Renderer: MockRenderer, + ModificationsObserver: MockModificationsObserver, + Caret: MockCaret, + }, + }; +}); + +const { dom, utils, i18n, modules: moduleMocks } = mockRegistry; +const { get: mockDomGet, isElement: mockDomIsElement } = dom; +const { + isObject: mockIsObject, + isString: mockIsString, + isEmpty: mockIsEmpty, + setLogLevel: mockSetLogLevel, + log: mockLog, +} = utils; +const { setDictionary: mockSetDictionary } = i18n; +const { + toolsPrepare: mockToolsPrepare, + uiPrepare: mockUIPrepare, + uiCheckEmptiness: mockUICheckEmptiness, + blockManagerPrepare: mockBlockManagerPrepare, + pastePrepare: mockPastePrepare, + blockSelectionPrepare: mockBlockSelectionPrepare, + rectangleSelectionPrepare: mockRectangleSelectionPrepare, + crossBlockSelectionPrepare: mockCrossBlockSelectionPrepare, + readOnlyPrepare: mockReadOnlyPrepare, + rendererRender: mockRendererRender, + modificationsObserverEnable: mockModificationsObserverEnable, + caretSetToBlock: mockCaretSetToBlock, +} = moduleMocks; + +// Import Core after mocks are configured +import Core from '../../../src/components/core'; + +const createReadyCore = async (config?: EditorConfig | string): Promise => { + const core = new Core(config); + + await core.isReady; + + return core; +}; + +describe('Core', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockDomIsElement.mockReturnValue(true); + mockDomGet.mockImplementation((id: string) => ({ id }) as unknown as HTMLElement); + + mockIsObject.mockImplementation( + (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + ); + mockIsString.mockImplementation((value: unknown): value is string => typeof value === 'string'); + mockIsEmpty.mockImplementation((value: unknown): boolean => { + if (value == null) { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (typeof value === 'object') { + return Object.keys(value).length === 0; + } + + return false; + }); + + mockRendererRender.mockResolvedValue(undefined); + }); + + describe('configuration', () => { + it('normalizes holderId and sets default values', async () => { + const core = await createReadyCore({ holderId: 'my-holder' }); + + expect(core.configuration.holder).toBe('my-holder'); + expect(core.configuration.holderId).toBeUndefined(); + expect(core.configuration.defaultBlock).toBe('paragraph'); + expect(core.configuration.minHeight).toBe(300); + expect(core.configuration.data?.blocks).toHaveLength(1); + expect(core.configuration.data?.blocks?.[0]?.type).toBe('paragraph'); + expect(core.configuration.readOnly).toBe(false); + expect(mockSetLogLevel).toHaveBeenCalledWith('VERBOSE'); + }); + + it('retains provided data and applies i18n dictionary', async () => { + const config: EditorConfig = { + holder: 'holder', + defaultBlock: 'header', + data: { + blocks: [ + { + id: '1', + type: 'quote', + data: { text: 'Hello' }, + }, + ], + }, + i18n: { + direction: 'rtl', + messages: { + toolNames: { + paragraph: 'Paragraph', + }, + }, + }, + }; + + const core = await createReadyCore(config); + + expect(core.configuration.defaultBlock).toBe('header'); + expect(core.configuration.data).toEqual(config.data); + expect(core.configuration.i18n?.direction).toBe('rtl'); + expect(mockSetDictionary).toHaveBeenCalledWith(config.i18n?.messages); + }); + }); + + describe('validate', () => { + it('throws when both holder and holderId are provided', async () => { + const core = await createReadyCore(); + + core.configuration = { + holder: 'element', + holderId: 'other', + } as EditorConfig; + + expect(() => core.validate()).toThrow('«holderId» and «holder» param can\'t assign at the same time.'); + }); + + it('throws when holder element is missing', async () => { + const core = await createReadyCore(); + + mockDomGet.mockImplementation((id: string) => { + if (id === 'missing') { + return undefined; + } + + return { id } as unknown as HTMLElement; + }); + + core.configuration = { + holder: 'missing', + } as EditorConfig; + + expect(() => core.validate()).toThrow('element with ID «missing» is missing. Pass correct holder\'s ID.'); + }); + + it('throws when holder is not a DOM element', async () => { + const core = await createReadyCore(); + + mockDomIsElement.mockReturnValue(false); + + core.configuration = { + holder: {} as unknown as HTMLElement, + } as EditorConfig; + + expect(() => core.validate()).toThrow('«holder» value must be an Element node'); + }); + }); + + describe('modules initialization', () => { + it('constructs modules and provides state without self references', async () => { + const core = await createReadyCore(); + const { moduleInstances } = core; + + expect(moduleInstances.Tools).toBeDefined(); + expect(moduleInstances.UI).toBeDefined(); + + const toolsState = moduleInstances.Tools.state as Partial; + + expect(toolsState.Tools).toBeUndefined(); + expect(toolsState.UI).toBe(moduleInstances.UI); + expect(toolsState.BlockManager).toBe(moduleInstances.BlockManager); + }); + }); + + describe('start', () => { + it('prepares all required modules', async () => { + await createReadyCore(); + + expect(mockToolsPrepare).toHaveBeenCalled(); + expect(mockUIPrepare).toHaveBeenCalled(); + expect(mockBlockManagerPrepare).toHaveBeenCalled(); + expect(mockPastePrepare).toHaveBeenCalled(); + expect(mockBlockSelectionPrepare).toHaveBeenCalled(); + expect(mockRectangleSelectionPrepare).toHaveBeenCalled(); + expect(mockCrossBlockSelectionPrepare).toHaveBeenCalled(); + expect(mockReadOnlyPrepare).toHaveBeenCalled(); + }); + + it('logs warning when non-critical module fails to prepare', async () => { + const core = await createReadyCore(); + const nonCriticalError = new Error('skip me'); + + mockPastePrepare.mockImplementationOnce(() => { + throw nonCriticalError; + }); + + await expect(core.start()).resolves.toBeUndefined(); + expect(mockLog).toHaveBeenCalledWith('Module Paste was skipped because of %o', 'warn', nonCriticalError); + }); + + it('rethrows when a module fails with CriticalError', async () => { + const core = await createReadyCore(); + + mockReadOnlyPrepare.mockImplementationOnce(() => { + throw new CriticalError('read-only failure'); + }); + + await expect(core.start()).rejects.toThrow('read-only failure'); + }); + }); + + describe('render', () => { + it('invokes renderer with current blocks', async () => { + const core = await createReadyCore(); + const render = (core as unknown as { render: () => Promise }).render.bind(core); + + await render(); + + expect(mockRendererRender).toHaveBeenLastCalledWith(core.configuration.data?.blocks); + }); + + it('throws when renderer module is missing', async () => { + const core = await createReadyCore(); + const render = (core as unknown as { render: () => Promise }).render.bind(core); + + delete (core.moduleInstances as Partial).Renderer; + + expect(() => render()).toThrow('Renderer module is not initialized'); + }); + + it('throws when editor data is missing', async () => { + const core = await createReadyCore(); + const render = (core as unknown as { render: () => Promise }).render.bind(core); + + (core.configuration as EditorConfig).data = undefined; + + expect(() => render()).toThrow('Editor data is not initialized'); + }); + }); + + describe('ready workflow', () => { + it('checks UI, enables observer and moves caret on autofocus', async () => { + const config: EditorConfig = { + holder: 'holder', + autofocus: true, + data: { + blocks: [ + { + id: 'custom', + type: 'paragraph', + data: {}, + }, + ], + }, + }; + + const core = await createReadyCore(config); + + expect(mockUICheckEmptiness).toHaveBeenCalledTimes(1); + expect(mockModificationsObserverEnable).toHaveBeenCalledTimes(1); + expect(mockCaretSetToBlock).toHaveBeenCalledWith( + core.moduleInstances.BlockManager.blocks[0], + 'start' + ); + }); + }); +}); + diff --git a/test/unit/components/dom.test.ts b/test/unit/components/dom.test.ts new file mode 100644 index 00000000..18af3451 --- /dev/null +++ b/test/unit/components/dom.test.ts @@ -0,0 +1,396 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Dom, { isCollapsedWhitespaces, calculateBaseline, toggleEmptyMark } from '../../../src/components/dom'; + +describe('Dom helper utilities', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + describe('element creation helpers', () => { + it('creates elements with classes and attributes via make()', () => { + const button = Dom.make('button', ['btn', undefined, 'primary'], { + type: 'button', + title: 'Action', + disabled: true, + 'data-testid': 'make-button', + }); + + expect(button.tagName).toBe('BUTTON'); + expect(button.className).toBe('btn primary'); + expect(button.getAttribute('data-testid')).toBe('make-button'); + expect((button as HTMLButtonElement).disabled).toBe(true); + + const input = Dom.make('input', 'field', { + value: 'Hello', + }); + + expect((input as HTMLInputElement).value).toBe('Hello'); + }); + + it('creates text nodes via text()', () => { + const node = Dom.text('EditorJS'); + + expect(node.nodeType).toBe(Node.TEXT_NODE); + expect(node.textContent).toBe('EditorJS'); + }); + }); + + describe('dom mutations', () => { + it('appends arrays and fragments via append()', () => { + const parent = document.createElement('div'); + const first = document.createElement('span'); + const second = document.createElement('span'); + + Dom.append(parent, [first, second]); + + expect(parent.children[0]).toBe(first); + expect(parent.children[1]).toBe(second); + }); + + it('prepends arrays with preserved order via prepend()', () => { + const parent = document.createElement('div'); + const initial = document.createElement('span'); + const first = document.createElement('span'); + const second = document.createElement('span'); + + parent.appendChild(initial); + Dom.prepend(parent, [first, second]); + + 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', () => { + it('finds nodes via find, findAll, and get', () => { + const holder = document.createElement('div'); + + holder.id = 'holder'; + holder.innerHTML = 'onetwo'; + document.body.appendChild(holder); + + const first = Dom.find(holder, '.item'); + + expect(first?.textContent).toBe('one'); + expect(Dom.findAll(holder, '.item')).toHaveLength(2); + expect(Dom.get('holder')).toBe(holder); + }); + }); + + describe('tag helpers', () => { + it('checks for single or line break tags', () => { + const br = document.createElement('br'); + const div = document.createElement('div'); + + expect(Dom.isSingleTag(br)).toBe(true); + expect(Dom.isSingleTag(div)).toBe(false); + expect(Dom.isLineBreakTag(br)).toBe(true); + }); + + it('detects native inputs and contenteditable elements', () => { + const input = document.createElement('input'); + const textarea = document.createElement('textarea'); + const editable = document.createElement('div'); + + editable.contentEditable = 'true'; + input.type = 'text'; + + expect(Dom.isNativeInput(input)).toBe(true); + expect(Dom.isNativeInput(textarea)).toBe(true); + expect(Dom.isContentEditable(editable)).toBe(true); + expect(Dom.isContentEditable(document.createElement('div'))).toBe(false); + }); + + it('determines when caret can be placed', () => { + const textInput = document.createElement('input'); + const fileInput = document.createElement('input'); + const editable = document.createElement('div'); + + textInput.type = 'text'; + fileInput.type = 'file'; + editable.contentEditable = 'true'; + + expect(Dom.canSetCaret(textInput)).toBe(true); + expect(Dom.canSetCaret(fileInput)).toBe(false); + expect(Dom.canSetCaret(editable)).toBe(true); + }); + }); + + describe('inputs discovery', () => { + it('finds contenteditable descendants and native inputs', () => { + const holder = document.createElement('div'); + const editableWithBlock = document.createElement('div'); + const editableInlineOnly = document.createElement('div'); + const textarea = document.createElement('textarea'); + const input = document.createElement('input'); + const paragraph = document.createElement('p'); + const inlineSpan = document.createElement('span'); + + editableWithBlock.setAttribute('contenteditable', 'true'); + editableInlineOnly.setAttribute('contenteditable', 'true'); + + paragraph.textContent = 'Block content'; + inlineSpan.textContent = 'Inline content'; + + editableWithBlock.appendChild(paragraph); + editableInlineOnly.appendChild(inlineSpan); + + input.type = 'text'; + + holder.append(editableWithBlock, editableInlineOnly, textarea, input); + + const inputs = Dom.findAllInputs(holder); + + expect(inputs).toContain(textarea); + expect(inputs).toContain(input); + expect(inputs).toContain(paragraph); + expect(inputs).toContain(editableInlineOnly); + }); + }); + + describe('deepest node helpers', () => { + it('finds deepest nodes from start or end', () => { + const root = document.createElement('div'); + const wrapper = document.createElement('div'); + const span = document.createElement('span'); + const text = document.createTextNode('Hello'); + const altText = document.createTextNode('World'); + + span.appendChild(text); + wrapper.append(span); + wrapper.append(altText); + root.appendChild(wrapper); + + expect(Dom.getDeepestNode(root)).toBe(text); + expect(Dom.getDeepestNode(root, true)).toBe(altText); + }); + + it('returns the same node for non-element containers', () => { + const fragment = document.createDocumentFragment(); + + expect(Dom.getDeepestNode(fragment)).toBe(fragment); + expect(Dom.getDeepestNode(null)).toBeNull(); + }); + }); + + describe('emptiness checks', () => { + it('checks individual nodes via isNodeEmpty()', () => { + const zeroWidthText = document.createTextNode('\u200B'); + const text = document.createTextNode('text'); + const input = document.createElement('input'); + const ignoreText = document.createTextNode('customcustom'); + + input.value = 'filled'; + + expect(Dom.isNodeEmpty(zeroWidthText)).toBe(true); + expect(Dom.isNodeEmpty(text)).toBe(false); + expect(Dom.isNodeEmpty(input)).toBe(false); + expect(Dom.isNodeEmpty(ignoreText, 'custom')).toBe(true); + }); + + it('checks trees via isEmpty()', () => { + const wrapper = document.createElement('div'); + + wrapper.appendChild(document.createTextNode(' ')); + expect(Dom.isEmpty(wrapper)).toBe(false); + expect(Dom.isEmpty(wrapper, ' ')).toBe(true); + + wrapper.appendChild(document.createTextNode('filled')); + expect(Dom.isEmpty(wrapper)).toBe(false); + }); + + it('checks leaf nodes', () => { + const leaf = document.createElement('span'); + const parent = document.createElement('div'); + + parent.appendChild(leaf); + + expect(Dom.isLeaf(leaf)).toBe(true); + leaf.appendChild(document.createElement('b')); + expect(Dom.isLeaf(leaf)).toBe(false); + }); + }); + + describe('string and content helpers', () => { + it('detects HTML strings', () => { + expect(Dom.isHTMLString('
')).toBe(true); + expect(Dom.isHTMLString('plain text')).toBe(false); + }); + + it('returns content lengths', () => { + const input = document.createElement('input'); + const text = document.createTextNode('abc'); + const span = document.createElement('span'); + + input.value = 'value'; + span.textContent = 'longer'; + + expect(Dom.getContentLength(input)).toBe(5); + expect(Dom.getContentLength(text)).toBe(3); + expect(Dom.getContentLength(span)).toBe(6); + }); + }); + + describe('block helpers', () => { + it('detects inline-only trees', () => { + const inline = document.createElement('div'); + const block = document.createElement('div'); + + inline.innerHTML = 'textbold'; + block.innerHTML = '

paragraph

'; + + expect(Dom.containsOnlyInlineElements(inline)).toBe(true); + expect(Dom.containsOnlyInlineElements(block)).toBe(false); + }); + + it('collects deepest block elements', () => { + const parent = document.createElement('div'); + const section = document.createElement('section'); + const article = document.createElement('article'); + const span = document.createElement('span'); + + span.textContent = 'inline'; + article.appendChild(span); + section.appendChild(article); + parent.appendChild(section); + + const blocks = Dom.getDeepestBlockElements(parent); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toBe(article); + }); + }); + + describe('holder and anchor helpers', () => { + it('resolves holders by id or element', () => { + const el = document.createElement('div'); + + el.id = 'holder-element'; + document.body.appendChild(el); + + expect(Dom.getHolder(el)).toBe(el); + expect(Dom.getHolder('holder-element')).toBe(el); + expect(() => Dom.getHolder('missing')).toThrow('Element with id "missing" not found'); + }); + + it('detects anchors', () => { + const link = document.createElement('a'); + const div = document.createElement('div'); + + expect(Dom.isAnchor(link)).toBe(true); + expect(Dom.isAnchor(div)).toBe(false); + }); + }); + + describe('geometry helpers', () => { + it('calculates offsets relative to document', () => { + const element = document.createElement('div'); + + document.body.appendChild(element); + document.documentElement.scrollLeft = 35; + document.documentElement.scrollTop = 15; + + const rect: DOMRect = { + x: 20, + y: 10, + width: 100, + height: 40, + top: 10, + left: 20, + right: 120, + bottom: 50, + toJSON() { + return {}; + }, + }; + + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect); + + const offset = Dom.offset(element); + + expect(offset).toEqual({ + top: 25, + left: 55, + bottom: 65, + right: 155, + }); + }); + }); + + describe('text traversal helper', () => { + it('returns node and offset for given total offset', () => { + const root = document.createElement('div'); + const text1 = document.createTextNode('Hello '); + const span = document.createElement('span'); + const text2 = document.createTextNode('world'); + + span.appendChild(text2); + root.append(text1, span); + + const exact = Dom.getNodeByOffset(root, 8); + const beyond = Dom.getNodeByOffset(root, 999); + + expect(exact.node).toBe(text2); + expect(exact.offset).toBe(2); + expect(beyond.node).toBe(text2); + expect(beyond.offset).toBe(text2.textContent?.length ?? 0); + }); + }); + + describe('standalone helpers', () => { + it('detects collapsed whitespaces', () => { + expect(isCollapsedWhitespaces(' ')).toBe(true); + expect(isCollapsedWhitespaces(' text ')).toBe(false); + }); + + it('calculates baselines using computed styles', () => { + const element = document.createElement('div'); + const style = { + fontSize: '20px', + lineHeight: '30px', + paddingTop: '4px', + borderTopWidth: '1px', + marginTop: '2px', + } as CSSStyleDeclaration; + + vi.spyOn(window, 'getComputedStyle').mockReturnValue(style); + + const baseline = calculateBaseline(element); + + // marginTop + borderTopWidth + paddingTop + (lineHeight - fontSize)/2 + fontSize * 0.8 + expect(baseline).toBeCloseTo(28); + }); + + it('toggles empty marks based on element content', () => { + const element = document.createElement('div'); + + element.textContent = ''; + toggleEmptyMark(element); + expect(element.dataset.empty).toBe('true'); + + element.textContent = 'filled'; + toggleEmptyMark(element); + expect(element.dataset.empty).toBe('false'); + }); + }); +}); +