test: move part of the tests from Cypress to Playwright

This commit is contained in:
JackUait 2025-11-14 18:51:31 +03:00
commit 0379064dbb
28 changed files with 3837 additions and 2937 deletions

View file

@ -846,9 +846,12 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @private
*/
private addToolDataAttributes(element: HTMLElement): void {
// Add data-block-tool attribute to identify the tool type used for the block
if (this.name === 'paragraph' && element.classList.contains('ce-paragraph')) {
element.setAttribute('data-block-tool', 'paragraph');
/**
* Add data-block-tool attribute to identify the tool type used for the block.
* Some tools (like Paragraph) add their own class names, but we can rely on the tool name for all cases.
*/
if (!element.hasAttribute('data-block-tool') && this.name) {
element.setAttribute('data-block-tool', this.name);
}
}

View file

@ -25,6 +25,32 @@ export default class BlockEvents extends Module {
*/
this.beforeKeydownProcessing(event);
const { BlockSelection, BlockManager, Caret } = this.Editor;
const isRemoveKey = event.keyCode === _.keyCodes.BACKSPACE || event.keyCode === _.keyCodes.DELETE;
const selectionExists = SelectionUtils.isSelectionExists;
const selectionCollapsed = SelectionUtils.isCollapsed === true;
const shouldHandleSelectionDeletion = isRemoveKey &&
BlockSelection.anyBlockSelected &&
(!selectionExists || selectionCollapsed);
if (shouldHandleSelectionDeletion) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
if (selectionPositionIndex !== undefined) {
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(insertedBlock, Caret.positions.START);
}
BlockSelection.clearSelection(event);
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
return;
}
/**
* Fire keydown processor by event.keyCode
*/

View file

@ -631,7 +631,7 @@ export default class BlockManager extends Module {
.sort((first, second) => second.index - first.index);
selectedBlockEntries.forEach(({ block }) => {
void this.removeBlock(block);
void this.removeBlock(block, false);
});
return selectedBlockEntries.length > 0

View file

@ -210,11 +210,23 @@ export default class Tools extends Module {
* Calls each Tool reset method to clean up anything set by Tool
*/
public destroy(): void {
Object.values(this.available).forEach(async tool => {
if (_.isFunction(tool.reset)) {
await tool.reset();
for (const tool of this.available.values()) {
const resetResult = (() => {
try {
return tool.reset();
} catch (error) {
_.log(`Tool "${tool.name}" reset failed`, 'warn', error);
return undefined;
}
})();
if (resetResult instanceof Promise) {
resetResult.catch(error => {
_.log(`Tool "${tool.name}" reset failed`, 'warn', error);
});
}
});
}
}
/**

View file

@ -159,17 +159,28 @@ export default class UI extends Module<UINodes> {
* Prepare components based on read-only state
*/
if (!readOnlyEnabled) {
/**
* Postpone events binding to the next tick to make sure all ui elements are ready
*/
window.requestIdleCallback(() => {
const bindListeners = (): void => {
/**
* Bind events for the UI elements
*/
this.bindReadOnlySensitiveListeners();
}, {
timeout: 2000,
});
};
/**
* Ensure listeners are attached immediately for interactive use.
*/
bindListeners();
const idleCallback = window.requestIdleCallback;
if (typeof idleCallback === 'function') {
/**
* Re-bind on idle to preserve historical behavior when additional nodes appear later.
*/
idleCallback(bindListeners, {
timeout: 2000,
});
}
} else {
/**
* Unbind all events
@ -574,11 +585,14 @@ export default class UI extends Module<UINodes> {
private backspacePressed(event: KeyboardEvent): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const selectionExists = Selection.isSelectionExists;
const selectionCollapsed = Selection.isCollapsed;
/**
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
* remove selected blocks
*/
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
if (BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true)) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
if (selectionPositionIndex === undefined) {
@ -645,11 +659,14 @@ export default class UI extends Module<UINodes> {
const hasPointerToBlock = BlockManager.currentBlockIndex >= 0;
const selectionExists = Selection.isSelectionExists;
const selectionCollapsed = Selection.isCollapsed;
/**
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
* remove selected blocks
*/
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
if (BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true)) {
/** Clear selection */
BlockSelection.clearSelection(event);
@ -899,7 +916,7 @@ export default class UI extends Module<UINodes> {
* to prevent unnecessary tree-walking on Tools with many nodes (for ex. Table)
* - Or, default-block is not empty
*/
if (!BlockManager.lastBlock.tool.isDefault || !BlockManager.lastBlock.isEmpty) {
if (!BlockManager.lastBlock?.tool.isDefault || !BlockManager.lastBlock?.isEmpty) {
BlockManager.insertAtEnd();
}

View file

@ -71,13 +71,12 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
this.wrapper.appendChild(iconWrapper);
this.wrapper.appendChild(this.input);
this.listeners.on(this.input, 'input', () => {
this.searchQuery = this.input.value;
this.overrideValueProperty();
this.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
const eventsToHandle = ['input', 'keyup', 'search', 'change'] as const;
eventsToHandle.forEach((eventName) => {
this.listeners.on(this.input, eventName, this.handleValueChange);
});
}
@ -100,14 +99,59 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
*/
public clear(): void {
this.input.value = '';
this.searchQuery = '';
}
/**
* Handles value changes for the input element
*/
private handleValueChange = (): void => {
this.applySearch(this.input.value);
};
/**
* Applies provided query to the search state and notifies listeners
*
* @param query - search query to apply
*/
private applySearch(query: string): void {
if (this.searchQuery === query) {
return;
}
this.searchQuery = query;
this.emit(SearchInputEvent.Search, {
query: '',
query,
items: this.foundItems,
});
}
/**
* Overrides value property setter to catch programmatic changes
*/
private overrideValueProperty(): void {
const prototype = Object.getPrototypeOf(this.input);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
if (descriptor?.set === undefined || descriptor.get === undefined) {
return;
}
const applySearch = this.applySearch.bind(this);
Object.defineProperty(this.input, 'value', {
configurable: descriptor.configurable ?? true,
enumerable: descriptor.enumerable ?? false,
get(): string {
return descriptor.get?.call(this) ?? '';
},
set(value: string): void {
descriptor.set?.call(this, value);
applySearch(value);
},
});
}
/**
* Clears memory
*/

View file

@ -1,853 +0,0 @@
import type { ToolboxConfigEntry, PasteConfig } from '../../../../types';
import type { HTMLPasteEvent, TunesMenuConfig } from '../../../../types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
/* eslint-disable @typescript-eslint/no-empty-function */
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
describe('Editor Tools Api', () => {
context('Tunes — renderSettings()', () => {
it('should contain a single block tune configured in tool\'s renderSettings() method', () => {
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): TunesMenuConfig {
return {
label: 'Test tool tune',
icon: ICON,
name: 'testToolTune',
onActivate: (): void => { },
};
}
/** Save method stub */
public save(): void { }
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
// Insert test tool block
cy.get(EDITOR_INTERFACE_SELECTOR)
.get(`[data-item-name="testTool"]`)
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured tune to exist in tunes menu
cy.get('[data-item-name=testToolTune]').should('exist');
});
it('should contain multiple block tunes if configured in tool\'s renderSettings() method', () => {
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): TunesMenuConfig {
return [
{
label: 'Test tool tune 1',
icon: ICON,
name: 'testToolTune1',
onActivate: (): void => { },
},
{
label: 'Test tool tune 2',
icon: ICON,
name: 'testToolTune2',
onActivate: (): void => { },
},
];
}
/** Save method stub */
public save(): void { }
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
// Insert test tool block
cy.get(EDITOR_INTERFACE_SELECTOR)
.get(`[data-item-name="testTool"]`)
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured tunes to exist in tunes menu
cy.get('[data-item-name=testToolTune1]').should('exist');
cy.get('[data-item-name=testToolTune2]').should('exist');
});
it('should contain block tunes represented as custom html if so configured in tool\'s renderSettings() method', () => {
const sampleText = 'sample text';
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): HTMLElement {
const element = document.createElement('div');
element.textContent = sampleText;
return element;
}
/** Save method stub */
public save(): void { }
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
// Insert test tool block
cy.get(EDITOR_INTERFACE_SELECTOR)
.get(`[data-item-name="testTool"]`)
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
// Expect preconfigured custom html tunes to exist in tunes menu
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover')
.should('contain.text', sampleText);
});
it('should support label alias', () => {
/** Tool with single tunes menu entry configured */
class TestTool {
/** Returns toolbox config as list of entries */
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Test tool',
icon: ICON,
};
}
/** Returns configuration for block tunes menu */
public renderSettings(): TunesMenuConfig {
return [
{
icon: ICON,
name: 'testToolTune1',
onActivate: (): void => {},
// Set text via title property
title: 'Test tool tune 1',
},
{
icon: ICON,
name: 'testToolTune2',
onActivate: (): void => {},
// Set test via label property
label: 'Test tool tune 2',
},
];
}
/** Save method stub */
public save(): void {}
/** Renders a block */
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
return element;
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
// Insert test tool block
cy.get(EDITOR_INTERFACE_SELECTOR)
.get(`[data-item-name="testTool"]`)
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('[data-name=testBlock]')
.type('some text')
.click();
// Open block tunes
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
// Expect both tunes to have correct text
cy.get('[data-item-name=testToolTune1]').contains('Test tool tune 1');
cy.get('[data-item-name=testToolTune2]').contains('Test tool tune 2');
});
});
/**
* @todo cover all the pasteConfig properties
*/
context('Paste — pasteConfig()', () => {
context('tags', () => {
/**
* tags: ['H1', 'H2']
*/
it('should use corresponding tool when the array of tag names specified', () => {
/**
* Test tool with pasteConfig.tags specified
*/
class TestImgTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [ 'img' ], // only tag name specified. Attributes should be sanitized
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('img');
}
}
const toolsOnPaste = cy.spy(TestImgTool.prototype, 'onPaste');
cy.createEditor({
tools: {
testTool: TestImgTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img>',
})
.then(() => {
expect(toolsOnPaste).to.be.called;
});
});
/**
* tags: ['img'] -> <img>
*/
it('should sanitize all attributes from tag, if only tag name specified ', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestImageTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [ 'img' ], // only tag name specified. Attributes should be sanitized
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('img');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestImageTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testImageTool: TestImageTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img src="foo" onerror="alert(123)"/>', // all attributes should be sanitized
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
expect(pastedElement.tagName.toLowerCase()).eq('img');
expect(pastedElement.attributes.length).eq(0);
});
});
/**
* tags: ['OL','LI',]
* -><ol>
* <li></li>
* <li></li>
* </ol>
*/
it('should sanitize all attributes from tags, even if tag names specified in uppercase', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestListTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: ['OL', 'LI'], // tag names specified in upper case
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('ol');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestListTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testListTool: TestListTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<ol start="50"><li>Ordered List</li><li>Unordered List</li></ol>', // all attributes should be sanitized, <li>s should be preserved
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
expect(pastedElement.tagName.toLowerCase()).eq('ol');
expect(pastedElement.attributes.length).eq(0);
// check number of children
expect(pastedElement.children.length).eq(2);
/**
* Check that all children are <li> tags
*/
pastedElement.childNodes.forEach((child) => {
if (child instanceof Element) {
expect(child.tagName.toLowerCase()).eq('li');
expect(child.attributes.length).eq(0);
}
});
});
});
/**
* tags: [{
* img: {
* src: true
* }
* }]
* -> <img src="">
*
*/
it('should leave attributes if entry specified as a sanitizer config ', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestImageTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [
{
img: {
src: true,
},
},
],
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('img');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestImageTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testImageTool: TestImageTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img src="foo" onerror="alert(123)"/>',
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
/**
* Check that the <img> has only "src" attribute
*/
expect(pastedElement.tagName.toLowerCase()).eq('img');
expect(pastedElement.getAttribute('src')).eq('foo');
expect(pastedElement.attributes.length).eq(1);
});
});
/**
* tags: [
* 'video',
* {
* source: {
* src: true
* }
* }
* ]
*/
it('should support mixed tag names and sanitizer config ', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [
'video', // video should not have attributes
{
source: { // source should have only src attribute
src: true,
},
},
],
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('video');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testTool: TestTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<video width="100"><source src="movie.mp4" type="video/mp4"></video>',
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
/**
* Check that <video> has no attributes
*/
expect(pastedElement.tagName.toLowerCase()).eq('video');
expect(pastedElement.attributes.length).eq(0);
/**
* Check that the <source> has only 'src' attribute
*/
const firstChild = pastedElement.firstChild;
if (firstChild instanceof Element) {
expect(firstChild.tagName.toLowerCase()).eq('source');
expect(firstChild.getAttribute('src')).eq('movie.mp4');
expect(firstChild.attributes.length).eq(1);
}
});
});
/**
* tags: [
* {
* td: { width: true },
* tr: { height: true }
* }
* ]
*/
it('should support config with several keys as the single entry', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [
{
video: {
width: true,
},
source: {
src: true,
},
},
],
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('video');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testTool: TestTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<video width="100"><source src="movie.mp4" type="video/mp4"></video>',
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
expect(pastedElement.tagName.toLowerCase()).eq('video');
/**
* Check that the <tr> has the 'height' attribute
*/
const firstChild = pastedElement.firstChild;
if (firstChild instanceof Element) {
expect(firstChild.tagName.toLowerCase()).eq('source');
expect(firstChild.getAttribute('src')).eq('movie.mp4');
}
});
});
/**
* It covers a workaround HTMLJanitor bug with tables (incorrect sanitizing of table.innerHTML)
* https://github.com/guardian/html-janitor/issues/3
*/
it('should correctly sanitize Table structure (test for HTMLJanitor bug)', () => {
/**
* Variable used for spying the pasted element we are passing to the Tool
*/
let pastedElement: HTMLElement | undefined;
/**
* Test tool with pasteConfig.tags specified
*/
class TestTool {
/** config specified handled tag */
public static get pasteConfig(): PasteConfig {
return {
tags: [
'table',
'tbody',
{
td: {
width: true,
},
tr: {
height: true,
},
},
],
};
}
/** onPaste callback will be stubbed below */
public onPaste(): void { }
/** save is required for correct implementation of the BlockTool class */
public save(): void { }
/** render is required for correct implementation of the BlockTool class */
public render(): HTMLElement {
return document.createElement('tbody');
}
}
/**
* Stub the onPaste method to access the PasteEvent data for assertion
*/
cy.stub(TestTool.prototype, 'onPaste').callsFake((event: HTMLPasteEvent) => {
pastedElement = event.detail.data;
});
cy.createEditor({
tools: {
testTool: TestTool,
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.paste({
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<table><tr height="50"><td width="300">Ho-Ho-Ho</td></tr></table>',
})
.then(() => {
expect(pastedElement).not.to.be.undefined;
if (pastedElement === undefined) {
throw new Error('pastedElement should be defined');
}
expect(pastedElement.tagName.toLowerCase()).eq('table');
/**
* Check that the <tr> has the 'height' attribute
*/
const trElement = pastedElement.querySelector('tr');
expect(trElement).not.to.be.null;
if (trElement !== null) {
expect(trElement.getAttribute('height')).eq('50');
}
/**
* Check that the <td> has the 'width' attribute
*/
const tdElement = pastedElement.querySelector('td');
expect(tdElement).not.to.be.null;
if (tdElement !== null) {
expect(tdElement.getAttribute('width')).eq('300');
}
});
});
});
});
});

