test: add missing coverage E2E tests

This commit is contained in:
JackUait 2025-11-16 23:46:42 +03:00
commit 26eb6f496b
42 changed files with 6955 additions and 199 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ node_modules/*
npm-debug.log
yarn-error.log
.yarn/install-state.gz
install-state.gz
test-results

View file

@ -1,5 +1,7 @@
---
alwaysApply: true
trigger: always_on
description:
globs:
---
# Rule: DO NOT MODIFY configuration files unless explicitly instructed

View file

@ -1,5 +1,7 @@
---
alwaysApply: true
trigger: always_on
description:
globs:
---
# Fix Problems Policy
@ -12,12 +14,12 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e
- **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it.
- **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines.
- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues.
- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely
## When to Apply
- During any code editing, reviewing, or generation task.
- Proactively scan for and fix problems in affected files using available tools (e.g., read_lints, grep, codebase_search).
- If a problem persists after reasonable efforts, document it clearly and suggest next steps rather than suppressing it.
- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely
## Notes
- This policy promotes robust, high-quality code that is easier to maintain and less prone to future issues.

View file

@ -1,6 +1,7 @@
---
alwaysApply: true
trigger: always_on
description: Enforce accessibility best practices so all users can use the application
globs:
---
### Accessibility guidance (must follow)

View file

@ -1,7 +1,7 @@
---
title: Frontend ESLint Code Style
alwaysApply: true
trigger: always_on
description: Defer all code style decisions to the project's ESLint configuration; do not invent new style rules
globs:
---
### Code Style Source of Truth

View file

@ -1,7 +1,7 @@
---
alwaysApply: true
globs: *.ts,*.tsx
trigger: always_on
description: Enforce fixing TypeScript errors by improving code quality, not suppressing them
globs: *.ts,*.tsx
---
# Fix TypeScript Errors Policy

View file

@ -1,7 +1,7 @@
---
alwaysApply: true
globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**"
trigger: always_on
description: "Frontend development principle: Keep solutions simple and avoid overengineering"
globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**"
---
# Frontend Simplicity Principle

View file

@ -1,6 +1,7 @@
---
alwaysApply: true
trigger: always_on
description: Policy for handling ESLint issues by preferring autofix with yarn lint:fix
globs:
---
# Lint Fix Policy

View file

@ -1,7 +1,7 @@
---
alwaysApply: true
globs: tests/**/*.spec.ts,tests/**/*.ts
trigger: always_on
description: Playwright end-to-end testing patterns and expectations
globs: tests/**/*.spec.ts,tests/**/*.ts
---
# Playwright E2E Tests

Binary file not shown.

View file

@ -142,6 +142,23 @@ export default class EditorJS {
this.destroy = destroy;
const apiMethods = editor.moduleInstances.API.methods;
const eventsDispatcherApi = editor.moduleInstances.EventsAPI?.methods ?? apiMethods.events;
if (eventsDispatcherApi !== undefined) {
const defineDispatcher = (target: object): void => {
if (!Object.prototype.hasOwnProperty.call(target, 'eventsDispatcher')) {
Object.defineProperty(target, 'eventsDispatcher', {
value: eventsDispatcherApi,
configurable: true,
enumerable: true,
writable: false,
});
}
};
defineDispatcher(apiMethods);
defineDispatcher(this as Record<string, unknown>);
}
if (Object.getPrototypeOf(apiMethods) !== EditorJS.prototype) {
Object.setPrototypeOf(apiMethods, EditorJS.prototype);

View file

@ -153,11 +153,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
public readonly config: ToolConfig;
/**
* Stores last successfully extracted block data
*/
private lastSavedData: BlockToolData;
/**
* Cached inputs
*/
private cachedInputs: HTMLElement[] = [];
/**
* Stores last successfully extracted tunes data
*/
private lastSavedTunes: { [name: string]: BlockTuneData } = {};
/**
* We'll store a reference to the tool's rendered element to access it later
*/
@ -221,9 +231,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
this.config = tool.settings.config ?? {};
this.config = this.settings;
this.editorEventBus = eventBus || null;
this.blockAPI = new BlockAPI(this);
this.lastSavedData = data ?? {};
this.lastSavedTunes = tunesData ?? {};
this.tool = tool;
@ -318,7 +330,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
const tunesData: { [name: string]: BlockTuneData } = { ...this.unavailableTunesData };
[
...this.tunesInstances.entries(),
@ -341,6 +353,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
if (finishedExtraction !== undefined) {
this.lastSavedData = finishedExtraction;
this.lastSavedTunes = { ...tunesData };
}
/** measure promise execution */
const measuringEnd = window.performance.now();
@ -633,6 +650,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
});
}
/**
* Returns last successfully extracted block data
*/
public get preservedData(): BlockToolData {
return this.lastSavedData ?? {};
}
/**
* Returns last successfully extracted tune data
*/
public get preservedTunes(): { [name: string]: BlockTuneData } {
return this.lastSavedTunes ?? {};
}
/**
* Returns tool's sanitizer config
*
@ -866,6 +897,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
if (!element.hasAttribute('data-block-tool') && this.name) {
element.setAttribute('data-block-tool', this.name);
}
const placeholderAttribute = 'data-placeholder';
const placeholder = this.config?.placeholder;
const placeholderText = typeof placeholder === 'string' ? placeholder.trim() : '';
if (placeholderText.length > 0) {
element.setAttribute(placeholderAttribute, placeholderText);
return;
}
if (placeholder === false && element.hasAttribute(placeholderAttribute)) {
element.removeAttribute(placeholderAttribute);
}
}
/**

View file

@ -148,7 +148,9 @@ export default class Core {
data: {},
};
this.config.placeholder = this.config.placeholder ?? false;
if (this.config.placeholder === undefined) {
this.config.placeholder = false;
}
this.config.sanitizer = this.config.sanitizer ?? {} as SanitizerConfig;
this.config.hideToolbar = this.config.hideToolbar ?? false;

View file

@ -919,7 +919,7 @@ export default class BlockManager extends Module {
*/
const savedBlock = await blockToConvert.save();
if (!savedBlock) {
if (!savedBlock || savedBlock.data === undefined) {
throw new Error('Could not convert Block. Failed to extract original Block data.');
}

View file

@ -13,6 +13,11 @@ export default class DragNDrop extends Module {
*/
private isStartedAtEditor = false;
/**
* Holds listener identifiers that prevent native drops in read-only mode
*/
private guardListenerIds: string[] = [];
/**
* Toggle read-only state
*
@ -27,7 +32,9 @@ export default class DragNDrop extends Module {
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (readOnlyEnabled) {
this.disableModuleBindings();
this.bindPreventDropHandlers();
} else {
this.clearGuardListeners();
this.enableModuleBindings();
}
}
@ -59,6 +66,62 @@ export default class DragNDrop extends Module {
*/
private disableModuleBindings(): void {
this.readOnlyMutableListeners.clearAll();
this.clearGuardListeners();
}
/**
* Prevents native drag-and-drop insertions while editor is locked
*/
private bindPreventDropHandlers(): void {
const { UI } = this.Editor;
this.addGuardListener(UI.nodes.holder, 'dragover', this.preventNativeDrop, true);
this.addGuardListener(UI.nodes.holder, 'drop', this.preventNativeDrop, true);
}
/**
* Cancels browser default drag/drop behavior
*
* @param event - drag-related event dispatched on the holder
*/
private preventNativeDrop = (event: Event): void => {
event.preventDefault();
if (event instanceof DragEvent) {
event.stopPropagation();
event.dataTransfer?.clearData();
}
};
/**
* Registers a listener to be cleaned up when unlocking editor
*
* @param element - target to bind listener to
* @param eventType - event type to listen for
* @param handler - event handler
* @param options - listener options
*/
private addGuardListener(
element: EventTarget,
eventType: string,
handler: (event: Event) => void,
options: boolean | AddEventListenerOptions = false
): void {
const listenerId = this.listeners.on(element, eventType, handler, options);
if (listenerId) {
this.guardListenerIds.push(listenerId);
}
}
/**
* Removes guard listeners bound for read-only mode
*/
private clearGuardListeners(): void {
this.guardListenerIds.forEach((id) => {
this.listeners.offById(id);
});
this.guardListenerIds = [];
}
/**
@ -75,12 +138,22 @@ export default class DragNDrop extends Module {
dropEvent.preventDefault();
if (this.Editor.ReadOnly?.isEnabled) {
this.preventNativeDrop(dropEvent);
return;
}
for (const block of BlockManager.blocks) {
block.dropTarget = false;
}
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) {
document.execCommand('delete');
const blockSelection = this.Editor.BlockSelection;
const hasBlockSelection = Boolean(blockSelection?.anyBlockSelected);
const hasTextSelection = SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed;
if (this.isStartedAtEditor && (hasTextSelection || hasBlockSelection)) {
this.removeDraggedSelection();
}
this.isStartedAtEditor = false;
@ -113,6 +186,65 @@ export default class DragNDrop extends Module {
await Paste.processDataTransfer(dataTransfer, true);
}
/**
* Removes currently selected content when drag originated from Editor
*/
private removeDraggedSelection(): void {
const { BlockSelection, BlockManager } = this.Editor;
if (!BlockSelection?.anyBlockSelected) {
this.removeTextSelection();
return;
}
const removedIndex = BlockManager.removeSelectedBlocks();
if (removedIndex === undefined) {
return;
}
BlockSelection.clearSelection();
}
/**
* Removes current text selection produced within the editor
*/
private removeTextSelection(): void {
const selection = SelectionUtils.get();
if (!selection) {
return;
}
if (selection.rangeCount === 0) {
this.deleteCurrentSelection(selection);
return;
}
const range = selection.getRangeAt(0);
if (!range.collapsed) {
range.deleteContents();
return;
}
this.deleteCurrentSelection(selection);
}
/**
* Removes current selection using browser API if available
*
* @param selection - current document selection
*/
private deleteCurrentSelection(selection: Selection): void {
if (typeof selection.deleteFromDocument === 'function') {
selection.deleteFromDocument();
}
}
/**
* Handle drag start event
*/

View file

@ -29,6 +29,25 @@ interface TagSubstitute {
sanitizationConfig?: SanitizerRule;
}
const SAFE_STRUCTURAL_TAGS = new Set([
'table',
'thead',
'tbody',
'tfoot',
'tr',
'th',
'td',
'caption',
'colgroup',
'col',
'ul',
'ol',
'li',
'dl',
'dt',
'dd',
]);
/**
* Pattern substitute object.
*/
@ -144,6 +163,56 @@ export default class Paste extends Module {
this.processTools();
}
/**
* Determines whether current block should be replaced by the pasted file tool.
*
* @param toolName - tool that is going to handle the file
*/
private shouldReplaceCurrentBlockForFile(toolName?: string): boolean {
const { BlockManager } = this.Editor;
const currentBlock = BlockManager.currentBlock;
if (!currentBlock) {
return false;
}
if (toolName && currentBlock.name === toolName) {
return true;
}
const isCurrentBlockDefault = Boolean(currentBlock.tool.isDefault);
return isCurrentBlockDefault && currentBlock.isEmpty;
}
/**
* Builds sanitize config that keeps structural tags such as tables and lists intact.
*
* @param node - root node to inspect
*/
private getStructuralTagsSanitizeConfig(node: HTMLElement): SanitizerConfig {
const config: SanitizerConfig = {} as SanitizerConfig;
const nodesToProcess: Element[] = [ node ];
while (nodesToProcess.length > 0) {
const current = nodesToProcess.pop();
if (!current) {
continue;
}
const tagName = current.tagName.toLowerCase();
if (SAFE_STRUCTURAL_TAGS.has(tagName)) {
config[tagName] = config[tagName] ?? {};
}
nodesToProcess.push(...Array.from(current.children));
}
return config;
}
/**
* Set read-only state
*
@ -157,6 +226,41 @@ export default class Paste extends Module {
}
}
/**
* Determines whether provided DataTransfer contains file-like entries
*
* @param dataTransfer - drag/drop payload to inspect
*/
private containsFiles(dataTransfer: DataTransfer): boolean {
const types = Array.from(dataTransfer.types);
/**
* Common case: browser exposes explicit "Files" entry
*/
if (types.includes('Files')) {
return true;
}
/**
* Drag/drop uploads sometimes omit `types` and set files directly
*/
if (dataTransfer.files?.length) {
return true;
}
try {
const legacyList = dataTransfer.types as unknown as DOMStringList;
if (typeof legacyList?.contains === 'function' && legacyList.contains('Files')) {
return true;
}
} catch {
// ignore and fallthrough
}
return false;
}
/**
* Handle pasted or dropped data transfer object
*
@ -165,14 +269,7 @@ export default class Paste extends Module {
*/
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
const { Tools } = this.Editor;
const types = dataTransfer.types;
/**
* In Microsoft Edge types is DOMStringList. So 'contains' is used to check if 'Files' type included
*/
const includesFiles = typeof types.includes === 'function'
? types.includes('Files')
: (types as unknown as DOMStringList).contains('Files');
const includesFiles = this.containsFiles(dataTransfer);
if (includesFiles && !_.isEmpty(this.toolsFiles)) {
await this.processFiles(dataTransfer.files);
@ -563,14 +660,15 @@ export default class Paste extends Module {
);
const dataToInsert = processedFiles.filter((data): data is { type: string; event: PasteEvent } => data != null);
const isCurrentBlockDefault = Boolean(BlockManager.currentBlock?.tool.isDefault);
const needToReplaceCurrentBlock = isCurrentBlockDefault && Boolean(BlockManager.currentBlock?.isEmpty);
if (dataToInsert.length === 0) {
return;
}
dataToInsert.forEach(
(data, i) => {
BlockManager.paste(data.type, data.event, i === 0 && needToReplaceCurrentBlock);
}
);
const shouldReplaceCurrentBlock = this.shouldReplaceCurrentBlockForFile(dataToInsert[0]?.type);
dataToInsert.forEach((data, index) => {
BlockManager.paste(data.type, data.event, index === 0 && shouldReplaceCurrentBlock);
});
}
/**
@ -695,7 +793,8 @@ export default class Paste extends Module {
return nextResult;
}, {} as SanitizerConfig);
const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig);
const structuralSanitizeConfig = this.getStructuralTagsSanitizeConfig(content);
const customConfig = Object.assign({}, structuralSanitizeConfig, toolTags, tool.baseSanitizeConfig);
const sanitizedContent = (() => {
if (content.tagName.toLowerCase() !== 'table') {
content.innerHTML = clean(content.innerHTML, customConfig);
@ -953,6 +1052,7 @@ export default class Paste extends Module {
const isSubstitutable = tags.includes(element.tagName);
const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase());
const isStructuralElement = SAFE_STRUCTURAL_TAGS.has(element.tagName.toLowerCase());
const containsAnotherToolTags = Array
.from(element.children)
.some(
@ -972,7 +1072,8 @@ export default class Paste extends Module {
if (
(isSubstitutable && !containsAnotherToolTags) ||
(isBlockElement && !containsBlockElements && !containsAnotherToolTags)
(isBlockElement && !containsBlockElements && !containsAnotherToolTags) ||
(isStructuralElement && !containsAnotherToolTags)
) {
return [...nodes, destNode, element];
}

View file

@ -74,18 +74,21 @@ export default class Saver extends Module {
private async getSavedData(block: Block): Promise<SaverValidatedData> {
const blockData = await block.save();
const toolName = block.name;
const normalizedData = blockData?.data !== undefined
? blockData
: this.getPreservedSavedData(block);
if (blockData === undefined) {
if (normalizedData === undefined) {
return {
tool: toolName,
isValid: false,
};
}
const isValid = await block.validate(blockData.data);
const isValid = await block.validate(normalizedData.data);
return {
...blockData,
...normalizedData,
isValid,
};
}
@ -222,4 +225,27 @@ export default class Saver extends Module {
public getLastSaveError(): unknown {
return this.lastSaveError;
}
/**
* Returns the last successfully extracted data for the provided block, if any.
*
* @param block - block whose preserved data should be returned
*/
private getPreservedSavedData(block: Block): (SavedData & { tunes?: Record<string, BlockTuneData> }) | undefined {
const preservedData = block.preservedData;
if (_.isEmpty(preservedData)) {
return undefined;
}
const preservedTunes = block.preservedTunes;
return {
id: block.id,
tool: block.name,
data: preservedData,
...( _.isEmpty(preservedTunes) ? {} : { tunes: preservedTunes }),
time: 0,
};
}
}

View file

@ -5,7 +5,7 @@ import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import * as tooltip from '../../utils/tooltip';
import type { ModuleConfig } from '../../../types-internal/module-config';
import type Block from '../../block';
import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
import { IconMenu, IconPlus } from '@codexteam/icons';
import { BlockHovered } from '../../events/BlockHovered';
@ -627,6 +627,12 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Subscribe to the 'block-hovered' event
*/
this.eventsDispatcher.on(BlockHovered, (data) => {
const hoveredBlock = (data as { block?: Block }).block;
if (!(hoveredBlock instanceof Block)) {
return;
}
/**
* Do not move toolbar if Block Settings or Toolbox opened
*/
@ -634,7 +640,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
return;
}
this.moveAndOpen(data.block);
this.moveAndOpen(hoveredBlock);
});
}
}

View file

