mirror of
https://github.com/codex-team/editor.js
synced 2026-03-16 15:45:47 +01:00
fix: all eslint issues
This commit is contained in:
parent
1d6d0a477e
commit
c6fc903e2a
5 changed files with 978 additions and 131 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
|
|
|||
456
test/unit/components/core.test.ts
Normal file
456
test/unit/components/core.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
396
test/unit/components/dom.test.ts
Normal file
396
test/unit/components/dom.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue