mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 23:25:47 +01:00
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:
parent
26eb6f496b
commit
aadbabfa6e
34 changed files with 965 additions and 1758 deletions
21
README.md
21
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -183,7 +183,6 @@
|
|||
"blockTunes": {
|
||||
"toggler": {
|
||||
"Click to tune": "Нажмите, чтобы настроить",
|
||||
"or drag to move": "или перетащите"
|
||||
},
|
||||
},
|
||||
"inlineToolbar": {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
"ui": {
|
||||
"blockTunes": {
|
||||
"toggler": {
|
||||
"Click to tune": "",
|
||||
"or drag to move": ""
|
||||
"Click to tune": ""
|
||||
}
|
||||
},
|
||||
"inlineToolbar": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -271,6 +271,9 @@ export default class Tools extends Module {
|
|||
paragraph: {
|
||||
class: toToolConstructable(Paragraph),
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
preserveBlank: true,
|
||||
},
|
||||
isInternal: true,
|
||||
},
|
||||
stub: {
|
||||
|
|
|
|||
|
|
@ -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 ( )
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/types-internal/editor-modules.d.ts
vendored
2
src/types-internal/editor-modules.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue