refactor: clean up selection tests and remove unused rectangle selection logic

refactor: simplify read-only tests by removing drag-and-drop functionality

test: update sanitisation tests to check for safe attributes in HTML

test: enhance configuration tests to ensure proper toolbox popover visibility

test: adjust keyboard shortcuts tests for inline tool handling and modifier key mapping

test: modify toolbox tests to use more specific selectors for paragraph blocks

test: improve UI module tests to utilize block selection methods directly

chore: remove deprecated drag-and-drop tests and related code

fix: update paste module error messages for clarity on configuration issues
This commit is contained in:
JackUait 2025-11-18 03:56:47 +03:00
commit aadbabfa6e
34 changed files with 965 additions and 1758 deletions

View file

@ -4,7 +4,7 @@
<source media="(prefers-color-scheme: dark)" srcset="./assets/logo_night.png">
<source media="(prefers-color-scheme: light)" srcset="./assets/logo_day.png">
<img alt="Editor.js Logo" src="./assets/logo_day.png">
</picture>
</picture>
</a>
</p>
@ -12,7 +12,7 @@
<a href="https://editorjs.io/">editorjs.io</a> |
<a href="https://editorjs.io/base-concepts/">documentation</a> |
<a href="https://github.com/codex-team/editor.js/blob/next/docs/CHANGELOG.md">changelog</a>
</p>
<p align="center">
@ -34,7 +34,7 @@
Editor.js is an open-source text editor offering a variety of features to help users create and format content efficiently. It has a modern, block-style interface that allows users to easily add and arrange different types of content, such as text, images, lists, quotes, etc. Each Block is provided via a separate plugin making Editor.js extremely flexible.
Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web, iOS, Android, AMP, Instant Articles, speech readers, AI chatbots — everywhere. Easy to sanitize, extend and integrate with your logic.
Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web, iOS, Android, AMP, Instant Articles, speech readers, AI chatbots — everywhere. Easy to sanitize, extend and integrate with your logic.
- 😍  Modern UI out of the box
- 💎  Clean JSON output
@ -44,13 +44,13 @@ Editor.js outputs a clean JSON data instead of heavy HTML markup. Use it in Web,
<picture>
<img alt="Editor.js Overview" src="./assets/overview.png">
</picture>
</picture>
## Installation
It's quite simple:
1. Install Editor.js
1. Install Editor.js
2. Install tools you need
3. Initialize Editor's instance
@ -64,7 +64,7 @@ Choose and install tools:
- [Heading](https://github.com/editor-js/header)
- [Quote](https://github.com/editor-js/quote)
- [Image](https://github.com/editor-js/image)
- [Image](https://github.com/editor-js/image)
- [Simple Image](https://github.com/editor-js/simple-image) (without backend requirement)
- [Nested List](https://github.com/editor-js/nested-list)
- [Checklist](https://github.com/editor-js/checklist)
@ -122,9 +122,9 @@ Take a look at the [example.html](example/example.html) to view more detailed ex
- [x] Ability to display several Toolbox buttons by the single Tool
- [x] Block Tunes become vertical
- [x] Block Tunes support nested menus
- [x] Block Tunes support separators
- [x] Block Tunes support separators
- [x] Conversion Menu added to the Block Tunes
- [x] Unified Toolbar supports hints
- [x] Unified Toolbar supports hints
- [x] Conversion Toolbar uses Unified Toolbar
- [x] Inline Toolbar uses Unified Toolbar
- Collaborative editing
@ -135,7 +135,6 @@ Take a look at the [example.html](example/example.html) to view more detailed ex
- [ ] Implement Server and communication
- [ ] Update basic tools to fit the new API
- Other features
- [ ] Blocks drag'n'drop
- [ ] New cross-block selection
- [ ] New cross-block caret moving
- Ecosystem improvements
@ -210,13 +209,13 @@ Support us by becoming a sponsor. Your logo will show up here with a link to you
### Contributors
This project exists thanks to all the people who contribute.
This project exists thanks to all the people who contribute.
<p><img src="https://opencollective.com/editorjs/contributors.svg?width=890&button=false&avatarHeight=34" /></p>
### Need something special?
Hire CodeX experts to resolve technical challenges and match your product requirements.
Hire CodeX experts to resolve technical challenges and match your product requirements.
- Resolve a problem that has high value for you
- Implement a new feature required by your business

View file

@ -183,7 +183,6 @@
"blockTunes": {
"toggler": {
"Click to tune": "Нажмите, чтобы настроить",
"or drag to move": "или перетащите"
},
},
"inlineToolbar": {

View file

@ -29,9 +29,13 @@ import type { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
import { PopoverItemType } from '@/types/utils/popover/popover-item-type';
const BLOCK_TOOL_ATTRIBUTE = 'data-block-tool';
/**
* Interface describes Block class constructor argument
*/
type BlockSaveResult = SavedData & { tunes: { [name: string]: BlockTuneData } };
interface BlockConstructorOptions {
/**
* Block's id. Should be passed for existed block, and omitted for a new one.
@ -75,12 +79,6 @@ interface BlockConstructorOptions {
* Available Block Tool API methods
*/
export enum BlockToolAPI {
/**
* @todo remove method in 3.0.0
* @deprecated use 'rendered' hook instead
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
APPEND_CALLBACK = 'appendCallback',
RENDERED = 'rendered',
MOVED = 'moved',
UPDATED = 'updated',
@ -114,7 +112,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
wrapperStretched: 'ce-block--stretched',
content: 'ce-block__content',
selected: 'ce-block--selected',
dropTarget: 'ce-block--drop-target',
};
}
@ -248,7 +245,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.composeTunes(tunesData);
this.holder = this.compose();
const holderElement = this.compose();
if (holderElement == null) {
throw new Error(`Tool "${this.name}" did not return a block holder element during render()`);
}
this.holder = holderElement;
/**
* Bind block events in RIC for optimizing of constructing process time
@ -291,14 +294,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return;
}
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
_.log(
'`appendCallback` hook is deprecated and will be removed in the next major release. ' +
'Use `rendered` hook instead',
'warn'
);
}
try {
// eslint-disable-next-line no-useless-call
method.call(this.toolInstance, params);
@ -328,8 +323,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @returns {object}
*/
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
public async save(): Promise<undefined | BlockSaveResult> {
const extractedBlock = await this.extractToolData();
if (extractedBlock === undefined) {
return undefined;
}
const tunesData: { [name: string]: BlockTuneData } = { ...this.unavailableTunesData };
[
@ -351,29 +351,63 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
const measuringStart = window.performance.now();
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
if (finishedExtraction !== undefined) {
this.lastSavedData = finishedExtraction;
this.lastSavedTunes = { ...tunesData };
this.lastSavedData = extractedBlock;
this.lastSavedTunes = { ...tunesData };
const measuringEnd = window.performance.now();
return {
id: this.id,
tool: this.name,
data: extractedBlock,
tunes: tunesData,
time: measuringEnd - measuringStart,
};
}
/**
* Safely executes tool.save capturing possible errors without breaking the saver pipeline
*/
private async extractToolData(): Promise<BlockToolData | undefined> {
try {
const extracted = await this.toolInstance.save(this.pluginsContent as HTMLElement);
if (!this.isEmpty || extracted === undefined || extracted === null || typeof extracted !== 'object') {
return extracted;
}
const normalized = { ...extracted } as Record<string, unknown>;
const sanitizeField = (field: string): void => {
const value = normalized[field];
if (typeof value !== 'string') {
return;
}
/** measure promise execution */
const measuringEnd = window.performance.now();
const container = document.createElement('div');
return {
id: this.id,
tool: this.name,
data: finishedExtraction,
tunes: tunesData,
time: measuringEnd - measuringStart,
};
})
.catch((error) => {
_.log(`Saving process for ${this.name} tool failed due to the ${error}`, 'log', 'red');
container.innerHTML = value;
return undefined;
});
if ($.isEmpty(container)) {
normalized[field] = '';
}
};
sanitizeField('text');
sanitizeField('html');
return normalized as BlockToolData;
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
_.log(
`Saving process for ${this.name} tool failed due to the ${normalizedError}`,
'log',
normalizedError
);
return undefined;
}
}
/**
@ -463,10 +497,54 @@ export default class Block extends EventsDispatcher<BlockEvents> {
const anchorNode = SelectionUtils.anchorNode;
const activeElement = document.activeElement;
if ($.isNativeInput(activeElement) || !anchorNode) {
this.currentInput = activeElement instanceof HTMLElement ? activeElement : undefined;
} else {
this.currentInput = anchorNode instanceof HTMLElement ? anchorNode : undefined;
const resolveInput = (node: Node | null): HTMLElement | undefined => {
if (!node) {
return undefined;
}
const element = node instanceof HTMLElement ? node : node.parentElement;
if (element === null) {
return undefined;
}
const directMatch = this.inputs.find((input) => input === element || input.contains(element));
if (directMatch !== undefined) {
return directMatch;
}
const closestEditable = element.closest($.allInputsSelector);
if (!(closestEditable instanceof HTMLElement)) {
return undefined;
}
const closestMatch = this.inputs.find((input) => input === closestEditable);
if (closestMatch !== undefined) {
return closestMatch;
}
return undefined;
};
if ($.isNativeInput(activeElement)) {
this.currentInput = activeElement;
return;
}
const candidateInput = resolveInput(anchorNode) ?? (activeElement instanceof HTMLElement ? resolveInput(activeElement) : undefined);
if (candidateInput !== undefined) {
this.currentInput = candidateInput;
return;
}
if (activeElement instanceof HTMLElement && this.inputs.includes(activeElement)) {
this.currentInput = activeElement;
}
}
@ -792,14 +870,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return this.holder.classList.contains(Block.CSS.wrapperStretched);
}
/**
* Toggle drop target state
*
* @param {boolean} state - 'true' if block is drop target, false otherwise
*/
public set dropTarget(state: boolean) {
this.holder.classList.toggle(Block.CSS.dropTarget, state);
}
/**
* Returns Plugins content
@ -828,6 +898,10 @@ export default class Block extends EventsDispatcher<BlockEvents> {
wrapper.setAttribute('data-cy', 'block-wrapper');
}
if (this.name && !wrapper.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
wrapper.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
}
/**
* Export id to the DOM three
* Useful for standalone modules development. For example, allows to identify Block by some child node. Or scroll to a particular Block by id.
@ -842,7 +916,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
// Handle async render: resolve the promise and update DOM when ready
pluginsContent.then((resolvedElement) => {
this.toolRenderedElement = resolvedElement;
this.addToolDataAttributes(resolvedElement);
this.addToolDataAttributes(resolvedElement, wrapper);
contentNode.appendChild(resolvedElement);
}).catch((error) => {
_.log(`Tool render promise rejected: %o`, 'error', error);
@ -850,7 +924,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
} else {
// Handle synchronous render
this.toolRenderedElement = pluginsContent;
this.addToolDataAttributes(pluginsContent);
this.addToolDataAttributes(pluginsContent, wrapper);
contentNode.appendChild(pluginsContent);
}
@ -887,15 +961,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* Add data attributes to tool-rendered element based on tool name
*
* @param element - The tool-rendered element
* @param blockWrapper - Block wrapper that hosts the tool render
* @private
*/
private addToolDataAttributes(element: HTMLElement): void {
private addToolDataAttributes(element: HTMLElement, blockWrapper: HTMLDivElement): void {
/**
* Add data-block-tool attribute to identify the tool type used for the block.
* Some tools (like Paragraph) add their own class names, but we can rely on the tool name for all cases.
*/
if (!element.hasAttribute('data-block-tool') && this.name) {
element.setAttribute('data-block-tool', this.name);
if (this.name && !blockWrapper.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
blockWrapper.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
}
if (this.name && !element.hasAttribute(BLOCK_TOOL_ATTRIBUTE)) {
element.setAttribute(BLOCK_TOOL_ATTRIBUTE, this.name);
}
const placeholderAttribute = 'data-placeholder';

View file

@ -129,6 +129,37 @@ export default class Core {
_.deprecationAssert(Boolean(this.config.initialBlock), 'config.initialBlock', 'config.defaultBlock');
this.config.defaultBlock = this.config.defaultBlock ?? this.config.initialBlock ?? 'paragraph';
const toolsConfig = this.config.tools;
const defaultBlockName = this.config.defaultBlock;
const hasDefaultBlockTool = toolsConfig != null &&
Object.prototype.hasOwnProperty.call(toolsConfig, defaultBlockName ?? '');
const initialBlocks = this.config.data?.blocks;
const hasInitialBlocks = Array.isArray(initialBlocks) && initialBlocks.length > 0;
if (
defaultBlockName &&
defaultBlockName !== 'paragraph' &&
!hasDefaultBlockTool &&
!hasInitialBlocks
) {
_.log(
`Default block "${defaultBlockName}" is not configured. Falling back to "paragraph" tool.`,
'warn'
);
this.config.defaultBlock = 'paragraph';
const existingTools = this.config.tools as Record<string, unknown> | undefined;
const updatedTools: Record<string, unknown> = {
...(existingTools ?? {}),
};
const paragraphEntry = updatedTools.paragraph;
updatedTools.paragraph = this.createParagraphToolConfig(paragraphEntry);
this.config.tools = updatedTools as EditorConfig['tools'];
}
/**
* Height of Editor's bottom area that allows to set focus on the last Block
*
@ -325,6 +356,50 @@ export default class Core {
}
}
/**
* Creates paragraph tool configuration with preserveBlank setting
*
* @param {unknown} paragraphEntry - existing paragraph entry from tools config
* @returns {Record<string, unknown>} paragraph tool configuration
*/
private createParagraphToolConfig(paragraphEntry: unknown): Record<string, unknown> {
if (paragraphEntry === undefined) {
return {
config: {
preserveBlank: true,
},
};
}
if (_.isFunction(paragraphEntry)) {
return {
class: paragraphEntry,
config: {
preserveBlank: true,
},
};
}
if (_.isObject(paragraphEntry)) {
const paragraphSettings = paragraphEntry as Record<string, unknown>;
const existingConfig = paragraphSettings.config;
return {
...paragraphSettings,
config: {
...(_.isObject(existingConfig) ? existingConfig as Record<string, unknown> : {}),
preserveBlank: true,
},
};
}
return {
config: {
preserveBlank: true,
},
};
}
/**
* Return modules without passed name
*

View file

@ -2,8 +2,7 @@
"ui": {
"blockTunes": {
"toggler": {
"Click to tune": "",
"or drag to move": ""
"Click to tune": ""
}
},
"inlineToolbar": {

View file

@ -1,6 +1,7 @@
import type { InlineTool, SanitizerConfig } from '../../../types';
import { IconBold } from '@codexteam/icons';
import type { MenuConfig } from '../../../types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../constants';
import SelectionUtils from '../selection';
/**
@ -36,10 +37,59 @@ export default class BoldInlineTool implements InlineTool {
} as SanitizerConfig;
}
/**
* Normalize any remaining legacy <b> tags within the editor wrapper
*/
private static normalizeAllBoldTags(): void {
if (typeof document === 'undefined') {
return;
}
const editorWrapperClass = SelectionUtils.CSS.editorWrapper;
const selector = `${EDITOR_INTERFACE_SELECTOR} b, .${editorWrapperClass} b`;
document.querySelectorAll(selector).forEach((boldNode) => {
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
});
}
/**
* Normalize bold tags within a mutated node if it belongs to the editor
*
* @param node - The node affected by mutation
*/
private static normalizeBoldInNode(node: Node): void {
const element = node.nodeType === Node.ELEMENT_NODE
? node as Element
: node.parentElement;
if (!element || typeof element.closest !== 'function') {
return;
}
const editorWrapperClass = SelectionUtils.CSS.editorWrapper;
const editorRoot = element.closest(`${EDITOR_INTERFACE_SELECTOR}, .${editorWrapperClass}`);
if (!editorRoot) {
return;
}
if (element.tagName === 'B') {
BoldInlineTool.ensureStrongElement(element as HTMLElement);
}
element.querySelectorAll?.('b').forEach((boldNode) => {
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
});
}
private static shortcutListenerRegistered = false;
private static selectionListenerRegistered = false;
private static inputListenerRegistered = false;
private static beforeInputListenerRegistered = false;
private static markerSequence = 0;
private static mutationObserver?: MutationObserver;
private static isProcessingMutation = false;
private static readonly DATA_ATTR_COLLAPSED_LENGTH = 'data-bold-collapsed-length';
private static readonly DATA_ATTR_COLLAPSED_ACTIVE = 'data-bold-collapsed-active';
private static readonly DATA_ATTR_PREV_LENGTH = 'data-bold-prev-length';
@ -69,6 +119,13 @@ export default class BoldInlineTool implements InlineTool {
document.addEventListener('input', BoldInlineTool.handleGlobalInput, true);
BoldInlineTool.inputListenerRegistered = true;
}
if (!BoldInlineTool.beforeInputListenerRegistered) {
document.addEventListener('beforeinput', BoldInlineTool.handleBeforeInput, true);
BoldInlineTool.beforeInputListenerRegistered = true;
}
BoldInlineTool.ensureMutationObserver();
}
/**
@ -99,7 +156,7 @@ export default class BoldInlineTool implements InlineTool {
}
if (node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element)) {
return node as HTMLElement;
return BoldInlineTool.ensureStrongElement(node as HTMLElement);
}
return BoldInlineTool.findBoldElement(node.parentNode);
@ -239,6 +296,8 @@ export default class BoldInlineTool implements InlineTool {
selection.addRange(insertedRange);
}
BoldInlineTool.normalizeAllBoldTags();
const boldElement = selection ? BoldInlineTool.findBoldElement(selection.focusNode) : null;
if (!boldElement) {
@ -570,6 +629,8 @@ export default class BoldInlineTool implements InlineTool {
? BoldInlineTool.exitCollapsedBold(selection, boldElement)
: this.startCollapsedBold(range);
document.dispatchEvent(new Event('selectionchange'));
if (updatedRange) {
selection.removeAllRanges();
selection.addRange(updatedRange);
@ -618,12 +679,33 @@ export default class BoldInlineTool implements InlineTool {
return;
}
const selection = window.getSelection();
const newRange = document.createRange();
newRange.setStart(textNode, 0);
newRange.collapse(true);
return newRange;
const merged = this.mergeAdjacentBold(strong);
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
BoldInlineTool.replaceNbspInBlock(selection);
BoldInlineTool.removeEmptyBoldElements(selection);
if (selection) {
selection.removeAllRanges();
selection.addRange(newRange);
}
this.notifySelectionChange();
return merged.firstChild instanceof Text ? (() => {
const caretRange = document.createRange();
caretRange.setStart(merged.firstChild, merged.firstChild.textContent?.length ?? 0);
caretRange.collapse(true);
return caretRange;
})() : newRange;
}
/**
@ -888,6 +970,10 @@ export default class BoldInlineTool implements InlineTool {
while (walker.nextNode()) {
BoldInlineTool.replaceNbspWithSpace(walker.currentNode);
}
block.querySelectorAll('b').forEach((boldNode) => {
BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
});
}
/**
@ -912,6 +998,13 @@ export default class BoldInlineTool implements InlineTool {
const focusNode = selection?.focusNode ?? null;
block.querySelectorAll('strong').forEach((strong) => {
const isCollapsedPlaceholder = strong.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true';
const hasTrackedLength = strong.hasAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH);
if (isCollapsedPlaceholder || hasTrackedLength) {
return;
}
if ((strong.textContent ?? '').length === 0 && !BoldInlineTool.isNodeWithin(focusNode, strong)) {
strong.remove();
}
@ -964,18 +1057,24 @@ export default class BoldInlineTool implements InlineTool {
? boldElement.firstChild as Text
: boldElement.appendChild(document.createTextNode('')) as Text;
boldTextNode.textContent = (boldTextNode.textContent ?? '') + extra;
if (selection?.isCollapsed && BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
const newRange = document.createRange();
const caretOffset = boldTextNode.textContent?.length ?? 0;
newRange.setStart(boldTextNode, caretOffset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
if (extra.length === 0) {
return;
}
boldTextNode.textContent = extra + (boldTextNode.textContent ?? '');
if (!selection?.isCollapsed || !BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
return;
}
const newRange = document.createRange();
const caretOffset = boldTextNode.textContent?.length ?? 0;
newRange.setStart(boldTextNode, caretOffset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
});
}
@ -995,6 +1094,12 @@ export default class BoldInlineTool implements InlineTool {
return;
}
const activePlaceholder = BoldInlineTool.findBoldElement(range.startContainer);
if (activePlaceholder?.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true') {
return;
}
if (BoldInlineTool.moveCaretFromElementContainer(selection, range)) {
return;
}
@ -1393,16 +1498,103 @@ export default class BoldInlineTool implements InlineTool {
*
*/
private static handleGlobalSelectionChange(): void {
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
BoldInlineTool.refreshSelectionState('selectionchange');
}
/**
*
*/
private static handleGlobalInput(): void {
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
BoldInlineTool.refreshSelectionState('input');
}
/**
* Normalize selection state after editor input or selection updates
*
* @param source - The event source triggering the refresh
*/
private static refreshSelectionState(source: 'selectionchange' | 'input'): void {
const selection = window.getSelection();
BoldInlineTool.enforceCollapsedBoldLengths(selection);
BoldInlineTool.synchronizeCollapsedBold(selection);
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
BoldInlineTool.replaceNbspInBlock(selection);
BoldInlineTool.removeEmptyBoldElements(selection);
if (source === 'input' && selection) {
BoldInlineTool.moveCaretAfterBoundaryBold(selection);
}
BoldInlineTool.normalizeAllBoldTags();
}
/**
* Ensure mutation observer is registered to convert legacy <b> tags
*/
private static ensureMutationObserver(): void {
if (typeof MutationObserver === 'undefined') {
return;
}
if (BoldInlineTool.mutationObserver) {
return;
}
const observer = new MutationObserver((mutations) => {
if (BoldInlineTool.isProcessingMutation) {
return;
}
BoldInlineTool.isProcessingMutation = true;
try {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
BoldInlineTool.normalizeBoldInNode(node);
});
if (mutation.type === 'characterData' && mutation.target) {
BoldInlineTool.normalizeBoldInNode(mutation.target);
}
});
} finally {
BoldInlineTool.isProcessingMutation = false;
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
});
BoldInlineTool.mutationObserver = observer;
}
/**
* Prevent the browser's native bold command to avoid <b> wrappers
*
* @param event - BeforeInput event fired by the browser
*/
private static handleBeforeInput(event: InputEvent): void {
if (event.inputType !== 'formatBold') {
return;
}
const selection = window.getSelection();
const isSelectionInside = Boolean(selection && BoldInlineTool.isSelectionInsideEditor(selection));
const isTargetInside = BoldInlineTool.isEventTargetInsideEditor(event.target);
if (!isSelectionInside && !isTargetInside) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
BoldInlineTool.normalizeAllBoldTags();
}
/**
@ -1603,6 +1795,45 @@ export default class BoldInlineTool implements InlineTool {
return Boolean(element?.closest(`.${SelectionUtils.CSS.editorWrapper}`));
}
/**
* Check if an event target resides inside the editor wrapper
*
* @param target - Event target to inspect
*/
private static isEventTargetInsideEditor(target: EventTarget | null): boolean {
if (!target || typeof Node === 'undefined') {
return false;
}
if (target instanceof Element) {
return Boolean(target.closest(`.${SelectionUtils.CSS.editorWrapper}`));
}
if (target instanceof Text) {
return Boolean(target.parentElement?.closest(`.${SelectionUtils.CSS.editorWrapper}`));
}
if (typeof ShadowRoot !== 'undefined' && target instanceof ShadowRoot) {
return BoldInlineTool.isEventTargetInsideEditor(target.host);
}
if (!(target instanceof Node)) {
return false;
}
const parentNode = target.parentNode;
if (!parentNode) {
return false;
}
if (parentNode instanceof Element) {
return Boolean(parentNode.closest(`.${SelectionUtils.CSS.editorWrapper}`));
}
return BoldInlineTool.isEventTargetInsideEditor(parentNode);
}
/**
* Get HTML content of a range with bold tags removed
*

View file

@ -22,6 +22,7 @@ const KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP: Record<string, number> = {
};
const PRINTABLE_SPECIAL_KEYS = new Set(['Enter', 'Process', 'Spacebar', 'Space', 'Dead']);
const EDITABLE_INPUT_SELECTOR = '[contenteditable="true"], textarea, input';
/**
*
@ -189,35 +190,6 @@ export default class BlockEvents extends Module {
this.Editor.UI.checkEmptiness();
}
/**
* Add drop target styles
*
* @param {DragEvent} event - drag over event
*/
public dragOver(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
if (!block) {
return;
}
block.dropTarget = true;
}
/**
* Remove drop target style
*
* @param {DragEvent} event - drag leave event
*/
public dragLeave(event: DragEvent): void {
const block = this.Editor.BlockManager.getBlockByChildNode(event.target as Node);
if (!block) {
return;
}
block.dropTarget = false;
}
/**
* Copying selected blocks
@ -642,7 +614,18 @@ export default class BlockEvents extends Module {
}
const { currentBlock } = this.Editor.BlockManager;
const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined;
const eventTarget = event.target as HTMLElement | null;
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const fallbackInputCandidates: Array<HTMLElement | undefined | null> = [
currentBlock?.inputs.find((input) => eventTarget !== null && input.contains(eventTarget)),
currentBlock?.inputs.find((input) => activeElement !== null && input.contains(activeElement)),
eventTarget?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
activeElement?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
];
const caretInput = currentBlock?.currentInput ?? fallbackInputCandidates.find((candidate): candidate is HTMLElement => {
return candidate instanceof HTMLElement;
});
const caretAtEnd = caretInput !== undefined ? caretUtils.isCaretAtEndOfInput(caretInput) : undefined;
const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected;
const isShiftDownKey = event.shiftKey && keyCode === _.keyCodes.DOWN;
@ -720,7 +703,18 @@ export default class BlockEvents extends Module {
}
const { currentBlock } = this.Editor.BlockManager;
const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined;
const eventTarget = event.target as HTMLElement | null;
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const fallbackInputCandidates: Array<HTMLElement | undefined | null> = [
currentBlock?.inputs.find((input) => eventTarget !== null && input.contains(eventTarget)),
currentBlock?.inputs.find((input) => activeElement !== null && input.contains(activeElement)),
eventTarget?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
activeElement?.closest(EDITABLE_INPUT_SELECTOR) as HTMLElement | null,
];
const caretInput = currentBlock?.currentInput ?? fallbackInputCandidates.find((candidate): candidate is HTMLElement => {
return candidate instanceof HTMLElement;
});
const caretAtStart = caretInput !== undefined ? caretUtils.isCaretAtStartOfInput(caretInput) : undefined;
const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected;
const isShiftUpKey = event.shiftKey && keyCode === _.keyCodes.UP;

View file

@ -289,11 +289,11 @@ export default class BlockManager extends Module {
public insert({
id = undefined,
tool,
data = {},
data,
index,
needToFocus = true,
replace = false,
tunes = {},
tunes,
}: {
id?: string;
tool?: string;
@ -310,12 +310,28 @@ export default class BlockManager extends Module {
throw new Error('Could not insert Block. Tool name is not specified.');
}
const block = this.composeBlock({
id,
const composeOptions: {
tool: string;
id?: string;
data?: BlockToolData;
tunes?: {[name: string]: BlockTuneData};
} = {
tool: toolName,
data,
tunes,
});
};
if (id !== undefined) {
composeOptions.id = id;
}
if (data !== undefined) {
composeOptions.data = data;
}
if (tunes !== undefined) {
composeOptions.tunes = tunes;
}
const block = this.composeBlock(composeOptions);
/**
* In case of block replacing (Converting OR from Toolbox or Shortcut on empty block OR on-paste to empty block)
@ -482,26 +498,11 @@ export default class BlockManager extends Module {
throw new Error('Could not insert default Block. Default block tool is not defined in the configuration.');
}
const block = this.composeBlock({ tool: defaultTool });
this.blocksStore[index] = block;
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockAddedMutationType, block, {
return this.insert({
tool: defaultTool,
index,
needToFocus,
});
if (needToFocus) {
this.currentBlockIndex = index;
}
if (!needToFocus && index <= this.currentBlockIndex) {
this.currentBlockIndex++;
}
return block;
}
/**
@ -1027,17 +1028,6 @@ export default class BlockManager extends Module {
}
});
this.readOnlyMutableListeners.on(block.holder, 'dragover', (event: Event) => {
if (event instanceof DragEvent) {
BlockEvents.dragOver(event);
}
});
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: Event) => {
if (event instanceof DragEvent) {
BlockEvents.dragLeave(event);
}
});
block.on('didMutated', (affectedBlock: Block) => {
return this.blockDidMutated(BlockChangedMutationType, affectedBlock, {

View file

@ -1,265 +0,0 @@
import SelectionUtils from '../selection';
import Module from '../__module';
/**
*
*/
export default class DragNDrop extends Module {
/**
* If drag has been started at editor, we save it
*
* @type {boolean}
* @private
*/
private isStartedAtEditor = false;
/**
* Holds listener identifiers that prevent native drops in read-only mode
*/
private guardListenerIds: string[] = [];
/**
* Toggle read-only state
*
* if state is true:
* - disable all drag-n-drop event handlers
*
* if state is false:
* - restore drag-n-drop event handlers
*
* @param {boolean} readOnlyEnabled - "read only" state
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (readOnlyEnabled) {
this.disableModuleBindings();
this.bindPreventDropHandlers();
} else {
this.clearGuardListeners();
this.enableModuleBindings();
}
}
/**
* Add drag events listeners to editor zone
*/
private enableModuleBindings(): void {
const { UI } = this.Editor;
this.readOnlyMutableListeners.on(UI.nodes.holder, 'drop', (dropEvent: Event) => {
void this.processDrop(dropEvent as DragEvent);
}, true);
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragstart', () => {
this.processDragStart();
});
/**
* Prevent default browser behavior to allow drop on non-contenteditable elements
*/
this.readOnlyMutableListeners.on(UI.nodes.holder, 'dragover', (dragEvent: Event) => {
this.processDragOver(dragEvent as DragEvent);
}, true);
}
/**
* Unbind drag-n-drop event handlers
*/
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 = [];
}
/**
* Handle drop event
*
* @param {DragEvent} dropEvent - drop event
*/
private async processDrop(dropEvent: DragEvent): Promise<void> {
const {
BlockManager,
Paste,
Caret,
} = this.Editor;
dropEvent.preventDefault();
if (this.Editor.ReadOnly?.isEnabled) {
this.preventNativeDrop(dropEvent);
return;
}
for (const block of BlockManager.blocks) {
block.dropTarget = false;
}
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;
/**
* Try to set current block by drop target.
* If drop target is not part of the Block, set last Block as current.
*/
const target = dropEvent.target;
const targetBlock = target instanceof Node
? BlockManager.setCurrentBlockByChildNode(target)
: undefined;
const lastBlock = BlockManager.lastBlock;
const fallbackBlock = lastBlock
? BlockManager.setCurrentBlockByChildNode(lastBlock.holder) ?? lastBlock
: undefined;
const blockForCaret = targetBlock ?? fallbackBlock;
if (blockForCaret) {
this.Editor.Caret.setToBlock(blockForCaret, Caret.positions.END);
}
const { dataTransfer } = dropEvent;
if (!dataTransfer) {
return;
}
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
*/
private processDragStart(): void {
if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed) {
this.isStartedAtEditor = true;
}
this.Editor.InlineToolbar.close();
}
/**
* @param {DragEvent} dragEvent - drag event
*/
private processDragOver(dragEvent: DragEvent): void {
dragEvent.preventDefault();
}
}

View file

@ -28,7 +28,6 @@ import BlockManager from './blockManager';
import BlockSelection from './blockSelection';
import Caret from './caret';
import CrossBlockSelection from './crossBlockSelection';
import DragNDrop from './dragNDrop';
import ModificationsObserver from './modificationsObserver';
import Paste from './paste';
import ReadOnly from './readonly';
@ -69,7 +68,6 @@ export default {
BlockSelection,
Caret,
CrossBlockSelection,
DragNDrop,
ModificationsObserver,
Paste,
ReadOnly,

View file

@ -229,7 +229,7 @@ export default class Paste extends Module {
/**
* Determines whether provided DataTransfer contains file-like entries
*
* @param dataTransfer - drag/drop payload to inspect
* @param dataTransfer - data transfer payload to inspect
*/
private containsFiles(dataTransfer: DataTransfer): boolean {
const types = Array.from(dataTransfer.types);
@ -242,7 +242,7 @@ export default class Paste extends Module {
}
/**
* Drag/drop uploads sometimes omit `types` and set files directly
* File uploads sometimes omit `types` and set files directly
*/
if (dataTransfer.files?.length) {
return true;
@ -262,12 +262,11 @@ export default class Paste extends Module {
}
/**
* Handle pasted or dropped data transfer object
* Handle pasted data transfer object
*
* @param {DataTransfer} dataTransfer - pasted or dropped data transfer object
* @param {boolean} isDragNDrop - true if data transfer comes from drag'n'drop events
* @param {DataTransfer} dataTransfer - pasted data transfer object
*/
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
public async processDataTransfer(dataTransfer: DataTransfer): Promise<void> {
const { Tools } = this.Editor;
const includesFiles = this.containsFiles(dataTransfer);
@ -280,23 +279,7 @@ export default class Paste extends Module {
const editorJSData = dataTransfer.getData(this.MIME_TYPE);
const plainData = dataTransfer.getData('text/plain');
const rawHtmlData = dataTransfer.getData('text/html');
const htmlData = (() => {
const trimmedPlainData = plainData.trim();
const trimmedHtmlData = rawHtmlData.trim();
if (isDragNDrop && trimmedPlainData.length > 0 && trimmedHtmlData.length > 0) {
const contentToWrap = trimmedHtmlData.length > 0 ? rawHtmlData : plainData;
return `<p>${contentToWrap}</p>`;
}
return rawHtmlData;
})();
const shouldWrapDraggedText = isDragNDrop && plainData.trim() && htmlData.trim();
const normalizedHtmlData = shouldWrapDraggedText
? `<p>${htmlData.trim() ? htmlData : plainData}</p>`
: htmlData;
const normalizedHtmlData = rawHtmlData;
/**
* If EditorJS json is passed, insert it
@ -309,9 +292,6 @@ export default class Paste extends Module {
} catch (e) { } // Do nothing and continue execution as usual if error appears
}
/**
* If text was drag'n'dropped, wrap content with P tag to insert it as the new Block
*/
/** Add all tags that can be substituted to sanitizer configuration */
const toolsTags = Object.fromEntries(
Object.keys(this.toolsTags).map((tag) => [
@ -328,9 +308,11 @@ export default class Paste extends Module {
{ br: {} }
);
const cleanData = clean(normalizedHtmlData, customConfig);
const cleanDataIsHtml = $.isHTMLString(cleanData);
const shouldProcessAsPlain = !cleanData.trim() || (cleanData.trim() === plainData || !cleanDataIsHtml);
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
if (shouldProcessAsPlain) {
await this.processText(plainData);
} else {
await this.processText(cleanData, true);
@ -536,7 +518,7 @@ export default class Paste extends Module {
return rawExtensions;
}
_.log(`«extensions» property of the onDrop config for «${tool.name}» Tool should be an array`);
_.log(`«extensions» property of the paste config for «${tool.name}» Tool should be an array`);
return [];
})();
@ -547,7 +529,7 @@ export default class Paste extends Module {
}
if (!Array.isArray(rawMimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
_.log(`«mimeTypes» property of the paste config for «${tool.name}» Tool should be an array`);
return [];
}
@ -648,7 +630,7 @@ export default class Paste extends Module {
/**
* Get files from data transfer object and insert related Tools
*
* @param {FileList} items - pasted or dropped items
* @param {FileList} items - pasted items
*/
private async processFiles(items: FileList): Promise<void> {
const { BlockManager } = this.Editor;

View file

@ -201,10 +201,16 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.initialize();
};
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout: 2000 });
} else {
const scheduleWithTimeout = (): void => {
window.setTimeout(callback, 0);
};
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => {
scheduleWithTimeout();
}, { timeout: 2000 });
} else {
scheduleWithTimeout();
}
}

View file

@ -271,6 +271,9 @@ export default class Tools extends Module {
paragraph: {
class: toToolConstructable(Paragraph),
inlineToolbar: true,
config: {
preserveBlank: true,
},
isInternal: true,
},
stub: {

View file

@ -90,6 +90,15 @@ export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLEleme
const textContent = tempDiv.textContent || '';
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' && typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.log('checkContenteditableSliceForEmptiness', {
direction,
textContent,
charCodes: Array.from(textContent).map((char) => char.codePointAt(0)),
});
}
/**
* In HTML there are two types of whitespaces:
* - visible (&nbsp;)

View file

@ -309,9 +309,31 @@ const wrapFunctionRule = (rule: SanitizerFunctionRule): SanitizerFunctionRule =>
};
};
const SAFE_ATTRIBUTES = new Set(['class', 'id', 'title', 'role', 'dir', 'lang']);
const isSafeAttribute = (attribute: string): boolean => {
const lowerName = attribute.toLowerCase();
return lowerName.startsWith('data-') || lowerName.startsWith('aria-') || SAFE_ATTRIBUTES.has(lowerName);
};
const preserveExistingAttributesRule: SanitizerFunctionRule = (element) => {
const preserved: TagConfig = {};
Array.from(element.attributes).forEach((attribute) => {
if (!isSafeAttribute(attribute.name)) {
return;
}
preserved[attribute.name] = true;
});
return preserved;
};
const cloneTagConfig = (rule: SanitizerRule): SanitizerRule => {
if (rule === true) {
return {};
return wrapFunctionRule(preserveExistingAttributesRule);
}
if (rule === false) {
@ -445,6 +467,20 @@ export const composeSanitizerConfig = (
continue;
}
if (sourceValue === true && _.isFunction(targetValue)) {
continue;
}
if (sourceValue === true) {
const targetIsPlainObject = _.isObject(targetValue) && !_.isFunction(targetValue);
base[tag] = targetIsPlainObject
? _.deepMerge({}, targetValue as SanitizerConfig)
: cloneTagConfig(sourceValue as SanitizerRule);
continue;
}
if (_.isObject(sourceValue) && _.isObject(targetValue)) {
base[tag] = _.deepMerge({}, targetValue as SanitizerConfig, sourceValue as SanitizerConfig);

View file

@ -1,91 +1,59 @@
@keyframes fade-in {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
.ce-block {
animation: fade-in 300ms ease;
animation-fill-mode: initial;
animation: fade-in 300ms ease;
animation-fill-mode: initial;
&:first-of-type {
margin-top: 0;
}
&:first-of-type {
margin-top: 0;
}
&--selected &__content {
background: var(--selectionColor);
&--selected &__content {
background: var(--selectionColor);
/**
/**
* Workaround Safari case when user can select inline-fragment with cross-block-selection
*/
& [contenteditable] {
-webkit-user-select: none;
user-select: none;
& [contenteditable] {
-webkit-user-select: none;
user-select: none;
}
img,
.ce-stub {
opacity: 0.55;
}
}
img,
.ce-stub {
opacity: 0.55;
}
}
&--stretched &__content {
max-width: none;
}
&__content {
position: relative;
max-width: var(--content-width);
margin: 0 auto;
transition: background-color 150ms ease;
}
&--drop-target &__content {
&:before {
content: '';
position: absolute;
top: 100%;
left: -20px;
margin-top: -1px;
height: 8px;
width: 8px;
border: solid var(--color-active-icon);
border-width: 1px 1px 0 0;
transform-origin: right;
transform: rotate(45deg);
&--stretched &__content {
max-width: none;
}
&:after {
content: '';
position: absolute;
top: 100%;
height: 1px;
width: 100%;
color: var(--color-active-icon);
background: repeating-linear-gradient(
90deg,
var(--color-active-icon),
var(--color-active-icon) 1px,
#fff 1px,
#fff 6px
);
&__content {
position: relative;
max-width: var(--content-width);
margin: 0 auto;
transition: background-color 150ms ease;
}
}
a {
cursor: pointer;
text-decoration: underline;
}
a {
cursor: pointer;
text-decoration: underline;
}
b {
font-weight: bold;
}
b {
font-weight: bold;
}
i {
font-style: italic;
}
i {
font-style: italic;
}
}

View file

@ -27,7 +27,6 @@ import BlockManager from '../components/modules/blockManager';
import BlockSelection from '../components/modules/blockSelection';
import Caret from '../components/modules/caret';
import CrossBlockSelection from '../components/modules/crossBlockSelection';
import DragNDrop from '../components/modules/dragNDrop';
import ModificationsObserver from '../components/modules/modificationsObserver';
import Paste from '../components/modules/paste';
import ReadOnly from '../components/modules/readonly';
@ -69,7 +68,6 @@ export interface EditorModules {
BlockSelection: BlockSelection,
Caret: Caret,
CrossBlockSelection: CrossBlockSelection,
DragNDrop: DragNDrop,
ModificationsObserver: ModificationsObserver,
Paste: Paste,
ReadOnly: ReadOnly,

View file

@ -31,6 +31,17 @@ const getParagraphByIndex = (page: Page, index: number): Locator => {
return getBlockByIndex(page, index).locator('.ce-paragraph');
};
const getCommandModifierKey = async (page: Page): Promise<'Meta' | 'Control'> => {
const isMac = await page.evaluate(() => {
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
const platform = (nav.userAgentData?.platform ?? nav.platform ?? '').toLowerCase();
return platform.includes('mac');
});
return isMac ? 'Meta' : 'Control';
};
type SerializableToolConfig = {
className?: string;
classCode?: string;
@ -252,22 +263,31 @@ const withClipboardEvent = async (
): Promise<Record<string, string>> => {
return await locator.evaluate((element, type) => {
return new Promise<Record<string, string>>((resolve) => {
const clipboardData: Record<string, string> = {};
const event = Object.assign(new Event(type, {
const clipboardStore: Record<string, string> = {};
const isClipboardEventSupported = typeof ClipboardEvent === 'function';
const isDataTransferSupported = typeof DataTransfer === 'function';
if (!isClipboardEventSupported || !isDataTransferSupported) {
resolve(clipboardStore);
return;
}
const dataTransfer = new DataTransfer();
const event = new ClipboardEvent(type, {
bubbles: true,
cancelable: true,
}), {
clipboardData: {
setData: (format: string, value: string) => {
clipboardData[format] = value;
},
},
clipboardData: dataTransfer,
});
element.dispatchEvent(event);
setTimeout(() => {
resolve(clipboardData);
Array.from(dataTransfer.types).forEach((format) => {
clipboardStore[format] = dataTransfer.getData(format);
});
resolve(clipboardStore);
}, 0);
});
}, eventName);
@ -708,21 +728,23 @@ test.describe('copy and paste', () => {
});
test('should copy several blocks', async ({ page }) => {
await createEditor(page);
const firstParagraph = getParagraphByIndex(page, 0);
await firstParagraph.click();
await firstParagraph.type('First block');
await page.keyboard.press('Enter');
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: { text: 'First block' },
},
{
type: 'paragraph',
data: { text: 'Second block' },
},
]);
const secondParagraph = getParagraphByIndex(page, 1);
await secondParagraph.type('Second block');
await page.keyboard.press('Home');
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowUp');
await page.keyboard.up('Shift');
await secondParagraph.click();
const commandModifier = await getCommandModifierKey(page);
await page.keyboard.press(`${commandModifier}+A`);
await page.keyboard.press(`${commandModifier}+A`);
const clipboardData = await copyFromElement(secondParagraph);
@ -770,10 +792,10 @@ test.describe('copy and paste', () => {
const secondParagraph = getParagraphByIndex(page, 1);
await secondParagraph.click();
await page.keyboard.press('Home');
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowUp');
await page.keyboard.up('Shift');
const commandModifier = await getCommandModifierKey(page);
await page.keyboard.press(`${commandModifier}+A`);
await page.keyboard.press(`${commandModifier}+A`);
const clipboardData = await cutFromElement(secondParagraph);

View file

@ -12,7 +12,6 @@ const TEST_PAGE_URL = pathToFileURL(
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} div.ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const SETTINGS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`;
const PLUS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`;
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} ${INLINE_TOOLBAR_INTERFACE_SELECTOR}`;
@ -247,6 +246,23 @@ const openInlineToolbarPopover = async (page: Page): Promise<Locator> => {
return inlinePopover;
};
const getParagraphLocatorByBlockIndex = async (page: Page, blockIndex = 0): Promise<Locator> => {
const blockId = await page.evaluate(
({ index }) => window.editorInstance?.blocks?.getBlockByIndex(index)?.id ?? null,
{ index: blockIndex }
);
if (!blockId) {
throw new Error(`Unable to resolve block id for index ${blockIndex}`);
}
const block = page.locator(`${BLOCK_SELECTOR}[data-id="${blockId}"]`);
await expect(block).toHaveCount(1);
return block.locator('[data-block-tool="paragraph"]');
};
test.describe('editor i18n', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
@ -1053,7 +1069,7 @@ test.describe('editor i18n', () => {
uiDict: uiDictionary }
);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = await getParagraphLocatorByBlockIndex(page);
await expect(paragraph).toHaveCount(1);
@ -1283,7 +1299,7 @@ test.describe('editor i18n', () => {
uiDict: uiDictionary }
);
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = await getParagraphLocatorByBlockIndex(page);
await expect(paragraph).toHaveCount(1);
@ -1332,7 +1348,7 @@ test.describe('editor i18n', () => {
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = await getParagraphLocatorByBlockIndex(page);
await expect(paragraph).toHaveCount(1);
@ -1477,7 +1493,7 @@ test.describe('editor i18n', () => {
},
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = await getParagraphLocatorByBlockIndex(page);
await expect(paragraph).toHaveCount(1);

View file

@ -12,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"] .ce-paragraph`;
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=inline-toolbar]`;
/**

View file

@ -5,21 +5,39 @@ import { pathToFileURL } from 'node:url';
import type EditorJS from '@/types';
import type { OutputData } from '@/types';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
import { INLINE_TOOLBAR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants';
import { 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 PARAGRAPH_SELECTOR = '[data-block-tool="paragraph"]';
const PARAGRAPH_CONTENT_SELECTOR = '[data-block-tool="paragraph"] .ce-paragraph';
const INLINE_TOOLBAR_SELECTOR = INLINE_TOOLBAR_INTERFACE_SELECTOR;
const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"] button`;
const LINK_INPUT_SELECTOR = `input[data-link-tool-input-opened]`;
const NOTIFIER_SELECTOR = '.cdx-notifies';
const getParagraphByText = (page: Page, text: string): Locator => {
return page.locator(PARAGRAPH_SELECTOR, { hasText: text });
return page.locator(PARAGRAPH_CONTENT_SELECTOR, { hasText: text });
};
const ensureLinkInputOpen = async (page: Page): Promise<Locator> => {
await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible();
const linkInput = page.locator(LINK_INPUT_SELECTOR);
if (await linkInput.isVisible()) {
return linkInput;
}
const linkButton = page.locator(LINK_BUTTON_SELECTOR);
await expect(linkButton).toBeVisible();
await linkButton.click();
await expect(linkInput).toBeVisible();
return linkInput;
};
const selectAll = async (locator: Locator): Promise<void> => {
@ -110,36 +128,74 @@ const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']):
* @param text - The text string to select within the element
*/
const selectText = async (locator: Locator, text: string): Promise<void> => {
// Get the full text content to find the position
const fullText = await locator.textContent();
await locator.evaluate((element, targetText) => {
const root = element as HTMLElement;
const doc = root.ownerDocument;
if (!fullText || !fullText.includes(text)) {
throw new Error(`Text "${text}" was not found in element`);
}
if (!doc) {
throw new Error('Unable to access ownerDocument for selection');
}
const startIndex = fullText.indexOf(text);
const endIndex = startIndex + text.length;
const fullText = root.textContent ?? '';
// Click on the element to focus it
await locator.click();
if (!fullText.includes(targetText)) {
throw new Error(`Text "${targetText}" was not found in element`);
}
// Get the page from the locator to use keyboard API
const page = locator.page();
const selection = doc.getSelection();
// Move cursor to the start of the element
await page.keyboard.press('Home');
if (!selection) {
throw new Error('Selection is not available');
}
// Navigate to the start position of the target text
for (let i = 0; i < startIndex; i++) {
await page.keyboard.press('ArrowRight');
}
const startIndex = fullText.indexOf(targetText);
const endIndex = startIndex + targetText.length;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
// Select the target text by holding Shift and moving right
await page.keyboard.down('Shift');
for (let i = startIndex; i < endIndex; i++) {
await page.keyboard.press('ArrowRight');
}
await page.keyboard.up('Shift');
let accumulatedLength = 0;
let startNode: Node | null = null;
let startOffset = 0;
let endNode: Node | null = null;
let endOffset = 0;
while (walker.nextNode()) {
const currentNode = walker.currentNode;
const nodeText = currentNode.textContent ?? '';
const nodeStart = accumulatedLength;
const nodeEnd = nodeStart + nodeText.length;
if (!startNode && startIndex >= nodeStart && startIndex < nodeEnd) {
startNode = currentNode;
startOffset = startIndex - nodeStart;
}
if (!endNode && endIndex <= nodeEnd) {
endNode = currentNode;
endOffset = endIndex - nodeStart;
break;
}
accumulatedLength = nodeEnd;
}
if (!startNode || !endNode) {
throw new Error('Failed to locate text nodes for selection');
}
const range = doc.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
selection.removeAllRanges();
selection.addRange(range);
if (root instanceof HTMLElement) {
root.focus();
}
doc.dispatchEvent(new Event('selectionchange'));
}, text);
};
/**
@ -179,8 +235,7 @@ test.describe('inline tool link', () => {
const paragraph = getParagraphByText(page, 'First block text');
await selectText(paragraph, 'First block text');
await page.keyboard.press(`${MODIFIER_KEY}+k`);
await ensureLinkInputOpen(page);
await submitLink(page, 'https://codex.so');
await expect(paragraph.locator('a')).toHaveAttribute('href', 'https://codex.so');
@ -200,11 +255,7 @@ test.describe('inline tool link', () => {
await selectText(paragraph, 'Link me');
const linkButton = page.locator(LINK_BUTTON_SELECTOR);
await expect(linkButton).toBeVisible();
await linkButton.click();
await ensureLinkInputOpen(page);
await submitLink(page, 'example.com');
const anchor = paragraph.locator('a');
@ -226,9 +277,7 @@ test.describe('inline tool link', () => {
const paragraph = getParagraphByText(page, 'Invalid URL test');
await selectText(paragraph, 'Invalid URL test');
await page.keyboard.press(`${MODIFIER_KEY}+k`);
const linkInput = page.locator(LINK_INPUT_SELECTOR);
const linkInput = await ensureLinkInputOpen(page);
await linkInput.fill('https://example .com');
await linkInput.press('Enter');
@ -257,13 +306,8 @@ test.describe('inline tool link', () => {
const paragraph = getParagraphByText(page, 'First block text');
await selectAll(paragraph);
// Use keyboard shortcut to trigger the link tool (this will open the toolbar and input)
await page.keyboard.press(`${MODIFIER_KEY}+k`);
const linkInput = await ensureLinkInputOpen(page);
const linkInput = page.locator(LINK_INPUT_SELECTOR);
// Wait for the input to appear (it should open automatically when a link is detected)
await expect(linkInput).toBeVisible();
await expect(linkInput).toHaveValue('https://codex.so');
// Verify button state - find button by data attributes directly
@ -289,8 +333,7 @@ test.describe('inline tool link', () => {
const paragraph = getParagraphByText(page, 'Link to remove');
await selectAll(paragraph);
// Use keyboard shortcut to trigger the link tool
await page.keyboard.press(`${MODIFIER_KEY}+k`);
await ensureLinkInputOpen(page);
// Find the unlink button by its data attributes
const linkButton = page.locator('button[data-link-tool-unlink="true"]');
@ -314,7 +357,7 @@ test.describe('inline tool link', () => {
const paragraph = getParagraphByText(page, 'Persist me');
await selectText(paragraph, 'Persist me');
await page.keyboard.press(`${MODIFIER_KEY}+k`);
await ensureLinkInputOpen(page);
await submitLink(page, 'https://codex.so');
const savedData = await page.evaluate<OutputData | undefined>(async () => {
@ -342,7 +385,7 @@ test.describe('inline tool link', () => {
// Create a link
await selectText(paragraph, 'Clickable link');
await page.keyboard.press(`${MODIFIER_KEY}+k`);
await ensureLinkInputOpen(page);
await submitLink(page, 'https://example.com');
// Verify link was created

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
@ -14,7 +14,6 @@ const TEST_PAGE_URL = pathToFileURL(
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;
@ -72,7 +71,9 @@ const createEditor = async (page: Page, options: CreateEditorOptions = {}): Prom
await page.evaluate(
async ({ holderId, data: initialData, serializedTools: toolsConfig, config: editorConfigOverrides }) => {
const resolveToolClass = (toolConfig: { className: string | null; classCode: string | null }): unknown => {
const resolveToolClass = (
toolConfig: { name?: string; className: string | null; classCode: string | null }
): unknown => {
if (toolConfig.className) {
const toolClass = (window as unknown as Record<string, unknown>)[toolConfig.className];
@ -82,8 +83,15 @@ const createEditor = async (page: Page, options: CreateEditorOptions = {}): Prom
}
if (toolConfig.classCode) {
// eslint-disable-next-line no-new-func -- evaluated in browser context to revive tool class
return new Function(`return (${toolConfig.classCode});`)();
const revivedClassCode = toolConfig.classCode.trim().replace(/;+\s*$/, '');
try {
return window.eval?.(revivedClassCode) ?? eval(revivedClassCode);
} catch (error) {
throw new Error(
`Failed to evaluate class code for tool "${toolConfig.name ?? 'unknown'}": ${(error as Error).message}`
);
}
}
return null;
@ -148,10 +156,6 @@ const saveEditor = async (page: Page): Promise<OutputData> => {
});
};
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) {
@ -366,7 +370,7 @@ test.describe('modules/blockManager', () => {
static get conversionConfig() {
return {
export: (data) => data.text ?? '';
export: (data) => data.text ?? '',
};
}
@ -511,14 +515,25 @@ test.describe('modules/blockManager', () => {
test('generates unique ids for newly inserted blocks', async ({ page }) => {
await createEditor(page);
const firstParagraph = getParagraphByIndex(page, 0);
const blockCount = await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
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 firstBlock = window.editorInstance.blocks.getBlockByIndex?.(0);
if (!firstBlock) {
throw new Error('Initial block not found');
}
await window.editorInstance.blocks.update(firstBlock.id, { text: 'First block' });
window.editorInstance.blocks.insert('paragraph', { text: 'Second block' });
window.editorInstance.blocks.insert('paragraph', { text: 'Third block' });
return window.editorInstance.blocks.getBlocksCount?.() ?? 0;
});
expect(blockCount).toBe(3);
const { blocks } = await saveEditor(page);
const ids = blocks.map((block) => block.id);

View file

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

View file

@ -16,8 +16,6 @@ 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';
@ -36,7 +34,7 @@ type BoundingBox = {
type ToolDefinition = {
name: string;
classSource: string;
classSource?: string;
config?: Record<string, unknown>;
};
@ -72,7 +70,9 @@ const resetEditor = async (page: Page): Promise<void> => {
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
}, {
holderId: HOLDER_ID,
});
};
const createEditorWithBlocks = async (
@ -80,30 +80,64 @@ const createEditorWithBlocks = async (
blocks: OutputData['blocks'],
tools: ToolDefinition[] = []
): Promise<void> => {
const hasParagraphOverride = tools.some((tool) => tool.name === 'paragraph');
const serializedTools: ToolDefinition[] = hasParagraphOverride
? tools
: [
{
name: 'paragraph',
config: {
config: {
preserveBlank: true,
},
},
},
...tools,
];
await resetEditor(page);
await page.evaluate(async ({ holderId, blocks: editorBlocks, serializedTools }) => {
await page.evaluate(async ({
holderId,
blocks: editorBlocks,
serializedTools: toolConfigs,
}: {
holderId: string;
blocks: OutputData['blocks'];
serializedTools: ToolDefinition[];
}) => {
const reviveToolClass = (classSource: string): unknown => {
return new Function(`return (${classSource});`)();
};
const revivedTools = serializedTools.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
const revivedTools = toolConfigs.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
if (toolConfig.classSource) {
const revivedClass = reviveToolClass(toolConfig.classSource);
return {
...accumulator,
[toolConfig.name]: toolConfig.config
? {
...toolConfig.config,
class: revivedClass,
}
: revivedClass,
};
return {
...accumulator,
[toolConfig.name]: toolConfig.config
? {
...toolConfig.config,
class: revivedClass,
}
: revivedClass,
};
}
if (toolConfig.config) {
return {
...accumulator,
[toolConfig.name]: toolConfig.config,
};
}
return accumulator;
}, {});
const editor = new window.EditorJS({
holder: holderId,
data: { blocks: editorBlocks },
...(serializedTools.length > 0 ? { tools: revivedTools } : {}),
...(toolConfigs.length > 0 ? { tools: revivedTools } : {}),
});
window.editorInstance = editor;
@ -111,7 +145,7 @@ const createEditorWithBlocks = async (
}, {
holderId: HOLDER_ID,
blocks,
serializedTools: tools,
serializedTools,
});
};
@ -380,61 +414,6 @@ test.describe('modules/selection', () => {
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';
@ -546,12 +525,11 @@ test.describe('modules/selection', () => {
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 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));

View file

@ -16,8 +16,6 @@ const TEST_PAGE_URL = pathToFileURL(
).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`;
@ -262,48 +260,6 @@ const paste = async (page: Page, locator: Locator, data: Record<string, string>)
});
};
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);
};
@ -478,56 +434,6 @@ test.describe('read-only mode', () => {
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: {

View file

@ -716,9 +716,9 @@ test.describe('sanitizing', () => {
const output = await saveEditor(page);
const text = output.blocks[0].data.text;
// Custom config should allow span and div
expect(text).toContain('<span>');
expect(text).toContain('<div>');
// Custom config should allow span and div, even when editor adds safe attributes
expect(text).toMatch(/<span\b[^>]*>Span<\/span>/);
expect(text).toMatch(/<div\b[^>]*>Div<\/div>/);
});
});

View file

@ -19,7 +19,7 @@ const TEST_PAGE_URL = pathToFileURL(
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 TOOLBOX_POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover[data-popover-opened="true"]:not(.ce-popover--inline)`;
const FAILING_TOOL_SOURCE = `
class FailingTool {
render() {
@ -187,7 +187,7 @@ const openToolbox = async (page: Page): Promise<void> => {
await plusButton.waitFor({ state: 'visible' });
await plusButton.click();
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toHaveCount(1);
};
const insertFailingToolAndTriggerSave = async (page: Page): Promise<void> => {
@ -895,7 +895,7 @@ test.describe('editor configuration options', () => {
editor.blocks.insert('configurableTool');
});
const configurableSelector = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="configurableTool"]`;
const configurableSelector = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="configurableTool"]`;
const blockCount = await page.locator(configurableSelector).count();
expect(blockCount).toBeGreaterThan(0);
@ -970,10 +970,14 @@ test.describe('editor configuration options', () => {
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"]');
const inlineToggleSelector = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="inlineToggleTool"]`;
const inlineToggleBlocks = page.locator(inlineToggleSelector);
await expect(inlineToggleBlocks).toHaveCount(1);
const blockContent = page.locator(`${inlineToggleSelector} [contenteditable="true"]`);
await expect(blockContent).toBeVisible();
await blockContent.click();
await blockContent.type('inline toolbar disabled');
await blockContent.selectText();

View file

@ -1,6 +1,6 @@
/* eslint-disable jsdoc/require-jsdoc */
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '@/types';
@ -12,9 +12,10 @@ import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../fixtures/test.html')
).href;
const EDITOR_BUNDLE_PATH = path.resolve(__dirname, '../../../../dist/editorjs.umd.js');
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"][data-block-tool="paragraph"]`;
type ToolDefinition = {
name: string;
@ -27,6 +28,7 @@ type SerializedToolConfig = {
classSource: string;
config?: Record<string, unknown>;
staticProps?: Record<string, unknown>;
isInlineTool?: boolean;
};
declare global {
@ -97,54 +99,6 @@ class CmdShortcutBlockTool {
}
}
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> => {
@ -169,12 +123,14 @@ const extractSerializableStaticProps = (toolClass: ToolDefinition['class']): Rec
const serializeTools = (tools: ToolDefinition[]): SerializedToolConfig[] => {
return tools.map((tool) => {
const staticProps = extractSerializableStaticProps(tool.class);
const isInlineTool = (tool.class as { isInline?: boolean }).isInline === true;
return {
name: tool.name,
classSource: tool.class.toString(),
config: tool.config,
staticProps: Object.keys(staticProps).length > 0 ? staticProps : undefined,
isInlineTool,
};
});
};
@ -198,6 +154,17 @@ const resetEditor = async (page: Page): Promise<void> => {
}, { holderId: HOLDER_ID });
};
const ensureEditorBundleAvailable = async (page: Page): Promise<void> => {
const hasGlobal = await page.evaluate(() => typeof window.EditorJS === 'function');
if (hasGlobal) {
return;
}
await page.addScriptTag({ path: EDITOR_BUNDLE_PATH });
await page.waitForFunction(() => typeof window.EditorJS === 'function');
};
const createEditorWithTools = async (
page: Page,
options: { data?: OutputData; tools?: ToolDefinition[] } = {}
@ -206,7 +173,7 @@ const createEditorWithTools = async (
const serializedTools = serializeTools(tools);
await resetEditor(page);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
await ensureEditorBundleAvailable(page);
await page.evaluate(
async ({ holderId, serializedTools: toolConfigs, initialData }) => {
@ -215,7 +182,8 @@ const createEditorWithTools = async (
return new Function(`return (${classSource});`)();
};
const revivedTools = toolConfigs.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
const inlineToolNames: string[] = [];
const revivedTools = toolConfigs.reduce<Record<string, Record<string, unknown>>>((accumulator, toolConfig) => {
const revivedClass = reviveToolClass(toolConfig.classSource);
if (toolConfig.staticProps) {
@ -233,14 +201,26 @@ const createEditorWithTools = async (
...(toolConfig.config ?? {}),
};
if (toolConfig.isInlineTool) {
inlineToolNames.push(toolConfig.name);
}
return {
...accumulator,
[toolConfig.name]: toolSettings,
};
}, {});
if (inlineToolNames.length > 0) {
revivedTools.paragraph = {
...(revivedTools.paragraph ?? {}),
inlineToolbar: inlineToolNames,
};
}
const editorConfig: Record<string, unknown> = {
holder: holderId,
...(inlineToolNames.length > 0 ? { inlineToolbar: inlineToolNames } : {}),
};
if (initialData) {
@ -274,17 +254,6 @@ const saveEditor = async (page: Page): Promise<OutputData> => {
});
};
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();
@ -317,11 +286,12 @@ test.describe('keyboard shortcuts', () => {
],
});
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = page.locator(PARAGRAPH_SELECTOR, { hasText: 'Custom shortcut block' });
const paragraphInput = paragraph.locator('[contenteditable="true"]');
await expect(paragraph).toHaveCount(1);
await paragraph.click();
await paragraph.type(' — activated');
await paragraphInput.click();
await paragraphInput.type(' — activated');
const combo = `${MODIFIER_KEY}+Shift+KeyM`;
@ -334,59 +304,6 @@ test.describe('keyboard shortcuts', () => {
}).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: {
@ -404,7 +321,7 @@ test.describe('keyboard shortcuts', () => {
name: 'cmdShortcutBlock',
class: CmdShortcutBlockTool as unknown as BlockToolConstructable,
config: {
shortcut: 'CMD+SHIFT+J',
shortcut: 'CMD+SHIFT+Y',
},
},
],
@ -412,38 +329,40 @@ test.describe('keyboard shortcuts', () => {
const isMacPlatform = process.platform === 'darwin';
const paragraph = page.locator(PARAGRAPH_SELECTOR);
const paragraph = page.locator(PARAGRAPH_SELECTOR, { hasText: 'Platform modifier paragraph' });
const paragraphInput = paragraph.locator('[contenteditable="true"]');
await paragraph.click();
await expect(paragraph).toHaveCount(1);
await paragraphInput.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 handler = (event: KeyboardEvent): void => {
if (event.code !== 'KeyY' || !event.shiftKey) {
return;
}
);
window.__lastShortcutEvent = {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
};
document.removeEventListener('keydown', handler, true);
};
document.addEventListener('keydown', handler, true);
});
const combo = `${MODIFIER_KEY}+Shift+KeyJ`;
const combo = `${MODIFIER_KEY}+Shift+KeyY`;
await page.keyboard.press(combo);
const shortcutEvent = await page.evaluate(() => window.__lastShortcutEvent);
await page.waitForFunction(() => window.__lastShortcutEvent !== null);
expect(shortcutEvent).toBeTruthy();
const shortcutEvent = await page.evaluate(() => window.__lastShortcutEvent);
expect(shortcutEvent?.metaKey).toBe(isMacPlatform);
expect(shortcutEvent?.ctrlKey).toBe(!isMacPlatform);

View file

@ -13,7 +13,7 @@ const TEST_PAGE_URL = pathToFileURL(
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const PARAGRAPH_BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block[data-block-tool="paragraph"]`;
const POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover`;
const POPOVER_ITEM_SELECTOR = `${POPOVER_SELECTOR} .ce-popover-item`;
const SECONDARY_TITLE_SELECTOR = '.ce-popover-item__secondary-title';
@ -304,7 +304,7 @@ test.describe('toolbox', () => {
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
await expect(paragraphBlock).toHaveCount(1);
@ -395,7 +395,7 @@ test.describe('toolbox', () => {
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
await expect(paragraphBlock).toHaveCount(1);
@ -480,7 +480,7 @@ test.describe('toolbox', () => {
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
await expect(paragraphBlock).toHaveCount(1);
@ -578,7 +578,7 @@ test.describe('toolbox', () => {
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR);
const paragraphBlock = page.locator(PARAGRAPH_BLOCK_SELECTOR);
await expect(paragraphBlock).toHaveCount(1);

View file

@ -142,12 +142,26 @@ test.describe('ui module', () => {
};
const selectBlocks = async (page: Page): Promise<void> => {
const firstParagraph = page.locator(PARAGRAPH_SELECTOR).filter({
hasText: 'The first block',
});
await page.evaluate(() => {
const editor = window.editorInstance as EditorJS & {
module?: {
blockSelection?: {
selectBlockByIndex?: (index: number) => void;
clearSelection?: () => void;
};
};
};
await firstParagraph.click();
await page.keyboard.press('Shift+ArrowDown');
const blockSelection = editor?.module?.blockSelection;
if (!blockSelection?.selectBlockByIndex || !blockSelection?.clearSelection) {
throw new Error('Block selection module is not available');
}
blockSelection.clearSelection?.();
blockSelection.selectBlockByIndex?.(0);
blockSelection.selectBlockByIndex?.(1);
});
};
const getSavedBlocksCount = async (page: Page): Promise<number> => {

View file

@ -147,11 +147,6 @@ const createKeyboardEvent = (options: Partial<KeyboardEvent>): KeyboardEvent =>
} as KeyboardEvent;
};
const createDragEvent = (options: Partial<DragEvent>): DragEvent => {
return {
...options,
} as DragEvent;
};
beforeEach(() => {
vi.clearAllMocks();
@ -190,39 +185,6 @@ describe('BlockEvents', () => {
});
});
describe('drag events', () => {
it('sets dropTarget to true on dragOver', () => {
const block = { dropTarget: false } as unknown as Block;
const getBlockByChildNode = vi.fn().mockReturnValue(block);
const blockEvents = createBlockEvents({
BlockManager: {
getBlockByChildNode,
} as unknown as EditorModules['BlockManager'],
});
const target = document.createElement('div');
blockEvents.dragOver(createDragEvent({ target }));
expect(getBlockByChildNode).toHaveBeenCalledWith(target);
expect(block.dropTarget).toBe(true);
});
it('sets dropTarget to false on dragLeave', () => {
const block = { dropTarget: true } as unknown as Block;
const getBlockByChildNode = vi.fn().mockReturnValue(block);
const blockEvents = createBlockEvents({
BlockManager: {
getBlockByChildNode,
} as unknown as EditorModules['BlockManager'],
});
const target = document.createElement('div');
blockEvents.dragLeave(createDragEvent({ target }));
expect(getBlockByChildNode).toHaveBeenCalledWith(target);
expect(block.dropTarget).toBe(false);
});
});
describe('handleCommandC', () => {
it('copies selected blocks when any block is selected', () => {

View file

@ -195,8 +195,6 @@ const createBlockManager = (
handleCommandX: vi.fn(),
keydown: vi.fn(),
keyup: vi.fn(),
dragOver: vi.fn(),
dragLeave: vi.fn(),
} as unknown as EditorModules['BlockEvents'],
ReadOnly: {
isEnabled: false,

View file

@ -1,270 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DragNDrop from '../../../../src/components/modules/dragNDrop';
import SelectionUtils from '../../../../src/components/selection';
import EventsDispatcher from '../../../../src/components/utils/events';
import type { EditorEventMap } from '../../../../src/components/events';
import type { EditorModules } from '../../../../src/types-internal/editor-modules';
import type { EditorConfig } from '../../../../types';
import type Block from '../../../../src/components/block';
type TestModules = {
UI: {
nodes: {
holder: HTMLElement;
};
};
BlockManager: {
blocks: Block[];
setCurrentBlockByChildNode: ReturnType<typeof vi.fn>;
lastBlock: Block | { holder: HTMLElement };
};
Paste: {
processDataTransfer: ReturnType<typeof vi.fn>;
};
Caret: {
setToBlock: ReturnType<typeof vi.fn>;
positions: {
START: string;
END: string;
};
};
InlineToolbar: {
close: ReturnType<typeof vi.fn>;
};
};
type PartialModules = Partial<TestModules>;
type DragNDropTestContext = {
dragNDrop: DragNDrop;
modules: TestModules;
};
type InternalDragNDrop = {
readOnlyMutableListeners: {
on: (element: EventTarget, event: string, handler: (event: Event) => void, options?: boolean | AddEventListenerOptions) => void;
clearAll: () => void;
};
processDrop: (event: DragEvent) => Promise<void>;
processDragStart: () => void;
processDragOver: (event: DragEvent) => void;
isStartedAtEditor: boolean;
};
const createDragNDrop = (overrides: PartialModules = {}): DragNDropTestContext => {
const dragNDrop = new DragNDrop({
config: {} as EditorConfig,
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
});
const holder = document.createElement('div');
const lastBlockHolder = document.createElement('div');
const defaults: TestModules = {
UI: {
nodes: {
holder,
},
},
BlockManager: {
blocks: [],
setCurrentBlockByChildNode: vi.fn(),
lastBlock: {
holder: lastBlockHolder,
},
},
Paste: {
processDataTransfer: vi.fn().mockResolvedValue(undefined),
},
Caret: {
setToBlock: vi.fn(),
positions: {
START: 'start-position',
END: 'end-position',
},
},
InlineToolbar: {
close: vi.fn(),
},
};
const mergedState: TestModules = {
...defaults,
...overrides,
};
dragNDrop.state = mergedState as unknown as EditorModules;
return {
dragNDrop,
modules: mergedState,
};
};
describe('DragNDrop', () => {
let dragNDrop: DragNDrop;
let modules: TestModules;
beforeEach(() => {
vi.clearAllMocks();
({
dragNDrop,
modules,
} = createDragNDrop());
});
afterEach(() => {
vi.restoreAllMocks();
});
const getInternal = (): InternalDragNDrop => {
return dragNDrop as unknown as InternalDragNDrop;
};
it('clears listeners when toggled to read-only mode', () => {
const internal = getInternal();
const clearSpy = vi.spyOn(internal.readOnlyMutableListeners, 'clearAll');
dragNDrop.toggleReadOnly(true);
expect(clearSpy).toHaveBeenCalledTimes(1);
});
it('attaches drag-and-drop listeners when read-only mode is disabled', () => {
const internal = getInternal();
const onSpy = vi.spyOn(internal.readOnlyMutableListeners, 'on');
const holder = modules.UI.nodes.holder;
dragNDrop.toggleReadOnly(false);
expect(onSpy).toHaveBeenNthCalledWith(1, holder, 'drop', expect.any(Function), true);
expect(onSpy).toHaveBeenNthCalledWith(2, holder, 'dragstart', expect.any(Function));
expect(onSpy).toHaveBeenNthCalledWith(3, holder, 'dragover', expect.any(Function), true);
});
it('marks drag start when selection exists in editor and closes inline toolbar', () => {
const internal = getInternal();
const { close } = modules.InlineToolbar;
vi.spyOn(SelectionUtils, 'isAtEditor', 'get').mockReturnValue(true);
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(false);
internal.isStartedAtEditor = false;
internal.processDragStart();
expect(internal.isStartedAtEditor).toBe(true);
expect(close).toHaveBeenCalledTimes(1);
});
it('does not mark drag start when selection is collapsed', () => {
const internal = getInternal();
const { close } = modules.InlineToolbar;
vi.spyOn(SelectionUtils, 'isAtEditor', 'get').mockReturnValue(true);
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(true);
internal.isStartedAtEditor = false;
internal.processDragStart();
expect(internal.isStartedAtEditor).toBe(false);
expect(close).toHaveBeenCalledTimes(1);
});
it('resets blocks drop target and positions caret on found block after drop', async () => {
const internal = getInternal();
const blockA = { dropTarget: true } as unknown as Block;
const blockB = { dropTarget: true } as unknown as Block;
const targetBlock = { id: 'target-block' } as unknown as Block;
const blocksManager = modules.BlockManager;
blocksManager.blocks = [
blockA,
blockB,
];
blocksManager.setCurrentBlockByChildNode = vi.fn().mockReturnValue(targetBlock);
const caret = modules.Caret;
const processDataTransfer = modules.Paste.processDataTransfer;
const dropEvent = {
preventDefault: vi.fn(),
target: document.createElement('div'),
dataTransfer: {} as DataTransfer,
} as unknown as DragEvent;
await internal.processDrop(dropEvent);
expect(dropEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(blockA.dropTarget).toBe(false);
expect(blockB.dropTarget).toBe(false);
expect(caret.setToBlock).toHaveBeenCalledWith(targetBlock, caret.positions.END);
expect(processDataTransfer).toHaveBeenCalledWith(dropEvent.dataTransfer, true);
expect(internal.isStartedAtEditor).toBe(false);
});
it('falls back to the last block when drop target block is not found', async () => {
const internal = getInternal();
const lastBlock = { id: 'last',
holder: document.createElement('div') } as unknown as Block;
const blocksManager = modules.BlockManager;
blocksManager.lastBlock = lastBlock;
blocksManager.setCurrentBlockByChildNode = vi.fn()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(lastBlock);
const caret = modules.Caret;
const dropEvent = {
preventDefault: vi.fn(),
target: document.createElement('div'),
dataTransfer: {} as DataTransfer,
} as unknown as DragEvent;
await internal.processDrop(dropEvent);
expect(blocksManager.setCurrentBlockByChildNode).toHaveBeenNthCalledWith(1, dropEvent.target);
expect(blocksManager.setCurrentBlockByChildNode).toHaveBeenNthCalledWith(2, lastBlock.holder);
expect(caret.setToBlock).toHaveBeenCalledWith(lastBlock, caret.positions.END);
});
it('deletes selection when drop starts inside editor with non-collapsed selection', async () => {
const internal = getInternal();
const blocksManager = modules.BlockManager;
blocksManager.blocks = [];
blocksManager.setCurrentBlockByChildNode = vi.fn().mockReturnValue(undefined);
internal.isStartedAtEditor = true;
vi.spyOn(SelectionUtils, 'isAtEditor', 'get').mockReturnValue(true);
vi.spyOn(SelectionUtils, 'isCollapsed', 'get').mockReturnValue(false);
const execCommandMock = vi.fn().mockReturnValue(true);
(document as Document & { execCommand: (commandId: string) => boolean }).execCommand = execCommandMock;
const dropEvent = {
preventDefault: vi.fn(),
target: document.createElement('div'),
dataTransfer: {} as DataTransfer,
} as unknown as DragEvent;
await internal.processDrop(dropEvent);
expect(execCommandMock).toHaveBeenCalledWith('delete');
expect(internal.isStartedAtEditor).toBe(false);
});
it('prevents default behavior on drag over', () => {
const internal = getInternal();
const preventDefault = vi.fn();
internal.processDragOver({
preventDefault,
} as unknown as DragEvent);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
});

View file

@ -384,7 +384,7 @@ describe('Paste module', () => {
mimeTypes: [ 'image/png' ],
});
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('«extensions» property of the onDrop config for «files» Tool should be an array')
expect.stringContaining('«extensions» property of the paste config for «files» Tool should be an array')
);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('MIME type value «invalid» for the «files» Tool is not a valid MIME type'),