@ -8,6 +8,7 @@ import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Shortcuts from '../../utils/shortcuts';
import type { ModuleConfig } from '../../../types-internal/module-config';
import type { EditorModules } from '../../../types-internal/editor-modules';
import { CommonInternalSettings } from '../../tools/base';
import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover';
import { PopoverItemType } from '../../utils/popover';
@ -58,6 +59,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
private initialized = false;
/**
* Ensures we don't schedule multiple initialization attempts simultaneously
*/
private initializationScheduled = false;
/**
* Currently visible tools instances
*/
@ -73,6 +79,16 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
private savedShortcutRange: Range | null = null;
/**
* Tracks whether inline shortcuts have been registered
*/
private shortcutsRegistered = false;
/**
* Prevents duplicate shortcut registration retries
*/
private shortcutRegistrationScheduled = false;
/**
* @param moduleConfiguration - Module Configuration
* @param moduleConfiguration.config - Editor's config
@ -96,9 +112,16 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
void this.tryToShow();
}, true);
window.requestIdleCallback(() => {
this.initialize();
}, { timeout: 2000 });
this.scheduleInitialization();
this.tryRegisterShortcuts();
}
/**
* Setter for Editor modules that ensures shortcuts registration is retried once dependencies are available
*/
public override set state(Editor: EditorModules) {
super.state = Editor;
this.tryRegisterShortcuts();
}
/**
@ -110,14 +133,81 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
if (!this.Editor?.UI?.nodes?.wrapper || this.Editor.Tools === undefined) {
this.scheduleInitialization();
return;
}
this.make();
this.registerInitialShortcuts();
this.tryRegisterShortcuts();
this.initialized = true;
}
/**
* Attempts to register inline shortcuts as soon as tools are available
*/
private tryRegisterShortcuts(): void {
if (this.shortcutsRegistered) {
return;
}
if (this.Editor?.Tools === undefined) {
this.scheduleShortcutRegistration();
return;
}
const shortcutsWereRegistered = this.registerInitialShortcuts();
if (shortcutsWereRegistered) {
this.shortcutsRegistered = true;
}
}
/**
* Schedules a retry for shortcut registration
*/
private scheduleShortcutRegistration(): void {
if (this.shortcutsRegistered || this.shortcutRegistrationScheduled) {
return;
}
this.shortcutRegistrationScheduled = true;
const callback = (): void => {
this.shortcutRegistrationScheduled = false;
this.tryRegisterShortcuts();
};
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
window.setTimeout(callback, 0);
} else {
callback();
}
}
/**
* Schedules the next initialization attempt, falling back to setTimeout when requestIdleCallback is unavailable
*/
private scheduleInitialization(): void {
if (this.initialized || this.initializationScheduled) {
return;
}
this.initializationScheduled = true;
const callback = (): void => {
this.initializationScheduled = false;
this.initialize();
};
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout: 2000 });
} else {
window.setTimeout(callback, 0);
}
}
/**
* Moving / appearance
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -736,6 +826,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return;
}
if (this.isShortcutTakenByAnotherTool(toolName, shortcut)) {
return;
}
if (registeredShortcut !== undefined) {
Shortcuts.remove(document, registeredShortcut);
this.registeredShortcuts.delete(toolName);
@ -777,6 +871,18 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.registeredShortcuts.set(toolName, shortcut);
}
/**
* Check if shortcut is already registered by another inline tool
*
* @param toolName - tool that is currently being processed
* @param shortcut - shortcut to check
*/
private isShortcutTakenByAnotherTool(toolName: string, shortcut: string): boolean {
return Array.from(this.registeredShortcuts.entries()).some(([name, registeredShortcut]) => {
return name !== toolName && registeredShortcut === shortcut;
});
}
/**
* Inline Tool button clicks
*
@ -886,14 +992,24 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Register shortcuts for inline tools ahead of time so they are available before the toolbar opens
*/
private registerInitialShortcuts(): void {
const toolNames = Array.from(this.Editor.Tools.inlineTools.keys());
private registerInitialShortcuts(): boolean {
const inlineTools = this.Editor.Tools?.inlineTools;
if (!inlineTools) {
this.scheduleShortcutRegistration();
return false;
}
const toolNames = Array.from(inlineTools.keys());
toolNames.forEach((toolName) => {
const shortcut = this.getToolShortcut(toolName);
this.tryEnableShortcut(toolName, shortcut);
});
return true;
}
/**

View file

@ -17,6 +17,7 @@ import MoveDownTune from '../block-tunes/block-tune-move-down';
import DeleteTune from '../block-tunes/block-tune-delete';
import MoveUpTune from '../block-tunes/block-tune-move-up';
import ToolsCollection from '../tools/collection';
import { CriticalError } from '../errors/critical';
const cacheableSanitizer = _.cacheable as (
target: object,
@ -139,15 +140,15 @@ export default class Tools extends Module {
* @returns {Promise<void>}
*/
public async prepare(): Promise<void> {
this.validateTools();
/**
* Assign internal tools
* Assign internal tools before validation so required fallbacks (like stub) are always present
*/
const userTools = this.config.tools ?? {};
this.config.tools = _.deepMerge({}, this.internalTools, userTools);
this.validateTools();
const toolsConfig = this.config.tools;
if (!toolsConfig || Object.keys(toolsConfig).length === 0) {
@ -500,7 +501,7 @@ export default class Tools extends Module {
const hasToolClass = _.isFunction(toolSettings.class);
if (!isConstructorFunction && !hasToolClass) {
throw Error(
throw new CriticalError(
`Tool «${toolName}» must be a constructor function or an object with function in the «class» property`
);
}

View file

@ -598,7 +598,11 @@ export default class UI extends Module<UINodes> {
* If any block selected and selection doesn't exists on the page (that means no other editable element is focused),
* remove selected blocks
*/
const shouldRemoveSelection = BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true);
const shouldRemoveSelection = BlockSelection.anyBlockSelected && (
!selectionExists ||
selectionCollapsed === true ||
this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted
);
if (!shouldRemoveSelection) {
return;

View file

@ -230,6 +230,118 @@ test.describe('api.blocks', () => {
});
});
test.describe('.renderFromHTML()', () => {
test('should clear existing content and render provided HTML string', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: { text: 'initial content' },
},
],
},
});
await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.blocks.renderFromHTML('<p>Rendered from HTML</p>');
});
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
await expect(blocks).toHaveCount(1);
await expect(blocks).toHaveText([ 'Rendered from HTML' ]);
const savedData = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
expect(savedData.blocks).toHaveLength(1);
expect(savedData.blocks[0].type).toBe('paragraph');
expect(savedData.blocks[0].data.text).toBe('Rendered from HTML');
});
});
test.describe('.composeBlockData()', () => {
const PREFILLED_TOOL_SOURCE = `class PrefilledTool {
constructor({ data }) {
this.initialData = {
text: data.text ?? 'Composed paragraph',
};
}
static get toolbox() {
return {
icon: 'P',
title: 'Prefilled',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.initialData.text;
return element;
}
save() {
return this.initialData;
}
}`;
test('should compose default block data for an existing tool', async ({ page }) => {
await createEditor(page, {
tools: [
{
name: 'prefilled',
classSource: PREFILLED_TOOL_SOURCE,
},
],
});
const data = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.blocks.composeBlockData('prefilled');
});
expect(data).toStrictEqual({ text: 'Composed paragraph' });
});
test('should throw when tool is not registered', async ({ page }) => {
await createEditor(page);
const error = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
try {
await window.editorInstance.blocks.composeBlockData('missing-tool');
return null;
} catch (err) {
return {
message: (err as Error).message,
};
}
});
expect(error?.message).toBe('Block Tool with type "missing-tool" not found');
});
});
/**
* api.blocks.update(id, newData)
*/
@ -852,6 +964,282 @@ test.describe('api.blocks', () => {
*/
expect(blocks[0].data.text).toBe(JSON.stringify(conversionTargetToolConfig));
});
test('should apply provided data overrides when converting a Block', async ({ page }) => {
const SOURCE_TOOL_SOURCE = `class SourceTool {
constructor({ data }) {
this.data = data;
}
static get conversionConfig() {
return {
export: 'text',
};
}
static get toolbox() {
return {
icon: 'S',
title: 'Source',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.classList.add('cdx-block');
element.innerHTML = this.data?.text ?? '';
return element;
}
save(block) {
return {
text: block.innerHTML,
};
}
}`;
const TARGET_TOOL_SOURCE = `class TargetTool {
constructor({ data, config }) {
this.data = data ?? {};
this.config = config ?? {};
}
static get conversionConfig() {
return {
import: (text, config) => ({
text: (config?.prefix ?? '') + text,
level: config?.defaultLevel ?? 1,
}),
};
}
static get toolbox() {
return {
icon: 'T',
title: 'Target',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.classList.add('cdx-block');
element.innerHTML = this.data?.text ?? '';
return element;
}
save(block) {
return {
...this.data,
text: block.innerHTML,
};
}
}`;
const blockId = 'convert-source-block';
const initialText = 'Source tool content';
const dataOverrides = {
level: 4,
customStyle: 'attention',
};
await createEditor(page, {
tools: [
{
name: 'sourceTool',
classSource: SOURCE_TOOL_SOURCE,
},
{
name: 'targetTool',
classSource: TARGET_TOOL_SOURCE,
config: {
prefix: '[Converted] ',
defaultLevel: 1,
},
},
],
data: {
blocks: [
{
id: blockId,
type: 'sourceTool',
data: {
text: initialText,
},
},
],
},
});
await page.evaluate(async ({ targetBlockId, overrides }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const { convert } = window.editorInstance.blocks;
await convert(targetBlockId, 'targetTool', overrides);
}, { targetBlockId: blockId,
overrides: dataOverrides });
await page.waitForFunction(async () => {
if (!window.editorInstance) {
return false;
}
const saved = await window.editorInstance.save();
return saved.blocks.length > 0 && saved.blocks[0].type === 'targetTool';
});
const savedData = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
expect(savedData.blocks).toHaveLength(1);
expect(savedData.blocks[0].type).toBe('targetTool');
expect(savedData.blocks[0].data).toStrictEqual({
text: `${'[Converted] '}${initialText}`,
level: dataOverrides.level,
customStyle: dataOverrides.customStyle,
});
});
test('should throw when block data cannot be extracted before conversion', async ({ page }) => {
const NON_SAVABLE_TOOL_SOURCE = `class NonSavableTool {
constructor({ data }) {
this.data = data;
}
static get conversionConfig() {
return {
export: 'text',
};
}
static get toolbox() {
return {
icon: 'N',
title: 'Non savable',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.classList.add('cdx-block');
element.innerHTML = this.data?.text ?? '';
return element;
}
save() {
return undefined;
}
}`;
const TARGET_TOOL_SOURCE = `class ConvertibleTargetTool {
constructor({ data }) {
this.data = data ?? {};
}
static get conversionConfig() {
return {
import: 'text',
};
}
static get toolbox() {
return {
icon: 'T',
title: 'Target',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.classList.add('cdx-block');
element.innerHTML = this.data?.text ?? '';
return element;
}
save(block) {
return {
text: block.innerHTML,
};
}
}`;
const blockId = 'non-savable-block';
await createEditor(page, {
tools: [
{
name: 'nonSavable',
classSource: NON_SAVABLE_TOOL_SOURCE,
},
{
name: 'convertibleTarget',
classSource: TARGET_TOOL_SOURCE,
},
],
data: {
blocks: [
{
id: blockId,
type: 'nonSavable',
data: {
text: 'Broken block',
},
},
],
},
});
const error = await page.evaluate(async ({ targetBlockId }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const { convert } = window.editorInstance.blocks;
try {
await convert(targetBlockId, 'convertibleTarget');
return null;
} catch (err) {
return {
message: (err as Error).message,
};
}
}, { targetBlockId: blockId });
expect(error?.message).toBe('Could not convert Block. Failed to extract original Block data.');
const savedData = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
expect(savedData.blocks).toHaveLength(1);
expect(savedData.blocks[0].type).toBe('nonSavable');
});
});
/**

View file

@ -684,5 +684,357 @@ test.describe('caret API', () => {
expect(result.firstBlockSelected).toBe(false);
});
});
test.describe('.setToFirstBlock', () => {
test('moves caret to the first block and places it at the start', async ({ page }) => {
const blocks = [
createParagraphBlock('first-block', 'First block content'),
createParagraphBlock('second-block', 'Second block content'),
];
await createEditor(page, {
data: {
blocks,
},
});
await clearSelection(page);
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const returnedValue = window.editorInstance.caret.setToFirstBlock('start');
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
return {
returnedValue,
rangeExists: !!range,
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
startOffset: range?.startOffset ?? null,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInFirstBlock).toBe(true);
expect(result.startOffset).toBe(0);
});
});
test.describe('.setToLastBlock', () => {
test('moves caret to the last block and places it at the end', async ({ page }) => {
const blocks = [
createParagraphBlock('first-block', 'First block content'),
createParagraphBlock('last-block', 'Last block text'),
];
await createEditor(page, {
data: {
blocks,
},
});
await clearSelection(page);
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const returnedValue = window.editorInstance.caret.setToLastBlock('end');
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const blocksCollection = document.querySelectorAll(blockSelector);
const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null;
return {
returnedValue,
rangeExists: !!range,
selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)),
startContainerTextLength: range?.startContainer?.textContent?.length ?? null,
startOffset: range?.startOffset ?? null,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInLastBlock).toBe(true);
expect(result.startOffset).toBe(result.startContainerTextLength);
});
});
test.describe('.setToPreviousBlock', () => {
test('moves caret to the previous block relative to the current one', async ({ page }) => {
const blocks = [
createParagraphBlock('first-block', 'First block'),
createParagraphBlock('middle-block', 'Middle block'),
createParagraphBlock('last-block', 'Last block'),
];
await createEditor(page, {
data: {
blocks,
},
});
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const currentSet = window.editorInstance.caret.setToBlock(2);
if (!currentSet) {
throw new Error('Failed to set initial caret position');
}
const returnedValue = window.editorInstance.caret.setToPreviousBlock('default');
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null;
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
const currentBlockId = currentBlockIndex !== undefined
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
: null;
return {
returnedValue,
rangeExists: !!range,
selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)),
currentBlockId,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInMiddleBlock).toBe(true);
expect(result.currentBlockId).toBe('middle-block');
});
});
test.describe('.setToNextBlock', () => {
test('moves caret to the next block relative to the current one', async ({ page }) => {
const blocks = [
createParagraphBlock('first-block', 'First block'),
createParagraphBlock('middle-block', 'Middle block'),
createParagraphBlock('last-block', 'Last block'),
];
await createEditor(page, {
data: {
blocks,
},
});
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const currentSet = window.editorInstance.caret.setToBlock(0);
if (!currentSet) {
throw new Error('Failed to set initial caret position');
}
const returnedValue = window.editorInstance.caret.setToNextBlock('default');
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null;
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
const currentBlockId = currentBlockIndex !== undefined
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
: null;
return {
returnedValue,
rangeExists: !!range,
selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)),
currentBlockId,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInMiddleBlock).toBe(true);
expect(result.currentBlockId).toBe('middle-block');
});
});
test.describe('.focus', () => {
test('focuses the first block when called without arguments', async ({ page }) => {
const blocks = [
createParagraphBlock('focus-first', 'First block content'),
createParagraphBlock('focus-second', 'Second block content'),
];
await createEditor(page, {
data: {
blocks,
},
});
await clearSelection(page);
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const returnedValue = window.editorInstance.focus();
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
return {
returnedValue,
rangeExists: !!range,
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
startOffset: range?.startOffset ?? null,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInFirstBlock).toBe(true);
expect(result.startOffset).toBe(0);
});
test('focuses the last block when called with atEnd = true', async ({ page }) => {
const blocks = [
createParagraphBlock('focus-first', 'First block'),
createParagraphBlock('focus-last', 'Last block content'),
];
await createEditor(page, {
data: {
blocks,
},
});
await clearSelection(page);
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const returnedValue = window.editorInstance.focus(true);
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const blocksCollection = document.querySelectorAll(blockSelector);
const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null;
return {
returnedValue,
rangeExists: !!range,
selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)),
startContainerTextLength: range?.startContainer?.textContent?.length ?? null,
startOffset: range?.startOffset ?? null,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInLastBlock).toBe(true);
expect(result.startOffset).toBe(result.startContainerTextLength);
});
test('autofocus configuration moves caret to the first block after initialization', async ({ page }) => {
const blocks = [ createParagraphBlock('autofocus-block', 'Autofocus content') ];
await createEditor(page, {
data: {
blocks,
},
config: {
autofocus: true,
},
});
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex();
const currentBlockId = currentBlockIndex !== undefined
? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null
: null;
return {
rangeExists: !!range,
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
currentBlockId,
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.rangeExists).toBe(true);
expect(result.selectionInFirstBlock).toBe(true);
expect(result.currentBlockId).toBe('autofocus-block');
});
test('focus can be restored after editor operations clear the selection', async ({ page }) => {
const blocks = [
createParagraphBlock('restore-first', 'First block'),
createParagraphBlock('restore-second', 'Second block'),
];
await createEditor(page, {
data: {
blocks,
},
});
const result = await page.evaluate(({ blockSelector }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const initialFocusResult = window.editorInstance.focus();
const initialSelection = window.getSelection();
const initialRangeCount = initialSelection?.rangeCount ?? 0;
window.editorInstance.blocks.insert('paragraph', { text: 'Inserted block' }, undefined, 1, false);
window.getSelection()?.removeAllRanges();
const selectionAfterOperation = window.getSelection();
const afterRangeCount = selectionAfterOperation?.rangeCount ?? 0;
const returnedValue = window.editorInstance.focus();
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
return {
initialFocusResult,
initialRangeCount,
afterRangeCount,
returnedValue,
rangeExists: !!range,
selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)),
blocksCount: window.editorInstance.blocks.getBlocksCount(),
};
}, { blockSelector: BLOCK_SELECTOR });
expect(result.initialFocusResult).toBe(true);
expect(result.initialRangeCount).toBeGreaterThan(0);
expect(result.afterRangeCount).toBe(0);
expect(result.returnedValue).toBe(true);
expect(result.rangeExists).toBe(true);
expect(result.selectionInFirstBlock).toBe(true);
expect(result.blocksCount).toBe(3);
});
});
});

View file

@ -0,0 +1,195 @@
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 { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { BlockChanged } from '../../../../src/components/events/BlockChanged';
import { BlockHovered } from '../../../../src/components/events/BlockHovered';
import { RedactorDomChanged } from '../../../../src/components/events/RedactorDomChanged';
import { FakeCursorAboutToBeToggled } from '../../../../src/components/events/FakeCursorAboutToBeToggled';
import { FakeCursorHaveBeenSet } from '../../../../src/components/events/FakeCursorHaveBeenSet';
import { EditorMobileLayoutToggled } from '../../../../src/components/events/EditorMobileLayoutToggled';
import { BlockSettingsOpened } from '../../../../src/components/events/BlockSettingsOpened';
import { BlockSettingsClosed } from '../../../../src/components/events/BlockSettingsClosed';
import type { EditorEventMap } from '../../../../src/components/events';
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
type EventTestCase = {
name: keyof EditorEventMap;
createPayload: () => unknown;
};
const EVENT_TEST_CASES: EventTestCase[] = [
{
name: BlockChanged,
createPayload: () => ({
event: {
type: BlockChangedMutationType,
detail: {
target: {
id: 'block-changed-test-block',
name: 'paragraph',
},
index: 0,
},
},
}) as unknown as EditorEventMap[typeof BlockChanged],
},
{
name: BlockHovered,
createPayload: () => ({
block: {
id: 'hovered-block',
},
}) as unknown as EditorEventMap[typeof BlockHovered],
},
{
name: RedactorDomChanged,
createPayload: () => ({
mutations: [],
}) as EditorEventMap[typeof RedactorDomChanged],
},
{
name: FakeCursorAboutToBeToggled,
createPayload: () => ({
state: true,
}) as EditorEventMap[typeof FakeCursorAboutToBeToggled],
},
{
name: FakeCursorHaveBeenSet,
createPayload: () => ({
state: false,
}) as EditorEventMap[typeof FakeCursorHaveBeenSet],
},
{
name: EditorMobileLayoutToggled,
createPayload: () => ({
isEnabled: true,
}) as EditorEventMap[typeof EditorMobileLayoutToggled],
},
{
name: BlockSettingsOpened,
createPayload: () => ({}),
},
{
name: BlockSettingsClosed,
createPayload: () => ({}),
},
];
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): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(async ({ holderId }) => {
const editor = new window.EditorJS({
holder: holderId,
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
const subscribeEmitAndUnsubscribe = async (
page: Page,
eventName: keyof EditorEventMap,
payload: unknown
): Promise<unknown[]> => {
return await page.evaluate(({ name, data }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const received: unknown[] = [];
const handler = (eventPayload: unknown): void => {
received.push(eventPayload);
};
window.editorInstance.on(name, handler);
window.editorInstance.emit(name, data);
window.editorInstance.off(name, handler);
window.editorInstance.emit(name, data);
return received;
}, {
name: eventName,
data: payload,
});
};
const TEST_PAGE_VISIT = async (page: Page): Promise<void> => {
await page.goto(TEST_PAGE_URL);
};
const eventsDispatcherExists = async (page: Page): Promise<boolean> => {
return await page.evaluate(() => {
return Boolean(window.editorInstance && 'eventsDispatcher' in window.editorInstance);
});
};
test.describe('api.events', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await TEST_PAGE_VISIT(page);
});
test('should expose events dispatcher via core API', async ({ page }) => {
await createEditor(page);
const dispatcherExists = await eventsDispatcherExists(page);
expect(dispatcherExists).toBe(true);
});
test.describe('subscription lifecycle', () => {
for (const { name, createPayload } of EVENT_TEST_CASES) {
test(`should subscribe, emit and unsubscribe for event "${name}"`, async ({ page }) => {
await createEditor(page);
const payload = createPayload();
const receivedPayloads = await subscribeEmitAndUnsubscribe(page, name, payload);
expect(receivedPayloads).toHaveLength(1);
expect(receivedPayloads[0]).toStrictEqual(payload);
});
}
});
});

View file

@ -0,0 +1,237 @@
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 {
EDITOR_INTERFACE_SELECTOR,
INLINE_TOOLBAR_INTERFACE_SELECTOR
} from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
const INLINE_TOOLBAR_CONTAINER_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover__container`;
const INITIAL_DATA: OutputData = {
blocks: [
{
type: 'paragraph',
data: {
text: 'Inline toolbar API end-to-end coverage text.',
},
},
],
};
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, data: OutputData): Promise<void> => {
await resetEditor(page);
await page.evaluate(
async ({ holderId, editorData }) => {
const editor = new window.EditorJS({
holder: holderId,
data: editorData,
});
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorData: data,
}
);
};
const setSelectionRange = async (locator: Locator, start: number, end: number): Promise<void> => {
if (start < 0 || end < start) {
throw new Error(`Invalid selection offsets: start (${start}) must be >= 0 and end (${end}) must be >= start.`);
}
await locator.scrollIntoViewIfNeeded();
await locator.focus();
await locator.evaluate(
(element, { start: selectionStart, end: selectionEnd }) => {
const ownerDocument = element.ownerDocument;
if (!ownerDocument) {
return;
}
const selection = ownerDocument.getSelection();
if (!selection) {
return;
}
const textNodes: Text[] = [];
const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let currentNode = walker.nextNode();
while (currentNode) {
textNodes.push(currentNode as Text);
currentNode = walker.nextNode();
}
if (textNodes.length === 0) {
return;
}
const findPosition = (offset: number): { node: Text; nodeOffset: number } | null => {
let accumulated = 0;
for (const node of textNodes) {
const length = node.textContent?.length ?? 0;
const nodeStart = accumulated;
const nodeEnd = accumulated + length;
if (offset >= nodeStart && offset <= nodeEnd) {
return {
node,
nodeOffset: Math.min(length, offset - nodeStart),
};
}
accumulated = nodeEnd;
}
if (offset === 0) {
const firstNode = textNodes[0];
return {
node: firstNode,
nodeOffset: 0,
};
}
return null;
};
const startPosition = findPosition(selectionStart);
const endPosition = findPosition(selectionEnd);
if (!startPosition || !endPosition) {
return;
}
const range = ownerDocument.createRange();
range.setStart(startPosition.node, startPosition.nodeOffset);
range.setEnd(endPosition.node, endPosition.nodeOffset);
selection.removeAllRanges();
selection.addRange(range);
ownerDocument.dispatchEvent(new Event('selectionchange'));
},
{ start,
end }
);
};
const selectText = async (locator: Locator, text: string): Promise<void> => {
const fullText = await locator.textContent();
if (!fullText || !fullText.includes(text)) {
throw new Error(`Text "${text}" was not found in element`);
}
const startIndex = fullText.indexOf(text);
const endIndex = startIndex + text.length;
await setSelectionRange(locator, startIndex, endIndex);
};
test.describe('api.inlineToolbar', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('inlineToolbar.open() shows the inline toolbar when selection exists', async ({ page }) => {
await createEditor(page, INITIAL_DATA);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
await selectText(paragraph, 'Inline toolbar');
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.inlineToolbar.open();
});
await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible();
});
test('inlineToolbar.close() hides the inline toolbar', async ({ page }) => {
await createEditor(page, INITIAL_DATA);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR);
await selectText(paragraph, 'Inline toolbar');
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.inlineToolbar.open();
});
await expect(toolbarContainer).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.inlineToolbar.close();
});
await expect(toolbarContainer).toHaveCount(0);
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -0,0 +1,220 @@
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 { EditorConfig } from '@/types';
import type { Listeners as ListenersAPI } from '@/types/api/listeners';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
declare global {
interface Window {
editorInstance?: EditorJS;
listenerCallCount?: number;
lifecycleCallCount?: number;
listenersTestTarget?: HTMLElement;
listenersTestHandler?: (event?: Event) => void;
listenersLifecycleTarget?: HTMLElement;
listenersLifecycleHandler?: (event?: Event) => void;
firstListenerId?: string | null;
secondListenerId?: string | null;
}
}
type EditorWithListeners = EditorJS & { listeners: ListenersAPI };
type CreateEditorOptions = Partial<EditorConfig>;
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> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async (params: { holderId: string; editorOptions: Record<string, unknown> }) => {
const config = Object.assign(
{ holder: params.holderId },
params.editorOptions
) as EditorConfig;
const editor = new window.EditorJS(config);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorOptions: options as Record<string, unknown>,
}
);
};
const clickElement = async (page: Page, selector: string): Promise<void> => {
await page.evaluate((targetSelector) => {
const target = document.querySelector<HTMLElement>(targetSelector);
target?.click();
}, selector);
};
test.describe('api.listeners', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('registers and removes DOM listeners via the public API', async ({ page }) => {
await createEditor(page);
await page.evaluate(() => {
const editor = window.editorInstance as EditorWithListeners | undefined;
if (!editor) {
throw new Error('Editor instance not found');
}
const target = document.createElement('button');
target.id = 'listeners-target';
target.textContent = 'listener target';
target.style.width = '2px';
target.style.height = '2px';
document.body.appendChild(target);
window.listenerCallCount = 0;
window.listenersTestTarget = target;
window.listenersTestHandler = (): void => {
window.listenerCallCount = (window.listenerCallCount ?? 0) + 1;
};
const listenerId = editor.listeners.on(target, 'click', window.listenersTestHandler);
window.firstListenerId = listenerId ?? null;
});
const firstListenerId = await page.evaluate(() => window.firstListenerId);
expect(firstListenerId).toBeTruthy();
await clickElement(page, '#listeners-target');
await page.waitForFunction(() => window.listenerCallCount === 1);
await page.evaluate(() => {
const editor = window.editorInstance as EditorWithListeners | undefined;
if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) {
throw new Error('Listener prerequisites were not set');
}
editor.listeners.off(window.listenersTestTarget, 'click', window.listenersTestHandler);
});
await clickElement(page, '#listeners-target');
let callCount = await page.evaluate(() => window.listenerCallCount);
expect(callCount).toBe(1);
await page.evaluate(() => {
const editor = window.editorInstance as EditorWithListeners | undefined;
if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) {
throw new Error('Listener prerequisites were not set');
}
window.listenerCallCount = 0;
const listenerId = editor.listeners.on(
window.listenersTestTarget,
'click',
window.listenersTestHandler
);
window.secondListenerId = listenerId ?? null;
});
await clickElement(page, '#listeners-target');
await page.waitForFunction(() => window.listenerCallCount === 1);
await page.evaluate(() => {
const editor = window.editorInstance as EditorWithListeners | undefined;
if (window.secondListenerId && editor) {
editor.listeners.offById(window.secondListenerId);
}
});
await clickElement(page, '#listeners-target');
callCount = await page.evaluate(() => window.listenerCallCount);
expect(callCount).toBe(1);
});
test('cleans up registered listeners when the editor is destroyed', async ({ page }) => {
await createEditor(page);
await page.evaluate(() => {
const editor = window.editorInstance as EditorWithListeners | undefined;
if (!editor) {
throw new Error('Editor instance not found');
}
const target = document.createElement('button');
target.id = 'listeners-lifecycle-target';
target.textContent = 'listener lifecycle target';
document.body.appendChild(target);
window.lifecycleCallCount = 0;
window.listenersLifecycleTarget = target;
window.listenersLifecycleHandler = (): void => {
window.lifecycleCallCount = (window.lifecycleCallCount ?? 0) + 1;
};
editor.listeners.on(target, 'click', window.listenersLifecycleHandler);
});
await clickElement(page, '#listeners-lifecycle-target');
await page.waitForFunction(() => window.lifecycleCallCount === 1);
await page.evaluate(() => {
window.editorInstance?.destroy?.();
window.editorInstance = undefined;
});
await clickElement(page, '#listeners-lifecycle-target');
const finalLifecycleCount = await page.evaluate(() => window.lifecycleCallCount);
expect(finalLifecycleCount).toBe(1);
});
});

View file

@ -0,0 +1,138 @@
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 { Notifier as NotifierAPI } from '@/types/api/notifier';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
type EditorWithNotifier = EditorJS & { notifier: NotifierAPI };
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const NOTIFIER_CONTAINER_SELECTOR = '.cdx-notifies';
const NOTIFICATION_SELECTOR = '.cdx-notify';
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
const holder = document.getElementById(holderId);
holder?.remove();
// Remove leftover notifications between tests to keep DOM deterministic
document.querySelectorAll('.cdx-notifies').forEach((node) => node.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): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(async ({ holderId }) => {
const editor = new window.EditorJS({
holder: holderId,
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
test.describe('api.notifier', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test.afterEach(async ({ page }) => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.querySelectorAll('.cdx-notifies').forEach((node) => node.remove());
document.getElementById(holderId)?.remove();
}, { holderId: HOLDER_ID });
});
test('should display notification message through the notifier API', async ({ page }) => {
await createEditor(page);
const message = 'Editor notifier alert';
await page.evaluate(({ text }) => {
const editor = window.editorInstance as EditorWithNotifier | undefined;
editor?.notifier.show({
message: text,
style: 'success',
time: 1000,
});
}, { text: message });
const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message });
await expect(notification).toBeVisible();
await expect(notification).toHaveClass(/cdx-notify--success/);
await expect(page.locator(NOTIFIER_CONTAINER_SELECTOR)).toBeVisible();
});
test('should render confirm notification with type-specific UI and styles', async ({ page }) => {
await createEditor(page);
const message = 'Delete current block?';
const okText = 'Yes, delete';
const cancelText = 'No, keep';
await page.evaluate(({ text, ok, cancel }) => {
const editor = window.editorInstance as EditorWithNotifier | undefined;
editor?.notifier.show({
message: text,
type: 'confirm',
style: 'error',
okText: ok,
cancelText: cancel,
});
}, {
text: message,
ok: okText,
cancel: cancelText,
});
const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message });
await expect(notification).toBeVisible();
await expect(notification).toHaveClass(/cdx-notify--error/);
await expect(notification.locator('.cdx-notify__button--confirm')).toHaveText(okText);
await expect(notification.locator('.cdx-notify__button--cancel')).toHaveText(cancelText);
});
});

View file

@ -0,0 +1,219 @@
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, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
const getBlockWrapperByIndex = (page: Page, index: number = 0): Locator => {
return page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`);
};
type SerializableOutputData = {
version?: string;
time?: number;
blocks: Array<{
id?: string;
type: string;
data: Record<string, unknown>;
tunes?: Record<string, unknown>;
}>;
};
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, data?: SerializableOutputData): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({ holderId, rawData }) => {
const editorConfig: Record<string, unknown> = {
holder: holderId,
};
if (rawData) {
editorConfig.data = rawData;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
rawData: data ?? null,
}
);
};
const defaultInitialData: SerializableOutputData = {
blocks: [
{
id: 'initial-block',
type: 'paragraph',
data: {
text: 'Initial block content',
},
},
],
};
test.describe('api.render', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('editor.render replaces existing document content', async ({ page }) => {
await createEditor(page, defaultInitialData);
const initialBlock = getBlockWrapperByIndex(page);
await expect(initialBlock).toHaveText('Initial block content');
const newData: SerializableOutputData = {
blocks: [
{
id: 'rendered-block',
type: 'paragraph',
data: { text: 'Rendered via API' },
},
],
};
await page.evaluate(async ({ data }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.render(data);
}, { data: newData });
await expect(initialBlock).toHaveText('Rendered via API');
});
test.describe('render accepts different data formats', () => {
const dataVariants: Array<{ title: string; data: SerializableOutputData; expectedText: string; }> = [
{
title: 'with metadata (version + time)',
data: {
version: '2.30.0',
time: Date.now(),
blocks: [
{
id: 'meta-block',
type: 'paragraph',
data: { text: 'Metadata format' },
},
],
},
expectedText: 'Metadata format',
},
{
title: 'minimal object containing only blocks',
data: {
blocks: [
{
type: 'paragraph',
data: { text: 'Minimal format' },
},
],
},
expectedText: 'Minimal format',
},
];
for (const variant of dataVariants) {
test(`renders data ${variant.title}`, async ({ page }) => {
await createEditor(page, defaultInitialData);
await page.evaluate(async ({ data }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.render(data);
}, { data: variant.data });
await expect(getBlockWrapperByIndex(page)).toHaveText(variant.expectedText);
});
}
});
test.describe('edge cases', () => {
test('inserts a default block when empty data is rendered', async ({ page }) => {
await createEditor(page, defaultInitialData);
const blockCount = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.render({ blocks: [] });
return window.editorInstance.blocks.getBlocksCount();
});
await expect(page.locator(BLOCK_WRAPPER_SELECTOR)).toHaveCount(1);
expect(blockCount).toBe(1);
});
test('throws a descriptive error when data is invalid', async ({ page }) => {
await createEditor(page, defaultInitialData);
const errorMessage = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
try {
await window.editorInstance.render({} as SerializableOutputData);
return null;
} catch (error) {
return (error as Error).message;
}
});
expect(errorMessage).toBe('Incorrect data passed to the render() method');
await expect(getBlockWrapperByIndex(page)).toHaveText('Initial block content');
});
});
});

View file

@ -0,0 +1,112 @@
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 { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
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): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(async ({ holderId }) => {
const editor = new window.EditorJS({
holder: holderId,
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Initial block',
},
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
};
test.describe('api.sanitizer', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await createEditor(page);
});
test('clean removes disallowed HTML', async ({ page }) => {
const sanitized = await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const dirtyHtml = '<p>Safe<script>alert("XSS")</script></p>';
return window.editorInstance.sanitizer.clean(dirtyHtml, {
p: true,
});
});
expect(sanitized).toBe('<p>Safe</p>');
expect(sanitized).not.toContain('<script>');
expect(sanitized).not.toContain('alert');
});
test('clean applies custom sanitizer config', async ({ page }) => {
const sanitized = await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
const dirtyHtml = '<span data-id="allowed" style="color:red">Span <em>content</em></span>';
return window.editorInstance.sanitizer.clean(dirtyHtml, {
span: {
'data-id': true,
},
em: {},
});
});
expect(sanitized).toContain('<span data-id="allowed">');
expect(sanitized).toContain('<em>content</em>');
expect(sanitized).not.toContain('style=');
});
});

View file

@ -9,10 +9,49 @@ import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const DIST_BUNDLE_PATH = path.resolve(__dirname, '../../../dist/editorjs.umd.js');
const HOLDER_ID = 'editorjs';
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
const TOOLBAR_OPENED_SELECTOR = `${TOOLBAR_SELECTOR}.ce-toolbar--opened`;
const TOOLBAR_ACTIONS_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__actions`;
const TOOLBAR_ACTIONS_OPENED_SELECTOR = `${TOOLBAR_ACTIONS_SELECTOR}.ce-toolbar__actions--opened`;
const TOOLBOX_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbox`;
const TOOLBOX_POPOVER_SELECTOR = `${TOOLBOX_SELECTOR} .ce-popover__container`;
const BLOCK_TUNES_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=block-tunes]`;
const BLOCK_TUNES_POPOVER_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover__container`;
const OPENED_BLOCK_TUNES_SELECTOR = `${BLOCK_TUNES_SELECTOR} .ce-popover[data-popover-opened="true"]`;
const expectToolbarToBeOpened = async (page: Page): Promise<void> => {
await expect(page.locator(TOOLBAR_SELECTOR)).toHaveAttribute('class', /\bce-toolbar--opened\b/);
};
/**
* Wait until the Editor bundle exposed the global constructor
*
* @param page - Playwright page instance
*/
const waitForEditorBundle = async (page: Page): Promise<void> => {
await page.waitForLoadState('domcontentloaded');
const editorAlreadyLoaded = await page.evaluate(() => typeof window.EditorJS === 'function');
if (editorAlreadyLoaded) {
return;
}
await page.addScriptTag({ path: DIST_BUNDLE_PATH });
await page.waitForFunction(() => typeof window.EditorJS === 'function');
};
/**
* Ensure Toolbar DOM is rendered (Toolbox lives inside it)
*
* @param page - Playwright page instance
*/
const waitForToolbarReady = async (page: Page): Promise<void> => {
await page.locator(TOOLBOX_SELECTOR).waitFor({ state: 'attached' });
};
/**
* Reset the editor holder and destroy any existing instance
@ -45,6 +84,7 @@ const resetEditor = async (page: Page): Promise<void> => {
* @param data - Initial editor data
*/
const createEditor = async (page: Page, data?: OutputData): Promise<void> => {
await waitForEditorBundle(page);
await resetEditor(page);
await page.evaluate(
async ({ holderId, editorData }) => {
@ -60,6 +100,7 @@ const createEditor = async (page: Page, data?: OutputData): Promise<void> => {
{ holderId: HOLDER_ID,
editorData: data }
);
await waitForToolbarReady(page);
};
test.describe('api.toolbar', () => {
@ -88,6 +129,118 @@ test.describe('api.toolbar', () => {
await createEditor(page, editorDataMock);
});
test.describe('*.open()', () => {
test('should open the toolbar and reveal block actions', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.open();
});
await expectToolbarToBeOpened(page);
await expect(page.locator(TOOLBAR_ACTIONS_OPENED_SELECTOR)).toBeVisible();
});
});
test.describe('*.close()', () => {
test('should close toolbar, toolbox and block settings', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.open();
window.editorInstance.toolbar.toggleToolbox(true);
});
await expectToolbarToBeOpened(page);
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings(true);
});
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.close();
});
await expect(page.locator(TOOLBAR_OPENED_SELECTOR)).toHaveCount(0);
await expect(page.locator(TOOLBAR_ACTIONS_OPENED_SELECTOR)).toHaveCount(0);
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeHidden();
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
});
});
test.describe('*.toggleBlockSettings()', () => {
test('should open block settings when opening state is true', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings(true);
});
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
});
test('should close block settings when opening state is false', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings(true);
});
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings(false);
});
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
});
test('should toggle block settings when opening state is omitted', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings();
});
await expect(page.locator(BLOCK_TUNES_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleBlockSettings();
});
await expect(page.locator(OPENED_BLOCK_TUNES_SELECTOR)).toHaveCount(0);
});
});
test.describe('*.toggleToolbox()', () => {
test('should open the toolbox', async ({ page }) => {
await page.evaluate(() => {

View file

@ -34,6 +34,7 @@ type SerializableTuneRenderConfig =
declare global {
interface Window {
editorInstance?: EditorJS;
__editorBundleInjectionRequested?: boolean;
}
}
@ -61,6 +62,34 @@ const resetEditor = async (page: Page): Promise<void> => {
}, { holderId: HOLDER_ID });
};
/**
* Ensure the Editor bundle is available on the page.
*
* Some tests were flaking because the fixture page occasionally loads before the UMD bundle is ready,
* leaving window.EditorJS undefined. As a fallback we inject the bundle manually once per run.
*
* @param page - The Playwright page object
*/
const ensureEditorBundleLoaded = async (page: Page): Promise<void> => {
await page.waitForFunction(() => {
if (typeof window.EditorJS === 'function') {
return true;
}
if (!window.__editorBundleInjectionRequested) {
window.__editorBundleInjectionRequested = true;
const script = document.createElement('script');
script.src = new URL('../../../dist/editorjs.umd.js', window.location.href).href;
script.dataset.testEditorBundle = 'injected';
document.head.appendChild(script);
}
return false;
});
};
/**
* Create an Editor instance configured with a tune that returns the provided render config.
*
@ -72,8 +101,7 @@ const createEditorWithTune = async (
renderConfig: SerializableTuneRenderConfig
): Promise<void> => {
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await ensureEditorBundleLoaded(page);
await page.evaluate(
async ({

View file

@ -41,6 +41,12 @@ type CreateEditorOptions = Pick<EditorConfig, 'data' | 'inlineToolbar' | 'placeh
tools?: Record<string, SerializableToolConfig>;
};
type ClipboardFileDescriptor = {
name: string;
type: string;
content: string;
};
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
@ -172,6 +178,32 @@ const paste = async (page: Page, locator: Locator, data: Record<string, string>)
});
};
const pasteFiles = async (page: Page, locator: Locator, files: ClipboardFileDescriptor[]): Promise<void> => {
await locator.evaluate((element: HTMLElement, fileDescriptors: ClipboardFileDescriptor[]) => {
const dataTransfer = new DataTransfer();
fileDescriptors.forEach(({ name, type, content }) => {
const file = new File([ content ], name, { type });
dataTransfer.items.add(file);
});
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,
});
element.dispatchEvent(pasteEvent);
}, files);
await page.evaluate(() => {
return new Promise((resolve) => {
setTimeout(resolve, 200);
});
});
};
const selectAllText = async (locator: Locator): Promise<void> => {
await locator.evaluate((element) => {
const ownerDocument = element.ownerDocument;
@ -305,6 +337,21 @@ test.describe('copy and paste', () => {
expect(texts).toStrictEqual(['First block', 'Second block']);
});
test('should paste plain text with special characters intact', async ({ page }) => {
await createEditor(page);
const block = getBlockByIndex(page, 0);
const specialText = 'Emoji 🚀 — “quotes” — 你好 — نص عربي — ñandú';
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': specialText,
});
await expect(block).toHaveText(specialText);
});
test('should paste several blocks if html contains several paragraphs', async ({ page }) => {
await createEditor(page);
@ -413,6 +460,172 @@ test.describe('copy and paste', () => {
});
});
test('should sanitize dangerous HTML fragments on paste', async ({ page }) => {
await createEditor(page);
const block = getBlockByIndex(page, 0);
const maliciousHtml = `
<div>
<p>Safe text</p>
<script>window.__maliciousPasteExecuted = true;</script>
<p>Another line</p>
</div>
`;
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': maliciousHtml,
});
const texts = (await page.locator(BLOCK_SELECTOR).allTextContents()).map((text) => text.trim()).filter(Boolean);
expect(texts).toStrictEqual(['Safe text', 'Another line']);
const scriptExecuted = await page.evaluate(() => {
return window.__maliciousPasteExecuted ?? false;
});
expect(scriptExecuted).toBe(false);
});
test('should fall back to plain text when invalid EditorJS data is pasted', async ({ page }) => {
await createEditor(page);
const paragraph = getParagraphByIndex(page, 0);
await paragraph.click();
await paste(page, paragraph, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'application/x-editor-js': '{not-valid-json',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Fallback plain text',
});
await expect(getParagraphByIndex(page, 0)).toContainText('Fallback plain text');
});
test('should handle file pastes via paste config', async ({ page }) => {
const fileToolSource = `
class FilePasteTool {
constructor({ data }) {
this.data = data ?? {};
this.element = null;
}
static get pasteConfig() {
return {
files: {
extensions: ['png'],
mimeTypes: ['image/png'],
},
};
}
render() {
this.element = document.createElement('div');
this.element.className = 'file-paste-tool';
this.element.contentEditable = 'true';
this.element.textContent = this.data.text ?? 'Paste file here';
return this.element;
}
save(element) {
return {
text: element.textContent ?? '',
};
}
onPaste(event) {
const file = event.detail?.file ?? null;
window.__lastPastedFile = file
? { name: file.name, type: file.type, size: file.size }
: null;
if (file && this.element) {
this.element.textContent = 'Pasted file: ' + file.name;
}
}
}
`;
await createEditor(page, {
tools: {
fileTool: {
classCode: fileToolSource,
},
},
data: {
blocks: [
{
type: 'fileTool',
data: {},
},
],
},
});
const block = page.locator('.file-paste-tool');
await expect(block).toHaveCount(1);
await block.click();
await pasteFiles(page, block, [
{
name: 'pasted-image.png',
type: 'image/png',
content: 'fake-image-content',
},
]);
await expect(block).toContainText('Pasted file: pasted-image.png');
const fileMeta = await page.evaluate(() => window.__lastPastedFile);
expect(fileMeta).toMatchObject({
name: 'pasted-image.png',
type: 'image/png',
});
});
test('should paste content copied from external applications', async ({ page }) => {
await createEditor(page);
const block = getBlockByIndex(page, 0);
const externalHtml = `
<html>
<head>
<meta charset="utf-8">
<style>p { color: red; }</style>
</head>
<body>
<!--StartFragment-->
<p>Copied from Word</p>
<p><b>Styled</b> paragraph</p>
<!--EndFragment-->
</body>
</html>
`;
const plainFallback = 'Copied from Word\n\nStyled paragraph';
await block.click();
await paste(page, block, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': externalHtml,
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': plainFallback,
});
const blocks = page.locator(BLOCK_SELECTOR);
const secondParagraph = getParagraphByIndex(page, 1);
await expect(blocks).toHaveCount(2);
await expect(getParagraphByIndex(page, 0)).toContainText('Copied from Word');
await expect(secondParagraph).toContainText('Styled paragraph');
await expect(secondParagraph.locator('b')).toHaveText('Styled');
});
test('should not prevent default behaviour if block paste config equals false', async ({ page }) => {
const blockToolSource = `
class BlockToolWithPasteHandler {
@ -617,6 +830,8 @@ declare global {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
blockToolPasteEvents?: Array<{ defaultPrevented: boolean }>;
__lastPastedFile?: { name: string; type: string; size: number } | null;
__maliciousPasteExecuted?: boolean;
}
}

View file

@ -0,0 +1,254 @@
/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type */
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 { OutputBlockData, OutputData } from '@/types';
import { ensureEditorBundleBuilt } from './helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
type SerializableOutputData = {
blocks?: Array<OutputBlockData>;
};
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 });
};
test.describe('editor error handling', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('reports a descriptive error when tool configuration is invalid', async ({ page }) => {
await resetEditor(page);
const errorMessage = await page.evaluate(async ({ holderId }) => {
try {
const editor = new window.EditorJS({
holder: holderId,
tools: {
brokenTool: {
inlineToolbar: true,
},
},
});
window.editorInstance = editor;
await editor.isReady;
return null;
} catch (error) {
return (error as Error).message;
}
}, { holderId: HOLDER_ID });
expect(errorMessage).toBe('Tool «brokenTool» must be a constructor function or an object with function in the «class» property');
});
test('logs a warning when required inline tool methods are missing', async ({ page }) => {
await resetEditor(page);
const warningPromise = page.waitForEvent('console', {
predicate: (message) => message.type() === 'warning' && message.text().includes('Incorrect Inline Tool'),
});
await page.evaluate(async ({ holderId }) => {
class InlineWithoutRender {
public static isInline = true;
}
const editor = new window.EditorJS({
holder: holderId,
tools: {
inlineWithoutRender: {
class: InlineWithoutRender,
},
},
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID });
const warningMessage = await warningPromise;
expect(warningMessage.text()).toContain('Incorrect Inline Tool: inlineWithoutRender');
});
test('throws a descriptive error when render() receives invalid data format', async ({ page }) => {
await resetEditor(page);
const initialData: SerializableOutputData = {
blocks: [
{
id: 'initial-block',
type: 'paragraph',
data: { text: 'Initial block' },
},
],
};
await page.evaluate(async ({ holderId, data }) => {
const editor = new window.EditorJS({
holder: holderId,
data,
});
window.editorInstance = editor;
await editor.isReady;
}, { holderId: HOLDER_ID,
data: initialData });
const errorMessage = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
try {
await window.editorInstance.render({} as OutputData);
return null;
} catch (error) {
return (error as Error).message;
}
});
expect(errorMessage).toBe('Incorrect data passed to the render() method');
});
test('blocks read-only initialization when tools do not support read-only mode', async ({ page }) => {
await resetEditor(page);
const errorMessage = await page.evaluate(async ({ holderId }) => {
try {
class NonReadOnlyTool {
public static get toolbox() {
return {
title: 'Non-readonly tool',
icon: '<svg></svg>',
};
}
public render(): HTMLElement {
const element = document.createElement('div');
element.textContent = 'Non read-only block';
return element;
}
public save(element: HTMLElement): Record<string, unknown> {
return {
text: element.textContent ?? '',
};
}
}
const editor = new window.EditorJS({
holder: holderId,
readOnly: true,
tools: {
nonReadOnly: {
class: NonReadOnlyTool,
},
},
data: {
blocks: [
{
type: 'nonReadOnly',
data: { text: 'content' },
},
],
},
});
window.editorInstance = editor;
await editor.isReady;
return null;
} catch (error) {
return (error as Error).message;
}
}, { holderId: HOLDER_ID });
expect(errorMessage).toContain('To enable read-only mode all connected tools should support it.');
expect(errorMessage).toContain('nonReadOnly');
});
test('throws a descriptive error when default holder element is missing', async ({ page }) => {
await page.evaluate(({ holderId }) => {
document.getElementById(holderId)?.remove();
}, { holderId: HOLDER_ID });
const errorMessage = await page.evaluate(async () => {
try {
const editor = new window.EditorJS();
window.editorInstance = editor;
await editor.isReady;
return null;
} catch (error) {
return (error as Error).message;
}
});
expect(errorMessage).toBe('element with ID «editorjs» is missing. Pass correct holder\'s ID.');
});
test('throws a descriptive error when holder config is not an Element node', async ({ page }) => {
await resetEditor(page);
const errorMessage = await page.evaluate(async ({ holderId }) => {
try {
const fakeHolder = { id: holderId };
const editor = new window.EditorJS({
holder: fakeHolder as unknown as HTMLElement,
});
window.editorInstance = editor;
await editor.isReady;
return null;
} catch (error) {
return (error as Error).message;
}
}, { holderId: HOLDER_ID });
expect(errorMessage).toBe('«holder» value must be an Element node');
});
});

