diff --git a/README.md b/README.md index eff9df7e..20b545bb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Editor.js Logo - +

@@ -12,7 +12,7 @@ editorjs.io | documentation | changelog - +

@@ -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, Editor.js Overview - + ## 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.

### 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 diff --git a/example/example-i18n.html b/example/example-i18n.html index f765f1e9..8f4e6fbe 100644 --- a/example/example-i18n.html +++ b/example/example-i18n.html @@ -183,7 +183,6 @@ "blockTunes": { "toggler": { "Click to tune": "Нажмите, чтобы настроить", - "or drag to move": "или перетащите" }, }, "inlineToolbar": { diff --git a/src/components/block/index.ts b/src/components/block/index.ts index eda77e26..53485b30 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -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 { 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 { 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 { 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 { * * @returns {object} */ - public async save(): Promise { - const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement); + public async save(): Promise { + 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 { */ 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 { + 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; + 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 { 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 { 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 { 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 { // 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 { } 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 { * 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'; diff --git a/src/components/core.ts b/src/components/core.ts index dcbdfac5..e7d64bce 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -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 | undefined; + const updatedTools: Record = { + ...(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} paragraph tool configuration + */ + private createParagraphToolConfig(paragraphEntry: unknown): Record { + if (paragraphEntry === undefined) { + return { + config: { + preserveBlank: true, + }, + }; + } + + if (_.isFunction(paragraphEntry)) { + return { + class: paragraphEntry, + config: { + preserveBlank: true, + }, + }; + } + + if (_.isObject(paragraphEntry)) { + const paragraphSettings = paragraphEntry as Record; + const existingConfig = paragraphSettings.config; + + return { + ...paragraphSettings, + config: { + ...(_.isObject(existingConfig) ? existingConfig as Record : {}), + preserveBlank: true, + }, + }; + } + + return { + config: { + preserveBlank: true, + }, + }; + } + /** * Return modules without passed name * diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json index 650a8b6d..20efd7e2 100644 --- a/src/components/i18n/locales/en/messages.json +++ b/src/components/i18n/locales/en/messages.json @@ -2,8 +2,7 @@ "ui": { "blockTunes": { "toggler": { - "Click to tune": "", - "or drag to move": "" + "Click to tune": "" } }, "inlineToolbar": { diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 444a0290..942d3eb7 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -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 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 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 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 * diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index ac89f9d1..e6228341 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -22,6 +22,7 @@ const KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP: Record = { }; 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 = [ + 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 = [ + 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; diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 94c8380f..1e996fe0 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -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, { diff --git a/src/components/modules/dragNDrop.ts b/src/components/modules/dragNDrop.ts deleted file mode 100644 index a6f55cf0..00000000 --- a/src/components/modules/dragNDrop.ts +++ /dev/null @@ -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 { - 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(); - } -} diff --git a/src/components/modules/index.ts b/src/components/modules/index.ts index 2c945f43..f70ad9f8 100644 --- a/src/components/modules/index.ts +++ b/src/components/modules/index.ts @@ -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, diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 7a8ae877..cbeb4279 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -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 { + public async processDataTransfer(dataTransfer: DataTransfer): Promise { 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 `

${contentToWrap}

`; - } - - return rawHtmlData; - })(); - - const shouldWrapDraggedText = isDragNDrop && plainData.trim() && htmlData.trim(); - const normalizedHtmlData = shouldWrapDraggedText - ? `

${htmlData.trim() ? htmlData : plainData}