View file

@ -1,242 +0,0 @@
import type { TunesMenuConfig } from '../../../../types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
/* eslint-disable @typescript-eslint/no-empty-function */
describe('Editor Tunes Api', () => {
it('should render a popover entry for block tune if configured', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return {
icon: 'ICON',
title: 'Test tune',
name: 'testTune',
onActivate: (): void => { },
};
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.type('some text')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
cy.get('[data-item-name=testTune]').should('exist');
});
it('should render several popover entries for block tune if configured', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return [
{
icon: 'ICON1',
title: 'Tune entry 1',
name: 'testTune1',
onActivate: (): void => { },
}, {
icon: 'ICON2',
title: 'Tune entry 2',
name: 'testTune2',
onActivate: (): void => { },
},
];
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.type('some text')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
cy.get('[data-item-name=testTune1]').should('exist');
cy.get('[data-item-name=testTune2]').should('exist');
});
it('should display custom html returned by tune\'s render() method inside tunes menu', () => {
const sampleText = 'sample text';
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): HTMLElement {
const element = document.createElement('div');
element.textContent = sampleText;
return element;
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.type('some text')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover')
.should('contain.text', sampleText);
});
it('should support label alias', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return [
{
icon: 'ICON1',
name: 'testTune1',
onActivate: (): void => { },
// Set text via title property
title: 'Tune entry 1',
}, {
icon: 'ICON2',
name: 'testTune2',
onActivate: (): void => { },
// Set text via label property
label: 'Tune entry 2',
},
];
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.type('some text')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
/** Check both tunes have correct text */
cy.get('[data-item-name=testTune1]').contains('Tune entry 1');
cy.get('[data-item-name=testTune2]').contains('Tune entry 2');
});
it('should display installed tunes above default tunes', () => {
/** Test tune that should appear be rendered in block tunes menu */
class TestTune {
/** Set Tool is Tune */
public static readonly isTune = true;
/** Tune's appearance in block settings menu */
public render(): TunesMenuConfig {
return [
{
icon: 'ICON',
label: 'Tune entry',
name: 'test-tune',
onActivate: (): void => { },
},
];
}
/** Save method stub */
public save(): void {}
}
cy.createEditor({
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.type('some text')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-toolbar__settings-btn')
.click();
/** Check test tune is inserted at index 0 */
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-settings .ce-popover-item')
.eq(0)
.should('have.attr', 'data-item-name', 'test-tune' );
/** Check default Move Up tune is inserted below the test tune */
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-settings .ce-popover-item')
.eq(1)
.should('have.attr', 'data-item-name', 'move-up' );
});
});

View file

@ -1,161 +0,0 @@
import Header from '@editorjs/header';
import { nanoid } from 'nanoid';
import type EditorJS from '../../../types/index';
import { EDITOR_INTERFACE_SELECTOR } from '../../../src/components/constants';
describe('Block ids', () => {
it('Should generate unique block ids for new blocks', () => {
cy.createEditor({
tools: {
header: Header,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click()
.type('First block ')
.type('{enter}')
.get('div.ce-block')
.last()
.type('Second block ')
.type('{enter}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=header]')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.last()
.click()
.type('Header');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
data.blocks.forEach(block => {
expect(typeof block.id).to.eq('string');
});
});
});
it('should preserve passed ids', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
data.blocks.forEach((block, index) => {
expect(block.id).to.eq(blocks[index].id);
});
});
});
it('should preserve passed ids if blocks were added', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.first()
.click()
.type('{enter}')
.next()
.type('Middle block');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
cy.wrap(await editor.save())
.then((data) => {
expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id);
});
});
});
it('should be stored at the Block wrapper\'s data-id attribute', () => {
cy.createEditor({})
.as('editorInstance');
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get<EditorJS>('@editorInstance')
.render({
blocks,
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.each(($block, index) => {
expect($block.attr('data-id')).to.eq(blocks[index].id);
});
});
});

View file

@ -1,509 +0,0 @@
import type EditorJS from '../../../../../types/index';
import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
describe('Delete keydown', () => {
describe('ending whitespaces handling', () => {
it('|&nbsp; — should delete visible space', () => {
createEditorWithTextBlocks([
'1&nbsp;',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}') // delete visible space
.type('{del}') // merge with next block
.should('have.text', '12');
});
it('"| " — should ignore invisible space after caret and handle it like regular delete case (merge with next)', () => {
createEditorWithTextBlocks([
'1 ',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
.should('have.text', '1 2');
});
it('|<b></b> — should ignore empty tags after caret and handle it like regular delete case (merge)', () => {
createEditorWithTextBlocks([
'1<b></b>',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('|&nbsp;<b></b> — should remove visible space and ignore empty tag', () => {
createEditorWithTextBlocks([
'1&nbsp;<b></b>',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}') // remove nbsp
.type('{del}'); // ignore empty tag and merge
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('|<b></b>&nbsp; — should remove visible space and ignore empty tag', () => {
createEditorWithTextBlocks([
'1<b></b>&nbsp;',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}') // remove nbsp
.type('{del}'); // ignore empty tag and merge
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
.should('have.text', '12');
});
it('"|&nbsp; " — should remove visible space and ignore space', () => {
createEditorWithTextBlocks([
'1&nbsp; ',
'2',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{moveToStart}')
.type('{rightArrow}') // set caret after "1";
.type('{del}') // remove nbsp
.type('{del}'); // ignore regular space and merge
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
/**
* In current implementation, we have different behaviour in Firefox:
* - Safari, Chrome merge blocks and without whitespace - "12"
* - Firefox merge blocks and with whitespace - "1 2"
*
* So, we have to check both variants.
*
* @todo remove this check after fixing the Firefox merge behaviour
*/
.should(($block) => {
const text = $block.text();
expect(text).to.match(/12|1 2/);
});
});
});
it('should just delete chars (native behaviour) when some fragment is selected', () => {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.selectText('The ')
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.first()
.should('have.text', 'first block');
});
it('should just delete chars (native behaviour) when Caret is not at the end of the Block', () => {
createEditorWithTextBlocks([
'The first block',
'The second block',
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click() // caret will be at the end of the block
.type('{leftarrow}') // now caret is not at the end
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.first()
.should('have.text', 'The first bloc'); // last char is removed
});
it('should navigate next input when Caret is not at the last input', () => {
/**
* 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(EDITOR_INTERFACE_SELECTOR)
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.first()
.click()
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('[data-cy=quote-tool]')
.find('div[contenteditable]')
.last()
.as('secondInput');
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('@secondInput').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
it('should remove next Block if Caret at the end of the Block and next Block is empty. Also, should close the Toolbox', () => {
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(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{del}');
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'); // first block is still here
});
});
it('should remove current Block if it is empty, but next is not. Also, should close the Toolbox and set Caret to the start of the next Block', () => {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: '1',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
],
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{backspace}') // remove '1' to make block empty
.type('{del}');
/**
* 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('block2'); // second block is still here
});
/**
* Caret is set to the start of the next 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(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
expect(range.startOffset).to.be.eq(0);
});
});
/**
* Toolbox has been closed
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should merge current Block with the next one if Caret at the end of the Block and both Blocks are mergeable. Also, should close the Toolbox.', () => {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block',
},
},
],
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{del}');
/**
* 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(EDITOR_INTERFACE_SELECTOR)
.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(EDITOR_INTERFACE_SELECTOR)
.find('.ce-toolbar')
.should('not.have.class', 'ce-toolbar--opened');
});
it('should simply set Caret to the start of the next Block if Caret at the end of the Block but Blocks are not mergeable. Also, should close the Toolbox.', () => {
/**
* Mock of tool without merge method
*/
class ExampleOfUnmergeableTool {
/**
* 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: ExampleOfUnmergeableTool,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'code',
data: {},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('[data-cy=unmergeable-tool]')
.as('secondBlock');
/**
* 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('@secondBlock').should(($div) => {
expect($div[0].contains(range.startContainer)).to.be.true;
});
});
});
describe('at the end of the last Block', () => {
it('should do nothing', () => {
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('{del}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.should('have.length', 1)
.should('have.text', 'The only block. Not empty');
});
});
});

View file

@ -1,79 +0,0 @@
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
describe('Enter keydown', () => {
it('should split block and remove selected fragment if some text fragment selected', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.selectText('with so')
.wait(0)
.type('{enter}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.then((blocks) => {
/**
* Check that there is two blocks after split
*/
expect(blocks.length).to.equal(2);
/**
* Check that selected text fragment has been removed
*/
expect(blocks[0].textContent).to.equal('The block ');
expect(blocks[1].textContent).to.equal('me text');
});
});
it('should set caret to the new block if it was created after Enter key press at very end of the block', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('{enter}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('div.ce-block')
.last()
.as('lastBlock');
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('@lastBlock').should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});

View file

@ -1,176 +0,0 @@
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
describe('Slash keydown', () => {
describe('pressed in empty block', () => {
it('should add "/" in a block and open Toolbox', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('/');
/**
* Block content should contain slash
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.invoke('text')
.should('eq', '/');
cy.get('[data-cy="toolbox"] .ce-popover__container')
.should('be.visible');
});
[
'ctrl',
'cmd',
].forEach((key) => {
it(`should not open Toolbox if Slash pressed with ${key}`, () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type(`{${key}}/`);
cy.get('[data-cy="toolbox"] .ce-popover__container')
.should('not.be.visible');
});
});
});
describe('pressed in non-empty block', () => {
it('should not open Toolbox and just add the / char', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('/');
cy.get('[data-cy="toolbox"] .ce-popover__container')
.should('not.be.visible');
/**
* Block content should contain slash
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.invoke('text')
.should('eq', 'Hello/');
});
});
describe('pressed outside editor', () => {
it('should not modify any text outside editor when text block is selected', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.document().then((doc) => {
const title = doc.querySelector('h1');
if (title) {
title.setAttribute('data-cy', 'page-title');
}
});
// Step 1
// Click on the plus button and select the text option
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-toolbar__plus')
.click({ force: true });
cy.get('[data-cy="toolbox"] .ce-popover__container')
.contains('Text')
.click();
// Step 2
// Select the 'Editor.js test page' text
cy.get('[data-cy=page-title]')
.invoke('attr', 'contenteditable', 'true')
.click()
.type('{selectall}')
.invoke('removeAttr', 'contenteditable');
// Step 3
// Press the Slash key
cy.get('[data-cy=page-title]')
.trigger('keydown', { key: '/',
code: 'Slash',
which: 191 });
cy.get('[data-cy=page-title]').should('have.text', 'Editor.js test page');
});
});
});
describe('CMD+Slash keydown', () => {
it('should open Block Tunes', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('{cmd}/');
cy.get('[data-cy="block-tunes"] .ce-popover__container')
.should('be.visible');
});
});

View file

@ -1,305 +0,0 @@
import Header from '@editorjs/header';
import NestedEditor, { NESTED_EDITOR_ID } from '../../support/utils/nestedEditorInstance';
import type { MenuConfig, ToolConstructable } from '@/types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('Inline Toolbar', () => {
it('should appear aligned with left coord of selection rect', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'First block text',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.selectText('block');
cy.get('[data-cy="inline-toolbar"] .ce-popover__container')
.should('be.visible')
.then(($toolbar) => {
const editorWindow = $toolbar.get(0).ownerDocument.defaultView;
if (!editorWindow) {
throw new Error('Unable to access window from toolbar element');
}
const selection = editorWindow.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('No selection available');
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
expect($toolbar.offset()?.left).to.be.closeTo(rect.left, 1);
});
});
it('should appear aligned with right side of text column when toolbar\'s width is not fit at right', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Writing is a powerful tool for communication and expression. When crafting content, it is important to consider your audience and the message you want to convey. Good writing requires careful thought, clear structure, and attention to detail. The process of editing helps refine your ideas and improve clarity.',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.as('blockWrapper')
.getLineWrapPositions()
.then((lineWrapIndexes) => {
const firstLineWrapIndex = lineWrapIndexes[0];
/**
* Select last 5 chars of the first line
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.selectTextByOffset([firstLineWrapIndex - 5, firstLineWrapIndex - 1]);
});
cy.get('[data-cy="inline-toolbar"] .ce-popover__container')
.should('be.visible')
.then(($toolbar) => {
cy.get('@blockWrapper')
.then(($blockWrapper) => {
const blockWrapperRect = $blockWrapper.get(0).getBoundingClientRect();
const toolbarOffset = $toolbar.offset();
const toolbarWidth = $toolbar.width();
if (!toolbarOffset || toolbarWidth === undefined) {
throw new Error('Unable to get toolbar offset or width');
}
/**
* Toolbar should be aligned with right side of text column
*/
expect(toolbarOffset.left + toolbarWidth).to.closeTo(blockWrapperRect.right, 10);
});
});
});
it('should be displayed in read-only mode if at least one inline tool of block supports it', () => {
cy.createEditor({
tools: {
header: {
class: Header as unknown as ToolConstructable,
inlineToolbar: ['bold', 'testTool'],
},
testTool: {
class: class {
public static isInline = true;
public static isReadOnlySupported = true;
// eslint-disable-next-line jsdoc/require-jsdoc
constructor() {
// Constructor required for InlineToolConstructable
}
// eslint-disable-next-line jsdoc/require-jsdoc
public render(): MenuConfig {
return {
title: 'Test Tool',
name: 'test-tool',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onActivate: () => {},
};
}
} as unknown as ToolConstructable,
},
},
readOnly: true,
data: {
blocks: [
{
type: 'header',
data: {
text: 'First block text',
},
},
],
},
});
/** Open Inline Toolbar */
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-header')
.selectText('block');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('[data-cy=inline-toolbar]')
.get('.ce-popover--opened')
.as('toolbar')
.should('exist');
cy.get('@toolbar')
.get('.ce-popover-item')
.should('have.length', 1)
.should('have.attr', 'data-item-name', 'test-tool');
});
it('should not submit form nesting editor when inline tool clicked', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
const onSubmit = cy.stub();
cy.document().then(doc => {
const form = doc.createElement('form');
form.onsubmit = onSubmit;
doc.body.appendChild(form);
/* Move editor to form */
const editorElement = doc.getElementById('editorjs');
if (!editorElement) {
throw new Error('Editor element not found');
}
form.appendChild(editorElement);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.selectText('Some text');
cy.get('[data-item-name=bold]')
.click();
expect(onSubmit).to.be.not.called;
});
});
describe('Conversion toolbar', () => {
it('should restore caret after converting of a block', () => {
cy.createEditor({
tools: {
header: {
class: Header as unknown as ToolConstructable,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Some text',
},
},
],
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.selectText('Some text');
cy.get('[data-item-name=convert-to]')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-inline-toolbar')
.find('.ce-popover-item[data-item-name=header]')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-header')
.should('have.text', 'Some text');
cy.window()
.then((window) => {
const selection = window.getSelection();
expect(selection?.rangeCount).to.be.equal(1);
const range = selection?.getRangeAt(0);
if (!range) {
throw new Error('No range available');
}
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-header')
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
describe('Nested Editor instance inline toolbar', () => {
it('should not close inline toolbar of the nested Editor instance when clicking within that toolbar', () => {
cy.createEditor({
tools: {
nestedEditor: {
class: NestedEditor,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Document editing requires precision and attention to detail. Every word matters when crafting clear and effective content.',
},
},
{
type: 'nestedEditor',
data: {
text: 'The nested editor allows for complex document structures and hierarchical content organization',
},
},
],
},
});
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-paragraph')
.selectText('document structures');
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('[data-item-name=link]')
.click();
// `wait()` function below is required. without it the test will always pass
// because cypress types the text in the field without delay, while we need some delay (just like user)
// to test the actual case that nested editor inline toolbar is still visible and not closed
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-inline-tool-input')
.click()
.wait(100)
.type('https://editorjs.io');
cy.get(`[data-cy=${NESTED_EDITOR_ID}]`)
.find('.ce-popover__container')
.then(($toolbar) => {
expect($toolbar).to.be.visible;
});
});
});
});

View file

@ -1,87 +0,0 @@
import type EditorJS from '../../../../types/index';
import Header from '@editorjs/header';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('Saver module', () => {
describe('save()', () => {
it('should correctly save block if there are some 3rd party (eg. browser extensions) nodes inserted into the layout', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The block with some text',
},
},
],
},
}).then((editor: EditorJS) => {
/**
* Add some node just like browser extensions doing
*/
const extensionNode = document.createElement('extension-node');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-block__content')
.then((blockContent) => {
blockContent.append(extensionNode);
})
.then(async () => {
const savedData = await editor.save();
expect(savedData.blocks.length).to.equal(1);
expect(savedData.blocks[0].data.text).to.equal('The block with some text');
});
});
});
/**
* This test case covers Block@detectToolRootChange
*/
it('should correctly save block data if block\'s main container element have been changed', () => {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'header',
data: {
text: 'The block with some text',
level: 1,
},
},
],
},
})
.as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('span.ce-toolbar__settings-btn')
.click();
/**
* Change header level
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-settings .ce-popover-item:nth-child(3)')
.click();
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
expect(data.blocks[0].type).to.equal('header');
expect(data.blocks[0].data.text).to.equal('The block with some text');
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
expect(data.blocks[0].data.level).to.equal(3);
});
});
});
});

View file

@ -1,144 +0,0 @@
import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks';
import type EditorJS from '../../../../types/index';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('Ui module', () => {
describe('documentKeydown', () => {
describe('Backspace', () => {
it('should remove selected blocks', () => {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'The first block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'The second block',
},
},
],
},
}).as('editorInstance');
/**
* Select two blocks by shift+down
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{shift+downArrow}')
.type('{backspace}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
/**
* Actually editor will contain 1 empty block, but save wont return it since it is empty
*/
expect(blocks.length).to.eq(0);
});
});
});
describe('Delete', () => {
it('should remove selected blocks', () => {
cy.createEditor({
data: {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'The first block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'The second block',
},
},
],
},
}).as('editorInstance');
/**
* Select two blocks by shift+down
*/
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.click()
.type('{shift+downArrow}')
.type('{del}');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
/**
* Actually editor will contain 1 empty block, but save wont return it since it is empty
*/
expect(blocks.length).to.eq(0);
});
});
});
});
describe('mousedown', () => {
it('should update current block by click on block', () => {
createEditorWithTextBlocks([
'first block',
'second block',
'third block',
])
.as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.eq(1)
.click();
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const currentBlockIndex = await editor.blocks.getCurrentBlockIndex();
expect(currentBlockIndex).to.eq(1);
});
});
it('(in readonly) should update current block by click on block', () => {
createEditorWithTextBlocks([
'first block',
'second block',
'third block',
], {
readOnly: true,
})
.as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.eq(1)
.click();
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const currentBlockIndex = await editor.blocks.getCurrentBlockIndex();
expect(currentBlockIndex).to.eq(1);
});
});
});
});

View file

@ -1,72 +0,0 @@
import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('inputs [data-empty] mark', () => {
it('should be added to inputs of editor on initialization', () => {
createEditorWithTextBlocks([
'First', // not empty block
'', // empty block
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.first()
.should('have.attr', 'data-empty', 'false');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.should('have.attr', 'data-empty', 'true');
});
it('should be added as "false" to the input on typing', () => {
createEditorWithTextBlocks([
'First', // not empty block
'', // empty block
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.type('Some text');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.should('have.attr', 'data-empty', 'false');
});
it('should be added as "true" to the input on chars removal', () => {
createEditorWithTextBlocks([
'', // empty block
'Some text', // not empty block
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.type('{selectall}{backspace}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.should('have.attr', 'data-empty', 'true');
});
it('should be added to the new block inputs', () => {
createEditorWithTextBlocks([
'First', // not empty block
'', // empty block
]);
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.type('{enter}');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.last()
.should('have.attr', 'data-empty', 'true');
});
});

View file

@ -0,0 +1,794 @@
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 { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
const DEFAULT_BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
type ToolDefinition = {
name: string;
classSource: string;
config?: Record<string, unknown>;
};
type EditorSetupOptions = {
data?: Record<string, unknown>;
tools?: ToolDefinition[];
config?: Record<string, unknown>;
};
interface ElementSnapshot {
tagName: string;
attributes: Array<{ name: string; value: string }>;
children: ElementSnapshot[];
text: string;
}
const getBlockWrapperSelectorByIndex = (index: number): string => {
return `:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`;
};
const getBlockByIndex = (page: Page, index: number): Locator => {
return page.locator(`${DEFAULT_BLOCK_SELECTOR}:nth-of-type(${index + 1})`);
};
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 createEditor = async (page: Page, options: EditorSetupOptions = {}): Promise<void> => {
const { data, tools = [], config = {} } = options;
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({ holderId, rawData, serializedTools, rawConfig }) => {
const reviveToolClass = (classSource: string): unknown => {
// eslint-disable-next-line no-new-func -- constructing helper class inside page context
return new Function(`return (${classSource});`)();
};
const revivedTools = serializedTools.reduce<Record<string, Record<string, unknown>>>(
(accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
const toolSettings: Record<string, unknown> = {
class: revivedClass,
};
if (toolConfig.config !== undefined) {
toolSettings.config = toolConfig.config;
}
return {
...accumulator,
[toolConfig.name]: toolSettings,
};
},
{}
);
const editorConfig: Record<string, unknown> = {
holder: holderId,
...rawConfig,
};
if (rawData) {
editorConfig.data = rawData;
}
if (serializedTools.length > 0) {
editorConfig.tools = revivedTools;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
rawData: data ?? null,
serializedTools: tools,
rawConfig: config,
}
);
};
const focusBlockByIndex = async (page: Page, index: number = 0): Promise<void> => {
await page.evaluate(({ blockIndex }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const didSetCaret = window.editorInstance.caret.setToBlock(blockIndex);
if (!didSetCaret) {
throw new Error(`Failed to set caret to block at index ${blockIndex}`);
}
}, { blockIndex: index });
};
const openBlockSettings = async (page: Page, index: number = 0): Promise<void> => {
await focusBlockByIndex(page, index);
const block = page.locator(getBlockWrapperSelectorByIndex(index));
await block.scrollIntoViewIfNeeded();
await block.click();
await block.hover();
const settingsButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`);
await settingsButton.waitFor({ state: 'visible' });
await settingsButton.click();
await expect(
page
.locator(`${EDITOR_INTERFACE_SELECTOR} [data-cy="block-tunes"] .ce-popover[data-popover-opened="true"]`)
).toHaveCount(1);
};
const paste = async (page: Page, target: Locator, data: Record<string, string>): Promise<void> => {
await target.evaluate((element: HTMLElement, pasteData: Record<string, string>) => {
const pasteEvent = Object.assign(new Event('paste', {
bubbles: true,
cancelable: true,
}), {
clipboardData: {
getData: (type: string): string => pasteData[type] ?? '',
types: Object.keys(pasteData),
},
});
element.dispatchEvent(pasteEvent);
}, data);
await page.evaluate(() => {
return new Promise((resolve) => {
setTimeout(resolve, 200);
});
});
};
const createTuneToolSource = (renderSettingsBody: string): string => {
return `
class TestTool {
constructor({ data }) {
this.data = data ?? {};
}
static get toolbox() {
return {
title: 'Test tool',
icon: '${ICON}',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.setAttribute('data-name', 'testBlock');
element.textContent = this.data?.text ?? '';
return element;
}
save(element) {
return {
text: element.textContent ?? '',
};
}
renderSettings() {
${renderSettingsBody}
}
}
`;
};
const createPasteToolSource = ({
pasteConfig,
renderBody,
}: {
pasteConfig: string;
renderBody: string;
}): string => {
return `
class TestTool {
constructor({ data }) {
this.data = data ?? {};
}
static get pasteConfig() {
return ${pasteConfig};
}
render() {
${renderBody}
}
save() {
return {};
}
onPaste(event) {
window.__onPasteCalls = (window.__onPasteCalls ?? 0) + 1;
const data = event.detail?.data ?? null;
window.__lastPasteSnapshot = this.createSnapshot(data);
}
createSnapshot(element) {
if (!(element instanceof HTMLElement)) {
return null;
}
return {
tagName: element.tagName.toLowerCase(),
attributes: Array.from(element.attributes).map((attr) => ({
name: attr.name,
value: attr.value,
})),
children: Array.from(element.children).map((child) => this.createSnapshot(child)),
text: element.textContent ?? '',
};
}
}
`;
};
test.describe('api.tools', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(() => {
window.__onPasteCalls = 0;
window.__lastPasteSnapshot = null;
});
});
test.describe('renderSettings', () => {
test('should render single tune configured via renderSettings()', async ({ page }) => {
const singleTuneToolSource = createTuneToolSource(`
return {
label: 'Test tool tune',
icon: '${ICON}',
name: 'testToolTune',
onActivate: () => {},
};
`);
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: singleTuneToolSource,
},
],
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'some text',
},
},
],
},
});
await openBlockSettings(page, 0);
await expect(page.locator('[data-item-name="testToolTune"]')).toBeVisible();
});
test('should render multiple tunes when renderSettings() returns array', async ({ page }) => {
const multipleTunesToolSource = createTuneToolSource(`
return [
{
label: 'Test tool tune 1',
icon: '${ICON}',
name: 'testToolTune1',
onActivate: () => {},
},
{
label: 'Test tool tune 2',
icon: '${ICON}',
name: 'testToolTune2',
onActivate: () => {},
},
];
`);
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: multipleTunesToolSource,
},
],
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'some text',
},
},
],
},
});
await openBlockSettings(page, 0);
await expect(page.locator('[data-item-name="testToolTune1"]')).toBeVisible();
await expect(page.locator('[data-item-name="testToolTune2"]')).toBeVisible();
});
test('should support custom html returned from renderSettings()', async ({ page }) => {
const sampleText = 'sample text';
const customHtmlToolSource = createTuneToolSource(`
const element = document.createElement('div');
element.textContent = '${sampleText}';
return element;
`);
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: customHtmlToolSource,
},
],
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'some text',
},
},
],
},
});
await openBlockSettings(page, 0);
await expect(
page.locator(
`${EDITOR_INTERFACE_SELECTOR} [data-cy="block-tunes"] .ce-popover[data-popover-opened="true"]`
)
).toContainText(sampleText);
});
test('should support title and label aliases for tune text', async ({ page }) => {
const labelAliasToolSource = createTuneToolSource(`
return [
{
icon: '${ICON}',
name: 'testToolTune1',
onActivate: () => {},
title: 'Test tool tune 1',
},
{
icon: '${ICON}',
name: 'testToolTune2',
onActivate: () => {},
label: 'Test tool tune 2',
},
];
`);
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: labelAliasToolSource,
},
],
data: {
blocks: [
{
type: 'testTool',
data: {
text: 'some text',
},
},
],
},
});
await openBlockSettings(page, 0);
await expect(page.locator('[data-item-name="testToolTune1"]')).toContainText('Test tool tune 1');
await expect(page.locator('[data-item-name="testToolTune2"]')).toContainText('Test tool tune 2');
});
});
test.describe('pasteConfig', () => {
test('should use corresponding tool when tag names are specified', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: ['img'],
}`,
renderBody: `
return document.createElement('img');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img>',
});
const onPasteCalls = await page.evaluate(() => {
return window.__onPasteCalls ?? 0;
});
expect(onPasteCalls).toBeGreaterThan(0);
});
test('should sanitize attributes when only tag name is specified', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: ['img'],
}`,
renderBody: `
return document.createElement('img');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img src="foo" onerror="alert(123)"/>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('img');
expect(snapshot?.attributes).toHaveLength(0);
});
test('should sanitize uppercase tag names and preserve children', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: ['OL', 'LI'],
}`,
renderBody: `
return document.createElement('ol');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<ol start="50"><li>Ordered List</li><li>Unordered List</li></ol>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('ol');
expect(snapshot?.attributes).toHaveLength(0);
expect(snapshot?.children).toHaveLength(2);
snapshot?.children.forEach((child) => {
expect(child.tagName).toBe('li');
expect(child.attributes).toHaveLength(0);
});
});
test('should preserve attributes defined in sanitizer config', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: [
{
img: {
src: true,
},
},
],
}`,
renderBody: `
return document.createElement('img');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<img src="foo" onerror="alert(123)"/>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('img');
expect(snapshot?.attributes).toHaveLength(1);
expect(snapshot?.attributes[0]).toStrictEqual({
name: 'src',
value: 'foo',
});
});
test('should support mixed tag names and sanitizer config', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: [
'video',
{
source: {
src: true,
},
},
],
}`,
renderBody: `
return document.createElement('video');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<video width="100"><source src="movie.mp4" type="video/mp4"></video>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('video');
expect(snapshot?.attributes).toHaveLength(0);
expect(snapshot?.children).toHaveLength(1);
const sourceSnapshot = snapshot?.children[0];
expect(sourceSnapshot?.tagName).toBe('source');
expect(sourceSnapshot?.attributes).toHaveLength(1);
expect(sourceSnapshot?.attributes[0]).toStrictEqual({
name: 'src',
value: 'movie.mp4',
});
});
test('should support configs with several keys as single entry', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: [
{
video: {
width: true,
},
source: {
src: true,
},
},
],
}`,
renderBody: `
return document.createElement('video');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<video width="100"><source src="movie.mp4" type="video/mp4"></video>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('video');
expect(snapshot?.attributes).toHaveLength(1);
expect(snapshot?.attributes[0]).toStrictEqual({
name: 'width',
value: '100',
});
const sourceSnapshot = snapshot?.children[0];
expect(sourceSnapshot?.tagName).toBe('source');
expect(sourceSnapshot?.attributes).toHaveLength(1);
expect(sourceSnapshot?.attributes[0]).toStrictEqual({
name: 'src',
value: 'movie.mp4',
});
});
test('should correctly sanitize table structure', async ({ page }) => {
const toolSource = createPasteToolSource({
pasteConfig: `{
tags: [
'table',
'tbody',
{
td: {
width: true,
},
tr: {
height: true,
},
},
],
}`,
renderBody: `
return document.createElement('tbody');
`,
});
await createEditor(page, {
tools: [
{
name: 'testTool',
classSource: toolSource,
},
],
});
const block = getBlockByIndex(page, 0);
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<table><tr height="50"><td width="300">Ho-Ho-Ho</td></tr></table>',
});
const snapshot = await page.evaluate<ElementSnapshot | null>(() => {
return (window.__lastPasteSnapshot ?? null) as ElementSnapshot | null;
});
expect(snapshot).not.toBeNull();
expect(snapshot?.tagName).toBe('table');
expect(snapshot?.children).toBeDefined();
const tbodySnapshot = snapshot?.children[0];
expect(tbodySnapshot?.tagName).toBe('tbody');
const trSnapshot = tbodySnapshot?.children[0];
expect(trSnapshot?.tagName).toBe('tr');
expect(trSnapshot?.attributes).toHaveLength(1);
expect(trSnapshot?.attributes[0]).toStrictEqual({
name: 'height',
value: '50',
});
const tdSnapshot = trSnapshot?.children[0];
expect(tdSnapshot?.tagName).toBe('td');
expect(tdSnapshot?.attributes).toHaveLength(1);
expect(tdSnapshot?.attributes[0]).toStrictEqual({
name: 'width',
value: '300',
});
expect(tdSnapshot?.text.trim()).toBe('Ho-Ho-Ho');
});
});
});
const ON_PASTE_CALLS_KEY = '__onPasteCalls';
const LAST_PASTE_SNAPSHOT_KEY = '__lastPasteSnapshot';
declare global {
interface Window {
editorInstance?: EditorJS;
[ON_PASTE_CALLS_KEY]?: number;
[LAST_PASTE_SNAPSHOT_KEY]?: unknown;
}
}

View file

@ -0,0 +1,277 @@
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 { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const FIRST_BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block:first-of-type`;
const SETTINGS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`;
const POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover.ce-popover--opened`;
const POPOVER_ITEM_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-settings .ce-popover-item`;
const FIRST_POPOVER_ITEM_SELECTOR = `${POPOVER_ITEM_SELECTOR}:nth-of-type(1)`;
const SECOND_POPOVER_ITEM_SELECTOR = `${POPOVER_ITEM_SELECTOR}:nth-of-type(2)`;
type SerializableTuneMenuItem = {
icon?: string;
title?: string;
label?: string;
name: string;
};
type SerializableTuneRenderConfig =
| { type: 'single'; item: SerializableTuneMenuItem }
| { type: 'multiple'; items: SerializableTuneMenuItem[] }
| { type: 'html'; text: string };
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
/**
* Reset the editor holder and destroy existing editor instance.
*
* @param page - The Playwright page object
*/
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }: { holderId: string }) => {
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 });
};
/**
* Create an Editor instance configured with a tune that returns the provided render config.
*
* @param page - The Playwright page object
* @param renderConfig - Serializable configuration describing tune render output
*/
const createEditorWithTune = async (
page: Page,
renderConfig: SerializableTuneRenderConfig
): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({
holderId,
config,
}: {
holderId: string;
config: SerializableTuneRenderConfig;
}) => {
const tuneConfig = config;
/**
* Tune implementation for testing purposes.
*/
class TestTune {
public static readonly isTune = true;
/**
* Render tune configuration for block tunes popover.
*
* @returns Tune menu configuration or custom element
*/
public render(): unknown {
if (tuneConfig.type === 'html') {
const element = document.createElement('div');
element.textContent = tuneConfig.text;
return element;
}
const baseItems = tuneConfig.type === 'single'
? [ tuneConfig.item ]
: tuneConfig.items;
const mappedItems = baseItems.map((item) => ({
...item,
onActivate: (): void => {},
}));
return tuneConfig.type === 'single'
? mappedItems[0]
: mappedItems;
}
/**
* Save hook stub required by the tune contract.
*/
public save(): void {}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
testTune: TestTune,
},
tunes: [ 'testTune' ],
});
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
config: renderConfig,
}
);
};
/**
* Focus the first block and type provided text to expose block tunes controls.
*
* @param page - The Playwright page object
* @param text - Text to type into the block
*/
const focusBlockAndType = async (page: Page, text: string): Promise<void> => {
const firstBlock = page.locator(FIRST_BLOCK_SELECTOR);
await firstBlock.click();
await page.keyboard.type(text);
await firstBlock.click();
};
/**
* Open block tunes popover from the currently focused block.
*
* @param page - The Playwright page object
*/
const openBlockTunes = async (page: Page): Promise<void> => {
const settingsButton = page.locator(SETTINGS_BUTTON_SELECTOR);
await expect(settingsButton).toBeVisible();
await settingsButton.click();
};
test.describe('api.tunes', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('renders a popover entry for block tune if configured', async ({ page }) => {
await createEditorWithTune(page, {
type: 'single',
item: {
icon: 'ICON',
title: 'Test tune',
name: 'testTune',
},
});
await focusBlockAndType(page, 'some text');
await openBlockTunes(page);
await expect(page.locator('[data-item-name="testTune"]')).toBeVisible();
});
test('renders several popover entries for block tune if configured', async ({ page }) => {
await createEditorWithTune(page, {
type: 'multiple',
items: [
{
icon: 'ICON1',
title: 'Tune entry 1',
name: 'testTune1',
},
{
icon: 'ICON2',
title: 'Tune entry 2',
name: 'testTune2',
},
],
});
await focusBlockAndType(page, 'some text');
await openBlockTunes(page);
await expect(page.locator('[data-item-name="testTune1"]')).toBeVisible();
await expect(page.locator('[data-item-name="testTune2"]')).toBeVisible();
});
test('displays custom HTML returned by tune render method inside tunes menu', async ({ page }) => {
const sampleText = 'sample text';
await createEditorWithTune(page, {
type: 'html',
text: sampleText,
});
await focusBlockAndType(page, 'some text');
await openBlockTunes(page);
await expect(page.locator(POPOVER_SELECTOR)).toContainText(sampleText);
});
test('supports label alias when rendering tunes', async ({ page }) => {
await createEditorWithTune(page, {
type: 'multiple',
items: [
{
icon: 'ICON1',
title: 'Tune entry 1',
name: 'testTune1',
},
{
icon: 'ICON2',
label: 'Tune entry 2',
name: 'testTune2',
},
],
});
await focusBlockAndType(page, 'some text');
await openBlockTunes(page);
await expect(page.locator('[data-item-name="testTune1"]')).toContainText('Tune entry 1');
await expect(page.locator('[data-item-name="testTune2"]')).toContainText('Tune entry 2');
});
test('displays installed tunes above default tunes', async ({ page }) => {
await createEditorWithTune(page, {
type: 'single',
item: {
icon: 'ICON',
label: 'Tune entry',
name: 'test-tune',
},
});
await focusBlockAndType(page, 'some text');
await openBlockTunes(page);
await expect(page.locator(FIRST_POPOVER_ITEM_SELECTOR)).toHaveAttribute('data-item-name', 'test-tune');
await expect(page.locator(SECOND_POPOVER_ITEM_SELECTOR)).toHaveAttribute('data-item-name', 'move-up');
});
});

View file

@ -0,0 +1,558 @@
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';
import { ensureEditorBundleBuilt } from '../../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
const QUOTE_TOOL_INPUT_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="quote-tool"] div[contenteditable]`;
const getBlockByIndex = (page: Page, index: number): Locator => {
return page.locator(`:nth-match(${BLOCK_SELECTOR}, ${index + 1})`);
};
const getLastBlock = async (page: Page): Promise<Locator> => {
const blockCount = await page.locator(BLOCK_SELECTOR).count();
if (blockCount === 0) {
throw new Error('No blocks found for selector: div.ce-block');
}
return page.locator(`:nth-match(${BLOCK_SELECTOR}, ${blockCount})`);
};
const getParagraphByIndex = (page: Page, index: number): Locator => {
return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`);
};
const getQuoteToolInputByIndex = (page: Page, index: number): Locator => {
return page.locator(`:nth-match(${QUOTE_TOOL_INPUT_SELECTOR}, ${index + 1})`);
};
const getLastQuoteToolInput = async (page: Page): Promise<Locator> => {
const inputCount = await page.locator(QUOTE_TOOL_INPUT_SELECTOR).count();
if (inputCount === 0) {
throw new Error('No quote tool inputs found');
}
return page.locator(`:nth-match(${QUOTE_TOOL_INPUT_SELECTOR}, ${inputCount})`);
};
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 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 });
};
const createParagraphEditor = async (page: Page, textBlocks: string[]): Promise<void> => {
const blocks: OutputData['blocks'] = textBlocks.map((text) => ({
type: 'paragraph',
data: { text },
}));
await createEditorWithBlocks(page, blocks);
};
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 });
};
const createEditorWithUnmergeableTool = async (page: Page): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId }) => {
/**
*
*/
class ExampleOfUnmergeableTool {
/**
*
*/
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, never> {
return {};
}
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
code: ExampleOfUnmergeableTool,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'code',
data: {},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
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();
});
};
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);
};
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 });
};
const expectCaretAtStart = async (locator: Locator): Promise<void> => {
const caretInfo = await getCaretInfo(locator);
expect(caretInfo?.inside).toBeTruthy();
expect(caretInfo?.offset).toBe(0);
};
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);
};
const expectToolbarClosed = async (page: Page): Promise<void> => {
const toolbar = page.locator(TOOLBAR_SELECTOR);
await expect(toolbar).not.toHaveClass(/ce-toolbar--opened/);
};
test.describe('delete keydown', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test.describe('ending whitespaces handling', () => {
test('should delete visible non-breaking space', async ({ page }) => {
await createParagraphEditor(page, ['1&nbsp;', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
await firstParagraph.press('Delete');
await expect(getBlockByIndex(page, 0)).toHaveText('12');
});
test('should merge blocks when invisible space follows caret', async ({ page }) => {
await createParagraphEditor(page, ['1 ', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
const lastBlock = await getLastBlock(page);
await expect(lastBlock).toHaveText('1 2');
});
test('should ignore empty tags after caret when merging', async ({ page }) => {
await createParagraphEditor(page, ['1<b></b>', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
const lastBlock = await getLastBlock(page);
await expect(lastBlock).toHaveText('12');
});
test('should remove non-breaking space and ignore empty tag', async ({ page }) => {
await createParagraphEditor(page, ['1&nbsp;<b></b>', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
await firstParagraph.press('Delete');
const lastBlock = await getLastBlock(page);
await expect(lastBlock).toHaveText('12');
});
test('should remove non-breaking space placed after empty tag', async ({ page }) => {
await createParagraphEditor(page, ['1<b></b>&nbsp;', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
await firstParagraph.press('Delete');
const lastBlock = await getLastBlock(page);
await expect(lastBlock).toHaveText('12');
});
test('should remove non-breaking space and ignore regular space', async ({ page }) => {
await createParagraphEditor(page, ['1&nbsp; ', '2']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Home');
await firstParagraph.press('ArrowRight');
await firstParagraph.press('Delete');
await firstParagraph.press('Delete');
const lastBlock = await getLastBlock(page);
await expect(lastBlock).toHaveText(/^(12|1 2)$/);
});
});
test('should delete selected fragment using native behaviour', async ({ page }) => {
await createParagraphEditor(page, ['The first block', 'The second block']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await selectText(firstParagraph, 'The ');
await page.keyboard.press('Delete');
await expect(getBlockByIndex(page, 0)).toHaveText('first block');
});
test('should delete character using native behaviour when caret is not at block end', async ({ page }) => {
await createParagraphEditor(page, ['The first block', 'The second block']);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('ArrowLeft');
await firstParagraph.press('Delete');
await expect(getBlockByIndex(page, 0)).toHaveText('The first bloc');
});
test('should focus next input when caret is not in the last input', async ({ page }) => {
await createMultiInputToolEditor(page);
const firstInput = getQuoteToolInputByIndex(page, 0);
await firstInput.click();
await firstInput.press('Delete');
const lastQuoteToolInput = await getLastQuoteToolInput(page);
const caretInfo = await getCaretInfo(lastQuoteToolInput);
expect(caretInfo?.inside).toBeTruthy();
});
test('should remove next empty block and close toolbox when caret at block end', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: '',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('End');
await firstParagraph.press('Delete');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block1');
await expectToolbarClosed(page);
});
test('should remove current empty block and place caret at next block start', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: {
text: '1',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Not empty block',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('Backspace');
await firstParagraph.press('Delete');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe('block2');
await expectCaretAtStart(getParagraphByIndex(page, 0));
await expectToolbarClosed(page);
});
test('should merge blocks when both are mergeable and caret at block end', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'Second block',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('End');
await firstParagraph.press('Delete');
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(getParagraphByIndex(page, 0), 'First block'.length, { normalize: true });
await expectToolbarClosed(page);
});
test('should place caret at start of next unmergeable block', async ({ page }) => {
await createEditorWithUnmergeableTool(page);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.press('End');
await firstParagraph.press('Delete');
const caretInfo = await getCaretInfo(page.locator(`${EDITOR_INTERFACE_SELECTOR} [data-cy=unmergeable-tool]`));
expect(caretInfo?.inside).toBeTruthy();
await expectToolbarClosed(page);
});
test.describe('at the end of the last block', () => {
test('should do nothing for non-empty block', async ({ page }) => {
await createParagraphEditor(page, [ 'The only block. Not empty' ]);
const onlyParagraph = getParagraphByIndex(page, 0);
await onlyParagraph.click();
await onlyParagraph.press('End');
await onlyParagraph.press('Delete');
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,166 @@
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';
import { ensureEditorBundleBuilt } from '../../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
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 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);
};
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();
});
};
const getLastItem = <T>(items: T[]): T => {
const lastItem = items.at(-1);
if (!lastItem) {
throw new Error('Expected to receive at least one item');
}
return lastItem;
};
test.describe('enter keydown', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('should split block and remove selected fragment when part of text is selected', async ({ page }) => {
await createParagraphEditor(page, [ 'The block with some text' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await selectText(paragraph, 'with so');
await page.keyboard.press('Enter');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(2);
expect((blocks[0].data as { text: string }).text).toBe('The block ');
expect((blocks[1].data as { text: string }).text).toBe('me text');
});
test('should place caret into new block when Enter pressed at block end', async ({ page }) => {
await createParagraphEditor(page, [ 'The block with some text' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await paragraph.press('End');
await paragraph.press('Enter');
const blockCount = page.locator(BLOCK_SELECTOR);
await expect(blockCount).toHaveCount(2);
const blockHandles = await page.locator(BLOCK_SELECTOR).elementHandles();
const lastBlockHandle = getLastItem(blockHandles);
const caretInsideLastBlock = await lastBlockHandle.evaluate((element) => {
const selection = element.ownerDocument?.getSelection();
if (!selection || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
return element.contains(range.startContainer);
});
expect(caretInsideLastBlock).toBeTruthy();
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -0,0 +1,204 @@
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';
import { ensureEditorBundleBuilt } from '../../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const TOOLBOX_CONTAINER_SELECTOR = '[data-cy="toolbox"] .ce-popover__container';
const TOOLBOX_ITEM_SELECTOR = (itemName: string): string =>
`${EDITOR_INTERFACE_SELECTOR} .ce-popover-item[data-item-name=${itemName}]`;
const BLOCK_TUNES_SELECTOR = '[data-cy="block-tunes"] .ce-popover__container';
const PLUS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`;
const modifierKeyVariants: Array<{ description: string; key: 'Control' | 'Meta' }> = [
{ description: 'Ctrl',
key: 'Control' },
{ description: 'Cmd',
key: 'Meta' },
];
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 getTextContent = async (locator: Locator): Promise<string> => {
return locator.evaluate((element) => element.textContent ?? '');
};
test.describe('slash keydown', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('should add "/" in empty block and open Toolbox', async ({ page }) => {
await createParagraphEditor(page, [ '' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await paragraph.type('/');
await expect(page.locator(TOOLBOX_CONTAINER_SELECTOR)).toBeVisible();
const textContent = await getTextContent(paragraph);
expect(textContent).toBe('/');
});
for (const { description, key } of modifierKeyVariants) {
test(`should not open Toolbox if Slash pressed with ${description}`, async ({ page }) => {
await createParagraphEditor(page, [ '' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await page.keyboard.down(key);
await page.keyboard.press('Slash');
await page.keyboard.up(key);
await expect(page.locator(TOOLBOX_CONTAINER_SELECTOR)).toBeHidden();
const textContent = await getTextContent(paragraph);
expect(textContent).toBe('');
});
}
test('should not open Toolbox in non-empty block and append slash character', async ({ page }) => {
await createParagraphEditor(page, [ 'Hello' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await paragraph.type('/');
await expect(page.locator(TOOLBOX_CONTAINER_SELECTOR)).toBeHidden();
const textContent = await getTextContent(paragraph);
expect(textContent).toBe('Hello/');
});
test('should not modify text outside editor when slash pressed', async ({ page }) => {
await createParagraphEditor(page, [ '' ]);
await page.evaluate(() => {
const title = document.querySelector('h1');
if (title) {
title.setAttribute('data-cy', 'page-title');
}
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
const plusButton = page.locator(PLUS_BUTTON_SELECTOR);
await plusButton.click();
const toolbox = page.locator(TOOLBOX_CONTAINER_SELECTOR);
await expect(toolbox).toBeVisible();
const textToolOption = page.locator(TOOLBOX_ITEM_SELECTOR('paragraph'));
await textToolOption.click();
const pageTitle = page.locator('[data-cy="page-title"]');
await pageTitle.evaluate((element) => {
element.setAttribute('contenteditable', 'true');
element.focus();
const selection = element.ownerDocument.getSelection();
const range = element.ownerDocument.createRange();
range.selectNodeContents(element);
selection?.removeAllRanges();
selection?.addRange(range);
});
await pageTitle.evaluate((element) => {
element.removeAttribute('contenteditable');
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
which: 191,
bubbles: true,
})
);
});
await expect(pageTitle).toHaveText('Editor.js test page');
});
test('should open Block Tunes when cmd+slash pressed', async ({ page }) => {
await createParagraphEditor(page, [ '' ]);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
await page.keyboard.down('Meta');
await page.keyboard.press('Slash');
await page.keyboard.up('Meta');
await expect(page.locator(BLOCK_TUNES_SELECTOR)).toBeVisible();
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -0,0 +1,377 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { nanoid } from 'nanoid';
import type EditorJS from '@/types';
import type { OutputData } from '@/types';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HEADER_TOOL_UMD_PATH = path.resolve(
__dirname,
'../../../../node_modules/@editorjs/header/dist/header.umd.js'
);
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`;
const PLUS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`;
const TOOLBOX_ITEM_SELECTOR = (itemName: string): string =>
`${EDITOR_INTERFACE_SELECTOR} .ce-popover-item[data-item-name=${itemName}]`;
type SerializableToolConfig = {
className?: string;
classCode?: string;
config?: Record<string, unknown>;
};
type CreateEditorOptions = {
data?: OutputData;
tools?: Record<string, SerializableToolConfig>;
};
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
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 createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
const { data = null, tools = {} } = options;
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
const serializedTools = Object.entries(tools).map(([name, tool]) => {
return {
name,
className: tool.className ?? null,
classCode: tool.classCode ?? null,
config: tool.config ?? {},
};
});
await page.evaluate(
async ({ holderId, data: initialData, serializedTools: toolsConfig }) => {
const editorConfig: Record<string, unknown> = {
holder: holderId,
};
if (initialData) {
editorConfig.data = initialData;
}
if (toolsConfig.length > 0) {
const resolvedTools = toolsConfig.reduce<Record<string, { class: unknown } & Record<string, unknown>>>(
(accumulator, { name, className, classCode, config }) => {
let toolClass: unknown = null;
if (className) {
toolClass = (window as unknown as Record<string, unknown>)[className] ?? null;
}
if (!toolClass && classCode) {
// eslint-disable-next-line no-new-func -- evaluated in browser context to reconstruct tool class
toolClass = new Function(`return (${classCode});`)();
}
if (!toolClass) {
throw new Error(`Tool "${name}" is not available globally`);
}
return {
...accumulator,
[name]: {
class: toolClass,
...config,
},
};
},
{}
);
editorConfig.tools = resolvedTools;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
data,
serializedTools,
}
);
};
const saveEditor = async (page: Page): Promise<OutputData> => {
return await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
};
/**
* Ensures that the provided value is a string.
*
* @param value - Value to validate.
* @param errorMessage - Message for the thrown error when validation fails.
*/
const assertIsString: (value: unknown, errorMessage: string) => asserts value is string = (
value,
errorMessage
) => {
if (typeof value !== 'string') {
throw new Error(errorMessage);
}
};
const getBlockIdByIndex = async (page: Page, blockIndex: number): Promise<string> => {
return await page.evaluate((index) => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const block = editor.blocks.getBlockByIndex(index);
if (!block) {
throw new Error(`Block at index ${index} was not found`);
}
const { id } = block;
if (typeof id !== 'string') {
throw new Error(`Block id at index ${index} is not a string`);
}
return id;
}, blockIndex);
};
const getLastBlockId = async (page: Page): Promise<string> => {
const blocksCount = await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
return editor.blocks.getBlocksCount();
});
if (blocksCount <= 0) {
throw new Error('Editor does not contain any blocks');
}
return await getBlockIdByIndex(page, blocksCount - 1);
};
test.describe('block ids', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('generates unique ids for new blocks', async ({ page }) => {
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });
await createEditor(page, {
tools: {
header: {
className: 'Header',
},
},
});
const firstParagraphId = await getBlockIdByIndex(page, 0);
const firstParagraph = page.locator(
`${EDITOR_INTERFACE_SELECTOR} [data-id="${firstParagraphId}"] [data-block-tool="paragraph"]`
);
await firstParagraph.click();
await page.keyboard.type('First block ');
await page.keyboard.press('Enter');
await page.keyboard.type('Second block ');
await page.keyboard.press('Enter');
const plusButton = page.locator(PLUS_BUTTON_SELECTOR);
await plusButton.click();
await page.locator(TOOLBOX_ITEM_SELECTOR('header')).click();
const headerBlockId = await getLastBlockId(page);
const headerBlock = page.locator(
`${EDITOR_INTERFACE_SELECTOR} [data-id="${headerBlockId}"] [data-block-tool="header"]`
);
await headerBlock.click();
await page.keyboard.type('Header');
const { blocks } = await saveEditor(page);
const blockIds = blocks.map((block) => block.id);
expect(blocks).not.toHaveLength(0);
for (const block of blocks) {
expect(typeof block.id).toBe('string');
}
expect(new Set(blockIds).size).toBe(blockIds.length);
});
test('preserves provided block ids', async ({ page }) => {
const blocks: OutputData['blocks'] = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
await createEditor(page, {
data: {
blocks,
},
});
const { blocks: savedBlocks } = await saveEditor(page);
expect(savedBlocks).toHaveLength(blocks.length);
savedBlocks.forEach((block, index) => {
expect(block.id).toBe(blocks[index]?.id);
});
});
test('preserves provided ids when new block is added', async ({ page }) => {
const blocks: OutputData['blocks'] = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
await createEditor(page, {
data: {
blocks,
},
});
const firstParagraphId = blocks[0]?.id;
assertIsString(firstParagraphId, 'First block id was not provided');
const firstParagraph = page.locator(
`${EDITOR_INTERFACE_SELECTOR} [data-id="${firstParagraphId}"] [data-block-tool="paragraph"]`
);
await firstParagraph.click();
await page.keyboard.press('Enter');
await page.keyboard.type('Middle block');
const { blocks: savedBlocks } = await saveEditor(page);
expect(savedBlocks).toHaveLength(3);
expect(savedBlocks[0]?.id).toBe(blocks[0]?.id);
expect(savedBlocks[2]?.id).toBe(blocks[1]?.id);
});
test('exposes block id on wrapper via data-id attribute', async ({ page }) => {
const blocks: OutputData['blocks'] = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
await createEditor(page, {
data: {
blocks,
},
});
const blockWrappers = page.locator(BLOCK_SELECTOR);
await expect(blockWrappers).toHaveCount(blocks.length);
const blockHandles = await blockWrappers.elementHandles();
await Promise.all(
blockHandles.map((handle, index) => {
return handle.evaluate((element, expectedId) => {
if (!(element instanceof HTMLElement)) {
throw new Error('Block wrapper is not an HTMLElement');
}
const dataId = element.getAttribute('data-id');
if (dataId !== expectedId) {
throw new Error(`Expected block data-id to equal "${expectedId}", but received "${dataId}"`);
}
}, blocks[index]?.id);
})
);
});
});

View file

@ -0,0 +1,236 @@
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';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HEADER_TOOL_UMD_PATH = path.resolve(
__dirname,
'../../../../node_modules/@editorjs/header/dist/header.umd.js'
);
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`;
const BLOCK_CONTENT_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block__content`;
const SETTINGS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`;
const SETTINGS_ITEM_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-settings .ce-popover-item`;
const BLOCK_TEXT = 'The block with some text';
type SerializableToolConfig = {
className?: string;
classCode?: string;
config?: Record<string, unknown>;
};
type CreateEditorOptions = {
data?: OutputData;
tools?: Record<string, SerializableToolConfig>;
};
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
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 createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
const { data = null, tools = {} } = options;
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
const serializedTools = Object.entries(tools).map(([name, tool]) => {
return {
name,
className: tool.className ?? null,
classCode: tool.classCode ?? null,
config: tool.config ?? {},
};
});
await page.evaluate(
async ({ holderId, data: initialData, serializedTools: toolsConfig }) => {
const editorConfig: Record<string, unknown> = {
holder: holderId,
};
if (initialData) {
editorConfig.data = initialData;
}
if (toolsConfig.length > 0) {
const resolvedTools = toolsConfig.reduce<Record<string, { class: unknown } & Record<string, unknown>>>(
(accumulator, { name, className, classCode, config }) => {
let toolClass: unknown = null;
if (className) {
toolClass = (window as unknown as Record<string, unknown>)[className] ?? null;
}
if (!toolClass && classCode) {
// eslint-disable-next-line no-new-func -- evaluated in browser context to reconstruct tool class
toolClass = new Function(`return (${classCode});`)();
}
if (!toolClass) {
throw new Error(`Tool "${name}" is not available globally`);
}
return {
...accumulator,
[name]: {
class: toolClass,
...config,
},
};
},
{}
);
editorConfig.tools = resolvedTools;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
data,
serializedTools,
}
);
};
const saveEditor = async (page: Page): Promise<OutputData> => {
return await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
};
test.describe('saver module', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('saves block data when extraneous DOM nodes are present', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: BLOCK_TEXT,
},
},
],
},
});
const blockContent = page.locator(BLOCK_CONTENT_SELECTOR).filter({ hasText: BLOCK_TEXT });
await blockContent.evaluate((element) => {
const extensionNode = element.ownerDocument.createElement('extension-node');
element.append(extensionNode);
});
const savedData = await saveEditor(page);
expect(savedData.blocks).toHaveLength(1);
expect(savedData.blocks[0]?.type).toBe('paragraph');
expect(savedData.blocks[0]?.data).toMatchObject({
text: BLOCK_TEXT,
});
});
test('saves header block data after container element changes', async ({ page }) => {
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });
await createEditor(page, {
data: {
blocks: [
{
type: 'header',
data: {
text: BLOCK_TEXT,
level: 1,
},
},
],
},
tools: {
header: {
className: 'Header',
},
},
});
await page.locator(BLOCK_SELECTOR).filter({ hasText: BLOCK_TEXT })
.click();
const settingsButton = page.locator(SETTINGS_BUTTON_SELECTOR);
await expect(settingsButton).toBeVisible();
await settingsButton.click();
const headerLevelOption = page.locator(SETTINGS_ITEM_SELECTOR).filter({ hasText: /^Heading 3$/ });
await headerLevelOption.waitFor({ state: 'visible' });
await headerLevelOption.click();
await page.waitForFunction(
({ editorSelector }) => {
const headerElement = document.querySelector(`${editorSelector} .ce-header`);
return headerElement?.tagName === 'H3';
},
{ editorSelector: EDITOR_INTERFACE_SELECTOR }
);
const savedData = await saveEditor(page);
expect(savedData.blocks[0]?.type).toBe('header');
expect(savedData.blocks[0]?.data).toMatchObject({
text: BLOCK_TEXT,
level: 3,
});
});
});

View file

@ -1,278 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any, jsdoc/require-jsdoc */
import { expect, test } from '@playwright/test';
import Tools from '../../../../src/components/modules/tools';
import BlockToolAdapter from '../../../../src/components/tools/block';
import type { EditorModules } from '../../../../src/types-internal/editor-modules';
import type { ModuleConfig } from '../../../../src/types-internal/module-config';
import type { EditorConfig } from '../../../../types';
const createModule = (config?: EditorConfig): Tools => {
const moduleConfig: EditorConfig = config ?? {
tools: {},
};
const module = new Tools({
config: moduleConfig,
eventsDispatcher: {},
} as unknown as ModuleConfig);
const APIMethods = {
method(): void {},
};
const editorModules = {
API: {
getMethodsForTool(): typeof APIMethods {
return APIMethods;
},
},
} as unknown as EditorModules;
module.state = editorModules;
return module;
};
test.describe('tools module', () => {
test.describe('.prepare()', () => {
test('resolves without errors when tools config is valid', async () => {
const module = createModule();
await expect(module.prepare()).resolves.toBeUndefined();
});
test('throws when tool configuration is corrupted', async () => {
const module = createModule({
tools: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore misconfigured tool
corruptedTool: 'value',
},
});
await expect(module.prepare()).rejects.toThrowError(Error);
});
test('calls a tool prepare method with user config', async () => {
class WithSuccessfulPrepare {
public static calls: Array<{ toolName: string; config: unknown }> = [];
public static prepare(data: { toolName: string; config: unknown }): void {
this.calls.push(data);
}
}
const config = {
property: 'value',
};
const module = createModule({
defaultBlock: 'withSuccessfulPrepare',
tools: {
withSuccessfulPrepare: {
class: WithSuccessfulPrepare as unknown as any,
config,
},
},
});
WithSuccessfulPrepare.calls = [];
await module.prepare();
expect(WithSuccessfulPrepare.calls).toStrictEqual([
{
toolName: 'withSuccessfulPrepare',
config,
},
]);
});
});
test.describe('collection accessors', () => {
let module: Tools;
test.beforeEach(async () => {
class InlineTool {
public static isInline = true;
public render(): void {}
public surround(): void {}
public checkState(): void {}
}
class InlineTool2 {
public static isInline = true;
public render(): void {}
public surround(): void {}
public checkState(): void {}
}
class UnavailableInlineTool {
public static isInline = true;
}
class WithSuccessfulPrepare {
public static prepare(): void {}
}
class WithFailedPrepare {
public static prepare(): void {
throw new Error();
}
}
class UnavailableBlockTune {
public static isTune = true;
public static prepare(): void {
throw new Error();
}
}
module = createModule({
defaultBlock: 'withoutPrepare',
tools: {
withSuccessfulPrepare: {
class: WithSuccessfulPrepare as unknown as any,
inlineToolbar: [ 'inlineTool2' ],
tunes: [ 'blockTune2' ],
},
withFailedPrepare: WithFailedPrepare as unknown as any,
withoutPrepare: {
class: class {} as unknown as any,
inlineToolbar: false,
tunes: false,
},
blockTool: {
class: class {} as unknown as any,
inlineToolbar: true,
},
blockToolWithoutSettings: class {} as unknown as any,
inlineTool: InlineTool as unknown as any,
inlineTool2: InlineTool2 as unknown as any,
unavailableInlineTool: UnavailableInlineTool as unknown as any,
blockTune: class {
public static isTune = true;
} as unknown as any,
blockTune2: class {
public static isTune = true;
} as unknown as any,
unavailableBlockTune: UnavailableBlockTune as unknown as any,
},
inlineToolbar: ['inlineTool2', 'inlineTool'],
tunes: ['blockTune2', 'blockTune'],
});
await module.prepare();
});
test('.available returns only ready to use tools', () => {
expect(module.available).toBeInstanceOf(Map);
expect(module.available.has('withSuccessfulPrepare')).toBe(true);
expect(module.available.has('withoutPrepare')).toBe(true);
expect(module.available.has('withFailedPrepare')).toBe(false);
expect(module.available.has('unavailableInlineTool')).toBe(false);
});
test('.unavailable returns tools that failed preparation', () => {
expect(module.unavailable).toBeInstanceOf(Map);
expect(module.unavailable.has('withSuccessfulPrepare')).toBe(false);
expect(module.unavailable.has('withoutPrepare')).toBe(false);
expect(module.unavailable.has('withFailedPrepare')).toBe(true);
expect(module.unavailable.has('unavailableInlineTool')).toBe(true);
});
test('.inlineTools contains only available inline tools', () => {
expect(module.inlineTools).toBeInstanceOf(Map);
expect(module.inlineTools.has('inlineTool')).toBe(true);
expect(module.inlineTools.has('unavailableInlineTool')).toBe(false);
expect(Array.from(module.inlineTools.values()).every(tool => tool.isInline())).toBe(true);
});
test('.blockTools contains only available block tools', () => {
expect(module.blockTools).toBeInstanceOf(Map);
expect(module.blockTools.has('withSuccessfulPrepare')).toBe(true);
expect(module.blockTools.has('withoutPrepare')).toBe(true);
expect(module.blockTools.has('withFailedPrepare')).toBe(false);
expect(Array.from(module.blockTools.values()).every(tool => tool.isBlock())).toBe(true);
});
test('block tools without settings contain default tunes', () => {
const tool = module.blockTools.get('blockToolWithoutSettings');
expect(tool?.tunes.has('delete')).toBe(true);
expect(tool?.tunes.has('moveUp')).toBe(true);
expect(tool?.tunes.has('moveDown')).toBe(true);
});
test('block tools contain default tunes', () => {
const tool = module.blockTools.get('blockTool');
expect(tool?.tunes.has('delete')).toBe(true);
expect(tool?.tunes.has('moveUp')).toBe(true);
expect(tool?.tunes.has('moveDown')).toBe(true);
});
test('block tools include tunes in the correct order', () => {
const toolWithInline = module.blockTools.get('blockTool');
const tunesOrder = Array.from(toolWithInline?.tunes.keys() ?? []);
expect(toolWithInline?.tunes.has('blockTune')).toBe(true);
expect(toolWithInline?.tunes.has('blockTune2')).toBe(true);
expect(tunesOrder).toStrictEqual(['blockTune2', 'blockTune', 'moveUp', 'delete', 'moveDown']);
const toolWithSuccessfulPrepare = module.blockTools.get('withSuccessfulPrepare');
expect(toolWithSuccessfulPrepare?.tunes.has('blockTune')).toBe(false);
expect(toolWithSuccessfulPrepare?.tunes.has('blockTune2')).toBe(true);
const toolWithoutPrepare = module.blockTools.get('withoutPrepare');
expect(toolWithoutPrepare?.tunes.has('blockTune')).toBe(false);
expect(toolWithoutPrepare?.tunes.has('blockTune2')).toBe(false);
});
test('block tools include inline tools in the correct order', () => {
const toolWithInline = module.blockTools.get('blockTool');
const inlineToolsOrder = Array.from(toolWithInline?.inlineTools.keys() ?? []);
expect(toolWithInline?.inlineTools.has('inlineTool')).toBe(true);
expect(toolWithInline?.inlineTools.has('inlineTool2')).toBe(true);
expect(inlineToolsOrder).toStrictEqual(['inlineTool2', 'inlineTool']);
const toolWithSuccessfulPrepare = module.blockTools.get('withSuccessfulPrepare');
expect(toolWithSuccessfulPrepare?.inlineTools.has('inlineTool')).toBe(false);
expect(toolWithSuccessfulPrepare?.inlineTools.has('inlineTool2')).toBe(true);
const toolWithoutPrepare = module.blockTools.get('withoutPrepare');
expect(toolWithoutPrepare?.inlineTools.has('inlineTool')).toBe(false);
expect(toolWithoutPrepare?.inlineTools.has('inlineTool2')).toBe(false);
});
test('.blockTunes contains only available block tunes', () => {
expect(module.blockTunes).toBeInstanceOf(Map);
expect(module.blockTunes.has('blockTune')).toBe(true);
expect(module.blockTunes.has('unavailableBlockTune')).toBe(false);
expect(Array.from(module.blockTunes.values()).every(tool => tool.isTune())).toBe(true);
});
test('.internal contains only internal tools', () => {
expect(module.internal).toBeInstanceOf(Map);
expect(Array.from(module.internal.values()).every(tool => tool.isInternal)).toBe(true);
});
test('.defaultTool returns a block tool adapter for the default tool', () => {
expect(module.defaultTool).toBeInstanceOf(BlockToolAdapter);
expect(module.defaultTool.isDefault).toBe(true);
});
});
});

View file

@ -0,0 +1,167 @@
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';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
const PARAGRAPH_INDEX_ATTRIBUTE = 'data-cy-paragraph-index';
const SELECT_ALL_SHORTCUT = process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
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 createEditorWithTextBlocks = async (page: Page, textBlocks: string[]): Promise<void> => {
const blocks: OutputData['blocks'] = textBlocks.map((text, index) => ({
id: `paragraph-${index + 1}`,
type: 'paragraph',
data: {
text,
},
}));
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({ holderId, blocksData }) => {
const editor = new window.EditorJS({
holder: holderId,
data: {
blocks: blocksData,
},
});
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
blocksData: blocks,
}
);
};
const assignParagraphIndexes = async (page: Page): Promise<void> => {
await page.evaluate(
({ selector, attribute }) => {
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
elements.forEach((element, index) => {
element.setAttribute(attribute, String(index));
});
},
{
selector: PARAGRAPH_SELECTOR,
attribute: PARAGRAPH_INDEX_ATTRIBUTE,
}
);
};
test.describe('data-empty attribute', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('reflects initial block content', async ({ page }) => {
await createEditorWithTextBlocks(page, ['First', '']);
await assignParagraphIndexes(page);
const paragraphs = page.locator(PARAGRAPH_SELECTOR);
const firstParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="0"]`
);
const secondParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="1"]`
);
await expect(paragraphs).toHaveCount(2);
await expect(firstParagraph).toHaveAttribute('data-empty', 'false');
await expect(secondParagraph).toHaveAttribute('data-empty', 'true');
});
test('updates to "false" after typing', async ({ page }) => {
await createEditorWithTextBlocks(page, ['First', '']);
await assignParagraphIndexes(page);
const lastParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="1"]`
);
await lastParagraph.click();
await lastParagraph.type('Some text');
await expect(lastParagraph).toHaveAttribute('data-empty', 'false');
});
test('updates to "true" after removing content', async ({ page }) => {
await createEditorWithTextBlocks(page, ['', 'Some text']);
await assignParagraphIndexes(page);
const lastParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="1"]`
);
await lastParagraph.click();
await page.keyboard.press(SELECT_ALL_SHORTCUT);
await page.keyboard.press('Backspace');
await expect(lastParagraph).toHaveAttribute('data-empty', 'true');
});
test('applies to newly created blocks', async ({ page }) => {
await createEditorWithTextBlocks(page, ['First', '']);
await assignParagraphIndexes(page);
const paragraphs = page.locator(PARAGRAPH_SELECTOR);
const secondParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="1"]`
);
await secondParagraph.click();
await page.keyboard.press('Enter');
await assignParagraphIndexes(page);
await expect(paragraphs).toHaveCount(3);
const newestParagraph = page.locator(
`${PARAGRAPH_SELECTOR}[${PARAGRAPH_INDEX_ATTRIBUTE}="2"]`
);
await expect(newestParagraph).toHaveAttribute('data-empty', 'true');
});
});

View file

@ -0,0 +1,352 @@
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';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
const REDACTOR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .codex-editor__redactor`;
type CreateEditorOptions = {
data?: OutputData;
readOnly?: boolean;
};
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
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 createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise<void> => {
const { data, readOnly } = options;
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({ holderId, editorData, readOnlyMode }) => {
const editorConfig: Record<string, unknown> = {
holder: holderId,
};
if (editorData !== null) {
editorConfig.data = editorData;
}
if (readOnlyMode !== null) {
editorConfig.readOnly = readOnlyMode;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorData: data ?? null,
readOnlyMode: typeof readOnly === 'boolean' ? readOnly : null,
}
);
};
const ensureBottomPadding = async (page: Page): Promise<void> => {
await page.evaluate(({ selector }) => {
const redactor = document.querySelector(selector);
if (!redactor) {
throw new Error('Redactor element not found');
}
(redactor as HTMLElement).style.paddingBottom = '200px';
}, { selector: REDACTOR_SELECTOR });
};
const clickBottomZone = async (page: Page): Promise<void> => {
const clickPoint = await page.evaluate(({ selector }) => {
const redactor = document.querySelector(selector);
if (!redactor) {
throw new Error('Redactor element not found');
}
const rect = redactor.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = Math.min(rect.bottom - 4, rect.top + rect.height - 4);
return {
x: clientX,
y: clientY,
};
}, { selector: REDACTOR_SELECTOR });
await page.mouse.click(clickPoint.x, clickPoint.y);
};
test.describe('ui module', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test.describe('documentKeydown', () => {
const initialData: OutputData = {
blocks: [
{
id: 'block1',
type: 'paragraph',
data: {
text: 'The first block',
},
},
{
id: 'block2',
type: 'paragraph',
data: {
text: 'The second block',
},
},
],
};
const selectBlocks = async (page: Page): Promise<void> => {
const firstParagraph = page.locator(PARAGRAPH_SELECTOR).filter({
hasText: 'The first block',
});
await firstParagraph.click();
await page.keyboard.press('Shift+ArrowDown');
};
const getSavedBlocksCount = async (page: Page): Promise<number> => {
return await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const savedData = await editor.save();
return savedData.blocks.length;
});
};
test('removes selected blocks with Backspace', async ({ page }) => {
await createEditor(page, { data: initialData });
await selectBlocks(page);
await page.keyboard.press('Backspace');
await page.waitForFunction(() => {
const editor = window.editorInstance;
if (!editor) {
return false;
}
return editor.blocks.getBlocksCount() === 1;
});
const savedBlocksCount = await getSavedBlocksCount(page);
expect(savedBlocksCount).toBe(0);
});
test('removes selected blocks with Delete', async ({ page }) => {
await createEditor(page, { data: initialData });
await selectBlocks(page);
await page.keyboard.press('Delete');
await page.waitForFunction(() => {
const editor = window.editorInstance;
if (!editor) {
return false;
}
return editor.blocks.getBlocksCount() === 1;
});
const savedBlocksCount = await getSavedBlocksCount(page);
expect(savedBlocksCount).toBe(0);
});
});
test.describe('mousedown', () => {
const textBlocks: OutputData = {
blocks: [
{
type: 'paragraph',
data: {
text: 'first block',
},
},
{
type: 'paragraph',
data: {
text: 'second block',
},
},
{
type: 'paragraph',
data: {
text: 'third block',
},
},
],
};
test('updates current block on click', async ({ page }) => {
await createEditor(page, { data: textBlocks });
const secondParagraph = page.locator(PARAGRAPH_SELECTOR).filter({
hasText: 'second block',
});
await secondParagraph.click();
const currentIndex = await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
return editor.blocks.getCurrentBlockIndex();
});
expect(currentIndex).toBe(1);
});
test('updates current block on click in read-only mode', async ({ page }) => {
await createEditor(page, {
data: textBlocks,
readOnly: true,
});
const secondParagraph = page.locator(PARAGRAPH_SELECTOR).filter({
hasText: 'second block',
});
await secondParagraph.click();
const currentIndex = await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
return editor.blocks.getCurrentBlockIndex();
});
expect(currentIndex).toBe(1);
});
});
test.describe('bottom zone interactions', () => {
test('keeps single empty default block when clicking bottom zone', async ({ page }) => {
await createEditor(page);
await ensureBottomPadding(page);
await clickBottomZone(page);
const result = await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
return {
blocksCount: editor.blocks.getBlocksCount(),
currentIndex: editor.blocks.getCurrentBlockIndex(),
};
});
expect(result.blocksCount).toBe(1);
expect(result.currentIndex).toBe(0);
});
test('inserts new default block when clicking bottom zone with non-empty block', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'The only block',
},
},
],
},
});
await ensureBottomPadding(page);
await clickBottomZone(page);
await page.waitForFunction(() => {
const editor = window.editorInstance;
if (!editor) {
return false;
}
return editor.blocks.getBlocksCount() === 2;
});
const result = await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const blocksCount = editor.blocks.getBlocksCount();
const currentIndex = editor.blocks.getCurrentBlockIndex();
const lastBlock = editor.blocks.getBlockByIndex(blocksCount - 1);
return {
blocksCount,
currentIndex,
lastBlockIsEmpty: lastBlock?.isEmpty ?? false,
};
});
expect(result.blocksCount).toBe(2);
expect(result.currentIndex).toBe(1);
expect(result.lastBlockIsEmpty).toBe(true);
});
});
});

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { OutputData } from '@/types';
@ -20,6 +20,34 @@ const POPOVER_ITEM_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover-item`;
const NOTHING_FOUND_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover__nothing-found-message`;
const POPOVER_CONTAINER_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover__container`;
/**
* Clear the provided search input and emit an input event so filtering logic reacts
*
* @param searchInput - locator pointing to the search field
*/
const clearSearchInputField = async (searchInput: Locator): Promise<void> => {
await searchInput.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) {
return;
}
const inputElement = element;
inputElement.value = '';
if (typeof InputEvent === 'function') {
inputElement.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: false,
data: null,
inputType: 'deleteContentBackward',
}));
} else {
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
}
});
};
interface SerializableMenuChildren {
searchable?: boolean;
isOpen?: boolean;
@ -385,7 +413,7 @@ test.describe('popover Search/Filter', () => {
await expect(page.locator('[data-item-name="second"]')).toBeHidden();
// Clear search input
await searchInput.clear();
await clearSearchInputField(searchInput);
// All items should be visible again
await expect(page.locator('[data-item-name="first"]')).toBeVisible();
@ -465,7 +493,7 @@ test.describe('popover Search/Filter', () => {
await expect(nothingFound).toBeVisible();
// Clear search to show items again
await searchInput.clear();
await clearSearchInputField(searchInput);
// "Nothing found" should be hidden
await expect(nothingFound).toBeHidden();
@ -678,7 +706,7 @@ test.describe('popover Search/Filter', () => {
await searchInput.fill('First');
// Clear to empty
await searchInput.clear();
await clearSearchInputField(searchInput);
// All items should be visible again
await expect(page.locator('[data-item-name="first"]')).toBeVisible();
@ -805,7 +833,7 @@ test.describe('popover Search/Filter', () => {
await expect(page.locator('[data-item-name="move-up"]')).toBeVisible();
await expect(page.locator('[data-item-name="move-down"]')).toBeVisible();
await searchInput.clear();
await clearSearchInputField(searchInput);
await expect(page.locator('[data-item-name="move-up"]')).toBeVisible();
await expect(page.locator('[data-item-name="move-down"]')).toBeVisible();
await expect(page.locator('[data-item-name="delete"]')).toBeVisible();

View file

@ -0,0 +1,545 @@
import { describe, it, expect, beforeEach } from 'vitest';
import Tools from '../../../../src/components/modules/tools';
import BlockToolAdapter from '../../../../src/components/tools/block';
import EventsDispatcher from '../../../../src/components/utils/events';
import type { EditorModules } from '../../../../src/types-internal/editor-modules';
import type { ModuleConfig } from '../../../../src/types-internal/module-config';
import type { EditorConfig } from '../../../../types';
import type { ToolConstructable } from '../../../../types/tools';
import type { EditorEventMap } from '../../../../src/components/events';
/**
* Creates a Tools module instance with provided editor config.
*
* @param config - optional editor configuration
*/
const createModule = (config?: EditorConfig): Tools => {
const editorConfig: EditorConfig = config ?? {
tools: {},
};
const moduleConfig: ModuleConfig = {
config: editorConfig,
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
};
const module = new Tools(moduleConfig);
const APIMethods = {
method(): void {},
};
const editorModules = {
API: {
getMethodsForTool(): typeof APIMethods {
return APIMethods;
},
},
} as unknown as EditorModules;
module.state = editorModules;
return module;
};
describe('tools module', () => {
describe('.prepare()', () => {
it('resolves without errors when tools config is valid', async () => {
const module = createModule();
await expect(module.prepare()).resolves.toBeUndefined();
});
it('throws when tool configuration is corrupted', async () => {
const module = createModule({
tools: {
corruptedTool: 'value' as unknown as ToolConstructable,
},
});
await expect(module.prepare()).rejects.toThrowError(Error);
});
it('calls a tool prepare method with user config', async () => {
/**
*
*/
class WithSuccessfulPrepare {
public static calls: Array<{ toolName: string; config: unknown }> = [];
/**
* Forwards tool name and config to tool-level prepare handler.
*
* @param data object containing tool identifier and its configuration
*/
public static prepare(data: { toolName: string; config: unknown }): void {
this.calls.push(data);
}
}
const config = {
property: 'value',
};
const module = createModule({
defaultBlock: 'withSuccessfulPrepare',
tools: {
withSuccessfulPrepare: {
class: WithSuccessfulPrepare as unknown as ToolConstructable,
config,
},
},
});
WithSuccessfulPrepare.calls = [];
await module.prepare();
expect(WithSuccessfulPrepare.calls).toStrictEqual([
{
toolName: 'withSuccessfulPrepare',
config,
},
]);
});
});
describe('collection accessors', () => {
let module: Tools;
beforeEach(async () => {
/**
*
*/
class InlineTool {
public static isInline = true;
/**
*
*/
public render(): void {}
/**
*
*/
public surround(): void {}
/**
*
*/
public checkState(): void {}
}
/**
*
*/
class InlineTool2 {
public static isInline = true;
/**
*
*/
public render(): void {}
/**
*
*/
public surround(): void {}
/**
*
*/
public checkState(): void {}
}
/**
*
*/
class UnavailableInlineTool {
public static isInline = true;
}
/**
*
*/
class WithSuccessfulPrepare {
/**
*
*/
public static prepare(): void {}
}
/**
*
*/
class WithFailedPrepare {
/**
*
*/
public static prepare(): void {
throw new Error();
}
}
/**
*
*/
class UnavailableBlockTune {
public static isTune = true;
/**
*
*/
public static prepare(): void {
throw new Error();
}
}
/**
*
*/
class BasicBlockTool {
/**
*
*/
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
return element;
}
/**
*
*/
public save(): void {}
}
/**
*
*/
class BlockToolWithoutSettings extends BasicBlockTool {}
/**
*
*/
class BlockToolWithInline extends BasicBlockTool {}
const moduleInstance = createModule({
defaultBlock: 'withoutPrepare',
tools: {
withSuccessfulPrepare: {
class: WithSuccessfulPrepare as unknown as ToolConstructable,
inlineToolbar: [ 'inlineTool2' ],
tunes: [ 'blockTune2' ],
},
withFailedPrepare: WithFailedPrepare as unknown as ToolConstructable,
withoutPrepare: {
class: BlockToolWithoutSettings as unknown as ToolConstructable,
inlineToolbar: false,
tunes: false,
},
blockTool: {
class: BlockToolWithInline as unknown as ToolConstructable,
inlineToolbar: true,
},
blockToolWithoutSettings: BlockToolWithoutSettings as unknown as ToolConstructable,
inlineTool: InlineTool as unknown as ToolConstructable,
inlineTool2: InlineTool2 as unknown as ToolConstructable,
unavailableInlineTool: UnavailableInlineTool as unknown as ToolConstructable,
blockTune: class {
public static isTune = true;
} as unknown as ToolConstructable,
blockTune2: class {
public static isTune = true;
} as unknown as ToolConstructable,
unavailableBlockTune: UnavailableBlockTune as unknown as ToolConstructable,
},
inlineToolbar: ['inlineTool2', 'inlineTool'],
tunes: ['blockTune2', 'blockTune'],
});
await moduleInstance.prepare();
module = moduleInstance;
});
it('.available returns only ready to use tools', () => {
expect(module.available).toBeInstanceOf(Map);
expect(module.available.has('withSuccessfulPrepare')).toBe(true);
expect(module.available.has('withoutPrepare')).toBe(true);
expect(module.available.has('withFailedPrepare')).toBe(false);
expect(module.available.has('unavailableInlineTool')).toBe(false);
});
it('.unavailable returns tools that failed preparation', () => {
expect(module.unavailable).toBeInstanceOf(Map);
expect(module.unavailable.has('withSuccessfulPrepare')).toBe(false);
expect(module.unavailable.has('withoutPrepare')).toBe(false);
expect(module.unavailable.has('withFailedPrepare')).toBe(true);
expect(module.unavailable.has('unavailableInlineTool')).toBe(true);
});
it('.inlineTools contains only available inline tools', () => {
expect(module.inlineTools).toBeInstanceOf(Map);
expect(module.inlineTools.has('inlineTool')).toBe(true);
expect(module.inlineTools.has('unavailableInlineTool')).toBe(false);
expect(Array.from(module.inlineTools.values()).every(tool => tool.isInline())).toBe(true);
});
it('.blockTools contains only available block tools', () => {
expect(module.blockTools).toBeInstanceOf(Map);
expect(module.blockTools.has('withSuccessfulPrepare')).toBe(true);
expect(module.blockTools.has('withoutPrepare')).toBe(true);
expect(module.blockTools.has('withFailedPrepare')).toBe(false);
expect(Array.from(module.blockTools.values()).every(tool => tool.isBlock())).toBe(true);
});
it('block tools without settings contain default tunes', () => {
const tool = module.blockTools.get('blockToolWithoutSettings');
expect(tool?.tunes.has('delete')).toBe(true);
expect(tool?.tunes.has('moveUp')).toBe(true);
expect(tool?.tunes.has('moveDown')).toBe(true);
});
it('block tools contain default tunes', () => {
const tool = module.blockTools.get('blockTool');
expect(tool?.tunes.has('delete')).toBe(true);
expect(tool?.tunes.has('moveUp')).toBe(true);
expect(tool?.tunes.has('moveDown')).toBe(true);
});
it('block tools include tunes in the correct order', () => {
const toolWithInline = module.blockTools.get('blockTool');
const tunesOrder = Array.from(toolWithInline?.tunes.keys() ?? []);
expect(toolWithInline?.tunes.has('blockTune')).toBe(true);
expect(toolWithInline?.tunes.has('blockTune2')).toBe(true);
expect(tunesOrder).toStrictEqual(['blockTune2', 'blockTune', 'moveUp', 'delete', 'moveDown']);
const toolWithSuccessfulPrepare = module.blockTools.get('withSuccessfulPrepare');
expect(toolWithSuccessfulPrepare?.tunes.has('blockTune')).toBe(false);
expect(toolWithSuccessfulPrepare?.tunes.has('blockTune2')).toBe(true);
const toolWithoutPrepare = module.blockTools.get('withoutPrepare');
expect(toolWithoutPrepare?.tunes.has('blockTune')).toBe(false);
expect(toolWithoutPrepare?.tunes.has('blockTune2')).toBe(false);
});
it('block tools include inline tools in the correct order', () => {
const toolWithInline = module.blockTools.get('blockTool');
const inlineToolsOrder = Array.from(toolWithInline?.inlineTools.keys() ?? []);
expect(toolWithInline?.inlineTools.has('inlineTool')).toBe(true);
expect(toolWithInline?.inlineTools.has('inlineTool2')).toBe(true);
expect(inlineToolsOrder).toStrictEqual(['inlineTool2', 'inlineTool']);
const toolWithSuccessfulPrepare = module.blockTools.get('withSuccessfulPrepare');
expect(toolWithSuccessfulPrepare?.inlineTools.has('inlineTool')).toBe(false);
expect(toolWithSuccessfulPrepare?.inlineTools.has('inlineTool2')).toBe(true);
const toolWithoutPrepare = module.blockTools.get('withoutPrepare');
expect(toolWithoutPrepare?.inlineTools.has('inlineTool')).toBe(false);
expect(toolWithoutPrepare?.inlineTools.has('inlineTool2')).toBe(false);
});
it('.blockTunes contains only available block tunes', () => {
expect(module.blockTunes).toBeInstanceOf(Map);
expect(module.blockTunes.has('blockTune')).toBe(true);
expect(module.blockTunes.has('unavailableBlockTune')).toBe(false);
expect(Array.from(module.blockTunes.values()).every(tool => tool.isTune())).toBe(true);
});
it('.internal contains only internal tools', () => {
expect(module.internal).toBeInstanceOf(Map);
expect(Array.from(module.internal.values()).every(tool => tool.isInternal)).toBe(true);
});
it('.defaultTool returns a block tool adapter for the default tool', () => {
expect(module.defaultTool).toBeInstanceOf(BlockToolAdapter);
expect(module.defaultTool.isDefault).toBe(true);
});
});
describe('.getAllInlineToolsSanitizeConfig()', () => {
it('returns merged inline tool sanitize configuration', async () => {
/**
*
*/
class InlineToolWithSanitize {
public static isInline = true;
public static sanitize = {
span: {
class: true,
},
};
/**
*
*/
public render(): void {}
/**
*
*/
public surround(): void {}
/**
*
*/
public checkState(): void {}
}
/**
*
*/
class AnotherInlineToolWithSanitize {
public static isInline = true;
public static sanitize = {
mark: {
style: true,
},
};
/**
*
*/
public render(): void {}
/**
*
*/
public surround(): void {}
/**
*
*/
public checkState(): void {}
}
const module = createModule({
tools: {
inlineToolOne: InlineToolWithSanitize as unknown as ToolConstructable,
inlineToolTwo: AnotherInlineToolWithSanitize as unknown as ToolConstructable,
},
inlineToolbar: ['inlineToolOne', 'inlineToolTwo'],
});
await module.prepare();
const sanitizeConfig = module.getAllInlineToolsSanitizeConfig();
expect(sanitizeConfig).toMatchObject({
span: {
class: true,
},
mark: {
style: true,
},
});
expect(module.getAllInlineToolsSanitizeConfig()).toBe(sanitizeConfig);
});
});
describe('.destroy()', () => {
it('calls reset on all available tools', async () => {
/**
*
*/
class AsyncResetBlockTool {
public static calls = 0;
/**
*
*/
public static reset(): Promise<void> {
this.calls += 1;
return Promise.resolve();
}
}
const module = createModule({
tools: {
asyncResetTool: AsyncResetBlockTool as unknown as ToolConstructable,
},
});
await module.prepare();
AsyncResetBlockTool.calls = 0;
module.destroy();
expect(AsyncResetBlockTool.calls).toBe(1);
});
it('handles errors raised during tool reset', async () => {
/**
*
*/
class ThrowingBlockTool {
public static wasResetCalled = false;
/**
*
*/
public static reset(): void {
this.wasResetCalled = true;
throw new Error('reset failure');
}
}
/**
*
*/
class RejectingBlockTool {
/**
*
*/
public static reset(): Promise<void> {
return Promise.reject(new Error('async reset failure'));
}
}
const module = createModule({
tools: {
throwing: ThrowingBlockTool as unknown as ToolConstructable,
rejecting: RejectingBlockTool as unknown as ToolConstructable,
},
});
await module.prepare();
let unhandledRejection: unknown;
const handleUnhandledRejection = (reason: unknown): void => {
unhandledRejection = reason;
};
process.on('unhandledRejection', handleUnhandledRejection);
expect(() => module.destroy()).not.toThrow();
await new Promise(resolve => {
setTimeout(resolve, 0);
});
process.off('unhandledRejection', handleUnhandledRejection);
expect(unhandledRejection).toBeUndefined();
expect(ThrowingBlockTool.wasResetCalled).toBe(true);
});
});
});