View file

@ -0,0 +1,537 @@
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 { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
type SerializableToolConfig = {
className?: string;
classCode?: string;
config?: Record<string, unknown>;
};
type CreateEditorOptions = {
data?: OutputData | null;
tools?: Record<string, SerializableToolConfig>;
config?: Record<string, unknown>;
};
type OutputBlock = OutputData['blocks'][number];
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 = {}, config = {} } = 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, config: editorConfigOverrides }) => {
const resolveToolClass = (toolConfig: { className: string | null; classCode: string | null }): unknown => {
if (toolConfig.className) {
const toolClass = (window as unknown as Record<string, unknown>)[toolConfig.className];
if (toolClass) {
return toolClass;
}
}
if (toolConfig.classCode) {
// eslint-disable-next-line no-new-func -- evaluated in browser context to revive tool class
return new Function(`return (${toolConfig.classCode});`)();
}
return null;
};
const resolvedTools = toolsConfig.reduce<Record<string, Record<string, unknown>>>((accumulator, toolConfig) => {
if (toolConfig.name === undefined) {
return accumulator;
}
const toolClass = resolveToolClass(toolConfig);
if (!toolClass) {
throw new Error(`Tool "${toolConfig.name}" is not available globally`);
}
return {
...accumulator,
[toolConfig.name]: {
class: toolClass,
...toolConfig.config,
},
};
}, {});
const editorConfig: Record<string, unknown> = {
holder: holderId,
...editorConfigOverrides,
...(initialData ? { data: initialData } : {}),
...(toolsConfig.length > 0 ? { tools: resolvedTools } : {}),
};
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
data,
serializedTools,
config,
}
);
};
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
await createEditor(page, {
data: {
blocks,
},
});
};
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();
});
};
const getParagraphByIndex = (page: Page, index: number): Locator => {
return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`);
};
const focusBlockByIndex = async (page: Page, index: number): 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): Promise<void> => {
await focusBlockByIndex(page, index);
const block = page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`);
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();
};
const clickTune = async (page: Page, tuneName: string): Promise<void> => {
const tuneButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} [data-item-name=${tuneName}]`);
await tuneButton.waitFor({ state: 'visible' });
await tuneButton.click();
};
test.describe('modules/blockManager', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('deletes the last block without adding fillers when other blocks remain', async ({ page }) => {
await createEditorWithBlocks(page, [
{
id: 'block1',
type: 'paragraph',
data: { text: 'First block' },
},
{
id: 'block2',
type: 'paragraph',
data: { text: 'Second block' },
},
]);
await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.blocks.delete(1);
});
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect((blocks[0]?.data as { text: string }).text).toBe('First block');
});
test('replaces a single deleted block with a new default block', async ({ page }) => {
const initialId = 'single-block';
await createEditorWithBlocks(page, [
{
id: initialId,
type: 'paragraph',
data: { text: 'Only block' },
},
]);
await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.blocks.delete(0);
});
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0]?.id).not.toBe(initialId);
expect((blocks[0]?.data as { text?: string }).text ?? '').toBe('');
});
test('converts a block to a compatible tool via API', async ({ page }) => {
const CONVERTABLE_SOURCE_TOOL = `(() => {
return class ConvertableSourceTool {
constructor({ data }) {
this.data = data || {};
}
static get toolbox() {
return { icon: '', title: 'Convertible Source' };
}
static get conversionConfig() {
return {
export: (data) => data.text ?? '',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.data.text ?? '';
return element;
}
save(element) {
return { text: element.innerHTML };
}
};
})();`;
const CONVERTABLE_TARGET_TOOL = `(() => {
return class ConvertableTargetTool {
constructor({ data }) {
this.data = data || {};
}
static get toolbox() {
return { icon: '', title: 'Convertible Target' };
}
static get conversionConfig() {
return {
import: (content) => ({ text: content.toUpperCase() }),
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.data.text ?? '';
return element;
}
save(element) {
return { text: element.innerHTML };
}
};
})();`;
await createEditor(page, {
tools: {
convertibleSource: {
classCode: CONVERTABLE_SOURCE_TOOL,
},
convertibleTarget: {
classCode: CONVERTABLE_TARGET_TOOL,
},
},
data: {
blocks: [
{
id: 'source-block',
type: 'convertibleSource',
data: { text: 'convert me' },
},
],
},
config: {
defaultBlock: 'convertibleSource',
},
});
await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.blocks.convert('source-block', 'convertibleTarget');
});
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0]?.type).toBe('convertibleTarget');
expect((blocks[0]?.data as { text?: string }).text).toBe('CONVERT ME');
});
test('fails conversion when target tool lacks conversionConfig', async ({ page }) => {
const CONVERTABLE_SOURCE_TOOL = `(() => {
return class ConvertableSourceTool {
constructor({ data }) {
this.data = data || {};
}
static get toolbox() {
return { icon: '', title: 'Convertible Source' };
}
static get conversionConfig() {
return {
export: (data) => data.text ?? '';
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.data.text ?? '';
return element;
}
save(element) {
return { text: element.innerHTML };
}
};
})();`;
const TOOL_WITHOUT_CONVERSION = `(() => {
return class ToolWithoutConversionConfig {
constructor({ data }) {
this.data = data || {};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.data.text ?? '';
return element;
}
save(element) {
return { text: element.innerHTML };
}
};
})();`;
await createEditor(page, {
tools: {
convertibleSource: {
classCode: CONVERTABLE_SOURCE_TOOL,
},
withoutConversion: {
classCode: TOOL_WITHOUT_CONVERSION,
},
},
data: {
blocks: [
{
id: 'non-convertable',
type: 'convertibleSource',
data: { text: 'stay text' },
},
],
},
config: {
defaultBlock: 'convertibleSource',
},
});
const errorMessage = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
try {
await window.editorInstance.blocks.convert('non-convertable', 'withoutConversion');
return null;
} catch (error) {
return (error as Error).message;
}
});
expect(errorMessage).toContain('Conversion from "convertibleSource" to "withoutConversion" is not possible');
const { blocks } = await saveEditor(page);
expect(blocks).toHaveLength(1);
expect(blocks[0]?.type).toBe('convertibleSource');
expect((blocks[0]?.data as { text?: string }).text).toBe('stay text');
});
test('moves a block up via the default tune', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: { text: 'First block' },
},
{
type: 'paragraph',
data: { text: 'Second block' },
},
{
type: 'paragraph',
data: { text: 'Third block' },
},
]);
await openBlockSettings(page, 1);
await clickTune(page, 'move-up');
const { blocks } = await saveEditor(page);
expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([
'Second block',
'First block',
'Third block',
]);
});
test('moves a block down via the default tune', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: { text: 'First block' },
},
{
type: 'paragraph',
data: { text: 'Second block' },
},
{
type: 'paragraph',
data: { text: 'Third block' },
},
]);
await openBlockSettings(page, 1);
await clickTune(page, 'move-down');
const { blocks } = await saveEditor(page);
expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([
'First block',
'Third block',
'Second block',
]);
});
test('generates unique ids for newly inserted blocks', async ({ page }) => {
await createEditor(page);
const firstParagraph = getParagraphByIndex(page, 0);
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');
await page.keyboard.type('Third block');
const { blocks } = await saveEditor(page);
const ids = blocks.map((block) => block.id);
expect(blocks).toHaveLength(3);
ids.forEach((id, index) => {
if (id === undefined) {
throw new Error(`Block id at index ${index} is undefined`);
}
expect(typeof id).toBe('string');
expect(id).not.toHaveLength(0);
});
expect(new Set(ids).size).toBe(ids.length);
});
});

View file

@ -0,0 +1,501 @@
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 { EditorConfig, 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, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const HOLDER_SELECTOR = `#${HOLDER_ID}`;
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`;
const SIMPLE_IMAGE_TOOL_UMD_PATH = path.resolve(
__dirname,
'../../../../node_modules/@editorjs/simple-image/dist/simple-image.umd.js'
);
type SerializableToolConfig = {
className?: string;
classCode?: string;
config?: Record<string, unknown>;
};
type CreateEditorOptions = Pick<EditorConfig, 'data' | 'readOnly'> & {
tools?: Record<string, SerializableToolConfig>;
};
type DropPayload = {
types?: Record<string, string>;
files?: Array<{ name: string; type: string; content: string }>;
};
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> => {
await resetEditor(page);
const { tools = {}, ...editorOptions } = options;
const serializedTools = Object.entries(tools).map(([name, tool]) => {
return {
name,
className: tool.className ?? null,
classCode: tool.classCode ?? null,
toolConfig: tool.config ?? {},
};
});
await page.evaluate(
async ({ holderId, editorOptions: rawOptions, serializedTools: toolsConfig }) => {
const { data, ...restOptions } = rawOptions;
const editorConfig: Record<string, unknown> = {
holder: holderId,
...restOptions,
};
if (data) {
editorConfig.data = data;
}
if (toolsConfig.length > 0) {
const resolvedTools = toolsConfig.reduce<Record<string, { class: unknown } & Record<string, unknown>>>(
(accumulator, { name, className, classCode, toolConfig }) => {
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 -- executed in browser context to reconstruct tool
toolClass = new Function(`return (${classCode});`)();
}
if (!toolClass) {
throw new Error(`Tool "${name}" is not available globally`);
}
return {
...accumulator,
[name]: {
class: toolClass,
...toolConfig,
},
};
},
{}
);
editorConfig.tools = resolvedTools;
}
const editor = new window.EditorJS(editorConfig as EditorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorOptions,
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();
});
};
const selectAllText = async (locator: Locator): Promise<void> => {
await locator.evaluate((element) => {
const selection = element.ownerDocument.getSelection();
if (!selection) {
throw new Error('Selection API is not available');
}
const range = element.ownerDocument.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
});
};
const getBlockByIndex = (page: Page, index: number): Locator => {
return page.locator(`${BLOCK_SELECTOR}:nth-of-type(${index + 1})`);
};
const getParagraphByIndex = (page: Page, index: number): Locator => {
return getBlockByIndex(page, index).locator('.ce-paragraph');
};
const selectText = async (locator: Locator, targetText: string): Promise<void> => {
await locator.evaluate((element, text) => {
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let foundNode: Text | null = null;
let offset = -1;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const content = node.textContent ?? '';
const index = content.indexOf(text);
if (index !== -1) {
foundNode = node;
offset = index;
break;
}
}
if (!foundNode || offset === -1) {
throw new Error(`Text "${text}" not found inside element`);
}
const selection = element.ownerDocument.getSelection();
const range = element.ownerDocument.createRange();
range.setStart(foundNode, offset);
range.setEnd(foundNode, offset + text.length);
selection?.removeAllRanges();
selection?.addRange(range);
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
}, targetText);
};
const startEditorDrag = async (page: Page): Promise<void> => {
await page.evaluate(({ selector }) => {
const holder = document.querySelector(selector);
if (!holder) {
throw new Error('Editor holder not found');
}
holder.dispatchEvent(new DragEvent('dragstart', {
bubbles: true,
cancelable: true,
}));
}, { selector: HOLDER_SELECTOR });
};
const dispatchDrop = async (page: Page, targetSelector: string, payload: DropPayload): Promise<void> => {
await page.evaluate(({ selector, payload: data }) => {
const target = document.querySelector(selector);
if (!target) {
throw new Error('Drop target not found');
}
const dataTransfer = new DataTransfer();
if (data.types) {
Object.entries(data.types).forEach(([type, value]) => {
dataTransfer.setData(type, value);
});
}
if (data.files) {
data.files.forEach(({ name, type, content }) => {
const file = new File([ content ], name, { type });
dataTransfer.items.add(file);
});
}
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer,
});
target.dispatchEvent(dropEvent);
}, {
selector: targetSelector,
payload,
});
};
const getBlockTexts = async (page: Page): Promise<string[]> => {
return await page.locator(BLOCK_SELECTOR).allTextContents()
.then((texts) => {
return texts.map((text) => text.trim()).filter(Boolean);
});
};
const toggleReadOnly = async (page: Page, state: boolean): Promise<void> => {
await page.evaluate(async ({ readOnlyState }) => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
await window.editorInstance.readOnly.toggle(readOnlyState);
}, { readOnlyState: state });
};
test.describe('modules/drag-and-drop', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('moves blocks when dragging their content between positions', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{ type: 'paragraph',
data: { text: 'First block' } },
{ type: 'paragraph',
data: { text: 'Second block' } },
{ type: 'paragraph',
data: { text: 'Third block' } },
],
},
});
const secondParagraph = getParagraphByIndex(page, 1);
await selectAllText(secondParagraph);
await startEditorDrag(page);
await dispatchDrop(page, `${BLOCK_SELECTOR}:nth-of-type(3) .ce-paragraph`, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Second block',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p>Second block</p>',
// eslint-disable-next-line @typescript-eslint/naming-convention
'application/x-editor-js': JSON.stringify([
{
tool: 'paragraph',
data: { text: 'Second block' },
},
]),
},
});
await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(3);
expect(await getBlockTexts(page)).toStrictEqual([
'First block',
'Third block',
'Second block',
]);
});
test('drags partial text between blocks', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{ type: 'paragraph',
data: { text: 'Alpha block' } },
{ type: 'paragraph',
data: { text: 'Beta block' } },
],
},
});
const firstParagraph = getParagraphByIndex(page, 0);
await selectText(firstParagraph, 'Alpha');
await startEditorDrag(page);
await dispatchDrop(page, `${BLOCK_SELECTOR}:nth-of-type(2) .ce-paragraph`, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Alpha',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': 'Alpha',
},
});
const texts = await getBlockTexts(page);
expect(texts[0]).toBe('block');
expect(texts[1]).toBe('Beta blockAlpha');
});
test('drops files into tools that support file paste config', async ({ page }) => {
await page.addScriptTag({ path: SIMPLE_IMAGE_TOOL_UMD_PATH });
await page.addScriptTag({
content: `
class SimpleImageWithInlineUpload extends window.SimpleImage {
static get isReadOnlySupported() {
return true;
}
static get pasteConfig() {
return {
files: {
mimeTypes: ['image/*'],
},
};
}
async onDropHandler(dropData) {
if (dropData.type !== 'file') {
return super.onDropHandler(dropData);
}
const file = dropData.file;
this.data = {
url: this.createObjectURL(file),
};
this._toggleLoader(false);
}
uploadFile(file) {
return Promise.resolve({
success: 1,
file: {
url: this.createObjectURL(file),
},
});
}
createObjectURL(file) {
if (window.URL && typeof window.URL.createObjectURL === 'function') {
return window.URL.createObjectURL(file);
}
return 'data:' + file.type + ';base64,' + btoa(file.name);
}
}
window.SimpleImage = SimpleImageWithInlineUpload;
`,
});
await createEditor(page, {
tools: {
image: {
className: 'SimpleImage',
},
},
});
await dispatchDrop(page, HOLDER_SELECTOR, {
files: [
{
name: 'test.png',
type: 'image/png',
content: 'fake image content',
},
],
});
const image = page.locator(`${EDITOR_INTERFACE_SELECTOR} img`);
await expect(image).toHaveCount(1);
const { blocks } = await saveEditor(page);
expect(blocks[blocks.length - 1]?.type).toBe('image');
});
test('shows and clears drop-target highlighting while dragging over blocks', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{ type: 'paragraph',
data: { text: 'Highlight A' } },
{ type: 'paragraph',
data: { text: 'Highlight B' } },
],
},
});
const targetBlock = getBlockByIndex(page, 1);
await targetBlock.locator('.ce-block__content').dispatchEvent('dragover', {
bubbles: true,
cancelable: true,
});
await expect(targetBlock).toHaveClass(/ce-block--drop-target/);
await targetBlock.locator('.ce-block__content').dispatchEvent('dragleave', {
bubbles: true,
cancelable: true,
});
await expect(targetBlock).not.toHaveClass(/ce-block--drop-target/);
});
test('ignores drops while read-only mode is enabled', async ({ page }) => {
await createEditor(page, {
readOnly: true,
data: {
blocks: [
{ type: 'paragraph',
data: { text: 'Locked block' } },
],
},
});
await dispatchDrop(page, HOLDER_SELECTOR, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Should not appear',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p>Should not appear</p>',
},
});
await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(1);
await toggleReadOnly(page, false);
await dispatchDrop(page, HOLDER_SELECTOR, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'New block',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p>New block</p>',
},
});
await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(2);
await expect(getBlockTexts(page)).resolves.toContain('New block');
});
});

View file

@ -0,0 +1,634 @@
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, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
const SELECT_ALL_SHORTCUT = process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
const RECTANGLE_OVERLAY_SELECTOR = '.codex-editor-overlay__container';
const RECTANGLE_ELEMENT_SELECTOR = '.codex-editor-overlay__rectangle';
const FAKE_BACKGROUND_SELECTOR = '.codex-editor__fake-background';
const BLOCK_SELECTED_CLASS = 'ce-block--selected';
declare global {
interface Window {
editorInstance?: EditorJS;
}
}
type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
type ToolDefinition = {
name: string;
classSource: string;
config?: Record<string, unknown>;
};
const getBlockWrapperSelectorByIndex = (index: number): string => {
return `:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`;
};
const getParagraphSelectorByIndex = (index: number): string => {
return `:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`;
};
const getBlockByIndex = (page: Page, index: number): Locator => {
return page.locator(getBlockWrapperSelectorByIndex(index));
};
const getParagraphByIndex = (page: Page, index: number): Locator => {
return page.locator(getParagraphSelectorByIndex(index));
};
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'],
tools: ToolDefinition[] = []
): Promise<void> => {
await resetEditor(page);
await page.evaluate(async ({ holderId, blocks: editorBlocks, serializedTools }) => {
const reviveToolClass = (classSource: string): unknown => {
return new Function(`return (${classSource});`)();
};
const revivedTools = serializedTools.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
return {
...accumulator,
[toolConfig.name]: toolConfig.config
? {
...toolConfig.config,
class: revivedClass,
}
: revivedClass,
};
}, {});
const editor = new window.EditorJS({
holder: holderId,
data: { blocks: editorBlocks },
...(serializedTools.length > 0 ? { tools: revivedTools } : {}),
});
window.editorInstance = editor;
await editor.isReady;
}, {
holderId: HOLDER_ID,
blocks,
serializedTools: tools,
});
};
const selectText = async (locator: Locator, text: string): Promise<void> => {
await locator.evaluate((element, targetText) => {
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Text | null = null;
let startOffset = -1;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const content = node.textContent ?? '';
const index = content.indexOf(targetText);
if (index !== -1) {
startNode = node;
startOffset = index;
break;
}
}
if (!startNode || startOffset === -1) {
throw new Error(`Text "${targetText}" not found inside locator`);
}
const range = element.ownerDocument.createRange();
range.setStart(startNode, startOffset);
range.setEnd(startNode, startOffset + targetText.length);
const selection = element.ownerDocument.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
}, text);
};
const placeCaretAtEnd = async (locator: Locator): Promise<void> => {
await locator.evaluate((element) => {
const doc = element.ownerDocument;
const selection = doc.getSelection();
if (!selection) {
return;
}
const range = doc.createRange();
const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let lastTextNode: Text | null = null;
while (walker.nextNode()) {
lastTextNode = walker.currentNode as Text;
}
if (lastTextNode) {
range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0);
} else {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
doc.dispatchEvent(new Event('selectionchange'));
});
};
const StaticBlockTool = class {
private data: { text?: string };
/**
* @param options - static block options
*/
constructor({ data }: { data?: { text?: string } }) {
this.data = data ?? {};
}
/**
* Toolbox metadata for static block
*/
public static get toolbox(): { title: string } {
return {
title: 'Static block',
};
}
/**
* Renders static block content wrapper
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.textContent = this.data.text ?? 'Static block without inputs';
wrapper.contentEditable = 'false';
return wrapper;
}
/**
* Serializes static block DOM into data
*
* @param element - block root element
*/
public save(element: HTMLElement): { text: string } {
return {
text: element.textContent ?? '',
};
}
};
const EditableTitleTool = class {
private data: { text?: string };
/**
* @param options - editable title options
*/
constructor({ data }: { data?: { text?: string } }) {
this.data = data ?? {};
}
/**
* Toolbox metadata for editable title block
*/
public static get toolbox(): { title: string } {
return {
title: 'Editable title',
};
}
/**
* Renders editable title block wrapper
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.contentEditable = 'true';
wrapper.dataset.cy = 'editable-title-block';
wrapper.textContent = this.data.text ?? 'Editable block';
return wrapper;
}
/**
* Serializes editable title DOM into data
*
* @param element - block root element
*/
public save(element: HTMLElement): { text: string } {
return {
text: element.textContent ?? '',
};
}
};
const STATIC_BLOCK_TOOL_SOURCE = StaticBlockTool.toString();
const EDITABLE_TITLE_TOOL_SOURCE = EditableTitleTool.toString();
const getRequiredBoundingBox = async (locator: Locator): Promise<BoundingBox> => {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Unable to determine element bounds for drag operation');
}
return box;
};
const getElementCenter = async (locator: Locator): Promise<{ x: number; y: number }> => {
const box = await getRequiredBoundingBox(locator);
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
};
};
test.describe('modules/selection', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('selects all blocks via CMD/CTRL + A', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: 'First block',
},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'paragraph',
data: {
text: 'Third block',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await page.keyboard.press(SELECT_ALL_SHORTCUT);
await page.keyboard.press(SELECT_ALL_SHORTCUT);
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
await expect(blocks).toHaveCount(3);
for (const index of [0, 1, 2]) {
await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
}
});
test('cross-block selection selects contiguous blocks when dragging across content', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: 'First block',
},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'paragraph',
data: {
text: 'Third block',
},
},
{
type: 'paragraph',
data: {
text: 'Fourth block',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
const thirdParagraph = getParagraphByIndex(page, 2);
const firstCenter = await getElementCenter(firstParagraph);
const thirdCenter = await getElementCenter(thirdParagraph);
await page.mouse.move(firstCenter.x, firstCenter.y);
await page.mouse.down();
await page.mouse.move(thirdCenter.x, thirdCenter.y, { steps: 10 });
await page.mouse.up();
await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 3)).not.toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
});
test('rectangle selection highlights multiple blocks when dragging overlay rectangle', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: 'Alpha',
},
},
{
type: 'paragraph',
data: {
text: 'Beta',
},
},
{
type: 'paragraph',
data: {
text: 'Gamma',
},
},
{
type: 'paragraph',
data: {
text: 'Delta',
},
},
]);
const firstBlock = getBlockByIndex(page, 0);
const thirdBlock = getBlockByIndex(page, 2);
const firstBox = await getRequiredBoundingBox(firstBlock);
const thirdBox = await getRequiredBoundingBox(thirdBlock);
const startX = Math.max(0, firstBox.x - 20);
const startY = Math.max(0, firstBox.y - 20);
const endX = thirdBox.x + thirdBox.width + 20;
const endY = thirdBox.y + thirdBox.height / 2;
const overlay = page.locator(RECTANGLE_OVERLAY_SELECTOR);
await expect(overlay).toHaveCount(1);
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 15 });
await expect(page.locator(RECTANGLE_ELEMENT_SELECTOR)).toBeVisible();
await page.mouse.up();
await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 3)).not.toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
});
test('selection API exposes save/restore, expandToTag, fake background helpers', async ({ page }) => {
const text = 'Important <strong>bold</strong> text inside paragraph';
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text,
},
},
]);
const paragraph = getParagraphByIndex(page, 0);
await selectText(paragraph, 'bold');
const paragraphText = (await paragraph.innerText()).trim();
const apiResults = await page.evaluate(({ fakeBackgroundSelector }) => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance is not ready');
}
const selection = window.getSelection();
const savedText = selection?.toString() ?? '';
editor.selection.save();
selection?.removeAllRanges();
const paragraphEl = document.querySelector('.ce-paragraph');
const textNode = paragraphEl?.firstChild as Text | null;
if (textNode) {
const range = document.createRange();
range.setStart(textNode, textNode.textContent?.length ?? 0);
range.collapse(true);
selection?.addRange(range);
}
editor.selection.restore();
const restored = window.getSelection()?.toString() ?? '';
const strongTag = editor.selection.findParentTag('STRONG');
if (paragraphEl instanceof HTMLElement) {
editor.selection.expandToTag(paragraphEl);
}
const expanded = window.getSelection()?.toString() ?? '';
editor.selection.setFakeBackground();
const fakeWrappersCount = document.querySelectorAll(fakeBackgroundSelector).length;
editor.selection.removeFakeBackground();
const fakeWrappersAfterRemoval = document.querySelectorAll(fakeBackgroundSelector).length;
return {
savedText,
restored,
strongTag: strongTag?.tagName ?? null,
expanded,
fakeWrappersCount,
fakeWrappersAfterRemoval,
};
}, { fakeBackgroundSelector: FAKE_BACKGROUND_SELECTOR });
expect(apiResults.savedText).toBe('bold');
expect(apiResults.restored).toBe('bold');
expect(apiResults.strongTag).toBe('STRONG');
expect(apiResults.expanded.trim()).toBe(paragraphText);
expect(apiResults.fakeWrappersCount).toBeGreaterThan(0);
expect(apiResults.fakeWrappersAfterRemoval).toBe(0);
});
test('cross-block selection deletes multiple blocks with Backspace', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: 'First block',
},
},
{
type: 'paragraph',
data: {
text: 'Second block',
},
},
{
type: 'paragraph',
data: {
text: 'Third block',
},
},
{
type: 'paragraph',
data: {
text: 'Fourth block',
},
},
]);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await placeCaretAtEnd(firstParagraph);
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');
await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
await page.keyboard.press('Backspace');
const blocks = page.locator(BLOCK_WRAPPER_SELECTOR);
await expect(blocks).toHaveCount(2);
const savedData = await page.evaluate<OutputData>(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance is not ready');
}
return editor.save();
});
expect(savedData.blocks).toHaveLength(2);
const blockTexts = savedData.blocks.map((block) => {
return (block.data as { text?: string }).text ?? '';
});
expect(blockTexts[0].trim()).toBe('');
expect(blockTexts[1]).toBe('Fourth block');
});
test('cross-block selection spans different block types with shift navigation', async ({ page }) => {
await createEditorWithBlocks(
page,
[
{
type: 'paragraph',
data: {
text: 'Paragraph content',
},
},
{
type: 'static-block',
data: {
text: 'Static content',
},
},
{
type: 'editable-title',
data: {
text: 'Editable tail',
},
},
],
[
{
name: 'static-block',
classSource: STATIC_BLOCK_TOOL_SOURCE,
},
{
name: 'editable-title',
classSource: EDITABLE_TITLE_TOOL_SOURCE,
},
]
);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await placeCaretAtEnd(firstParagraph);
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');
for (const index of [0, 1, 2]) {
await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS));
}
});
});

View file

@ -0,0 +1,572 @@
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 { EditorConfig } from '@/types';
import { ensureEditorBundleBuilt } from './helpers/ensure-build';
import {
EDITOR_INTERFACE_SELECTOR,
INLINE_TOOLBAR_INTERFACE_SELECTOR
} from '../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const HOLDER_SELECTOR = `#${HOLDER_ID}`;
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`;
const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`;
const SETTINGS_BUTTON_SELECTOR = `${TOOLBAR_SELECTOR} .ce-toolbar__settings-btn`;
const INLINE_TOOL_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover-item`;
const HEADER_TOOL_UMD_PATH = path.resolve(
__dirname,
'../../../node_modules/@editorjs/header/dist/header.umd.js'
);
const READ_ONLY_INLINE_TOOL_SOURCE = `
class ReadOnlyInlineTool {
static isInline = true;
static isReadOnlySupported = true;
render() {
return {
title: 'Read-only tool',
name: 'read-only-inline',
onActivate: () => {},
};
}
}
`;
const UNSUPPORTED_INLINE_TOOL_SOURCE = `
class UnsupportedInlineTool {
static isInline = true;
render() {
return {
title: 'Legacy inline tool',
name: 'unsupported-inline',
onActivate: () => {},
};
}
}
`;
const UNSUPPORTED_BLOCK_TOOL_SOURCE = `
class LegacyBlockTool {
constructor({ data }) {
this.data = data ?? { text: 'Legacy block' };
}
static get toolbox() {
return {
title: 'Legacy',
icon: 'L',
};
}
static get isReadOnlySupported() {
return false;
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.innerHTML = this.data?.text ?? '';
return element;
}
save(element) {
return {
text: element.innerHTML,
};
}
}
`;
type SerializableToolConfig = {
className?: string;
classCode?: string;
config?: Record<string, unknown>;
};
type CreateEditorOptions = Partial<Pick<EditorConfig, 'data' | 'inlineToolbar' | 'placeholder' | 'readOnly'>> & {
tools?: Record<string, SerializableToolConfig>;
};
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => 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> => {
await resetEditor(page);
const { tools = {}, ...editorOptions } = options;
const serializedTools = Object.entries(tools).map(([name, tool]) => {
return {
name,
className: tool.className ?? null,
classCode: tool.classCode ?? null,
toolConfig: tool.config ?? {},
};
});
await page.evaluate(
async ({ holderId, editorOptions: rawOptions, serializedTools: toolsConfig }) => {
const { data, ...restOptions } = rawOptions;
const editorConfig: Record<string, unknown> = {
holder: holderId,
...restOptions,
};
if (data) {
editorConfig.data = data;
}
if (toolsConfig.length > 0) {
const resolvedTools = toolsConfig.reduce<Record<string, { class: unknown } & Record<string, unknown>>>(
(accumulator, { name, className, classCode, toolConfig }) => {
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 -- executed in browser context to recreate the tool class
toolClass = new Function(`return (${classCode});`)();
}
if (!toolClass) {
throw new Error(`Tool "${name}" is not available globally`);
}
return {
...accumulator,
[name]: {
class: toolClass,
...toolConfig,
},
};
},
{}
);
editorConfig.tools = resolvedTools;
}
const editor = new window.EditorJS(editorConfig as EditorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorOptions,
serializedTools,
}
);
};
const toggleReadOnly = async (page: Page, state: boolean): Promise<void> => {
await page.evaluate(async ({ targetState }) => {
const editor = window.editorInstance ?? (() => {
throw new Error('Editor instance not found');
})();
await editor.readOnly.toggle(targetState);
}, { targetState: state });
};
const selectText = async (locator: Locator, text: string): Promise<void> => {
await locator.evaluate((element, targetText) => {
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let foundNode: Text | null = null;
let offset = -1;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const content = node.textContent ?? '';
const index = content.indexOf(targetText);
if (index !== -1) {
foundNode = node;
offset = index;
break;
}
}
if (!foundNode || offset === -1) {
throw new Error(`Text "${targetText}" was not found inside element`);
}
const selection = element.ownerDocument.getSelection();
const range = element.ownerDocument.createRange();
range.setStart(foundNode, offset);
range.setEnd(foundNode, offset + targetText.length);
selection?.removeAllRanges();
selection?.addRange(range);
element.ownerDocument.dispatchEvent(new Event('selectionchange'));
}, text);
};
const paste = async (page: Page, locator: Locator, data: Record<string, string>): Promise<void> => {
await locator.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);
});
});
};
type DropPayload = {
types?: Record<string, string>;
files?: Array<{ name: string; type: string; content: string }>;
};
const dispatchDrop = async (page: Page, payload: DropPayload): Promise<void> => {
await page.evaluate(({ selector, payload: data }) => {
const holder = document.querySelector(selector);
if (!holder) {
throw new Error('Drop target not found');
}
const dataTransfer = new DataTransfer();
if (data.types) {
Object.entries(data.types).forEach(([type, value]) => {
dataTransfer.setData(type, value);
});
}
if (data.files) {
data.files.forEach(({ name, type, content }) => {
const file = new File([ content ], name, { type });
dataTransfer.items.add(file);
});
}
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer,
});
holder.dispatchEvent(dropEvent);
}, {
selector: HOLDER_SELECTOR,
payload,
});
};
const expectSettingsButtonToDisappear = async (page: Page): Promise<void> => {
await page.waitForFunction((selector) => document.querySelector(selector) === null, SETTINGS_BUTTON_SELECTOR);
};
const waitForReadOnlyState = async (page: Page, expected: boolean): Promise<void> => {
await page.waitForFunction(({ expectedState }) => {
return window.editorInstance?.readOnly.isEnabled === expectedState;
}, { expectedState: expected });
};
test.describe('read-only mode', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test('allows toggling editing state dynamically', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Editable text',
},
},
],
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
await paragraph.click();
await page.keyboard.type(' + edit');
await expect(paragraph).toContainText('Editable text + edit');
await toggleReadOnly(page, true);
await waitForReadOnlyState(page, true);
await expect(paragraph).toHaveAttribute('contenteditable', 'false');
await expect(paragraph).toHaveText('Editable text + edit');
await paragraph.click();
await page.keyboard.type(' should not appear');
await expect(paragraph).toHaveText('Editable text + edit');
await toggleReadOnly(page, false);
await waitForReadOnlyState(page, false);
await expect(paragraph).toHaveAttribute('contenteditable', 'true');
await paragraph.click();
await page.keyboard.type(' + writable again');
await expect(paragraph).toContainText('writable again');
});
test('only shows read-only inline tools when editor is locked', async ({ page }) => {
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });
await createEditor(page, {
readOnly: true,
data: {
blocks: [
{
type: 'header',
data: {
text: 'Read me carefully',
},
},
],
},
tools: {
header: {
className: 'Header',
config: {
inlineToolbar: ['readOnlyInline', 'legacyInline'],
},
},
readOnlyInline: {
classCode: READ_ONLY_INLINE_TOOL_SOURCE,
},
legacyInline: {
classCode: UNSUPPORTED_INLINE_TOOL_SOURCE,
},
},
});
const headerBlock = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-header`);
await selectText(headerBlock, 'Read me');
const readOnlyToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="read-only-inline"]`);
const unsupportedToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="unsupported-inline"]`);
await expect(readOnlyToolItem).toBeVisible();
await expect(unsupportedToolItem).toHaveCount(0);
await toggleReadOnly(page, false);
await waitForReadOnlyState(page, false);
await selectText(headerBlock, 'Read me');
await expect(readOnlyToolItem).toBeVisible();
await expect(unsupportedToolItem).toBeVisible();
});
test('removes block settings UI while read-only is enabled', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Block tunes availability',
},
},
],
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
await paragraph.click();
await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible();
await toggleReadOnly(page, true);
await expectSettingsButtonToDisappear(page);
await toggleReadOnly(page, false);
await waitForReadOnlyState(page, false);
await paragraph.click();
await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible();
});
test('prevents paste operations while read-only is enabled', async ({ page }) => {
await createEditor(page, {
readOnly: true,
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Original content',
},
},
],
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
await paste(page, paragraph, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': ' + pasted text',
});
await expect(paragraph).toHaveText('Original content');
await toggleReadOnly(page, false);
await waitForReadOnlyState(page, false);
await paragraph.click();
await paste(page, paragraph, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': ' + pasted text',
});
await expect(paragraph).toContainText('Original content + pasted text');
});
test('blocks drag-and-drop insertions while read-only is enabled', async ({ page }) => {
await createEditor(page, {
readOnly: true,
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Initial block',
},
},
],
},
});
await dispatchDrop(page, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Dropped text',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p>Dropped text</p>',
},
});
await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(1);
await toggleReadOnly(page, false);
await waitForReadOnlyState(page, false);
await dispatchDrop(page, {
types: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/plain': 'Dropped text',
// eslint-disable-next-line @typescript-eslint/naming-convention
'text/html': '<p>Dropped text</p>',
},
});
const blocks = page.locator(BLOCK_SELECTOR);
await expect(async () => {
const count = await blocks.count();
expect(count).toBeGreaterThanOrEqual(2);
}).toPass();
await expect(blocks).toHaveCount(2);
await expect(blocks.filter({ hasText: 'Dropped text' })).toHaveCount(1);
});
test('throws descriptive error when enabling read-only with unsupported tools', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'legacy',
data: {
text: 'Legacy feature block',
},
},
],
},
tools: {
legacy: {
classCode: UNSUPPORTED_BLOCK_TOOL_SOURCE,
},
},
});
const errorMessage = await page.evaluate(async () => {
const editor = window.editorInstance ?? (() => {
throw new Error('Editor instance not found');
})();
try {
await editor.readOnly.toggle(true);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
});
expect(errorMessage).toContain('Tools legacy don\'t support read-only mode');
const isReadOnlyEnabled = await page.evaluate(() => {
return window.editorInstance?.readOnly.isEnabled ?? false;
});
expect(isReadOnlyEnabled).toBe(false);
});
});

