mirror of
https://github.com/codex-team/editor.js
synced 2026-03-18 00:19:53 +01:00
test: add missing coverage E2E tests
This commit is contained in:
parent
181e73f3c9
commit
26eb6f496b
42 changed files with 6955 additions and 199 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ node_modules/*
|
|||
npm-debug.log
|
||||
yarn-error.log
|
||||
.yarn/install-state.gz
|
||||
install-state.gz
|
||||
|
||||
test-results
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
trigger: always_on
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
# Rule: DO NOT MODIFY configuration files unless explicitly instructed
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
17
src/codex.ts
17
src/codex.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
195
test/playwright/tests/api/events.spec.ts
Normal file
195
test/playwright/tests/api/events.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
237
test/playwright/tests/api/inline-toolbar.spec.ts
Normal file
237
test/playwright/tests/api/inline-toolbar.spec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
220
test/playwright/tests/api/listeners.spec.ts
Normal file
220
test/playwright/tests/api/listeners.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
138
test/playwright/tests/api/notifier.spec.ts
Normal file
138
test/playwright/tests/api/notifier.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
219
test/playwright/tests/api/render.spec.ts
Normal file
219
test/playwright/tests/api/render.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
112
test/playwright/tests/api/sanitizer.spec.ts
Normal file
112
test/playwright/tests/api/sanitizer.spec.ts
Normal 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=');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
254
test/playwright/tests/error-handling.spec.ts
Normal file
254
test/playwright/tests/error-handling.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
537
test/playwright/tests/modules/blockManager.spec.ts
Normal file
537
test/playwright/tests/modules/blockManager.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
501
test/playwright/tests/modules/drag-and-drop.spec.ts
Normal file
501
test/playwright/tests/modules/drag-and-drop.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
634
test/playwright/tests/modules/selection.spec.ts
Normal file
634
test/playwright/tests/modules/selection.spec.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
572
test/playwright/tests/read-only.spec.ts
Normal file
572
test/playwright/tests/read-only.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
985
test/playwright/tests/ui/configuration.spec.ts
Normal file
985
test/playwright/tests/ui/configuration.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
457
test/playwright/tests/ui/keyboard-shortcuts.spec.ts
Normal file
457
test/playwright/tests/ui/keyboard-shortcuts.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue