fix: lint issues in index.ts

This commit is contained in:
JackUait 2025-11-07 15:08:25 +03:00
commit ca50d7895e
16 changed files with 1778 additions and 1496 deletions

View file

@ -1,60 +0,0 @@
{
"extends": [
"codex/ts"
],
"globals": {
"Node": true,
"Range": true,
"HTMLElement": true,
"HTMLDivElement": true,
"Element": true,
"Selection": true,
"SVGElement": true,
"Text": true,
"InsertPosition": true,
"PropertyKey": true,
"MouseEvent": true,
"TouchEvent": true,
"KeyboardEvent": true,
"ClipboardEvent": true,
"DragEvent": true,
"Event": true,
"EventTarget": true,
"Document": true,
"NodeList": true,
"File": true,
"FileList": true,
"MutationRecord": true,
"AddEventListenerOptions": true,
"DataTransfer": true,
"DOMRect": true,
"ClientRect": true,
"ArrayLike": true,
"InputEvent": true,
"unknown": true,
"requestAnimationFrame": true,
"navigator": true
},
"rules": {
"jsdoc/require-returns-type": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error",
"prefer-arrow-callback": "error",
"func-style": ["error", "expression", { "allowArrowFunctions": true }]
},
"overrides": [
{
"files": [
"tsconfig.json",
"package.json",
"tsconfig.*.json",
"tslint.json"
],
"rules": {
"quotes": [1, "double"],
"semi": [1, "never"]
}
}
]
}

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ yarn-error.log
test/cypress/screenshots
test/cypress/videos
test-results
dist/

54
.vscode/settings.json vendored
View file

@ -6,14 +6,50 @@
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"eslint.options": {
"extensions": [".ts", ".tsx", ".js", ".jsx"]
},
"eslint.workingDirectories": [{ "mode": "auto" }],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"eslint.useFlatConfig": true,
"cSpell.words": [
"autofocused",
"Behaviour",
"cacheable",
"childs",
"codexteam",
"colspan",
"contenteditable",
"contentless",
"Convertable",
"cssnano",
"cssnext",
"Debouncer",
"devserver",
"editorjs",
"entrypoints",
"Flippable",
"GRAMMARLY",
"hsablonniere",
"intellij",
"keydown",
"keydowns",
"Kilian",
"mergeable",
"movetostart",
"nofollow",
"opencollective",
"preconfigured",
"resetors",
"rowspan",
"selectall",
"sometool",
"stylelint",
"textareas",
"twitterwidget",
"typeof",
"Unmergeable",
"viewports"
]
}

Binary file not shown.

146
eslint.config.mjs Normal file
View file

@ -0,0 +1,146 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
import cypress from 'eslint-plugin-cypress';
import playwright from 'eslint-plugin-playwright';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
{
ignores: [
'node_modules/**',
'**/*.d.ts',
'src/components/tools/paragraph/**',
'src/polyfills.ts',
],
},
...compat.config({
root: true,
extends: ['codex/ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
},
globals: {
Node: true,
Range: true,
HTMLElement: true,
HTMLDivElement: true,
Element: true,
Selection: true,
SVGElement: true,
Text: true,
InsertPosition: true,
PropertyKey: true,
MouseEvent: true,
TouchEvent: true,
KeyboardEvent: true,
ClipboardEvent: true,
DragEvent: true,
Event: true,
EventTarget: true,
Document: true,
NodeList: true,
File: true,
FileList: true,
MutationRecord: true,
AddEventListenerOptions: true,
DataTransfer: true,
DOMRect: true,
ClientRect: true,
ArrayLike: true,
InputEvent: true,
unknown: true,
requestAnimationFrame: true,
navigator: true,
},
rules: {
'jsdoc/require-returns-type': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
'prefer-arrow-callback': 'error',
'func-style': ['error', 'expression', { allowArrowFunctions: true }],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['*.ts', '*.tsx'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
},
{
files: ['tsconfig.json', 'package.json', 'tsconfig.*.json', 'tslint.json'],
rules: {
quotes: [1, 'double'],
semi: [1, 'never'],
},
},
],
}),
{
files: ['test/cypress/**/*.ts'],
plugins: {
cypress,
},
languageOptions: {
globals: {
// Cypress/Mocha globals
describe: 'readonly',
it: 'readonly',
context: 'readonly',
before: 'readonly',
after: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
cy: 'readonly',
Cypress: 'readonly',
expect: 'readonly',
assert: 'readonly',
// Custom globals
EditorJS: 'readonly',
},
},
rules: {
...cypress.configs.recommended.rules,
'cypress/require-data-selectors': 'error',
'cypress/no-unnecessary-waiting': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
},
},
{
files: ['test/playwright/**/*.ts'],
plugins: {
playwright,
},
languageOptions: {
globals: {
// Playwright globals
test: 'readonly',
expect: 'readonly',
// Custom globals
EditorJS: 'readonly',
},
},
rules: {
...playwright.configs.recommended.rules,
'@typescript-eslint/no-magic-numbers': 'off',
},
},
];

View file

@ -17,12 +17,14 @@
"serve": "vite --no-open",
"build": "vite build --mode production",
"build:test": "vite build --mode test",
"lint": "eslint src/ --ext .ts && yarn lint:tests",
"lint:errors": "eslint src/ --ext .ts --quiet",
"lint:fix": "eslint src/ --ext .ts --fix",
"lint:tests": "eslint test/ --ext .ts",
"lint": "eslint src/ && yarn lint:tests",
"lint:errors": "eslint src/ --quiet",
"lint:fix": "eslint src/ --fix",
"lint:tests": "eslint test/",
"test:e2e": "yarn build:test && cypress run",
"test:e2e:open": "yarn build:test && cypress open"
"test:e2e:open": "yarn build:test && cypress open",
"test:e2e:playwright": "playwright test",
"test:e2e:playwright:ui": "playwright test --ui"
},
"author": "CodeX",
"license": "Apache-2.0",
@ -31,6 +33,7 @@
"url": "git+https://github.com/codex-team/editor.js.git"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",
"@babel/register": "^7.21.0",
"@codexteam/icons": "0.3.2",
"@codexteam/shortcuts": "^1.1.1",
@ -40,6 +43,8 @@
"@editorjs/header": "^2.8.8",
"@editorjs/paragraph": "^2.11.6",
"@editorjs/simple-image": "^1.4.1",
"@eslint/eslintrc": "^3.1.0",
"@playwright/test": "^1.56.1",
"@types/node": "^18.15.11",
"chai-subset": "^1.6.0",
"core-js": "3.30.0",
@ -52,6 +57,7 @@
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-playwright": "^2.3.0",
"html-janitor": "^2.0.4",
"nanoid": "^4.0.2",
"postcss-apply": "^0.12.0",

30
playwright.config.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineConfig } from '@playwright/test';
/**
* Playwright Configuration
*
* Recommended plugins installed:
* - @axe-core/playwright: For accessibility testing
* Usage in tests: import { injectAxe, checkA11y } from '@axe-core/playwright';
* await injectAxe(page);
* await checkA11y(page);
*
* - eslint-plugin-playwright: For linting Playwright tests
* Configured in eslint.config.mjs
*/
export default defineConfig({
testDir: 'test/playwright/tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
reporter: [ [ 'list' ] ],
use: {
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
retries: process.env.CI ? 2 : 0,
});

View file

@ -67,7 +67,7 @@ interface BlockConstructorOptions {
/**
* @class Block
* @classdesc This class describes editor`s block, including block`s HTMLElement, data and tool
* @property {BlockTool} tool current block tool (Paragraph, for example)
* @property {BlockToolAdapter} tool current block tool (Paragraph, for example)
* @property {object} CSS block`s css classes
*/
@ -98,7 +98,7 @@ interface BlockEvents {
/**
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
* @property {BlockTool} tool - Tool instance
* @property {BlockToolAdapter} tool - Tool instance
* @property {HTMLElement} holder - Div element that wraps block content with Tool's content. Has `ce-block` CSS class
* @property {HTMLElement} pluginsContent - HTML content that returns by Tool's render function
*/
@ -196,11 +196,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
private readonly editorEventBus: EventsDispatcher<EditorEventMap> | null = null;
/**
* Link to editor dom change callback. Used to remove listener on remove
*/
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void;
/**
* Current block API interface
*/
@ -226,10 +221,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
this.config = tool.settings.config || {};
this.config = tool.settings.config ?? {};
this.editorEventBus = eventBus || null;
this.blockAPI = new BlockAPI(this);
this.tool = tool;
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
@ -265,6 +261,270 @@ export default class Block extends EventsDispatcher<BlockEvents> {
});
}
/**
* Calls Tool's method
*
* Method checks tool property {MethodName}. Fires method with passes params If it is instance of Function
*
* @param {string} methodName - method to call
* @param {object} params - method argument
*/
public call(methodName: string, params?: object): void {
/**
* call Tool's method with the instance context
*/
const method = (this.toolInstance as unknown as Record<string, unknown>)[methodName];
if (_.isFunction(method)) {
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
_.log(
'`appendCallback` hook is deprecated and will be removed in the next major release. ' +
'Use `rendered` hook instead',
'warn'
);
}
try {
// eslint-disable-next-line no-useless-call
method.call(this.toolInstance, params);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
_.log(`Error during '${methodName}' call: ${errorMessage}`, 'error');
}
}
}
/**
* Call plugins merge method
*
* @param {BlockToolData} data - data to merge
*/
public async mergeWith(data: BlockToolData): Promise<void> {
if (!_.isFunction(this.toolInstance.merge)) {
throw new Error(`Block tool "${this.name}" does not support merging`);
}
await this.toolInstance.merge(data);
}
/**
* Extracts data from Block
* Groups Tool's save processing time
*
* @returns {object}
*/
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
[
...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries(),
]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
try {
tunesData[name] = tune.save();
} catch (e) {
_.log(`Tune ${tune.constructor.name} save method throws an Error %o`, 'warn', e);
}
}
});
/**
* Measuring execution time
*/
const measuringStart = window.performance.now();
let measuringEnd;
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
/** measure promise execution */
measuringEnd = window.performance.now();
return {
id: this.id,
tool: this.name,
data: finishedExtraction,
tunes: tunesData,
time: measuringEnd - measuringStart,
};
})
.catch((error) => {
_.log(`Saving process for ${this.name} tool failed due to the ${error}`, 'log', 'red');
return undefined;
});
}
/**
* Uses Tool's validation method to check the correctness of output data
* Tool's validation method is optional
*
* @description Method returns true|false whether data passed the validation or not
* @param {BlockToolData} data - data to validate
* @returns {Promise<boolean>} valid
*/
public async validate(data: BlockToolData): Promise<boolean> {
let isValid = true;
if (this.toolInstance.validate instanceof Function) {
isValid = await this.toolInstance.validate(data);
}
return isValid;
}
/**
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
const toolTunesPopoverParams: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
if ($.isElement(tunesDefinedInTool)) {
toolTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tunesDefinedInTool,
});
} else if (Array.isArray(tunesDefinedInTool)) {
toolTunesPopoverParams.push(...tunesDefinedInTool);
} else {
toolTunesPopoverParams.push(tunesDefinedInTool);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
commonTunesPopoverParams.push(tuneConfig);
}
});
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
* Update current input index with selection anchor node
*/
public updateCurrentInput(): void {
/**
* If activeElement is native input, anchorNode points to its parent.
* So if it is native input use it instead of anchorNode
*
* If anchorNode is undefined, also use activeElement
*/
const anchorNode = SelectionUtils.anchorNode;
const activeElement = document.activeElement;
if ($.isNativeInput(activeElement) || !anchorNode) {
this.currentInput = activeElement instanceof HTMLElement ? activeElement : undefined;
} else {
this.currentInput = anchorNode instanceof HTMLElement ? anchorNode : undefined;
}
}
/**
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
*/
public dispatchChange(): void {
this.didMutated();
}
/**
* Call Tool instance destroy method
*/
public destroy(): void {
this.unwatchBlockMutations();
this.removeInputEvents();
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
this.toolInstance.destroy();
}
}
/**
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
* This method returns the entry that is related to the Block (depended on the Block data)
*/
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
const toolboxSettings = this.tool.toolbox;
if (!toolboxSettings) {
return undefined;
}
/**
* If Tool specifies just the single entry, treat it like an active
*/
if (toolboxSettings.length === 1) {
return Promise.resolve(toolboxSettings[0]);
}
/**
* If we have several entries with their own data overrides,
* find those who matches some current data property
*
* Example:
* Tools' toolbox: [
* {title: "Heading 1", data: {level: 1} },
* {title: "Heading 2", data: {level: 2} }
* ]
*
* the Block data: {
* text: "Heading text",
* level: 2
* }
*
* that means that for the current block, the second toolbox item (matched by "{level: 2}") is active
*/
const blockData = await this.data;
return toolboxSettings.find((item) => {
return isSameBlockData(item.data, blockData);
});
}
/**
* Exports Block data as string using conversion config
*/
public async exportDataAsString(): Promise<string> {
const blockData = await this.data;
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Link to editor dom change callback. Used to remove listener on remove
*/
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void = () => {};
/**
* Find and return all editable elements (contenteditable and native inputs) in the Tool HTML
*/
@ -306,7 +566,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @param element - HTML Element to set as current input
*/
public set currentInput(element: HTMLElement) {
public set currentInput(element: HTMLElement | undefined) {
if (element === undefined) {
return;
}
const index = this.inputs.findIndex((input) => input === element || input.contains(element));
if (index !== -1) {
@ -483,7 +747,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @param {boolean} state - 'true' if block is drop target, false otherwise
*/
public set dropTarget(state) {
public set dropTarget(state: boolean) {
this.holder.classList.toggle(Block.CSS.dropTarget, state);
}
@ -493,250 +757,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @returns {HTMLElement}
*/
public get pluginsContent(): HTMLElement {
if (this.toolRenderedElement === null) {
throw new Error('Block pluginsContent is not yet initialized');
}
return this.toolRenderedElement;
}
/**
* Calls Tool's method
*
* Method checks tool property {MethodName}. Fires method with passes params If it is instance of Function
*
* @param {string} methodName - method to call
* @param {object} params - method argument
*/
public call(methodName: string, params?: object): void {
/**
* call Tool's method with the instance context
*/
if (_.isFunction(this.toolInstance[methodName])) {
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
_.log(
'`appendCallback` hook is deprecated and will be removed in the next major release. ' +
'Use `rendered` hook instead',
'warn'
);
}
try {
// eslint-disable-next-line no-useless-call
this.toolInstance[methodName].call(this.toolInstance, params);
} catch (e) {
_.log(`Error during '${methodName}' call: ${e.message}`, 'error');
}
}
}
/**
* Call plugins merge method
*
* @param {BlockToolData} data - data to merge
*/
public async mergeWith(data: BlockToolData): Promise<void> {
await this.toolInstance.merge(data);
}
/**
* Extracts data from Block
* Groups Tool's save processing time
*
* @returns {object}
*/
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
[
...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries(),
]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
try {
tunesData[name] = tune.save();
} catch (e) {
_.log(`Tune ${tune.constructor.name} save method throws an Error %o`, 'warn', e);
}
}
});
/**
* Measuring execution time
*/
const measuringStart = window.performance.now();
let measuringEnd;
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
/** measure promise execution */
measuringEnd = window.performance.now();
return {
id: this.id,
tool: this.name,
data: finishedExtraction,
tunes: tunesData,
time: measuringEnd - measuringStart,
};
})
.catch((error) => {
_.log(`Saving process for ${this.name} tool failed due to the ${error}`, 'log', 'red');
});
}
/**
* Uses Tool's validation method to check the correctness of output data
* Tool's validation method is optional
*
* @description Method returns true|false whether data passed the validation or not
* @param {BlockToolData} data - data to validate
* @returns {Promise<boolean>} valid
*/
public async validate(data: BlockToolData): Promise<boolean> {
let isValid = true;
if (this.toolInstance.validate instanceof Function) {
isValid = await this.toolInstance.validate(data);
}
return isValid;
}
/**
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
const toolTunesPopoverParams: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
if ($.isElement(tunesDefinedInTool)) {
toolTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tunesDefinedInTool,
});
} else if (Array.isArray(tunesDefinedInTool)) {
toolTunesPopoverParams.push(...tunesDefinedInTool);
} else {
toolTunesPopoverParams.push(tunesDefinedInTool);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
commonTunesPopoverParams.push(tuneConfig);
}
});
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
* Update current input index with selection anchor node
*/
public updateCurrentInput(): void {
/**
* If activeElement is native input, anchorNode points to its parent.
* So if it is native input use it instead of anchorNode
*
* If anchorNode is undefined, also use activeElement
*/
this.currentInput = $.isNativeInput(document.activeElement) || !SelectionUtils.anchorNode
? document.activeElement
: SelectionUtils.anchorNode;
}
/**
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
*/
public dispatchChange(): void {
this.didMutated();
}
/**
* Call Tool instance destroy method
*/
public destroy(): void {
this.unwatchBlockMutations();
this.removeInputEvents();
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
this.toolInstance.destroy();
}
}
/**
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
* This method returns the entry that is related to the Block (depended on the Block data)
*/
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
const toolboxSettings = this.tool.toolbox;
/**
* If Tool specifies just the single entry, treat it like an active
*/
if (toolboxSettings.length === 1) {
return Promise.resolve(this.tool.toolbox[0]);
}
/**
* If we have several entries with their own data overrides,
* find those who matches some current data property
*
* Example:
* Tools' toolbox: [
* {title: "Heading 1", data: {level: 1} },
* {title: "Heading 2", data: {level: 2} }
* ]
*
* the Block data: {
* text: "Heading text",
* level: 2
* }
*
* that means that for the current block, the second toolbox item (matched by "{level: 2}") is active
*/
const blockData = await this.data;
const toolboxItems = toolboxSettings;
return toolboxItems?.find((item) => {
return isSameBlockData(item.data, blockData);
});
}
/**
* Exports Block data as string using conversion config
*/
public async exportDataAsString(): Promise<string> {
const blockData = await this.data;
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Make default Block wrappers and put Tool`s content there
*
@ -759,10 +786,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* Saving a reference to plugin's content element for guaranteed accessing it later
* Handle both synchronous HTMLElement and Promise<HTMLElement> cases
*/
this.toolRenderedElement = pluginsContent;
contentNode.appendChild(this.toolRenderedElement);
if (pluginsContent instanceof Promise) {
// Handle async render: resolve the promise and update DOM when ready
pluginsContent.then((resolvedElement) => {
this.toolRenderedElement = resolvedElement;
contentNode.appendChild(resolvedElement);
}).catch((error) => {
_.log(`Tool render promise rejected: %o`, 'error', error);
});
} else {
// Handle synchronous render
this.toolRenderedElement = pluginsContent;
contentNode.appendChild(pluginsContent);
}
/**
* Block Tunes might wrap Block's content node to provide any UI changes
@ -866,7 +904,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* - InputEvent <input> change
* - undefined manual triggering of block.dispatchChange()
*/
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent | undefined = undefined): void => {
/**
* Block API have dispatchChange() method. In this case, mutations list will be undefined.
*/
@ -914,10 +952,15 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* "characterData" mutation record has Text node as a target, so we need to get parent element to check it for mutation-free attribute
*/
node = node.parentElement;
const parentElement = node.parentElement;
if (!parentElement) {
return false;
}
node = parentElement;
}
return node && (node as HTMLElement).closest('[data-mutation-free="true"]') !== null;
return (node as HTMLElement).closest('[data-mutation-free="true"]') !== null;
});
});
@ -964,7 +1007,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.redactorDomChangedCallback = (payload) => {
const { mutations } = payload;
const mutationBelongsToBlock = mutations.some(record => isMutationBelongsToElement(record, this.toolRenderedElement));
const toolElement = this.toolRenderedElement;
const mutationBelongsToBlock = toolElement !== null
&& mutations.some(record => isMutationBelongsToElement(record, toolElement));
if (mutationBelongsToBlock) {
this.didMutated(mutations);
@ -988,8 +1033,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @param mutations - records of block content mutations
*/
private detectToolRootChange(mutations: MutationRecord[]): void {
const toolElement = this.toolRenderedElement;
if (toolElement === null) {
return;
}
mutations.forEach(record => {
const toolRootHasBeenUpdated = Array.from(record.removedNodes).includes(this.toolRenderedElement);
const toolRootHasBeenUpdated = Array.from(record.removedNodes).includes(toolElement);
if (toolRootHasBeenUpdated) {
const newToolElement = record.addedNodes[record.addedNodes.length - 1];

View file

@ -895,8 +895,8 @@ export default class BlockManager extends Module {
const queue = new PromiseQueue();
// Create a copy of the blocks array to avoid issues with array modification during iteration
const blocksToRemove = [...this.blocks];
const blocksToRemove = [ ...this.blocks ];
blocksToRemove.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);

View file

@ -43,39 +43,38 @@ interface ToolbarNodes {
}
/**
*
* «Toolbar» is the node that moves up/down over current block
*«Toolbar» is the node that moves up/down over current block
*
* ______________________________________ Toolbar ____________________________________________
* | |
* | ..................... Content ......................................................... |
* | . ........ Block Actions ........... |
* | . . [Open Settings] . |
* | . [Plus Button] [Toolbox: {Tool1}, {Tool2}] . . |
* | . . [Settings Panel] . |
* | . .................................. |
* | ....................................................................................... |
* | |
* |___________________________________________________________________________________________|
*______________________________________ Toolbar ____________________________________________
*| |
*| ..................... Content ......................................................... |
*| . ........ Block Actions ........... |
*| . . [Open Settings] . |
*| . [Plus Button] [Toolbox: {Tool1}, {Tool2}] . . |
*| . . [Settings Panel] . |
*| . .................................. |
*| ....................................................................................... |
*| |
*|___________________________________________________________________________________________|
*
*
* Toolbox its an Element contains tools buttons. Can be shown by Plus Button.
*Toolbox its an Element contains tools buttons. Can be shown by Plus Button.
*
* _______________ Toolbox _______________
* | |
* | [Header] [Image] [List] [Quote] ... |
* |_______________________________________|
*_______________ Toolbox _______________
*| |
*| [Header] [Image] [List] [Quote] ... |
*|_______________________________________|
*
*
* Settings Panel is an Element with block settings:
*
* ____ Settings Panel ____
* | ...................... |
* | . Tool Settings . |
* | ...................... |
* | . Default Settings . |
* | ...................... |
* |________________________|
*Settings Panel is an Element with block settings:
*
*____ Settings Panel ____
*| ...................... |
*| . Tool Settings . |
*| ...................... |
*| . Default Settings . |
*| ...................... |
*|________________________|
*
* @class
* @classdesc Toolbar module

View file

@ -371,7 +371,7 @@ export const sequence = async (
return waitNextBlock(currentValue, success, fallback);
}, Promise.resolve());
}
};
/**
* Make array from array-like collection
@ -380,9 +380,9 @@ export const sequence = async (
* @returns {Array}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function array(collection: ArrayLike<any>): any[] {
export const array = (collection: ArrayLike<any>): any[] => {
return Array.prototype.slice.call(collection);
}
};
/**
* Delays method execution

View file

@ -1,738 +0,0 @@
import type EditorJS from '../../../../../types/index';
import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader';
import type { ConversionConfig } from '../../../../../types/index';
import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks';
describe('Backspace keydown', function () {
describe('starting whitespaces handling', function () {
it('&nbsp;| — should delete visible space', function () {
createEditorWithTextBlocks([
'1',
'&nbsp;2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '2');
});
it(' | — should ignore invisible space before caret and handle it like regular backspace case (merge with previous)', function () {
createEditorWithTextBlocks([
'1',
' 2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('<b></b>| — should ignore empty tags before caret and handle it like regular backspace case (merge with previous)', function () {
createEditorWithTextBlocks([
'1',
'<b></b>2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('<b></b>&nbsp;| — should remove visible space and ignore empty tag', function () {
createEditorWithTextBlocks([
'1',
'<b></b>&nbsp;2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}') // remove nbsp
.type('{backspace}'); // ignore empty tag and merge
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('&nbsp;<b></b>| — should remove visible space and ignore empty tag', function () {
createEditorWithTextBlocks([
'1',
'<b></b>&nbsp;2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}') // remove nbsp
.type('{backspace}'); // ignore empty tag and merge
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it(' &nbsp;| — should remove visible space and ignore space', function () {
createEditorWithTextBlocks([
'1',
' &nbsp;2',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{leftArrow}') // set caret before "2"
.type('{backspace}') // remove nbsp
.type('{backspace}'); // ignore regular space and merge
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('&nbsp; &nbsp;| — should delete visible and invisble whitespaces in the abscence of any non whitespace characters', function () {
createEditorWithTextBlocks([
'1',
'&nbsp; &nbsp;',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{downArrow}')
.type('{backspace}')
.type('{backspace}')
.type('{backspace}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', '1');
});
});
it('should just delete chars (native behaviour) when some fragment is selected', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.selectText('The ')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', 'second block');
});
it('should just delete chars (native behaviour) when Caret is not at the start of the Block', function () {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click() // caret will be at the end of the block
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.last()
.should('have.text', 'The second bloc'); // last char is removed
});
it('should navigate previous input when Caret is not at the first input', function () {
/**
* Mock of tool with several inputs
*/
class ExampleOfToolWithSeveralInputs {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
const input = document.createElement('div');
const input2 = document.createElement('div');
container.setAttribute('data-cy', 'quote-tool');
input.setAttribute('contenteditable', 'true');
input2.setAttribute('contenteditable', 'true');
container.append(input, input2);
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void { }
}
cy.createEditor({
tools: {
quote: ExampleOfToolWithSeveralInputs,
},
data: {
blocks: [
{
type: 'quote',
data: {},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.last()
.click()
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.first()
.as('firstInput');
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('@firstInput').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should remove previous Block if Caret at the start of the Block and previous Block is empty. Also, should close the Toolbox', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: '', // empty block
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block2'); // second block is still here
});
});
it('should remove current Block if it is empty, but previous is not. Also, should close the Toolbox and set Caret to the end of the prev Block', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: '', // empty block
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{backspace}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
});
/**
* Caret is set to the end of the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
expect(range.startOffset).to.be.eq($block[0].textContent?.length ?? 0);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge current Block with the previous one if Caret at the start of the Block and both Blocks are mergeable. Also, should close the Toolbox. Caret should be places in a place of glue', function () {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
/**
* Current Block has been removed
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
expect(blocks[0].data.text).to.eq('First blockSecond block'); // text has been merged
});
/**
* Caret is set to the place of merging
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
range.startContainer.normalize(); // glue merged text nodes
expect(range.startOffset).to.be.eq('First block'.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge blocks of different types (Paragraph -> Header) if they have a valid conversion config. Also, should close the Toolbox. Caret should be places in a place of glue', function () {
cy.createEditor({
tools: {
header: SimpleHeader,
},
data: {
blocks: [
{
id: 'block1',
type: 'header',
data: {
text: 'First block heading',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block paragraph',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
expect(blocks[0].data.text).to.eq('First block headingSecond block paragraph'); // text has been merged
});
/**
* Caret is set to the place of merging
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('[data-cy=block-wrapper]')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
range.startContainer.normalize(); // glue merged text nodes
expect(range.startOffset).to.be.eq('First block heading'.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge blocks of different types (Header -> Paragraph) if they have a valid conversion config. Also, should close the Toolbox. Caret should be places in a place of glue', function () {
cy.createEditor({
tools: {
header: SimpleHeader,
},
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block paragraph',
},
},
{
id: 'block2',
type: 'header',
data: {
text: 'Second block heading',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('[data-cy="block-wrapper"][data-id="block2"]')
.click()
.type('{home}') // move caret to the beginning
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1); // one block has been removed
expect(blocks[0].id).to.eq('block1'); // second block is still here
expect(blocks[0].data.text).to.eq('First block paragraphSecond block heading'); // text has been merged
});
/**
* Caret is set to the place of merging
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('[data-cy=block-wrapper]')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
range.startContainer.normalize(); // glue merged text nodes
expect(range.startOffset).to.be.eq('First block paragraph'.length);
});
});
/**
* Toolbox has been closed
*/
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() and conversionConfig). Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge() method
*/
class UnmergeableToolWithoutConversionConfig {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
* Saving logic is not necessary for this test
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
public save(): void { }
}
cy.createEditor({
tools: {
code: UnmergeableToolWithoutConversionConfig,
},
data: {
blocks: [
{
type: 'code',
data: {},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=unmergeable-tool]')
.as('firstBlock');
/**
* Caret is set to the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('@firstBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() but has the conversionConfig). Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge() method
*/
class UnmergeableToolWithConversionConfig {
/**
* Render method mock
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
* Saving logic is not necessary for this test
*/
public save(): { key: string } {
return {
key: 'value',
};
}
/**
* Mock of the conversionConfig
*/
public static get conversionConfig(): ConversionConfig {
return {
export: 'key',
import: 'key',
};
}
}
cy.createEditor({
tools: {
code: UnmergeableToolWithConversionConfig,
},
data: {
blocks: [
{
type: 'code',
data: {},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('[data-cy=unmergeable-tool]')
.as('firstBlock');
/**
* Caret is set to the previous Block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('Selection is null or has no ranges');
}
const range = selection.getRangeAt(0);
cy.get('@firstBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
describe('at the start of the first Block', function () {
it('should do nothing if Block is not empty', function () {
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.type('{home}')
.type('{backspace}');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.should('have.length', 1)
.should('have.text', 'The only block. Not empty');
});
});
});

View file

@ -1,394 +0,0 @@
import ToolMock from '../../../fixtures/tools/ToolMock';
/**
* Mock of tool that contains two inputs
*/
class ToolWithTwoInputs extends ToolMock {
/**
* Create element with two inputs
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
const input1 = document.createElement('div');
const input2 = document.createElement('div');
input1.contentEditable = 'true';
input2.contentEditable = 'true';
wrapper.setAttribute('data-cy', 'tool-with-two-inputs');
wrapper.appendChild(input1);
wrapper.appendChild(input2);
return wrapper;
}
}
/**
* Mock of tool without inputs
*/
class ContentlessTool extends ToolMock {
public static contentless = true;
/**
* Create element without inputs
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-cy', 'contentless-tool');
wrapper.textContent = '***';
return wrapper;
}
}
/**
* Time to wait for caret to finish moving
*/
const CARET_MOVE_TIME = 100;
describe('Tab keydown', function () {
it('should focus next Block if Block contains only one input', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.then(($secondBlock) => {
const editorWindow = $secondBlock.get(0).ownerDocument.defaultView;
if (!editorWindow) {
throw new Error('Window is not available');
}
const selection = editorWindow.getSelection();
if (!selection) {
throw new Error('Selection is not available');
}
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer.parentElement).to.equal($secondBlock.get(0));
});
});
it('should focus next input if Block contains several inputs', () => {
cy.createEditor({
tools: {
toolWithTwoInputs: {
class: ToolWithTwoInputs,
},
},
data: {
blocks: [
{
type: 'toolWithTwoInputs',
data: {},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.last()
.then(($secondInput) => {
const editorWindow = $secondInput.get(0).ownerDocument.defaultView;
if (!editorWindow) {
throw new Error('Window is not available');
}
const selection = editorWindow.getSelection();
if (!selection) {
throw new Error('Selection is not available');
}
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer).to.equal($secondInput.get(0));
});
});
it('should highlight next Block if it does not contain any inputs (contentless Block)', () => {
cy.createEditor({
tools: {
contentlessTool: {
class: ContentlessTool,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'contentlessTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'third paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.click()
.trigger('keydown', { keyCode: 9 })
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=contentless-tool]')
.parents('.ce-block')
.should('have.class', 'ce-block--selected');
});
it('should focus next input after Editor when pressed in last Block', () => {
cy.createEditor({});
/**
* Add regular input after Editor
*/
cy.window()
.then((window) => {
const input = window.document.createElement('input');
input.setAttribute('data-cy', 'regular-input');
window.document.body.appendChild(input);
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.tab();
cy.get('[data-cy=regular-input]')
.should('have.focus');
});
});
describe('Shift+Tab keydown', function () {
it('should focus previous Block if Block contains only one input', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'first paragraph',
},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.first()
.then(($firstBlock) => {
const editorWindow = $firstBlock.get(0).ownerDocument.defaultView;
if (!editorWindow) {
throw new Error('Window is not available');
}
const selection = editorWindow.getSelection();
if (!selection) {
throw new Error('Selection is not available');
}
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer.parentElement).to.equal($firstBlock.get(0));
});
});
it('should focus previous input if Block contains several inputs', () => {
cy.createEditor({
tools: {
toolWithTwoInputs: {
class: ToolWithTwoInputs,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'toolWithTwoInputs',
data: {},
},
],
},
});
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=tool-with-two-inputs]')
.find('[contenteditable=true]')
.first()
.then(($firstInput) => {
const editorWindow = $firstInput.get(0).ownerDocument.defaultView;
if (!editorWindow) {
throw new Error('Window is not available');
}
const selection = editorWindow.getSelection();
if (!selection) {
throw new Error('Selection is not available');
}
const range = selection.getRangeAt(0);
/**
* Check that second block contains range
*/
expect(range.startContainer).to.equal($firstInput.get(0));
});
});
it('should highlight previous Block if it does not contain any inputs (contentless Block)', () => {
cy.createEditor({
tools: {
contentlessTool: {
class: ContentlessTool,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'contentlessTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'third paragraph',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.trigger('keydown', {
keyCode: 9,
shiftKey: true,
})
.wait(CARET_MOVE_TIME);
cy.get('[data-cy=contentless-tool]')
.parents('.ce-block')
.should('have.class', 'ce-block--selected');
});
it('should focus previous input before Editor when pressed in first Block', () => {
cy.createEditor({});
/**
* Add regular input before Editor
*/
cy.window()
.then((window) => {
const input = window.document.createElement('input');
input.setAttribute('data-cy', 'regular-input');
window.document.body.insertBefore(input, window.document.body.firstChild);
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click()
.tab({ shift: true });
cy.get('[data-cy=regular-input]')
.should('have.focus');
});
});

View file

@ -0,0 +1,726 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '../../../../../types';
import type { OutputData } from '../../../../../types';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const EDITOR_SELECTOR = '[data-cy=editorjs]';
const BLOCK_SELECTOR = `${EDITOR_SELECTOR} div.ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} .ce-paragraph`;
const TOOLBAR_SELECTOR = `${EDITOR_SELECTOR} .ce-toolbar`;
const HOLDER_ID = 'editorjs';
/**
* Resets the editor instance by destroying any existing instance and clearing the holder.
*
* @param page - The Playwright Page object to interact with the browser.
*/
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
/**
*
* @param page - Playwright Page instance
* @param blocks - Array of block data to initialize the editor with
*/
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
const editor = new window.EditorJS({
holder: holderId,
data: { blocks: editorBlocks },
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID,
blocks });
};
/**
*
* @param page - The Playwright page object
* @param textBlocks - Array of text strings to create paragraph blocks from
*/
const createParagraphEditor = async (page: Page, textBlocks: string[]): Promise<void> => {
const blocks: OutputData['blocks'] = textBlocks.map((text) => ({
type: 'paragraph',
data: { text },
}));
await createEditorWithBlocks(page, blocks);
};
/**
*
* @param page - Playwright Page instance
* @param blocks - Array of block data to initialize the editor with
*/
const createEditorWithSimpleHeader = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
/**
*
*/
class SimpleHeader {
private data: { text: string };
private element: HTMLHeadingElement | null = null;
/**
*
* @param root0 - Constructor parameter object
* @param root0.data - Initial data for the header
*/
constructor({ data }: { data: { text: string } }) {
this.data = data;
}
/**
*
*/
public render(): HTMLHeadingElement {
this.element = document.createElement('h1');
this.element.contentEditable = 'true';
this.element.innerHTML = this.data.text;
return this.element;
}
/**
*
* @param data - Data object containing text to merge
*/
public merge(data: { text: string }): void {
this.element?.insertAdjacentHTML('beforeend', data.text);
}
/**
*
* @param element - The HTML heading element to save
*/
public save(element: HTMLHeadingElement): { text: string; level: number } {
return {
text: element.innerHTML,
level: 1,
};
}
/**
*
*/
public static get conversionConfig(): { export: string; import: string } {
return {
export: 'text',
import: 'text',
};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
header: SimpleHeader,
},
data: { blocks: editorBlocks },
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID,
blocks });
};
/**
*
* @param page - Playwright Page instance
*/
const createMultiInputToolEditor = async (page: Page): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId }) => {
/**
*
*/
class ExampleOfToolWithSeveralInputs {
/**
*
*/
public render(): HTMLElement {
const container = document.createElement('div');
const input = document.createElement('div');
const input2 = document.createElement('div');
container.dataset.cy = 'quote-tool';
input.contentEditable = 'true';
input2.contentEditable = 'true';
container.append(input, input2);
return container;
}
/**
*
*/
public save(): Record<string, never> {
return {};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
quote: ExampleOfToolWithSeveralInputs,
},
data: {
blocks: [
{
type: 'quote',
data: {},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
/**
*
* @param page - Playwright Page instance
* @param options - Configuration options
* @param options.hasConversionConfig - Whether the tool has a conversion config
*/
const createUnmergeableToolEditor = async (page: Page, options: { hasConversionConfig: boolean }): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId, hasConversionConfig }) => {
/**
*
*/
class UnmergeableTool {
/**
*
*/
public render(): HTMLElement {
const container = document.createElement('div');
container.dataset.cy = 'unmergeable-tool';
container.contentEditable = 'true';
container.innerHTML = 'Unmergeable not empty tool';
return container;
}
/**
*
*/
public save(): Record<string, string> {
return hasConversionConfig ? { key: 'value' } : {};
}
/**
*
*/
public static get conversionConfig(): { export: string; import: string } | undefined {
if (!hasConversionConfig) {
return undefined;
}
return {
export: 'key',
import: 'key',
};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
code: UnmergeableTool,
},
data: {
blocks: [
{
type: 'code',
data: {},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID,
hasConversionConfig: options.hasConversionConfig });
};
/**
*
* @param page - Playwright Page instance
*/
const saveEditor = async (page: Page): Promise<OutputData> => {
return page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance is not initialized');
}
return window.editorInstance.save();
});
};
/**
*
* @param locator - Playwright Locator for the element
* @param text - Text to select within the element
*/
const selectText = async (locator: Locator, text: string): Promise<void> => {
await locator.evaluate((element, targetText) => {
const textNode = element.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
throw new Error('Element does not contain a text node');
}
const content = textNode.textContent ?? '';
const start = content.indexOf(targetText);
if (start === -1) {
throw new Error(`Text "${targetText}" was not found`);
}
const range = element.ownerDocument.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, start + targetText.length);
const selection = element.ownerDocument.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}, text);
};
/**
*
* @param locator - Playwright Locator for the element
* @param options - Options for caret info retrieval
* @param options.normalize - Whether to normalize the range before getting caret info
*/
const getCaretInfo = (locator: Locator, options: { normalize?: boolean } = {}): Promise<{ inside: boolean; offset: number; textLength: number } | null> => {
return locator.evaluate((element, { normalize }) => {
const selection = element.ownerDocument.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
const range = selection.getRangeAt(0);
if (normalize) {
range.startContainer.normalize();
}
return {
inside: element.contains(range.startContainer),
offset: range.startOffset,
textLength: element.textContent?.length ?? 0,
};
}, { normalize: options.normalize ?? false });
};
/**
*
* @param locator - Playwright Locator for the element
*/
const expectCaretAtEnd = async (locator: Locator): Promise<void> => {
const caretInfo = await getCaretInfo(locator);
expect(caretInfo?.inside).toBeTruthy();
expect(caretInfo?.offset).toBe(caretInfo?.textLength);
};
/**
*
* @param locator - Playwright Locator for the element
* @param expectedOffset - Expected caret offset position
* @param options - Options for caret offset check
* @param options.normalize - Whether to normalize the range before checking offset
*/
const expectCaretOffset = async (locator: Locator, expectedOffset: number, options?: { normalize?: boolean }): Promise<void> => {
const caretInfo = await getCaretInfo(locator, options);
expect(caretInfo?.inside).toBeTruthy();
expect(caretInfo?.offset).toBe(expectedOffset);
};
/**
*
* @param page - Playwright Page instance
*/
const expectToolbarClosed = async (page: Page): Promise<void> => {
const toolbar = page.locator(TOOLBAR_SELECTOR);
await expect(toolbar).not.toHaveClass(/ce-toolbar--opened/);
};
test.describe('Backspace keydown', () => {
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test.describe('starting whitespaces handling', () => {
test('should delete non-breaking space at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', '&nbsp;2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('2');
});
test('should merge blocks when invisible space precedes caret at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', ' 2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('12');
});
test('should merge blocks when empty tags precede caret at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', '<b></b>2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('12');
});
test('should remove non-breaking space and ignore empty tags at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', '<b></b>&nbsp;2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('12');
});
test('should remove non-breaking space before empty tags at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', '<b></b>&nbsp;2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('12');
});
test('should remove non-breaking space and regular space at block start', async ({ page }) => {
await createParagraphEditor(page, ['1', ' &nbsp;2']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowLeft');
await lastParagraph.press('Backspace');
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('12');
});
test('should delete all whitespaces when block contains only whitespace characters', async ({ page }) => {
await createParagraphEditor(page, ['1', '&nbsp; &nbsp;']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('ArrowDown');
for (let i = 0; i < 4; i += 1) {
await page.keyboard.press('Backspace');
}
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('1');
});
});
test('should delete selected text using native behavior', async ({ page }) => {
await createParagraphEditor(page, ['The first block', 'The second block']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await selectText(lastParagraph, 'The ');
await page.keyboard.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('second block');
});
test('should delete character using native behavior when caret is not at block start', async ({ page }) => {
await createParagraphEditor(page, ['The first block', 'The second block']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Backspace');
await expect(page.locator(BLOCK_SELECTOR).last()).toHaveText('The second bloc');
});
test('should navigate to previous input when caret is not at first input', async ({ page }) => {
await createMultiInputToolEditor(page);
const inputs = page.locator(`${EDITOR_SELECTOR} [data-cy=quote-tool] div[contenteditable]`);
const lastInput = inputs.last();
await lastInput.click();
await lastInput.press('Backspace');
const caretInfo = await getCaretInfo(inputs.first());
expect(caretInfo?.inside).toBeTruthy();
});
test('should remove previous empty block and close toolbar when caret is at block start', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: { text: '' },
},
{
id: 'block2',
type: 'paragraph',
data: { text: 'Not empty block' },
},
]);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Home');
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block2');
});
test('should remove current empty block and place caret at end of previous block', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: { text: 'Not empty block' },
},
{
id: 'block2',
type: 'paragraph',
data: { text: '' },
},
]);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block1');
await expectCaretAtEnd(page.locator(PARAGRAPH_SELECTOR).first());
await expectToolbarClosed(page);
});
test('should merge mergeable blocks and place caret at merge point when caret is at block start', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: { text: 'First block' },
},
{
id: 'block2',
type: 'paragraph',
data: { text: 'Second block' },
},
]);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Home');
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block1');
expect((blocks[0].data as { text: string }).text).toBe('First blockSecond block');
await expectCaretOffset(page.locator(PARAGRAPH_SELECTOR).first(), 'First block'.length, { normalize: true });
await expectToolbarClosed(page);
});
test('should merge paragraph into header when conversion config is valid', async ({ page }) => {
await createEditorWithSimpleHeader(page, [
{
id: 'block1',
type: 'header',
data: { text: 'First block heading' },
},
{
id: 'block2',
type: 'paragraph',
data: { text: 'Second block paragraph' },
},
]);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Home');
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block1');
expect((blocks[0].data as { text: string }).text).toBe('First block headingSecond block paragraph');
await expectCaretOffset(page.locator(`${EDITOR_SELECTOR} [data-cy=block-wrapper]`).first(), 'First block heading'.length, { normalize: true });
await expectToolbarClosed(page);
});
test('should merge header into paragraph when conversion config is valid', async ({ page }) => {
await createEditorWithSimpleHeader(page, [
{
id: 'block1',
type: 'paragraph',
data: { text: 'First block paragraph' },
},
{
id: 'block2',
type: 'header',
data: { text: 'Second block heading' },
},
]);
const targetBlock = page.locator(`${EDITOR_SELECTOR} [data-cy="block-wrapper"][data-id="block2"]`);
await targetBlock.click();
await targetBlock.press('Home');
await targetBlock.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block1');
expect((blocks[0].data as { text: string }).text).toBe('First block paragraphSecond block heading');
await expectCaretOffset(page.locator(`${EDITOR_SELECTOR} [data-cy=block-wrapper]`).first(), 'First block paragraph'.length, { normalize: true });
await expectToolbarClosed(page);
});
test('should move caret to end of previous block when blocks are not mergeable without merge method', async ({ page }) => {
await createUnmergeableToolEditor(page, { hasConversionConfig: false });
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Home');
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(2);
await expectCaretAtEnd(page.locator(`${EDITOR_SELECTOR} [data-cy=unmergeable-tool]`));
await expectToolbarClosed(page);
});
test('should move caret to end of previous block when blocks are not mergeable despite conversion config', async ({ page }) => {
await createUnmergeableToolEditor(page, { hasConversionConfig: true });
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Home');
await lastParagraph.press('Backspace');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(2);
await expectCaretAtEnd(page.locator(`${EDITOR_SELECTOR} [data-cy=unmergeable-tool]`));
await expectToolbarClosed(page);
});
test.describe('at the start of the first block', () => {
test('should do nothing when block is not empty', async ({ page }) => {
await createParagraphEditor(page, [ 'The only block. Not empty' ]);
const onlyParagraph = page.locator(PARAGRAPH_SELECTOR).first();
await onlyParagraph.click();
await onlyParagraph.press('Home');
await onlyParagraph.press('Backspace');
await expect(page.locator(PARAGRAPH_SELECTOR)).toHaveCount(1);
await expect(onlyParagraph).toHaveText('The only block. Not empty');
});
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -0,0 +1,361 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '../../../../../types';
import type { OutputData } from '../../../../../types';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const EDITOR_SELECTOR = '[data-cy=editorjs]';
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} .ce-paragraph`;
const TOOL_WITH_TWO_INPUTS_SELECTOR = '[data-cy=tool-with-two-inputs] div[contenteditable=true]';
const CONTENTLESS_TOOL_SELECTOR = '[data-cy=contentless-tool]';
const REGULAR_INPUT_SELECTOR = '[data-cy=regular-input]';
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
const createParagraphEditor = async (page: Page, paragraphs: string[]): Promise<void> => {
const blocks: OutputData['blocks'] = paragraphs.map((text) => ({
type: 'paragraph',
data: { text },
}));
await resetEditor(page);
await page.evaluate(async ({ holderId, blocks: editorBlocks }) => {
const editor = new window.EditorJS({
holder: holderId,
data: { blocks: editorBlocks },
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID,
blocks });
};
const createDefaultEditor = async (page: Page): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId }) => {
const editor = new window.EditorJS({ holder: holderId });
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
const createEditorWithTwoInputTool = async (page: Page): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId }) => {
/**
*
*/
class ToolWithTwoInputs {
/**
*
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
const input1 = document.createElement('div');
const input2 = document.createElement('div');
wrapper.dataset.cy = 'tool-with-two-inputs';
input1.contentEditable = 'true';
input2.contentEditable = 'true';
wrapper.append(input1, input2);
return wrapper;
}
/**
*
*/
public save(): Record<string, never> {
return {};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
toolWithTwoInputs: ToolWithTwoInputs,
},
data: {
blocks: [
{
type: 'toolWithTwoInputs',
data: {},
},
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
const createEditorWithContentlessTool = async (page: Page): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId }) => {
/**
*
*/
class ContentlessTool {
public static contentless = true;
/**
*
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.dataset.cy = 'contentless-tool';
wrapper.textContent = '***';
return wrapper;
}
/**
*
*/
public save(): Record<string, never> {
return {};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
contentlessTool: ContentlessTool,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'second paragraph',
},
},
{
type: 'contentlessTool',
data: {},
},
{
type: 'paragraph',
data: {
text: 'third paragraph',
},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
const addRegularInput = async (page: Page, position: 'before' | 'after'): Promise<void> => {
await page.evaluate(({ placement, holderId }) => {
const input = document.createElement('input');
const holder = document.getElementById(holderId);
if (!holder || !holder.parentNode) {
throw new Error('Editor holder is not available');
}
input.dataset.cy = 'regular-input';
if (placement === 'before') {
holder.parentNode.insertBefore(input, holder);
} else if (holder.nextSibling) {
holder.parentNode.insertBefore(input, holder.nextSibling);
} else {
holder.parentNode.appendChild(input);
}
}, { placement: position,
holderId: HOLDER_ID });
};
test.describe('Tab keydown', () => {
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('should focus next block when current block has single input', async ({ page }) => {
await createParagraphEditor(page, ['first paragraph', 'second paragraph']);
const firstParagraph = page.locator(PARAGRAPH_SELECTOR).first();
const secondParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await firstParagraph.click();
await firstParagraph.press('Tab');
await expect(secondParagraph).toBeFocused();
});
test('should focus next input within same block when block has multiple inputs', async ({ page }) => {
await createEditorWithTwoInputTool(page);
const firstInput = page.locator(TOOL_WITH_TWO_INPUTS_SELECTOR).first();
const secondInput = page.locator(TOOL_WITH_TWO_INPUTS_SELECTOR).last();
await firstInput.click();
await firstInput.press('Tab');
await expect(secondInput).toBeFocused();
});
test('should highlight next block when it is contentless (has no inputs)', async ({ page }) => {
await createEditorWithContentlessTool(page);
const firstParagraph = page.locator(PARAGRAPH_SELECTOR).first();
await firstParagraph.click();
await firstParagraph.press('Tab');
await page.waitForFunction(
({ selector }) => {
const element = document.querySelector(selector);
return element?.closest('.ce-block')?.classList.contains('ce-block--selected') ?? false;
},
{ selector: CONTENTLESS_TOOL_SELECTOR }
);
const contentlessTool = page.locator(CONTENTLESS_TOOL_SELECTOR);
const isSelected = await contentlessTool.evaluate((element) => {
return element.closest('.ce-block')?.classList.contains('ce-block--selected') ?? false;
});
expect(isSelected).toBeTruthy();
});
test('should focus input outside editor when Tab pressed in last block', async ({ page }) => {
await createDefaultEditor(page);
await addRegularInput(page, 'after');
await page.evaluate(() => {
/**
* Hide block tune popovers to keep the tab order identical to the Cypress plugin,
* which skips hidden elements when emulating native Tab navigation.
*/
document.querySelectorAll('.ce-popover__items').forEach((element) => {
(element as HTMLElement).style.display = 'none';
});
});
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
const regularInput = page.locator(REGULAR_INPUT_SELECTOR);
await lastParagraph.click();
await lastParagraph.press('Tab');
await expect(regularInput).toBeFocused();
});
});
test.describe('Shift+Tab keydown', () => {
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('should focus previous block when current block has single input', async ({ page }) => {
await createParagraphEditor(page, ['first paragraph', 'second paragraph']);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
const firstParagraph = page.locator(PARAGRAPH_SELECTOR).first();
await lastParagraph.click();
await lastParagraph.press('Shift+Tab');
await expect(firstParagraph).toBeFocused();
});
test('should focus previous input within same block when block has multiple inputs', async ({ page }) => {
await createEditorWithTwoInputTool(page);
const firstInput = page.locator(TOOL_WITH_TWO_INPUTS_SELECTOR).first();
const secondInput = page.locator(TOOL_WITH_TWO_INPUTS_SELECTOR).last();
await secondInput.click();
await secondInput.press('Shift+Tab');
await expect(firstInput).toBeFocused();
});
test('should highlight previous block when it is contentless (has no inputs)', async ({ page }) => {
await createEditorWithContentlessTool(page);
const lastParagraph = page.locator(PARAGRAPH_SELECTOR).last();
await lastParagraph.click();
await lastParagraph.press('Shift+Tab');
await page.waitForFunction(
({ selector }) => {
const element = document.querySelector(selector);
return element?.closest('.ce-block')?.classList.contains('ce-block--selected') ?? false;
},
{ selector: CONTENTLESS_TOOL_SELECTOR }
);
const contentlessTool = page.locator(CONTENTLESS_TOOL_SELECTOR);
const isSelected = await contentlessTool.evaluate((element) => {
return element.closest('.ce-block')?.classList.contains('ce-block--selected') ?? false;
});
expect(isSelected).toBeTruthy();
});
test('should focus input outside editor when Shift+Tab pressed in first block', async ({ page }) => {
await createDefaultEditor(page);
await addRegularInput(page, 'before');
const paragraph = page.locator(PARAGRAPH_SELECTOR).first();
const regularInput = page.locator(REGULAR_INPUT_SELECTOR);
await paragraph.click();
await paragraph.press('Shift+Tab');
await expect(regularInput).toBeFocused();
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

120
yarn.lock
View file

@ -5,6 +5,17 @@ __metadata:
version: 8
cacheKey: 10c0
"@axe-core/playwright@npm:^4.11.0":
version: 4.11.0
resolution: "@axe-core/playwright@npm:4.11.0"
dependencies:
axe-core: "npm:~4.11.0"
peerDependencies:
playwright-core: ">= 1.0.0"
checksum: 10c0/73f93cbe510b2399986aa14e4b07bff79fb757b7205240a6ed59b39f3c252014beeb6e6ce0850c1d392fe1f3f3f04c4e5ff860cbcdeaaf7b6dc1598c3209e539
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/code-frame@npm:7.27.1"
@ -739,6 +750,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@editorjs/editorjs@workspace:."
dependencies:
"@axe-core/playwright": "npm:^4.11.0"
"@babel/register": "npm:^7.21.0"
"@codexteam/icons": "npm:0.3.2"
"@codexteam/shortcuts": "npm:^1.1.1"
@ -749,6 +761,8 @@ __metadata:
"@editorjs/header": "npm:^2.8.8"
"@editorjs/paragraph": "npm:^2.11.6"
"@editorjs/simple-image": "npm:^1.4.1"
"@eslint/eslintrc": "npm:^3.1.0"
"@playwright/test": "npm:^1.56.1"
"@types/node": "npm:^18.15.11"
chai-subset: "npm:^1.6.0"
codex-notifier: "npm:^1.1.2"
@ -763,6 +777,7 @@ __metadata:
eslint-config-codex: "npm:^1.7.1"
eslint-plugin-chai-friendly: "npm:^0.7.2"
eslint-plugin-cypress: "npm:2.12.1"
eslint-plugin-playwright: "npm:^2.3.0"
html-janitor: "npm:^2.0.4"
nanoid: "npm:^4.0.2"
postcss-apply: "npm:^0.12.0"
@ -1012,6 +1027,23 @@ __metadata:
languageName: node
linkType: hard
"@eslint/eslintrc@npm:^3.1.0":
version: 3.3.1
resolution: "@eslint/eslintrc@npm:3.3.1"
dependencies:
ajv: "npm:^6.12.4"
debug: "npm:^4.3.2"
espree: "npm:^10.0.1"
globals: "npm:^14.0.0"
ignore: "npm:^5.2.0"
import-fresh: "npm:^3.2.1"
js-yaml: "npm:^4.1.0"
minimatch: "npm:^3.1.2"
strip-json-comments: "npm:^3.1.1"
checksum: 10c0/b0e63f3bc5cce4555f791a4e487bf999173fcf27c65e1ab6e7d63634d8a43b33c3693e79f192cbff486d7df1be8ebb2bd2edc6e70ddd486cbfa84a359a3e3b41
languageName: node
linkType: hard
"@eslint/js@npm:8.57.1":
version: 8.57.1
resolution: "@eslint/js@npm:8.57.1"
@ -1194,6 +1226,17 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.56.1":
version: 1.56.1
resolution: "@playwright/test@npm:1.56.1"
dependencies:
playwright: "npm:1.56.1"
bin:
playwright: cli.js
checksum: 10c0/2b5b0e1f2e6a18f6e5ce6897c7440ca78f64e0b004834e9808e93ad2b78b96366b562ae4366602669cf8ad793a43d85481b58541e74be71e905e732d833dd691
languageName: node
linkType: hard
"@types/json-schema@npm:^7.0.12":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@ -1787,6 +1830,13 @@ __metadata:
languageName: node
linkType: hard
"axe-core@npm:~4.11.0":
version: 4.11.0
resolution: "axe-core@npm:4.11.0"
checksum: 10c0/7d7020a568a824c303711858c2fcfe56d001d27e46c0c2ff75dc31b436cfddfd4857a301e70536cc9e64829d25338f7fb782102d23497ebdc66801e9900fc895
languageName: node
linkType: hard
"balanced-match@npm:^1.0.0":
version: 1.0.2
resolution: "balanced-match@npm:1.0.2"
@ -3274,6 +3324,17 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-playwright@npm:^2.3.0":
version: 2.3.0
resolution: "eslint-plugin-playwright@npm:2.3.0"
dependencies:
globals: "npm:^16.4.0"
peerDependencies:
eslint: ">=8.40.0"
checksum: 10c0/ae7dcff6b65da93992e97dd558d34b07147c9bd3b41f7183e41222b8bf9445406be9449907162dded8f906044a6b65b6876f0c48e43bf24ecf3a027f4c924e79
languageName: node
linkType: hard
"eslint-plugin-promise@npm:6.1.1":
version: 6.1.1
resolution: "eslint-plugin-promise@npm:6.1.1"
@ -3364,7 +3425,7 @@ __metadata:
languageName: node
linkType: hard
"espree@npm:^10.1.0":
"espree@npm:^10.0.1, espree@npm:^10.1.0":
version: 10.4.0
resolution: "espree@npm:10.4.0"
dependencies:
@ -3800,6 +3861,16 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@npm:~2.3.2":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@ -3810,6 +3881,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@ -4046,6 +4126,20 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:^14.0.0":
version: 14.0.0
resolution: "globals@npm:14.0.0"
checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d
languageName: node
linkType: hard
"globals@npm:^16.4.0":
version: 16.5.0
resolution: "globals@npm:16.5.0"
checksum: 10c0/615241dae7851c8012f5aa0223005b1ed6607713d6813de0741768bd4ddc39353117648f1a7086b4b0fa45eae733f1c0a0fe369aa4e543bb63f8de8990178ea9
languageName: node
linkType: hard
"globalthis@npm:^1.0.4":
version: 1.0.4
resolution: "globalthis@npm:1.0.4"
@ -6048,6 +6142,30 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.56.1":
version: 1.56.1
resolution: "playwright-core@npm:1.56.1"
bin:
playwright-core: cli.js
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
languageName: node
linkType: hard
"playwright@npm:1.56.1":
version: 1.56.1
resolution: "playwright@npm:1.56.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.56.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
languageName: node
linkType: hard
"possible-typed-array-names@npm:^1.0.0":
version: 1.1.0
resolution: "possible-typed-array-names@npm:1.1.0"