View file

@ -0,0 +1,985 @@
import { expect, test } from '@playwright/test';
import type { ConsoleMessage, 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,
INLINE_TOOLBAR_INTERFACE_SELECTOR,
MODIFIER_KEY
} from '../../../../src/components/constants';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../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`;
const TOOLBOX_POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover`;
const FAILING_TOOL_SOURCE = `
class FailingTool {
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
return element;
}
save() {
throw new Error('Save failure');
}
}
`;
type ToolDefinition = {
name: string;
classSource: string;
config?: Record<string, unknown>;
inlineToolbar?: string[] | boolean;
toolbox?: { title: string; icon?: string };
shortcut?: string;
};
type CreateEditorOptions = {
data?: OutputData;
config?: Record<string, unknown>;
tools?: ToolDefinition[];
};
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
__toolConfigReceived?: unknown;
__onReadyCalls?: number;
}
}
const getParagraphByIndex = (page: Page, index = 0): ReturnType<Page['locator']> => {
return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${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: CreateEditorOptions = {}): Promise<void> => {
const { data = null, config = {}, tools = [] } = options;
await resetEditor(page);
await page.evaluate(
async ({ holderId, editorData, editorConfig, toolDefinitions }) => {
const reviveToolClass = (source: string): unknown => {
// eslint-disable-next-line no-new-func -- revive tool class inside the page context
return new Function(`return (${source});`)();
};
const finalConfig: Record<string, unknown> = {
holder: holderId,
...editorConfig,
};
if (editorData) {
finalConfig.data = editorData;
}
if (toolDefinitions.length > 0) {
const revivedTools = toolDefinitions.reduce<Record<string, Record<string, unknown>>>(
(accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
const toolSettings: Record<string, unknown> = {
class: revivedClass,
};
if (toolConfig.config) {
toolSettings.config = toolConfig.config;
}
if (toolConfig.inlineToolbar !== undefined) {
if (toolConfig.inlineToolbar === false) {
toolSettings.inlineToolbar = false;
} else {
toolSettings.inlineToolbar = toolConfig.inlineToolbar;
}
}
if (toolConfig.toolbox) {
toolSettings.toolbox = toolConfig.toolbox;
}
if (toolConfig.shortcut) {
toolSettings.shortcut = toolConfig.shortcut;
}
return {
...accumulator,
[toolConfig.name]: toolSettings,
};
},
{}
);
finalConfig.tools = revivedTools;
}
const editor = new window.EditorJS(finalConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
editorData: data,
editorConfig: config,
toolDefinitions: tools,
}
);
};
const getSelectionState = async (page: Page): Promise<{ isInsideParagraph: boolean; offset: number }> => {
return await page.evaluate(({ paragraphSelector }) => {
const paragraph = document.querySelector(paragraphSelector);
const selection = window.getSelection();
if (!paragraph || !selection || selection.rangeCount === 0) {
return {
isInsideParagraph: false,
offset: -1,
};
}
return {
isInsideParagraph: paragraph.contains(selection.anchorNode ?? null),
offset: selection.anchorOffset ?? -1,
};
}, { paragraphSelector: PARAGRAPH_SELECTOR });
};
const openToolbox = async (page: Page): Promise<void> => {
const paragraph = getParagraphByIndex(page);
await paragraph.click();
const plusButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`);
await plusButton.waitFor({ state: 'visible' });
await plusButton.click();
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
};
const insertFailingToolAndTriggerSave = async (page: Page): Promise<void> => {
await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
editor.blocks.insert('failingTool');
try {
await editor.save();
} catch (error) {
// Intentionally swallow to observe console logging side effects
}
});
await page.waitForFunction((waitMs) => {
return new Promise((resolve) => {
setTimeout(() => resolve(true), waitMs);
});
}, 50);
};
test.describe('editor configuration options', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
});
test.describe('autofocus', () => {
test('focuses the default block when editor starts empty', async ({ page }) => {
await createEditor(page, {
config: {
autofocus: true,
},
});
await expect.poll(async () => {
const { isInsideParagraph } = await getSelectionState(page);
return isInsideParagraph;
}).toBe(true);
});
test('focuses the first block when initial data is provided', async ({ page }) => {
await createEditor(page, {
config: {
autofocus: true,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Prefilled content',
},
},
],
},
});
await expect.poll(async () => {
const { isInsideParagraph, offset } = await getSelectionState(page);
return isInsideParagraph && offset === 0;
}).toBe(true);
});
test('does not focus any block when autofocus is false on empty editor', async ({ page }) => {
await createEditor(page, {
config: {
autofocus: false,
},
});
const selectionState = await getSelectionState(page);
expect(selectionState.isInsideParagraph).toBe(false);
expect(selectionState.offset).toBe(-1);
});
test('does not focus when autofocus is omitted on empty editor', async ({ page }) => {
await createEditor(page);
const selectionState = await getSelectionState(page);
expect(selectionState.isInsideParagraph).toBe(false);
expect(selectionState.offset).toBe(-1);
});
test('does not focus last block when autofocus is false for prefilled data', async ({ page }) => {
await createEditor(page, {
config: {
autofocus: false,
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Prefilled content',
},
},
],
},
});
const selectionState = await getSelectionState(page);
expect(selectionState.isInsideParagraph).toBe(false);
});
test('does not focus when autofocus is omitted for prefilled data', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Prefilled content',
},
},
],
},
});
const selectionState = await getSelectionState(page);
expect(selectionState.isInsideParagraph).toBe(false);
});
});
test.describe('placeholder', () => {
const getPlaceholderValue = async (page: Page): Promise<string | null> => {
return await page.evaluate(({ paragraphSelector }) => {
const paragraph = document.querySelector(paragraphSelector);
if (!(paragraph instanceof HTMLElement)) {
return null;
}
return paragraph.getAttribute('data-placeholder');
}, { paragraphSelector: PARAGRAPH_SELECTOR });
};
test('uses provided placeholder string', async ({ page }) => {
const placeholder = 'Start typing...';
await createEditor(page, {
config: {
placeholder,
},
});
await expect.poll(async () => {
return await getPlaceholderValue(page);
}).toBe(placeholder);
});
test('hides placeholder when set to false', async ({ page }) => {
await createEditor(page, {
config: {
placeholder: false,
},
});
await expect.poll(async () => {
return await getPlaceholderValue(page);
}).toBeNull();
});
test('does not set placeholder when option is omitted', async ({ page }) => {
await createEditor(page);
await expect.poll(async () => {
return await getPlaceholderValue(page);
}).toBeNull();
});
});
test('applies custom minHeight padding', async ({ page }) => {
await createEditor(page, {
config: {
minHeight: 180,
},
});
const paddingBottom = await page.evaluate(({ selector }) => {
const redactor = document.querySelector(selector) as HTMLElement | null;
return redactor?.style.paddingBottom ?? null;
}, { selector: REDACTOR_SELECTOR });
expect(paddingBottom).toBe('180px');
});
test('uses default minHeight when option is omitted', async ({ page }) => {
await createEditor(page);
const paddingBottom = await page.evaluate(({ selector }) => {
const redactor = document.querySelector(selector) as HTMLElement | null;
return redactor?.style.paddingBottom ?? null;
}, { selector: REDACTOR_SELECTOR });
expect(paddingBottom).toBe('300px');
});
test('respects logLevel configuration', async ({ page }) => {
const consoleMessages: { type: string; text: string }[] = [];
const listener = (message: ConsoleMessage): void => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
};
page.on('console', listener);
const triggerInvalidMove = async (): Promise<void> => {
await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
editor.blocks.move(-1, -1);
});
await page.evaluate(() => {
return new Promise((resolve) => {
setTimeout(resolve, 50);
});
});
};
await createEditor(page);
await triggerInvalidMove();
const warningsWithDefaultLevel = consoleMessages.filter((message) => {
return message.type === 'warning' && message.text.includes("Warning during 'move' call");
}).length;
await createEditor(page, {
config: {
logLevel: 'ERROR',
},
});
const warningsBeforeSuppressedMove = consoleMessages.length;
await triggerInvalidMove();
const warningsAfterSuppressedMove = consoleMessages
.slice(warningsBeforeSuppressedMove)
.filter((message) => message.type === 'warning' && message.text.includes("Warning during 'move' call"))
.length;
page.off('console', listener);
expect(warningsWithDefaultLevel).toBeGreaterThan(0);
expect(warningsAfterSuppressedMove).toBe(0);
});
test('logLevel VERBOSE outputs both warnings and log messages', async ({ page }) => {
const consoleMessages: { type: string; text: string }[] = [];
const listener = (message: ConsoleMessage): void => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
};
page.on('console', listener);
await createEditor(page, {
config: {
logLevel: 'VERBOSE',
},
data: {
blocks: [
{
type: 'missingTool',
data: { text: 'should warn' },
},
],
},
tools: [
{
name: 'failingTool',
classSource: FAILING_TOOL_SOURCE,
},
],
});
await insertFailingToolAndTriggerSave(page);
page.off('console', listener);
const warningCount = consoleMessages.filter((message) => {
return message.type === 'warning';
}).length;
const logCount = consoleMessages.filter((message) => {
return message.type === 'log' && message.text.includes('Saving process for');
}).length;
expect(warningCount).toBeGreaterThan(0);
expect(logCount).toBeGreaterThan(0);
});
test('logLevel INFO suppresses labeled warnings but keeps log messages', async ({ page }) => {
const consoleMessages: { type: string; text: string }[] = [];
const listener = (message: ConsoleMessage): void => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
};
page.on('console', listener);
await createEditor(page, {
config: {
logLevel: 'INFO',
},
data: {
blocks: [
{
type: 'missingTool',
data: { text: 'should warn' },
},
],
},
tools: [
{
name: 'failingTool',
classSource: FAILING_TOOL_SOURCE,
},
],
});
await insertFailingToolAndTriggerSave(page);
page.off('console', listener);
const warningCount = consoleMessages.filter((message) => message.type === 'warning').length;
const logCount = consoleMessages.filter((message) => message.type === 'log').length;
expect(warningCount).toBe(0);
expect(logCount).toBeGreaterThan(0);
});
test('logLevel WARN outputs warnings while suppressing log messages', async ({ page }) => {
const consoleMessages: { type: string; text: string }[] = [];
const listener = (message: ConsoleMessage): void => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
};
page.on('console', listener);
await createEditor(page, {
config: {
logLevel: 'WARN',
},
data: {
blocks: [
{
type: 'missingTool',
data: { text: 'should warn' },
},
],
},
tools: [
{
name: 'failingTool',
classSource: FAILING_TOOL_SOURCE,
},
],
});
await insertFailingToolAndTriggerSave(page);
page.off('console', listener);
const warningCount = consoleMessages.filter((message) => message.type === 'warning').length;
const logCount = consoleMessages.filter((message) => message.type === 'log').length;
expect(warningCount).toBeGreaterThan(0);
expect(logCount).toBe(0);
});
test('uses configured defaultBlock when data is empty', async ({ page }) => {
const simpleBlockTool = `
class SimpleBlockTool {
constructor({ data }) {
this.data = data || {};
}
static get toolbox() {
return {
title: 'Simple block',
icon: '<svg></svg>',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.textContent = this.data.text || '';
return element;
}
save(element) {
return {
text: element.textContent || '',
};
}
}
`;
await createEditor(page, {
config: {
defaultBlock: 'simple',
},
tools: [
{
name: 'simple',
classSource: simpleBlockTool,
},
],
});
const firstBlockType = await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const data = await editor.save();
return data.blocks[0]?.type ?? null;
});
expect(firstBlockType).toBe('simple');
});
test('falls back to paragraph when configured defaultBlock is missing', async ({ page }) => {
await createEditor(page, {
config: {
defaultBlock: 'nonexistentTool',
},
});
const firstBlockType = await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const data = await editor.save();
return data.blocks[0]?.type ?? null;
});
expect(firstBlockType).toBe('paragraph');
});
test('applies custom sanitizer configuration', async ({ page }) => {
await createEditor(page, {
config: {
sanitizer: {
span: true,
},
},
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '<span data-test="allowed">Span content</span>',
},
},
],
},
});
const savedHtml = await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const data = await editor.save();
return data.blocks[0]?.data?.text ?? '';
});
expect(savedHtml).toContain('<span');
expect(savedHtml).toContain('data-test="allowed"');
});
test('uses default sanitizer rules when option is omitted', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '<script>window.__danger = true;</script><b>Safe text</b>',
},
},
],
},
});
const savedHtml = await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
const data = await editor.save();
return data.blocks[0]?.data?.text ?? '';
});
expect(savedHtml).not.toContain('<script');
expect(savedHtml).toContain('Safe text');
});
test('invokes onReady callback after initialization', async ({ page }) => {
await resetEditor(page);
const onReadyCalls = await page.evaluate(async ({ holderId }) => {
window.__onReadyCalls = 0;
const editor = new window.EditorJS({
holder: holderId,
onReady() {
window.__onReadyCalls = (window.__onReadyCalls ?? 0) + 1;
},
});
window.editorInstance = editor;
await editor.isReady;
return window.__onReadyCalls ?? 0;
}, { holderId: HOLDER_ID });
expect(onReadyCalls).toBe(1);
});
test('activates tool via configured shortcut', async ({ page }) => {
const shortcutTool = `
class ShortcutTool {
constructor({ data }) {
this.data = data || {};
}
static get toolbox() {
return {
title: 'Shortcut block',
icon: '<svg></svg>',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.textContent = this.data.text || '';
return element;
}
save(element) {
return {
text: element.textContent || '',
};
}
}
`;
await createEditor(page, {
tools: [
{
name: 'shortcutTool',
classSource: shortcutTool,
shortcut: 'CMD+SHIFT+L',
},
],
});
const paragraph = getParagraphByIndex(page);
await paragraph.click();
await paragraph.type('Shortcut text');
const combo = `${MODIFIER_KEY}+Shift+KeyL`;
await page.keyboard.press(combo);
await expect.poll(async () => {
const data = await page.evaluate(async () => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
return await editor.save();
});
return data.blocks.some((block: { type: string }) => block.type === 'shortcutTool');
}).toBe(true);
});
test('applies tool inlineToolbar, toolbox, and config overrides', async ({ page }) => {
const configurableToolSource = `
class ConfigurableTool {
constructor({ data, config }) {
this.data = data || {};
this.config = config || {};
window.__toolConfigReceived = config;
}
static get toolbox() {
return {
title: 'Default title',
icon: '<svg></svg>',
};
}
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
element.textContent = this.data.text || '';
if (this.config.placeholderText) {
element.dataset.placeholder = this.config.placeholderText;
}
return element;
}
save(element) {
return {
text: element.textContent || '',
};
}
}
`;
await page.evaluate(() => {
window.__toolConfigReceived = undefined;
});
await createEditor(page, {
tools: [
{
name: 'configurableTool',
classSource: configurableToolSource,
inlineToolbar: [ 'bold' ],
toolbox: {
title: 'Configured Tool',
icon: '<svg><circle cx="5" cy="5" r="5"></circle></svg>',
},
config: {
placeholderText: 'Custom placeholder',
},
},
],
});
await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
editor.blocks.insert('configurableTool');
});
const configurableSelector = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="configurableTool"]`;
const blockCount = await page.locator(configurableSelector).count();
expect(blockCount).toBeGreaterThan(0);
const customBlock = page.locator(`:nth-match(${configurableSelector}, ${blockCount})`);
const blockContent = customBlock.locator('[contenteditable="true"]');
await blockContent.click();
await blockContent.type('Config text');
await expect(blockContent).toHaveAttribute('data-placeholder', 'Custom placeholder');
await blockContent.selectText();
const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR);
await expect(inlineToolbar).toBeVisible();
await expect(inlineToolbar.locator('[data-item-name="bold"]')).toBeVisible();
await expect(inlineToolbar.locator('[data-item-name="link"]')).toHaveCount(0);
await openToolbox(page);
const toolboxItem = page.locator(`${TOOLBOX_POPOVER_SELECTOR} [data-item-name="configurableTool"]`);
await expect(toolboxItem).toContainText('Configured Tool');
const receivedConfig = await page.evaluate(() => {
return window.__toolConfigReceived ?? null;
});
expect(receivedConfig).toMatchObject({
placeholderText: 'Custom placeholder',
});
});
test('disables inline toolbar when tool config sets inlineToolbar to false', async ({ page }) => {
const inlineToggleTool = `
class InlineToggleTool {
render() {
const element = document.createElement('div');
element.contentEditable = 'true';
return element;
}
save(element) {
return {
text: element.textContent || '',
};
}
}
`;
await createEditor(page, {
tools: [
{
name: 'inlineToggleTool',
classSource: inlineToggleTool,
inlineToolbar: false,
},
],
});
await page.evaluate(() => {
const editor = window.editorInstance;
if (!editor) {
throw new Error('Editor instance not found');
}
editor.blocks.insert('inlineToggleTool');
});
const inlineToggleSelector = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="inlineToggleTool"]`;
const customBlock = page.locator(`${inlineToggleSelector}:last-of-type`);
const blockContent = customBlock.locator('[contenteditable="true"]');
await blockContent.click();
await blockContent.type('inline toolbar disabled');
await blockContent.selectText();
const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR);
await expect(inlineToolbar).toBeHidden();
});
});

View file

@ -610,6 +610,78 @@ test.describe('inline toolbar', () => {
expect(submitCount).toBe(0);
});
test('allows controlling inline toolbar visibility via API', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Inline toolbar API control test',
},
},
],
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await selectText(paragraph, 'toolbar');
const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR);
await expect(toolbarContainer).toBeVisible();
await page.evaluate(() => {
window.editorInstance?.inlineToolbar?.close();
});
await expect(toolbarContainer).toHaveCount(0);
await selectText(paragraph, 'toolbar');
await page.evaluate(() => {
window.editorInstance?.inlineToolbar?.open();
});
await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible();
});
test('reflects inline tool state changes based on current selection', async ({ page }) => {
await createEditor(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Bold part and plain part',
},
},
],
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await selectText(paragraph, 'Bold part');
const boldButton = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="bold"]`);
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
await boldButton.click();
await expect(boldButton).toHaveClass(/ce-popover-item--active/);
await selectText(paragraph, 'plain part');
await page.evaluate(() => {
window.editorInstance?.inlineToolbar?.open();
});
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
});
test('should restore caret after converting a block', async ({ page }) => {
await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH });

