fix: all eslint issues

This commit is contained in:
JackUait 2025-11-15 03:55:38 +03:00
commit c6fc903e2a
5 changed files with 978 additions and 131 deletions

View file

@ -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',

View file

@ -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>) => 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);
});
});
}
/**

View file

@ -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';
};

View file

@ -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<Core> => {
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<string, unknown> =>
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<EditorModules>;
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<void> }).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<void> }).render.bind(core);
delete (core.moduleInstances as Partial<EditorModules>).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<void> }).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'
);
});
});
});

View file

@ -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 = '<span class="item">one</span><span class="item">two</span>';
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('<div></div>')).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 = '<span>text</span><b>bold</b>';
block.innerHTML = '<p>paragraph</p>';
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');
});
});
});