` - : 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 { const { BlockManager } = this.Editor; diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index d7950913..d1360751 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -201,10 +201,16 @@ export default class InlineToolbar extends Module { 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(); } } diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index cc1d713d..355858a9 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -271,6 +271,9 @@ export default class Tools extends Module { paragraph: { class: toToolConstructable(Paragraph), inlineToolbar: true, + config: { + preserveBlank: true, + }, isInternal: true, }, stub: { diff --git a/src/components/utils/caret.ts b/src/components/utils/caret.ts index b79c1fc6..8a707799 100644 --- a/src/components/utils/caret.ts +++ b/src/components/utils/caret.ts @@ -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 ( ) diff --git a/src/components/utils/sanitizer.ts b/src/components/utils/sanitizer.ts index 414e8af8..5fd06618 100644 --- a/src/components/utils/sanitizer.ts +++ b/src/components/utils/sanitizer.ts @@ -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); diff --git a/src/styles/block.css b/src/styles/block.css index d4288aae..1ac179e6 100644 --- a/src/styles/block.css +++ b/src/styles/block.css @@ -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; + } } diff --git a/src/types-internal/editor-modules.d.ts b/src/types-internal/editor-modules.d.ts index 3e455c34..0a606f5e 100644 --- a/src/types-internal/editor-modules.d.ts +++ b/src/types-internal/editor-modules.d.ts @@ -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, diff --git a/test/playwright/tests/copy-paste.spec.ts b/test/playwright/tests/copy-paste.spec.ts index b51b9b64..cb8df47c 100644 --- a/test/playwright/tests/copy-paste.spec.ts +++ b/test/playwright/tests/copy-paste.spec.ts @@ -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> => { return await locator.evaluate((element, type) => { return new Promise>((resolve) => { - const clipboardData: Record = {}; - const event = Object.assign(new Event(type, { + const clipboardStore: Record = {}; + 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); diff --git a/test/playwright/tests/i18n.spec.ts b/test/playwright/tests/i18n.spec.ts index 67cf76f1..7c681271 100644 --- a/test/playwright/tests/i18n.spec.ts +++ b/test/playwright/tests/i18n.spec.ts @@ -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 => { return inlinePopover; }; +const getParagraphLocatorByBlockIndex = async (page: Page, blockIndex = 0): Promise => { + 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); diff --git a/test/playwright/tests/inline-tools/bold.spec.ts b/test/playwright/tests/inline-tools/bold.spec.ts index b13586e2..d20360ff 100644 --- a/test/playwright/tests/inline-tools/bold.spec.ts +++ b/test/playwright/tests/inline-tools/bold.spec.ts @@ -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]`; /** diff --git a/test/playwright/tests/inline-tools/link.spec.ts b/test/playwright/tests/inline-tools/link.spec.ts index 59651dd4..947ccbcb 100644 --- a/test/playwright/tests/inline-tools/link.spec.ts +++ b/test/playwright/tests/inline-tools/link.spec.ts @@ -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 => { + 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 => { @@ -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 => { - // 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(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 diff --git a/test/playwright/tests/modules/blockManager.spec.ts b/test/playwright/tests/modules/blockManager.spec.ts index e594a7e4..d2eeb4f0 100644 --- a/test/playwright/tests/modules/blockManager.spec.ts +++ b/test/playwright/tests/modules/blockManager.spec.ts @@ -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)[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 => { }); }; -const getParagraphByIndex = (page: Page, index: number): Locator => { - return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`); -}; - const focusBlockByIndex = async (page: Page, index: number): Promise => { 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); diff --git a/test/playwright/tests/modules/drag-and-drop.spec.ts b/test/playwright/tests/modules/drag-and-drop.spec.ts deleted file mode 100644 index c53ed8ed..00000000 --- a/test/playwright/tests/modules/drag-and-drop.spec.ts +++ /dev/null @@ -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; -}; - -type CreateEditorOptions = Pick & { - tools?: Record; -}; - -type DropPayload = { - types?: Record; - files?: Array<{ name: string; type: string; content: string }>; -}; - -declare global { - interface Window { - editorInstance?: EditorJS; - } -} - -const resetEditor = async (page: Page): Promise => { - 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 => { - 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 = { - holder: holderId, - ...restOptions, - }; - - if (data) { - editorConfig.data = data; - } - - if (toolsConfig.length > 0) { - const resolvedTools = toolsConfig.reduce>>( - (accumulator, { name, className, classCode, toolConfig }) => { - let toolClass: unknown = null; - - if (className) { - toolClass = (window as unknown as Record)[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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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': '

Second block

', - // 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': '

Should not appear

', - }, - }); - - 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': '

New block

', - }, - }); - - await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(2); - await expect(getBlockTexts(page)).resolves.toContain('New block'); - }); -}); - diff --git a/test/playwright/tests/modules/selection.spec.ts b/test/playwright/tests/modules/selection.spec.ts index 5f8f2175..5fb3a49a 100644 --- a/test/playwright/tests/modules/selection.spec.ts +++ b/test/playwright/tests/modules/selection.spec.ts @@ -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; }; @@ -72,7 +70,9 @@ const resetEditor = async (page: Page): Promise => { 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 => { + 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>((accumulator, toolConfig) => { - const revivedClass = reviveToolClass(toolConfig.classSource); + const revivedTools = toolConfigs.reduce>((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 bold 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)); diff --git a/test/playwright/tests/read-only.spec.ts b/test/playwright/tests/read-only.spec.ts index c1226729..0fa2b67a 100644 --- a/test/playwright/tests/read-only.spec.ts +++ b/test/playwright/tests/read-only.spec.ts @@ -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) }); }; -type DropPayload = { - types?: Record; - files?: Array<{ name: string; type: string; content: string }>; -}; - -const dispatchDrop = async (page: Page, payload: DropPayload): Promise => { - 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 => { 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': '

Dropped text

', - }, - }); - - 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': '

Dropped text

', - }, - }); - - 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: { diff --git a/test/playwright/tests/sanitisation.spec.ts b/test/playwright/tests/sanitisation.spec.ts index 30b6b8d8..169b561e 100644 --- a/test/playwright/tests/sanitisation.spec.ts +++ b/test/playwright/tests/sanitisation.spec.ts @@ -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(''); - expect(text).toContain('
'); + // Custom config should allow span and div, even when editor adds safe attributes + expect(text).toMatch(/]*>Span<\/span>/); + expect(text).toMatch(/]*>Div<\/div>/); }); }); diff --git a/test/playwright/tests/ui/configuration.spec.ts b/test/playwright/tests/ui/configuration.spec.ts index 8ad90291..dc4d8b37 100644 --- a/test/playwright/tests/ui/configuration.spec.ts +++ b/test/playwright/tests/ui/configuration.spec.ts @@ -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 => { 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 => { @@ -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(); diff --git a/test/playwright/tests/ui/keyboard-shortcuts.spec.ts b/test/playwright/tests/ui/keyboard-shortcuts.spec.ts index 8a363be2..734da868 100644 --- a/test/playwright/tests/ui/keyboard-shortcuts.spec.ts +++ b/test/playwright/tests/ui/keyboard-shortcuts.spec.ts @@ -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; staticProps?: Record; + 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 => { @@ -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 => { }, { holderId: HOLDER_ID }); }; +const ensureEditorBundleAvailable = async (page: Page): Promise => { + 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>((accumulator, toolConfig) => { + const inlineToolNames: string[] = []; + const revivedTools = toolConfigs.reduce>>((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 = { holder: holderId, + ...(inlineToolNames.length > 0 ? { inlineToolbar: inlineToolNames } : {}), }; if (initialData) { @@ -274,17 +254,6 @@ const saveEditor = async (page: Page): Promise => { }); }; -const selectAllText = async (locator: Locator): Promise => { - 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); diff --git a/test/playwright/tests/ui/toolbox.spec.ts b/test/playwright/tests/ui/toolbox.spec.ts index 9b8d1065..c196b9e8 100644 --- a/test/playwright/tests/ui/toolbox.spec.ts +++ b/test/playwright/tests/ui/toolbox.spec.ts @@ -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); diff --git a/test/playwright/tests/ui/ui-module.spec.ts b/test/playwright/tests/ui/ui-module.spec.ts index 6511ef5b..6d8f3d8f 100644 --- a/test/playwright/tests/ui/ui-module.spec.ts +++ b/test/playwright/tests/ui/ui-module.spec.ts @@ -142,12 +142,26 @@ test.describe('ui module', () => { }; const selectBlocks = async (page: Page): Promise => { - 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 => { diff --git a/test/unit/components/modules/blockEvents.test.ts b/test/unit/components/modules/blockEvents.test.ts index b97fb7e0..c1eac6a3 100644 --- a/test/unit/components/modules/blockEvents.test.ts +++ b/test/unit/components/modules/blockEvents.test.ts @@ -147,11 +147,6 @@ const createKeyboardEvent = (options: Partial): KeyboardEvent => } as KeyboardEvent; }; -const createDragEvent = (options: Partial): 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', () => { diff --git a/test/unit/components/modules/blockManager.test.ts b/test/unit/components/modules/blockManager.test.ts index 208bbe2b..5c9c209e 100644 --- a/test/unit/components/modules/blockManager.test.ts +++ b/test/unit/components/modules/blockManager.test.ts @@ -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, diff --git a/test/unit/components/modules/dragNDrop.test.ts b/test/unit/components/modules/dragNDrop.test.ts deleted file mode 100644 index 22a92302..00000000 --- a/test/unit/components/modules/dragNDrop.test.ts +++ /dev/null @@ -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; - lastBlock: Block | { holder: HTMLElement }; - }; - Paste: { - processDataTransfer: ReturnType; - }; - Caret: { - setToBlock: ReturnType; - positions: { - START: string; - END: string; - }; - }; - InlineToolbar: { - close: ReturnType; - }; -}; -type PartialModules = Partial; -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; - processDragStart: () => void; - processDragOver: (event: DragEvent) => void; - isStartedAtEditor: boolean; -}; - -const createDragNDrop = (overrides: PartialModules = {}): DragNDropTestContext => { - const dragNDrop = new DragNDrop({ - config: {} as EditorConfig, - eventsDispatcher: new EventsDispatcher(), - }); - - 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); - }); -}); - - diff --git a/test/unit/components/modules/paste.test.ts b/test/unit/components/modules/paste.test.ts index 5d051d7b..978e572f 100644 --- a/test/unit/components/modules/paste.test.ts +++ b/test/unit/components/modules/paste.test.ts @@ -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'),