View file

@ -0,0 +1,457 @@
/* eslint-disable jsdoc/require-jsdoc */
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 type { BlockToolConstructable, InlineToolConstructable } from '@/types/tools';
import { EDITOR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
type ToolDefinition = {
name: string;
class: BlockToolConstructable | InlineToolConstructable;
config?: Record<string, unknown>;
};
type SerializedToolConfig = {
name: string;
classSource: string;
config?: Record<string, unknown>;
staticProps?: Record<string, unknown>;
};
declare global {
interface Window {
editorInstance?: EditorJS;
__inlineShortcutLog?: string[];
__lastShortcutEvent?: { metaKey: boolean; ctrlKey: boolean } | null;
}
}
class ShortcutBlockTool {
private data: { text?: string };
constructor({ data }: { data?: { text?: string } }) {
this.data = data ?? {};
}
public static get toolbox(): { title: string; icon: string } {
return {
title: 'Shortcut block',
icon: '<svg></svg>',
};
}
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.textContent = this.data.text ?? '';
return element;
}
public save(element: HTMLElement): { text: string } {
return {
text: element.textContent ?? '',
};
}
}
class CmdShortcutBlockTool {
private data: { text?: string };
constructor({ data }: { data?: { text?: string } }) {
this.data = data ?? {};
}
public static get toolbox(): { title: string; icon: string } {
return {
title: 'CMD shortcut block',
icon: '<svg></svg>',
};
}
public render(): HTMLElement {
const element = document.createElement('div');
element.contentEditable = 'true';
element.textContent = this.data.text ?? '';
return element;
}
public save(element: HTMLElement): { text: string } {
return {
text: element.textContent ?? '',
};
}
}
class PrimaryShortcutInlineTool {
public static isInline = true;
public static title = 'Primary inline shortcut';
public static shortcut = 'CMD+SHIFT+8';
public render(): HTMLElement {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Primary inline';
return button;
}
public surround(): void {
window.__inlineShortcutLog = window.__inlineShortcutLog ?? [];
window.__inlineShortcutLog.push('primary-inline');
}
public checkState(): boolean {
return false;
}
}
class SecondaryShortcutInlineTool {
public static isInline = true;
public static title = 'Secondary inline shortcut';
public static shortcut = 'CMD+SHIFT+8';
public render(): HTMLElement {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Secondary inline';
return button;
}
public surround(): void {
window.__inlineShortcutLog = window.__inlineShortcutLog ?? [];
window.__inlineShortcutLog.push('secondary-inline');
}
public checkState(): boolean {
return false;
}
}
const STATIC_PROP_BLACKLIST = new Set(['length', 'name', 'prototype']);
const extractSerializableStaticProps = (toolClass: ToolDefinition['class']): Record<string, unknown> => {
return Object.getOwnPropertyNames(toolClass).reduce<Record<string, unknown>>((props, propName) => {
if (STATIC_PROP_BLACKLIST.has(propName)) {
return props;
}
const descriptor = Object.getOwnPropertyDescriptor(toolClass, propName);
if (!descriptor || typeof descriptor.value === 'function' || descriptor.value === undefined) {
return props;
}
return {
...props,
[propName]: descriptor.value,
};
}, {});
};
const serializeTools = (tools: ToolDefinition[]): SerializedToolConfig[] => {
return tools.map((tool) => {
const staticProps = extractSerializableStaticProps(tool.class);
return {
name: tool.name,
classSource: tool.class.toString(),
config: tool.config,
staticProps: Object.keys(staticProps).length > 0 ? staticProps : undefined,
};
});
};
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 createEditorWithTools = async (
page: Page,
options: { data?: OutputData; tools?: ToolDefinition[] } = {}
): Promise<void> => {
const { data = null, tools = [] } = options;
const serializedTools = serializeTools(tools);
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await page.evaluate(
async ({ holderId, serializedTools: toolConfigs, initialData }) => {
const reviveToolClass = (classSource: string): unknown => {
// eslint-disable-next-line no-new-func -- executed inside the browser context to revive tool classes
return new Function(`return (${classSource});`)();
};
const revivedTools = toolConfigs.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
if (toolConfig.staticProps) {
Object.entries(toolConfig.staticProps).forEach(([prop, value]) => {
Object.defineProperty(revivedClass, prop, {
value,
configurable: true,
writable: true,
});
});
}
const toolSettings: Record<string, unknown> = {
class: revivedClass,
...(toolConfig.config ?? {}),
};
return {
...accumulator,
[toolConfig.name]: toolSettings,
};
}, {});
const editorConfig: Record<string, unknown> = {
holder: holderId,
};
if (initialData) {
editorConfig.data = initialData;
}
if (toolConfigs.length > 0) {
editorConfig.tools = revivedTools;
}
const editor = new window.EditorJS(editorConfig);
window.editorInstance = editor;
await editor.isReady;
},
{
holderId: HOLDER_ID,
serializedTools,
initialData: data,
}
);
};
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();
});
};
const selectAllText = async (locator: Locator): Promise<void> => {
await locator.evaluate((element) => {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(element);
selection?.removeAllRanges();
selection?.addRange(range);
});
};
test.describe('keyboard shortcuts', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test('activates custom block tool via configured shortcut', async ({ page }) => {
await createEditorWithTools(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Custom shortcut block',
},
},
],
},
tools: [
{
name: 'shortcutBlock',
class: ShortcutBlockTool as unknown as BlockToolConstructable,
config: {
shortcut: 'CMD+SHIFT+M',
},
},
],
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await expect(paragraph).toHaveCount(1);
await paragraph.click();
await paragraph.type(' — activated');
const combo = `${MODIFIER_KEY}+Shift+KeyM`;
await page.keyboard.press(combo);
await expect.poll(async () => {
const data = await saveEditor(page);
return data.blocks.map((block) => block.type);
}).toContain('shortcutBlock');
});
test('registers first inline tool when shortcuts conflict', async ({ page }) => {
await createEditorWithTools(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Conflict test paragraph',
},
},
],
},
tools: [
{
name: 'primaryInline',
class: PrimaryShortcutInlineTool as unknown as InlineToolConstructable,
config: {
shortcut: 'CMD+SHIFT+8',
},
},
{
name: 'secondaryInline',
class: SecondaryShortcutInlineTool as unknown as InlineToolConstructable,
config: {
shortcut: 'CMD+SHIFT+8',
},
},
],
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const pageErrors: Error[] = [];
page.on('pageerror', (error) => {
pageErrors.push(error);
});
await paragraph.click();
await selectAllText(paragraph);
await page.evaluate(() => {
window.__inlineShortcutLog = [];
});
const combo = `${MODIFIER_KEY}+Shift+Digit8`;
await page.keyboard.press(combo);
const activations = await page.evaluate(() => window.__inlineShortcutLog ?? []);
expect(activations).toStrictEqual([ 'primary-inline' ]);
expect(pageErrors).toHaveLength(0);
});
test('maps CMD shortcut definitions to platform-specific modifier keys', async ({ page }) => {
await createEditorWithTools(page, {
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Platform modifier paragraph',
},
},
],
},
tools: [
{
name: 'cmdShortcutBlock',
class: CmdShortcutBlockTool as unknown as BlockToolConstructable,
config: {
shortcut: 'CMD+SHIFT+J',
},
},
],
});
const isMacPlatform = process.platform === 'darwin';
const paragraph = page.locator(PARAGRAPH_SELECTOR);
await paragraph.click();
expect(MODIFIER_KEY).toBe(isMacPlatform ? 'Meta' : 'Control');
await page.evaluate(() => {
window.__lastShortcutEvent = null;
document.addEventListener(
'keydown',
(event) => {
if (event.code === 'KeyJ' && event.shiftKey) {
window.__lastShortcutEvent = {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
};
}
},
{
once: true,
capture: true,
}
);
});
const combo = `${MODIFIER_KEY}+Shift+KeyJ`;
await page.keyboard.press(combo);
const shortcutEvent = await page.evaluate(() => window.__lastShortcutEvent);
expect(shortcutEvent).toBeTruthy();
expect(shortcutEvent?.metaKey).toBe(isMacPlatform);
expect(shortcutEvent?.ctrlKey).toBe(!isMacPlatform);
await expect.poll(async () => {
const data = await saveEditor(page);
return data.blocks.map((block) => block.type);
}).toContain('cmdShortcutBlock');
});
});

View file

@ -4,105 +4,105 @@ This document will describe various test cases of the editor.js functionality. F
## Configuration
- [ ] Zero configuration
- [ ] Editor.js should be initialized on the element with the default `editorjs` id.
- [ ] Editor.js should throw an error in case when there is no element with `editorjs` id.
- [ ] Editor.js should be initialized with the Paragraph tool only.
- [ ] The Inline Toolbar of the Paragraph tool should contain all default Inline Tools - `bold`, `italic`, `link`.
- [x] Zero configuration
- [x] Editor.js should be initialized on the element with the default `editorjs` id.
- [x] Editor.js should throw an error in case when there is no element with `editorjs` id.
- [x] Editor.js should be initialized with the Paragraph tool only.
- [x] The Inline Toolbar of the Paragraph tool should contain all default Inline Tools - `bold`, `italic`, `link`.
- [ ] `holder` property
- [ ] Editor.js should be initialized on the element with passed via `holder` property.
- [ ] Editor.js should throw an error if passed `holder` value is not an Element node.
- [x] `holder` property
- [x] Editor.js should be initialized on the element with passed via `holder` property.
- [x] Editor.js should throw an error if passed `holder` value is not an Element node.
- [ ] `autofocus` property
- [ ] With the empty editor
- [ ] If `true` passed, the caret should be placed to the first empty block.
- [ ] If `false` passed, the caret shouldn't be placed anywhere.
- [ ] If omitted, the caret shouldn't be placed anywhere.
- [ ] With the not-empty editor
- [ ] If `true` passed, the caret should be placed to the end of the last block.
- [ ] If `false` passed, the caret shouldn't be placed anywhere.
- [ ] If omitted, the caret shouldn't be placed anywhere.
- [x] `autofocus` property
- [x] With the empty editor
- [x] If `true` passed, the caret should be placed to the first empty block.
- [x] If `false` passed, the caret shouldn't be placed anywhere.
- [x] If omitted, the caret shouldn't be placed anywhere.
- [x] With the not-empty editor
- [x] If `true` passed, the caret should be placed to the end of the last block.
- [x] If `false` passed, the caret shouldn't be placed anywhere.
- [x] If omitted, the caret shouldn't be placed anywhere.
- [ ] `placeholder` property
- [ ] With the empty editor
- [ ] If `string` passed, the string should be placed as a placeholder to the first empty block only.
- [ ] If `false` passed, the first empty block should be placed without a placeholder.
- [ ] If omitted, the first empty block should be placed without a placeholder.
- [x] `placeholder` property
- [x] With the empty editor
- [x] If `string` passed, the string should be placed as a placeholder to the first empty block only.
- [x] If `false` passed, the first empty block should be placed without a placeholder.
- [x] If omitted, the first empty block should be placed without a placeholder.
- [ ] `minHeight` property
- [ ] If `number` passed, the height of the editor's bottom area from the last Block should be the `number`.
- [ ] If omitted the height of editor's bottom area from the last Block should be the default `300`.
- [x] `minHeight` property
- [x] If `number` passed, the height of the editor's bottom area from the last Block should be the `number`.
- [x] If omitted the height of editor's bottom area from the last Block should be the default `300`.
- [ ] `logLevel` property
- [ ] If `VERBOSE` passed, the editor should output all messages to the console.
- [ ] If `INFO` passed, the editor should output info and debug messages to the console.
- [ ] If `WARN` passed, the editor should output only warning messages to the console.
- [ ] If `ERROR` passed, the editor should output only error messages to the console.
- [ ] If omitted, the editor should output all messages to the console.
- [x] `logLevel` property
- [x] If `VERBOSE` passed, the editor should output all messages to the console.
- [x] If `INFO` passed, the editor should output info and debug messages to the console.
- [x] If `WARN` passed, the editor should output only warning messages to the console.
- [x] If `ERROR` passed, the editor should output only error messages to the console.
- [x] If omitted, the editor should output all messages to the console.
- [ ] `defaultBlock` property
- [ ] If `string` passed
- [ ] If passed `string` in the `tools` option, the passed tool should be used as the default tool.
- [ ] If passed `string` not in the `tools` option, the Paragraph tool should be used as the default tool.
- [ ] If omitted the Paragraph tool should be used as default tool.
- [x] `defaultBlock` property
- [x] If `string` passed
- [x] If passed `string` in the `tools` option, the passed tool should be used as the default tool.
- [x] If passed `string` not in the `tools` option, the Paragraph tool should be used as the default tool.
- [x] If omitted the Paragraph tool should be used as default tool.
- [ ] `sanitizer` property
- [ ] If `object` passed
- [ ] The Editor.js should clean the HTML tags according to mentioned configuration.
- [ ] If omitted the Editor.js should be initialized with the default `sanitizer` configuration, which allows the tags like `paragraph`, `anchor`, and `bold` for cleaning HTML.
- [x] `sanitizer` property
- [x] If `object` passed
- [x] The Editor.js should clean the HTML tags according to mentioned configuration.
- [x] If omitted the Editor.js should be initialized with the default `sanitizer` configuration, which allows the tags like `paragraph`, `anchor`, and `bold` for cleaning HTML.
- [ ] `tools` property
- [ ] If omitted,the Editor.js should be initialized with the Paragraph tool only.
- [ ] If `object` passed
- [ ] Editor.js should be initialized with all the passed tools.
- [ ] The keys of the object should be represented as `type` fields for corresponded blocks in output JSON
- [ ] If value is a JavaScript class, the class should be used as a tool
- [ ] If value is an `object`
- [ ] Checking the `class` property
- [ ] If omitted, the tool should be skipped with a warning in a console.
- [ ] If existed, the value of the `class` property should be used as a tool
- [ ] Checking the `config` property
- [ ] If `object` passed Editor.js should initialize `tool` and pass this object as `config` parameter of the tool's constructor
- [ ] Checking the `shortcut` property
- [ ] If `string` passed Editor.js should append the `tool` when such keys combination executed.
- [ ] Checking the `inlineToolbar` property
- [ ] If `true` passed, the Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings.
- [ ] If `false` passed, the Editor.js should not show the Inline Toolbar for this tool.
- [ ] If `array` passed, the Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order.
- [ ] If omitted, the Editor.js should not show the Inline Toolbar for this tool.
- [ ] Checking the `toolbox` property
- [ ] If it contains `title`, this title should be used as a tool title
- [ ] If it contains `icon`, this HTML code (maybe SVG) should be used as a tool icon
- [x] `tools` property
- [x] If omitted,the Editor.js should be initialized with the Paragraph tool only.
- [x] If `object` passed
- [x] Editor.js should be initialized with all the passed tools.
- [x] The keys of the object should be represented as `type` fields for corresponded blocks in output JSON
- [x] If value is a JavaScript class, the class should be used as a tool
- [x] If value is an `object`
- [x] Checking the `class` property
- [x] If omitted, the tool should be skipped with a warning in a console.
- [x] If existed, the value of the `class` property should be used as a tool
- [x] Checking the `config` property
- [x] If `object` passed Editor.js should initialize `tool` and pass this object as `config` parameter of the tool's constructor
- [x] Checking the `shortcut` property
- [x] If `string` passed Editor.js should append the `tool` when such keys combination executed.
- [x] Checking the `inlineToolbar` property
- [x] If `true` passed, the Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings.
- [x] If `false` passed, the Editor.js should not show the Inline Toolbar for this tool.
- [x] If `array` passed, the Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order.
- [x] If omitted, the Editor.js should not show the Inline Toolbar for this tool.
- [x] Checking the `toolbox` property
- [x] If it contains `title`, this title should be used as a tool title
- [x] If it contains `icon`, this HTML code (maybe SVG) should be used as a tool icon
- [ ] `onReady` property
- [ ] If `function` passed, the Editor.js should call the `function` when it's ready to work.
- [ ] If omitted, the Editor.js should be initialized with the `tools` only.
- [x] `onReady` property
- [x] If `function` passed, the Editor.js should call the `function` when it's ready to work.
- [x] If omitted, the Editor.js should be initialized with the `tools` only.
- [ ] `onChange` property
- [ ] If `function` passed,the Editor.js should call the `function` when something changed in Editor.js DOM.
- [ ] If omitted, the Editor.js should be initialized with the `tools` only.
- [x] `onChange` property
- [x] If `function` passed,the Editor.js should call the `function` when something changed in Editor.js DOM.
- [x] If omitted, the Editor.js should be initialized with the `tools` only.
- [ ] `data` property
- [ ] If omitted
- [ ] the Editor.js should be initialized with the `tools` only.
- [ ] the Editor.js should be empty.
- [ ] If `object` passed
- [ ] Checking the `blocks` property
- [ ] If `array` of `object` passed,
- [ ] for each `object`
- [ ] Checking the `type` and `data` property
- [ ] the Editor.js should be initialize with `block` of class `type`
- [x] `data` property
- [x] If omitted
- [x] the Editor.js should be initialized with the `tools` only.
- [x] the Editor.js should be empty.
- [x] If `object` passed
- [x] Checking the `blocks` property
- [x] If `array` of `object` passed,
- [x] for each `object`
- [x] Checking the `type` and `data` property
- [x] the Editor.js should be initialize with `block` of class `type`
- [ ] If `type` not present in `tools`, the Editor.js should throw an error.
- [ ] If omitted
- [ ] the Editor.js should be initialized with the `tools` only.
- [ ] the Editor.js should be empty.
- [x] If omitted
- [x] the Editor.js should be initialized with the `tools` only.
- [x] the Editor.js should be empty.
- [ ] `readOnly` property
- [ ] If `true` passed,
- [ ] If any `tool` have not readOnly getter defined,The Editor.js should throw an error.
- [ ] otherwise, the Editor.js should be initialize with readOnly mode.
- [ ] If `false` passed,the Editor.js should be initialized with the `tools` only.
- [ ] If omitted,the Editor.js should be initialized with the `tools` only.
- [x] `readOnly` property
- [x] If `true` passed,
- [x] If any `tool` have not readOnly getter defined,The Editor.js should throw an error.
- [x] otherwise, the Editor.js should be initialize with readOnly mode.
- [x] If `false` passed,the Editor.js should be initialized with the `tools` only.
- [x] If omitted,the Editor.js should be initialized with the `tools` only.
- [ ] `i18n` property

View file

@ -30,6 +30,7 @@ type ToolbarEditorMock = {
describe('ToolbarAPI', () => {
let toolbarApi: ToolbarAPI;
let editorMock: ToolbarEditorMock;
const unspecifiedState = undefined as unknown as boolean;
const createToolbarApi = (overrides?: Partial<ToolbarEditorMock>): void => {
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
@ -71,68 +72,97 @@ describe('ToolbarAPI', () => {
vi.restoreAllMocks();
});
it('opens the toolbar via Editor module', () => {
toolbarApi.open();
describe('methods getter', () => {
it('exposes bound toolbar controls', () => {
const openSpy = vi.spyOn(toolbarApi, 'open').mockImplementation(() => {});
const closeSpy = vi.spyOn(toolbarApi, 'close').mockImplementation(() => {});
const toggleBlockSettingsSpy = vi
.spyOn(toolbarApi, 'toggleBlockSettings')
.mockImplementation(() => {});
const toggleToolboxSpy = vi
.spyOn(toolbarApi, 'toggleToolbox')
.mockImplementation(() => {});
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
const { open, close, toggleBlockSettings, toggleToolbox } = toolbarApi.methods;
open();
close();
toggleBlockSettings(true);
toggleToolbox(false);
expect(openSpy).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(toggleBlockSettingsSpy).toHaveBeenCalledWith(true);
expect(toggleToolboxSpy).toHaveBeenCalledWith(false);
});
});
it('closes the toolbar via Editor module', () => {
toolbarApi.close();
describe('open/close', () => {
it('opens the toolbar via Editor module', () => {
toolbarApi.open();
expect(editorMock.Toolbar.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
});
it('closes the toolbar via Editor module', () => {
toolbarApi.close();
expect(editorMock.Toolbar.close).toHaveBeenCalledTimes(1);
});
});
it('opens block settings when toggleBlockSettings decides to open', () => {
toolbarApi.toggleBlockSettings(undefined as unknown as boolean);
describe('toggleBlockSettings', () => {
it('opens block settings when state is omitted and block settings are closed', () => {
toolbarApi.toggleBlockSettings(unspecifiedState);
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
});
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
});
it('closes block settings when toggleBlockSettings decides to close', () => {
editorMock.BlockSettings.opened = true;
it('closes block settings when state is omitted and block settings are opened', () => {
editorMock.BlockSettings.opened = true;
toolbarApi.toggleBlockSettings(undefined as unknown as boolean);
toolbarApi.toggleBlockSettings(unspecifiedState);
expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
});
expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
});
it('forces opening block settings when openingState is true', () => {
editorMock.BlockSettings.opened = true;
it('forces opening when openingState is true', () => {
editorMock.BlockSettings.opened = true;
toolbarApi.toggleBlockSettings(true);
toolbarApi.toggleBlockSettings(true);
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
});
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1);
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
});
it('forces closing block settings when openingState is false', () => {
toolbarApi.toggleBlockSettings(false);
it('forces closing when openingState is false', () => {
toolbarApi.toggleBlockSettings(false);
expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
});
expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
});
it('logs a warning when no block is selected for toggleBlockSettings', () => {
const logSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => {});
it('logs a warning when no block is selected', () => {
const logSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => {});
editorMock.BlockManager.currentBlockIndex = -1;
editorMock.BlockManager.currentBlockIndex = -1;
toolbarApi.toggleBlockSettings(true);
toolbarApi.toggleBlockSettings(true);
expect(logSpy).toHaveBeenCalledWith(
'Could\'t toggle the Toolbar because there is no block selected ',
'warn'
);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(
'Could\'t toggle the Toolbar because there is no block selected ',
'warn'
);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.open).not.toHaveBeenCalled();
expect(editorMock.BlockSettings.close).not.toHaveBeenCalled();
});
});
it('opens toolbox when toggleToolbox receives opening state', () => {
@ -152,7 +182,7 @@ describe('ToolbarAPI', () => {
});
it('toggles toolbox when opening state is omitted', () => {
toolbarApi.toggleToolbox(undefined as unknown as boolean);
toolbarApi.toggleToolbox(unspecifiedState);
expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.toolbox.open).toHaveBeenCalledTimes(1);
@ -160,7 +190,7 @@ describe('ToolbarAPI', () => {
vi.clearAllMocks();
editorMock.Toolbar.toolbox.opened = true;
toolbarApi.toggleToolbox(undefined as unknown as boolean);
toolbarApi.toggleToolbox(unspecifiedState);
expect(editorMock.Toolbar.toolbox.close).toHaveBeenCalledTimes(1);
expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();