From c48898bb5b0274cac833cfa89c3f20daf367dfe5 Mon Sep 17 00:00:00 2001 From: JackUait Date: Sat, 22 Nov 2025 02:46:08 +0300 Subject: [PATCH] refactor: update inline tool interfaces and remove deprecated methods - Refactored inline tool interfaces to use MenuConfig directly. - Removed deprecated methods and properties from InlineTool and related types. - Updated tests to reflect changes in inline tool handling and ensure proper functionality. - Enhanced test coverage for inline tools, including link and italic tools. - Cleaned up unused code and improved overall test structure. --- .cursor/rules/fix-problems.mdc | 2 +- .vscode/settings.json | 2 +- docs/tools-inline.md | 96 +-- src/components/core.ts | 20 +- src/components/flipper.ts | 7 +- .../inline-tools/inline-tool-bold.ts | 2 +- .../inline-tools/inline-tool-italic.ts | 520 +++++++++++++-- .../inline-tools/inline-tool-link.ts | 399 +++++++----- src/components/modules/api/blocks.ts | 38 -- .../modules/toolbar/blockSettings.ts | 42 +- src/components/modules/toolbar/inline.ts | 163 +---- src/components/modules/tools.ts | 13 +- src/components/selection.ts | 25 +- src/components/tools/inline.ts | 9 - src/components/utils.ts | 73 --- src/components/utils/resolve-aliases.ts | 32 - src/styles/export.css | 196 +++--- src/styles/inline-toolbar.css | 275 ++++---- src/styles/popover-inline.css | 8 + src/styles/rtl.css | 119 ++-- src/styles/variables.css | 231 ++++--- test/playwright/tests/api/tools.spec.ts | 49 +- test/playwright/tests/api/tunes.spec.ts | 26 +- .../tests/inline-tools/italic.spec.ts | 616 ++++++++++++++++++ .../inline-tools/link-edge-cases.spec.ts | 370 +++++++++++ .../tests/inline-tools/link.spec.ts | 342 +++++++++- test/playwright/tests/modules/Saver.spec.ts | 3 +- .../tests/tools/inline-tool.spec.ts | 29 +- test/playwright/tests/utils/popover.spec.ts | 5 +- test/unit/components/core.test.ts | 27 - .../inline-tools/inline-tool-italic.test.ts | 86 +-- .../inline-tools/inline-tool-link.test.ts | 161 +++-- .../components/modules/api/blocks.test.ts | 49 -- .../modules/toolbar/blockSettings.test.ts | 54 +- test/unit/components/modules/tools.test.ts | 56 +- test/unit/components/selection.test.ts | 38 +- .../components/utils/resolve-aliases.test.ts | 63 -- test/unit/modules/toolbar/inline.test.ts | 127 +--- test/unit/polyfills.test.ts | 4 +- test/unit/tools/inline.test.ts | 44 +- test/unit/utils/utils.test.ts | 86 --- types/api/blocks.d.ts | 16 - types/configs/editor-config.d.ts | 13 +- types/tools/adapters/inline-tool-adapter.d.ts | 5 - types/tools/inline-tool.d.ts | 33 +- types/tools/menu-config.d.ts | 14 +- types/tools/tool.d.ts | 2 +- 47 files changed, 2779 insertions(+), 1811 deletions(-) delete mode 100644 src/components/utils/resolve-aliases.ts create mode 100644 test/playwright/tests/inline-tools/italic.spec.ts create mode 100644 test/playwright/tests/inline-tools/link-edge-cases.spec.ts delete mode 100644 test/unit/components/utils/resolve-aliases.test.ts diff --git a/.cursor/rules/fix-problems.mdc b/.cursor/rules/fix-problems.mdc index c7518773..f2dd056b 100644 --- a/.cursor/rules/fix-problems.mdc +++ b/.cursor/rules/fix-problems.mdc @@ -11,7 +11,7 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e - **Refactor for correctness**: Resolve issues by improving the code structure, using precise types, type guards, proper error handling, and best practices. - **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it. - **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines. -- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues. +- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`, `yarn test`), or manual checks to ensure the problem is truly resolved without introducing new issues. - **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely ## When to Apply diff --git a/.vscode/settings.json b/.vscode/settings.json index eeb50194..317fe7f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,5 @@ "source.fixAll.eslint": "always" }, "eslint.useFlatConfig": true, - + "editor.formatOnSave": false } diff --git a/docs/tools-inline.md b/docs/tools-inline.md index 6ed6cae1..5a974540 100644 --- a/docs/tools-inline.md +++ b/docs/tools-inline.md @@ -7,16 +7,12 @@ selected fragment of text. The simplest example is `bold` or `italic` Tools. First of all, Tool's class should have a `isInline` property (static getter) set as `true`. -After that Inline Tool should implement next methods. +After that Inline Tool should implement the `render` method. -- `render()` — create a button -- `surround()` — works with selected range -- `checkState()` — get Tool's activated state by selected range +- `render()` — returns Tool's visual representation and logic -Also, you can provide optional methods +Also, you can provide optional methods: -- `renderActions()` — create additional element below the buttons -- `clear()` — clear Tool's stuff on opening/closing of Inline Toolbar - `sanitize()` — sanitizer configuration At the constructor of Tool's class exemplar you will accept an object with the [API](api.md) as a parameter. @@ -25,7 +21,7 @@ At the constructor of Tool's class exemplar you will accept an object with the [ ### render() -Method that returns button to append at the Inline Toolbar +Method that returns Menu Config for the Inline Toolbar #### Parameters @@ -35,75 +31,27 @@ Method does not accept any parameters type | description | -- | -- | -`HTMLElement` | element that will be added to the Inline Toolbar | +`MenuConfig` | configuration object for the tool's button and behavior | + +#### Example + +```typescript +render(): MenuConfig { + return { + icon: '...', + title: 'Bold', + isActive: () => { + // check if current selection is bold + }, + onActivate: () => { + // toggle bold state + } + }; +} +``` --- -### surround(range: Range) - -Method that accepts selected range and wrap it somehow - -#### Parameters - -name | type | description | --- |-- | -- | -range | Range | first range of current Selection | - -#### Return value - -There is no return value - ---- - -### checkState(selection: Selection) - -Get Selection and detect if Tool was applied. For example, after that Tool can highlight button or show some details. - -#### Parameters - -name | type | description | --- |-- | -- | -selection | Selection | current Selection | - -#### Return value - -type | description | --- | -- | -`Boolean` | `true` if Tool is active, otherwise `false` | - ---- - -### renderActions() - -Optional method that returns additional Element with actions. -For example, input for the 'link' tool or textarea for the 'comment' tool. -It will be places below the buttons list at Inline Toolbar. - -#### Parameters - -Method does not accept any parameters - -#### Return value - -type | description | --- | -- | -`HTMLElement` | element that will be added to the Inline Toolbar | - ---- - -### clear() - -Optional method that will be called on opening/closing of Inline Toolbar. -Can contain logic for clearing Tool's stuff, such as inputs, states and other. - -#### Parameters - -Method does not accept any parameters - -#### Return value - -Method should not return a value. - ### static get sanitize() We recommend to specify the Sanitizer config that corresponds with inline tags that is used by your Tool. diff --git a/src/components/core.ts b/src/components/core.ts index e7d64bce..9962a104 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -93,7 +93,7 @@ export default class Core { }; } else { /** - * Process zero-configuration or with only holderId + * Process zero-configuration or with only holder * Make config object */ this.config = { @@ -101,15 +101,6 @@ export default class Core { }; } - /** - * If holderId is preset, assign him to holder property and work next only with holder - */ - _.deprecationAssert(Boolean(this.config.holderId), 'config.holderId', 'config.holder'); - if (Boolean(this.config.holderId) && this.config.holder == null) { - this.config.holder = this.config.holderId; - this.config.holderId = undefined; - } - /** * If holder is empty then set a default value */ @@ -126,8 +117,7 @@ export default class Core { /** * If default Block's Tool was not passed, use the Paragraph Tool */ - _.deprecationAssert(Boolean(this.config.initialBlock), 'config.initialBlock', 'config.defaultBlock'); - this.config.defaultBlock = this.config.defaultBlock ?? this.config.initialBlock ?? 'paragraph'; + this.config.defaultBlock = this.config.defaultBlock ?? 'paragraph'; const toolsConfig = this.config.tools; const defaultBlockName = this.config.defaultBlock; @@ -229,11 +219,7 @@ export default class Core { * Checks for required fields in Editor's config */ public validate(): void { - const { holderId, holder } = this.config; - - if (Boolean(holderId) && Boolean(holder)) { - throw Error('«holderId» and «holder» param can\'t assign at the same time.'); - } + const { holder } = this.config; /** * Check for a holder element's existence diff --git a/src/components/flipper.ts b/src/components/flipper.ts index aea153e9..26485f52 100644 --- a/src/components/flipper.ts +++ b/src/components/flipper.ts @@ -299,10 +299,7 @@ export default class Flipper { */ event.stopPropagation(); event.stopImmediatePropagation(); - // eslint-disable-next-line no-param-reassign - event.cancelBubble = true; - // eslint-disable-next-line no-param-reassign - event.returnValue = false; + /** * Prevent only used keys default behaviour @@ -416,7 +413,7 @@ export default class Flipper { */ private flipCallback(): void { if (this.iterator?.currentItem) { - this.iterator.currentItem.scrollIntoViewIfNeeded(); + this.iterator.currentItem.scrollIntoViewIfNeeded?.(); } this.flipCallbacks.forEach(cb => cb()); diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 62665aa8..6330cf26 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -20,7 +20,7 @@ export default class BoldInlineTool implements InlineTool { public static isInline = true; /** - * Title for hover-tooltip + * Title for the Inline Tool */ public static title = 'Bold'; diff --git a/src/components/inline-tools/inline-tool-italic.ts b/src/components/inline-tools/inline-tool-italic.ts index 7914ce9f..468d9366 100644 --- a/src/components/inline-tools/inline-tool-italic.ts +++ b/src/components/inline-tools/inline-tool-italic.ts @@ -1,5 +1,6 @@ import type { InlineTool, SanitizerConfig } from '../../../types'; import { IconItalic } from '@codexteam/icons'; +import type { MenuConfig } from '../../../types/tools'; /** * Italic Tool @@ -17,76 +18,39 @@ export default class ItalicInlineTool implements InlineTool { public static isInline = true; /** - * Title for hover-tooltip + * Title for the Inline Tool */ public static title = 'Italic'; /** * Sanitizer Rule - * Leave tags + * Leave and tags * * @returns {object} */ public static get sanitize(): SanitizerConfig { return { i: {}, + em: {}, } as SanitizerConfig; } - /** - * Native Document's command that uses for Italic - */ - private readonly commandName: string = 'italic'; - - /** - * Styles - */ - private readonly CSS = { - button: 'ce-inline-tool', - buttonActive: 'ce-inline-tool--active', - buttonModifier: 'ce-inline-tool--italic', - }; - - /** - * Elements - */ - private nodes: {button: HTMLButtonElement | null} = { - button: null, - }; - /** * Create button for Inline Toolbar */ - public render(): HTMLElement { - const button = document.createElement('button'); + public render(): MenuConfig { + return { + icon: IconItalic, + name: 'italic', + onActivate: () => { + this.toggleItalic(); + }, + isActive: () => { + const selection = window.getSelection(); - button.type = 'button'; - button.classList.add(this.CSS.button, this.CSS.buttonModifier); - button.innerHTML = IconItalic; - - this.nodes.button = button; - - return button; - } - - /** - * Wrap range with tag - */ - public surround(): void { - document.execCommand(this.commandName); - } - - /** - * Check selection and set activated state to button if there are tag - */ - public checkState(): boolean { - const isActive = document.queryCommandState(this.commandName); - - if (this.nodes.button) { - this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive); - } - - return isActive; + return selection ? this.isSelectionVisuallyItalic(selection) : false; + }, + }; } /** @@ -95,4 +59,456 @@ export default class ItalicInlineTool implements InlineTool { public get shortcut(): string { return 'CMD+I'; } + + /** + * Apply or remove italic formatting using modern Selection API + */ + private toggleItalic(): void { + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + + if (range.collapsed) { + this.toggleCollapsedItalic(range, selection); + + return; + } + + const shouldUnwrap = this.isRangeItalic(range, { ignoreWhitespace: true }); + + if (shouldUnwrap) { + this.unwrapItalicTags(range); + } else { + this.wrapWithItalic(range); + } + } + + /** + * Handle toggle for collapsed selection (caret) + * + * @param range - Current range + * @param selection - Current selection + */ + private toggleCollapsedItalic(range: Range, selection: Selection): void { + const isItalic = this.isRangeItalic(range, { ignoreWhitespace: true }); + + if (isItalic) { + const textNode = document.createTextNode('\u200B'); + + range.insertNode(textNode); + range.selectNode(textNode); + this.unwrapItalicTags(range); + + const newRange = document.createRange(); + + newRange.setStart(textNode, 1); + newRange.setEnd(textNode, 1); + + selection.removeAllRanges(); + selection.addRange(newRange); + } else { + const i = document.createElement('i'); + const textNode = document.createTextNode('\u200B'); + + i.appendChild(textNode); + range.insertNode(i); + + const newRange = document.createRange(); + + newRange.setStart(textNode, 1); + newRange.setEnd(textNode, 1); + + selection.removeAllRanges(); + selection.addRange(newRange); + } + } + + /** + * Check if current selection is within an italic tag + * + * @param selection - The Selection object to check + */ + private isSelectionVisuallyItalic(selection: Selection): boolean { + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + + return this.isRangeItalic(range, { ignoreWhitespace: true }); + } + + /** + * Check if a range contains italic text + * + * @param range - The range to check + * @param options - Options for checking italic status + */ + private isRangeItalic(range: Range, options: { ignoreWhitespace: boolean }): boolean { + if (range.collapsed) { + return Boolean(this.findItalicElement(range.startContainer)); + } + + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + try { + return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } catch (error) { + const nodeRange = document.createRange(); + + nodeRange.selectNodeContents(node); + + const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0; + const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0; + + return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }, + } + ); + + const textNodes: Text[] = []; + + while (walker.nextNode()) { + const textNode = walker.currentNode as Text; + const value = textNode.textContent ?? ''; + + if (options.ignoreWhitespace && value.trim().length === 0) { + continue; + } + + if (value.length === 0) { + continue; + } + + textNodes.push(textNode); + } + + if (textNodes.length === 0) { + return Boolean(this.findItalicElement(range.startContainer)); + } + + return textNodes.every((textNode) => this.hasItalicParent(textNode)); + } + + /** + * Wrap selection with tag + * + * @param range - The Range object containing the selection to wrap + */ + private wrapWithItalic(range: Range): void { + const html = this.getRangeHtmlWithoutItalic(range); + const insertedRange = this.replaceRangeWithHtml(range, `${html}`); + const selection = window.getSelection(); + + if (selection && insertedRange) { + selection.removeAllRanges(); + selection.addRange(insertedRange); + } + } + + /** + * Remove italic tags (/) while preserving content + * + * @param range - The Range object containing the selection to unwrap + */ + private unwrapItalicTags(range: Range): void { + const italicAncestors = this.collectItalicAncestors(range); + const selection = window.getSelection(); + + if (!selection) { + return; + } + + const marker = document.createElement('span'); + const fragment = range.extractContents(); + + marker.appendChild(fragment); + this.removeNestedItalic(marker); + + range.insertNode(marker); + + const markerRange = document.createRange(); + + markerRange.selectNodeContents(marker); + selection.removeAllRanges(); + selection.addRange(markerRange); + + for (; ;) { + const currentItalic = this.findItalicElement(marker); + + if (!currentItalic) { + break; + } + + this.moveMarkerOutOfItalic(marker, currentItalic); + } + + const firstChild = marker.firstChild; + const lastChild = marker.lastChild; + + this.unwrapElement(marker); + + const finalRange = firstChild && lastChild ? (() => { + const newRange = document.createRange(); + + newRange.setStartBefore(firstChild); + newRange.setEndAfter(lastChild); + + selection.removeAllRanges(); + selection.addRange(newRange); + + return newRange; + })() : undefined; + + if (!finalRange) { + selection.removeAllRanges(); + } + + italicAncestors.forEach((element) => { + if ((element.textContent ?? '').length === 0) { + element.remove(); + } + }); + } + + /** + * Check if a node or any of its parents is an italic tag + * + * @param node - The node to check + */ + private hasItalicParent(node: Node | null): boolean { + if (!node) { + return false; + } + + if (node.nodeType === Node.ELEMENT_NODE && this.isItalicTag(node as Element)) { + return true; + } + + return this.hasItalicParent(node.parentNode); + } + + /** + * Find an italic element in the parent chain + * + * @param node - The node to start searching from + */ + private findItalicElement(node: Node | null): HTMLElement | null { + if (!node) { + return null; + } + + if (node.nodeType === Node.ELEMENT_NODE && this.isItalicTag(node as Element)) { + return node as HTMLElement; + } + + return this.findItalicElement(node.parentNode); + } + + /** + * Check if an element is an italic tag ( or ) + * + * @param node - The element to check + */ + private isItalicTag(node: Element): boolean { + const tag = node.tagName; + + return tag === 'I' || tag === 'EM'; + } + + /** + * Collect all italic ancestor elements within a range + * + * @param range - The range to search for italic ancestors + */ + private collectItalicAncestors(range: Range): HTMLElement[] { + const ancestors = new Set(); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + try { + return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } catch (error) { + const nodeRange = document.createRange(); + + nodeRange.selectNodeContents(node); + + const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0; + const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0; + + return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }, + } + ); + + while (walker.nextNode()) { + const italicElement = this.findItalicElement(walker.currentNode); + + if (italicElement) { + ancestors.add(italicElement); + } + } + + return Array.from(ancestors); + } + + /** + * Get HTML content of a range with italic tags removed + * + * @param range - The range to extract HTML from + */ + private getRangeHtmlWithoutItalic(range: Range): string { + const contents = range.cloneContents(); + + this.removeNestedItalic(contents); + + const container = document.createElement('div'); + + container.appendChild(contents); + + return container.innerHTML; + } + + /** + * Remove nested italic tags from a root node + * + * @param root - The root node to process + */ + private removeNestedItalic(root: ParentNode): void { + const italicNodes = root.querySelectorAll?.('i,em'); + + if (!italicNodes) { + return; + } + + italicNodes.forEach((node) => { + this.unwrapElement(node); + }); + } + + /** + * Unwrap an element by moving its children to the parent + * + * @param element - The element to unwrap + */ + private unwrapElement(element: Element): void { + const parent = element.parentNode; + + if (!parent) { + element.remove(); + + return; + } + + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + + parent.removeChild(element); + } + + /** + * Replace the current range contents with provided HTML snippet + * + * @param range - Range to replace + * @param html - HTML string to insert + */ + private replaceRangeWithHtml(range: Range, html: string): Range | undefined { + const fragment = this.createFragmentFromHtml(html); + const firstInserted = fragment.firstChild ?? null; + const lastInserted = fragment.lastChild ?? null; + + range.deleteContents(); + + if (!firstInserted || !lastInserted) { + return; + } + + range.insertNode(fragment); + + const newRange = document.createRange(); + + newRange.setStartBefore(firstInserted); + newRange.setEndAfter(lastInserted); + + return newRange; + } + + /** + * Convert an HTML snippet to a document fragment + * + * @param html - HTML string to convert + */ + private createFragmentFromHtml(html: string): DocumentFragment { + const template = document.createElement('template'); + + template.innerHTML = html; + + return template.content; + } + + /** + * Move a temporary marker element outside of an italic ancestor while preserving content order + * + * @param marker - Marker element wrapping the selection contents + * @param italicElement - Italic ancestor containing the marker + */ + private moveMarkerOutOfItalic(marker: HTMLElement, italicElement: HTMLElement): void { + const parent = italicElement.parentNode; + + if (!parent) { + return; + } + + // Remove empty text nodes to ensure accurate child count + Array.from(italicElement.childNodes).forEach((node) => { + if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').length === 0) { + node.remove(); + } + }); + + const isOnlyChild = italicElement.childNodes.length === 1 && italicElement.firstChild === marker; + + if (isOnlyChild) { + italicElement.replaceWith(marker); + + return; + } + + const isFirstChild = italicElement.firstChild === marker; + + if (isFirstChild) { + parent.insertBefore(marker, italicElement); + + return; + } + + const isLastChild = italicElement.lastChild === marker; + + if (isLastChild) { + parent.insertBefore(marker, italicElement.nextSibling); + + return; + } + + const trailingClone = italicElement.cloneNode(false) as HTMLElement; + + while (marker.nextSibling) { + trailingClone.appendChild(marker.nextSibling); + } + + parent.insertBefore(trailingClone, italicElement.nextSibling); + parent.insertBefore(marker, trailingClone); + } } diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 622b7ceb..348fc32e 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -1,8 +1,16 @@ import SelectionUtils from '../selection'; import * as _ from '../utils'; -import type { InlineTool, SanitizerConfig, API } from '../../../types'; +import type { + InlineTool, + InlineToolConstructable, + InlineToolConstructorOptions, + SanitizerConfig +} from '../../../types'; +import { PopoverItemType } from '../utils/popover'; import type { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api'; -import { IconLink, IconUnlink } from '@codexteam/icons'; +import type { MenuConfig } from '../../../types/tools'; +import { IconLink } from '@codexteam/icons'; +import { INLINE_TOOLBAR_INTERFACE_SELECTOR } from '../constants'; /** * Link Tool @@ -11,7 +19,7 @@ import { IconLink, IconUnlink } from '@codexteam/icons'; * * Wrap selected text with tag */ -export default class LinkInlineTool implements InlineTool { +const LinkInlineTool: InlineToolConstructable = class LinkInlineTool implements InlineTool { /** * Specifies Tool as Inline Toolbar Tool * @@ -20,7 +28,7 @@ export default class LinkInlineTool implements InlineTool { public static isInline = true; /** - * Title for hover-tooltip + * Title for the Inline Tool */ public static title = 'Link'; @@ -40,17 +48,6 @@ export default class LinkInlineTool implements InlineTool { } as SanitizerConfig; } - /** - * Native Document's commands for link/unlink - */ - private readonly commandLink: string = 'createLink'; - private readonly commandUnlink: string = 'unlink'; - - /** - * Enter key code - */ - private readonly ENTER_KEY: number = 13; - /** * Styles */ @@ -75,11 +72,11 @@ export default class LinkInlineTool implements InlineTool { * Elements */ private nodes: { - button: HTMLButtonElement | null; input: HTMLInputElement | null; + button: HTMLButtonElement | null; } = { - button: null, input: null, + button: null, }; /** @@ -92,6 +89,11 @@ export default class LinkInlineTool implements InlineTool { */ private inputOpened = false; + /** + * Tracks whether unlink action is available via toolbar button toggle + */ + private unlinkAvailable = false; + /** * Available Toolbar methods (open/close) */ @@ -115,130 +117,56 @@ export default class LinkInlineTool implements InlineTool { /** * @param api - Editor.js API */ - constructor({ api }: { api: API }) { + constructor({ api }: InlineToolConstructorOptions) { this.toolbar = api.toolbar; this.inlineToolbar = api.inlineToolbar; this.notifier = api.notifier; this.i18n = api.i18n; this.selection = new SelectionUtils(); + this.nodes.input = this.createInput(); } /** * Create button for Inline Toolbar */ - public render(): HTMLElement { - this.nodes.button = document.createElement('button') as HTMLButtonElement; - this.nodes.button.type = 'button'; - this.nodes.button.classList.add(this.CSS.button, this.CSS.buttonModifier); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, false); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, false); - - this.nodes.button.innerHTML = IconLink; - - return this.nodes.button; + public render(): MenuConfig { + return { + icon: IconLink, + isActive: () => !!this.selection.findParentTag('A'), + children: { + items: [ + { + type: PopoverItemType.Html, + element: this.nodes.input!, + }, + ], + onOpen: () => { + this.openActions(true); + }, + onClose: () => { + this.closeActions(); + }, + }, + }; } /** * Input for the link */ - public renderActions(): HTMLElement { - this.nodes.input = document.createElement('input') as HTMLInputElement; - this.nodes.input.placeholder = this.i18n.t('Add a link'); - this.nodes.input.enterKeyHint = 'done'; - this.nodes.input.classList.add(this.CSS.input); - this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false); - this.nodes.input.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.keyCode === this.ENTER_KEY) { + private createInput(): HTMLInputElement { + const input = document.createElement('input') as HTMLInputElement; + + input.placeholder = this.i18n.t('Add a link'); + input.enterKeyHint = 'done'; + input.classList.add(this.CSS.input); + this.setBooleanStateAttribute(input, this.DATA_ATTRIBUTES.inputOpened, false); + input.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter') { this.enterPressed(event); } }); - return this.nodes.input; - } - - /** - * Handle clicks on the Inline Toolbar icon - * - * @param {Range | null} range - range to wrap with link - */ - public surround(range: Range | null): void { - if (!range) { - this.toggleActions(); - - return; - } - - /** - * Save selection before change focus to the input - */ - if (!this.inputOpened) { - /** Create blue background instead of selection */ - this.selection.setFakeBackground(); - this.selection.save(); - } else { - this.selection.restore(); - this.selection.removeFakeBackground(); - } - const parentAnchor = this.selection.findParentTag('A'); - - /** - * Unlink icon pressed - */ - if (parentAnchor) { - this.selection.expandToTag(parentAnchor); - this.unlink(); - this.closeActions(); - this.checkState(); - this.toolbar.close(); - - return; - } - - this.toggleActions(); - } - - /** - * Check selection and set activated state to button if there are tag - */ - public checkState(): boolean { - const anchorTag = this.selection.findParentTag('A'); - - if (!this.nodes.button || !this.nodes.input) { - return !!anchorTag; - } - - if (anchorTag) { - this.nodes.button.innerHTML = IconUnlink; - this.nodes.button.classList.add(this.CSS.buttonUnlink); - this.nodes.button.classList.add(this.CSS.buttonActive); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, true); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, true); - this.openActions(); - - /** - * Fill input value with link href - */ - const hrefAttr = anchorTag.getAttribute('href'); - - this.nodes.input.value = hrefAttr !== null ? hrefAttr : ''; - - this.selection.save(); - } else { - this.nodes.button.innerHTML = IconLink; - this.nodes.button.classList.remove(this.CSS.buttonUnlink); - this.nodes.button.classList.remove(this.CSS.buttonActive); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonUnlink, false); - this.setBooleanStateAttribute(this.nodes.button, this.DATA_ATTRIBUTES.buttonActive, false); - } - - return !!anchorTag; - } - - /** - * Function called with Inline Toolbar closing - */ - public clear(): void { - this.closeActions(); + return input; } /** @@ -248,17 +176,6 @@ export default class LinkInlineTool implements InlineTool { return 'CMD+K'; } - /** - * Show/close link input - */ - private toggleActions(): void { - if (!this.inputOpened) { - this.openActions(true); - } else { - this.closeActions(false); - } - } - /** * @param {boolean} needFocus - on link creation we need to focus input. On editing - nope. */ @@ -266,13 +183,114 @@ export default class LinkInlineTool implements InlineTool { if (!this.nodes.input) { return; } + + const anchorTag = this.selection.findParentTag('A'); + + const hasAnchor = Boolean(anchorTag); + + this.updateButtonStateAttributes(hasAnchor); + this.unlinkAvailable = hasAnchor; + + if (anchorTag) { + /** + * Fill input value with link href + */ + const hrefAttr = anchorTag.getAttribute('href'); + + this.nodes.input.value = hrefAttr !== null ? hrefAttr : ''; + } else { + this.nodes.input.value = ''; + } + this.nodes.input.classList.add(this.CSS.inputShowed); this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, true); + + this.selection.save(); + if (needFocus) { - this.nodes.input.focus(); + this.focusInputWithRetry(); } this.inputOpened = true; } + /** + * Ensures the link input receives focus even if other listeners steal it + */ + private focusInputWithRetry(): void { + if (!this.nodes.input) { + return; + } + + this.nodes.input.focus(); + + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + window.setTimeout(() => { + if (document.activeElement !== this.nodes.input) { + this.nodes.input?.focus(); + } + }, 0); + } + + /** + * Resolve the current inline toolbar button element + */ + private getButtonElement(): HTMLButtonElement | null { + if (this.nodes.button && document.contains(this.nodes.button)) { + return this.nodes.button; + } + + const button = document.querySelector( + `${INLINE_TOOLBAR_INTERFACE_SELECTOR} [data-item-name="link"]` + ); + + if (button) { + button.addEventListener('click', this.handleButtonClick, true); + } + + this.nodes.button = button ?? null; + + return this.nodes.button; + } + + /** + * Update button state attributes for e2e hooks + * + * @param hasAnchor - Optional override for anchor presence + */ + private updateButtonStateAttributes(hasAnchor?: boolean): void { + const button = this.getButtonElement(); + + if (!button) { + return; + } + + const anchorPresent = typeof hasAnchor === 'boolean' ? hasAnchor : Boolean(this.selection.findParentTag('A')); + + this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonActive, anchorPresent); + this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonUnlink, anchorPresent); + } + + /** + * Handles toggling the inline tool button while actions menu is open + * + * @param event - Click event emitted by the inline tool button + */ + private handleButtonClick = (event: MouseEvent): void => { + if (!this.inputOpened || !this.unlinkAvailable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + this.restoreSelection(); + this.unlink(); + this.inlineToolbar.close(); + }; + /** * Close input @@ -281,17 +299,11 @@ export default class LinkInlineTool implements InlineTool { * on toggle-clicks on the icon of opened Toolbar */ private closeActions(clearSavedSelection = true): void { - if (this.selection.isFakeBackgroundEnabled) { - // if actions is broken by other selection We need to save new selection - const currentSelection = new SelectionUtils(); + const shouldRestoreSelection = this.selection.isFakeBackgroundEnabled || + (clearSavedSelection && !!this.selection.savedSelectionRange); - currentSelection.save(); - - this.selection.restore(); - this.selection.removeFakeBackground(); - - // and recover new selection after removing fake background - currentSelection.restore(); + if (shouldRestoreSelection) { + this.restoreSelection(); } if (!this.nodes.input) { @@ -300,12 +312,54 @@ export default class LinkInlineTool implements InlineTool { this.nodes.input.classList.remove(this.CSS.inputShowed); this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false); this.nodes.input.value = ''; + this.updateButtonStateAttributes(false); + this.unlinkAvailable = false; if (clearSavedSelection) { this.selection.clearSaved(); } this.inputOpened = false; } + /** + * Restore selection after closing actions + */ + private restoreSelection(): void { + // if actions is broken by other selection We need to save new selection + const currentSelection = new SelectionUtils(); + const isSelectionInEditor = SelectionUtils.isAtEditor; + + if (isSelectionInEditor) { + currentSelection.save(); + } + + this.selection.removeFakeBackground(); + this.selection.restore(); + + // and recover new selection after removing fake background + if (!isSelectionInEditor && this.selection.savedSelectionRange) { + const range = this.selection.savedSelectionRange; + const container = range.commonAncestorContainer; + const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement; + + element?.focus(); + } + + if (!isSelectionInEditor) { + return; + } + + currentSelection.restore(); + + const range = currentSelection.savedSelectionRange; + + if (range) { + const container = range.commonAncestorContainer; + const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement; + + element?.focus(); + } + } + /** * Enter pressed on input * @@ -322,6 +376,8 @@ export default class LinkInlineTool implements InlineTool { this.unlink(); event.preventDefault(); this.closeActions(); + // Explicitly close inline toolbar as well, similar to legacy behavior + this.inlineToolbar.close(); return; } @@ -339,8 +395,8 @@ export default class LinkInlineTool implements InlineTool { const preparedValue = this.prepareLink(value); - this.selection.restore(); this.selection.removeFakeBackground(); + this.selection.restore(); this.insertLink(preparedValue); @@ -417,20 +473,63 @@ export default class LinkInlineTool implements InlineTool { /** * Edit all link, not selected part */ - const anchorTag = this.selection.findParentTag('A'); + const anchorTag = this.selection.findParentTag('A') as HTMLAnchorElement; if (anchorTag) { this.selection.expandToTag(anchorTag); + + anchorTag.href = link; + anchorTag.target = '_blank'; + anchorTag.rel = 'nofollow'; + + return; } - document.execCommand(this.commandLink, false, link); + const range = SelectionUtils.range; + + if (!range) { + return; + } + + const anchor = document.createElement('a'); + + anchor.href = link; + anchor.target = '_blank'; + anchor.rel = 'nofollow'; + + anchor.appendChild(range.extractContents()); + + range.insertNode(anchor); + + this.selection.expandToTag(anchor); } /** * Removes tag */ private unlink(): void { - document.execCommand(this.commandUnlink); + const anchorTag = this.selection.findParentTag('A'); + + if (anchorTag) { + this.unwrap(anchorTag); + this.updateButtonStateAttributes(false); + this.unlinkAvailable = false; + } + } + + /** + * Unwrap passed node + * + * @param term - node to unwrap + */ + private unwrap(term: HTMLElement): void { + const docFrag = document.createDocumentFragment(); + + while (term.firstChild) { + docFrag.appendChild(term.firstChild); + } + + term.parentNode?.replaceChild(docFrag, term); } /** @@ -447,4 +546,6 @@ export default class LinkInlineTool implements InlineTool { element.setAttribute(attributeName, state ? 'true' : 'false'); } -} +}; + +export default LinkInlineTool; diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index f96d46ef..e8d17b13 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -30,8 +30,6 @@ export default class BlocksAPI extends Module { getBlockIndex: (id: string): number | undefined => this.getBlockIndex(id), getBlocksCount: (): number => this.getBlocksCount(), getBlockByElement: (element: HTMLElement) => this.getBlockByElement(element), - stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status), - insertNewBlock: (): void => this.insertNewBlock(), insert: this.insert, insertMany: this.insertMany, update: this.update, @@ -218,29 +216,6 @@ export default class BlocksAPI extends Module { return this.Editor.Paste.processText(data, true); } - /** - * Stretch Block's content - * - * @param {number} index - index of Block to stretch - * @param {boolean} status - true to enable, false to disable - * @deprecated Use BlockAPI interface to stretch Blocks - */ - public stretchBlock(index: number, status = true): void { - _.deprecationAssert( - true, - 'blocks.stretchBlock()', - 'BlockAPI' - ); - - const block = this.Editor.BlockManager.getBlockByIndex(index); - - if (!block) { - return; - } - - block.stretched = status; - } - /** * Insert new Block and returns it's API * @@ -298,19 +273,6 @@ export default class BlocksAPI extends Module { return block.data; }; - /** - * Insert new Block - * After set caret to this Block - * - * @todo remove in 3.0.0 - * @deprecated with insert() method - */ - public insertNewBlock(): void { - _.log('Method blocks.insertNewBlock() is deprecated and it will be removed in the next major release. ' + - 'Use blocks.insert() instead.', 'warn'); - this.insert(); - } - /** * Updates block data by id * diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index aacfdf59..7442cfc9 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -6,8 +6,7 @@ import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; import type { MenuConfigItem } from '../../../../types/tools'; -import { resolveAliases } from '../../utils/resolve-aliases'; -import type { PopoverItemParams, PopoverItemDefaultBaseParams } from '../../utils/popover'; +import type { PopoverItemParams } from '../../utils/popover'; import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemType } from '../../utils/popover'; import type { PopoverParams } from '@/types/utils/popover/popover'; import { PopoverEvent } from '@/types/utils/popover/popover-event'; @@ -299,7 +298,7 @@ export default class BlockSettings extends Module { items.push(...commonTunes); - return items.map(tune => this.resolveTuneAliases(tune)); + return items; } /** @@ -309,43 +308,6 @@ export default class BlockSettings extends Module { this.close(); }; - /** - * Resolves aliases in tunes menu items - * - * @param item - item with resolved aliases - */ - private resolveTuneAliases(item: MenuConfigItem): PopoverItemParams { - if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) { - return item; - } - - const baseItem = resolveAliases(item, { label: 'title' }) as MenuConfigItem; - - const itemWithConfirmation = ('confirmation' in item && item.confirmation !== undefined) - ? { - ...baseItem, - confirmation: resolveAliases(item.confirmation, { label: 'title' }) as PopoverItemDefaultBaseParams, - } - : baseItem; - - if (!('children' in item) || item.children === undefined) { - return itemWithConfirmation as PopoverItemParams; - } - - const { onActivate: _onActivate, ...itemWithoutOnActivate } = itemWithConfirmation as MenuConfigItem & { onActivate?: undefined }; - const childrenItems = item.children.items?.map((childItem) => { - return this.resolveTuneAliases(childItem as MenuConfigItem); - }); - - return { - ...itemWithoutOnActivate, - children: { - ...item.children, - items: childrenItems, - }, - } as PopoverItemParams; - } - /** * Attaches keydown listener to delegate navigation events to the shared flipper * diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index d1360751..efd6b2ee 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -10,7 +10,7 @@ import Shortcuts from '../../utils/shortcuts'; import type { ModuleConfig } from '../../../types-internal/module-config'; import type { EditorModules } from '../../../types-internal/editor-modules'; import { CommonInternalSettings } from '../../tools/base'; -import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover'; +import type { Popover, PopoverItemParams } from '../../utils/popover'; import { PopoverItemType } from '../../utils/popover'; import { PopoverInline } from '../../utils/popover/popover-inline'; import type InlineToolAdapter from 'src/components/tools/inline'; @@ -74,11 +74,6 @@ export default class InlineToolbar extends Module { */ private registeredShortcuts: Map = new Map(); - /** - * Range captured before activating an inline tool via shortcut - */ - private savedShortcutRange: Range | null = null; - /** * Tracks whether inline shortcuts have been registered */ @@ -250,9 +245,8 @@ export default class InlineToolbar extends Module { } for (const toolInstance of this.tools.values()) { - if (_.isFunction(toolInstance.clear)) { - toolInstance.clear(); - } + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + toolInstance; } this.tools = new Map(); @@ -274,7 +268,6 @@ export default class InlineToolbar extends Module { } this.popover = null; - this.savedShortcutRange = null; } /** @@ -362,8 +355,6 @@ export default class InlineToolbar extends Module { } this.popover.show?.(); - - this.checkToolsState(); } /** @@ -640,9 +631,6 @@ export default class InlineToolbar extends Module { ): void { const commonPopoverItemParams = { name: toolName, - onActivate: () => { - this.toolClicked(instance); - }, hint: { title: toolTitle, description: shortcutBeautified, @@ -650,8 +638,6 @@ export default class InlineToolbar extends Module { } as PopoverItemParams; if ($.isElement(item)) { - this.processElementItem(item, instance, commonPopoverItemParams, popoverItems); - return; } @@ -682,71 +668,6 @@ export default class InlineToolbar extends Module { this.processDefaultItem(item, commonPopoverItemParams, popoverItems, index); } - /** - * Process an element-based popover item (deprecated way) - * - * @param item - HTML element - * @param instance - tool instance - * @param commonPopoverItemParams - common parameters for popover item - * @param popoverItems - array to add the processed item to - */ - private processElementItem( - item: HTMLElement, - instance: IInlineTool, - commonPopoverItemParams: PopoverItemParams, - popoverItems: PopoverItemParams[] - ): void { - /** - * Deprecated way to add custom html elements to the Inline Toolbar - */ - - const popoverItem = { - ...commonPopoverItemParams, - element: item, - type: PopoverItemType.Html, - } as PopoverItemParams; - - /** - * If tool specifies actions in deprecated manner, append them as children - */ - if (_.isFunction(instance.renderActions)) { - const actions = instance.renderActions(); - const selection = SelectionUtils.get(); - - (popoverItem as WithChildren).children = { - isOpen: selection ? instance.checkState?.(selection) ?? false : false, - /** Disable keyboard navigation in actions, as it might conflict with enter press handling */ - isFlippable: false, - items: [ - { - type: PopoverItemType.Html, - element: actions, - }, - ], - }; - } else { - this.checkLegacyToolState(instance); - } - - popoverItems.push(popoverItem); - } - - /** - * Check state for legacy inline tools that might perform UI mutating logic - * - * @param instance - tool instance - */ - private checkLegacyToolState(instance: IInlineTool): void { - /** - * Legacy inline tools might perform some UI mutating logic in checkState method, so, call it just in case - */ - const selection = this.resolveSelection(); - - if (selection) { - instance.checkState?.(selection); - } - } - /** * Process a default popover item * @@ -780,15 +701,6 @@ export default class InlineToolbar extends Module { } popoverItems.push(popoverItem); - - /** - * Append a separator after the item if it has children and not the last one - */ - if ('children' in popoverItem && index < this.tools.size - 1) { - popoverItems.push({ - type: PopoverItemType.Separator, - }); - } } /** @@ -889,27 +801,12 @@ export default class InlineToolbar extends Module { }); } - /** - * Inline Tool button clicks - * - * @param tool - Tool's instance - */ - private toolClicked(tool: IInlineTool): void { - const range = SelectionUtils.range ?? this.restoreShortcutRange(); - - tool.surround?.(range); - this.savedShortcutRange = null; - this.checkToolsState(); - } - /** * Activates inline tool triggered by keyboard shortcut * * @param toolName - tool to activate */ private async activateToolByShortcut(toolName: string): Promise { - const initialRange = SelectionUtils.range; - if (!this.opened) { await this.tryToShow(); } @@ -917,68 +814,14 @@ export default class InlineToolbar extends Module { const selection = SelectionUtils.get(); if (!selection) { - this.savedShortcutRange = initialRange ? initialRange.cloneRange() : null; this.popover?.activateItemByName(toolName); return; } - const toolEntry = Array.from(this.tools.entries()) - .find(([ toolAdapter ]) => toolAdapter.name === toolName); - - const toolInstance = toolEntry?.[1]; - const isToolActive = toolInstance?.checkState?.(selection) ?? false; - - if (isToolActive) { - this.savedShortcutRange = null; - - return; - } - - const currentRange = SelectionUtils.range ?? initialRange ?? null; - - this.savedShortcutRange = currentRange ? currentRange.cloneRange() : null; - this.popover?.activateItemByName(toolName); } - /** - * Restores selection from the shortcut-captured range if present - */ - private restoreShortcutRange(): Range | null { - if (!this.savedShortcutRange) { - return null; - } - - const selection = SelectionUtils.get(); - - if (selection) { - selection.removeAllRanges(); - const restoredRange = this.savedShortcutRange.cloneRange(); - - selection.addRange(restoredRange); - - return restoredRange; - } - - return this.savedShortcutRange; - } - - /** - * Check Tools` state by selection - */ - private checkToolsState(): void { - const selection = this.resolveSelection(); - - if (!selection) { - return; - } - - this.tools?.forEach((toolInstance) => { - toolInstance.checkState?.(selection); - }); - } - /** * Get inline tools tools * Tools that has isInline is true diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index 355858a9..773a65a9 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -1,7 +1,6 @@ import Paragraph from '@editorjs/paragraph'; import Module from '../__module'; import * as _ from '../utils'; -import type { ChainData } from '../utils'; import PromiseQueue from '../utils/promise-queue'; import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types'; import BoldInlineTool from '../inline-tools/inline-tool-bold'; @@ -19,6 +18,17 @@ import MoveUpTune from '../block-tunes/block-tune-move-up'; import ToolsCollection from '../tools/collection'; import { CriticalError } from '../errors/critical'; +/** + * @typedef {object} ChainData + * @property {object} data - data that will be passed to the success or fallback + * @property {Function} function - function's that must be called asynchronously + * @interface ChainData + */ +export interface ChainData { + data?: object; + function: (...args: unknown[]) => unknown; +} + const cacheableSanitizer = _.cacheable as ( target: object, propertyKey: string | symbol, @@ -171,7 +181,6 @@ export default class Tools extends Module { return Promise.resolve(); } - /* to see how it works {@link '../utils.ts#sequence'} */ const handlePrepareSuccess = (data: object): void => { if (!this.isToolPrepareData(data)) { return; diff --git a/src/components/selection.ts b/src/components/selection.ts index ed09a1c0..c26dce78 100644 --- a/src/components/selection.ts +++ b/src/components/selection.ts @@ -423,10 +423,24 @@ export default class SelectionUtils { return; } + const firstElement = this.fakeBackgroundElements[0]; + const lastElement = this.fakeBackgroundElements[this.fakeBackgroundElements.length - 1]; + + const firstChild = firstElement.firstChild; + const lastChild = lastElement.lastChild; + this.fakeBackgroundElements.forEach((element) => { this.unwrapFakeBackground(element); }); + if (firstChild && lastChild) { + const newRange = document.createRange(); + + newRange.setStart(firstChild, 0); + newRange.setEnd(lastChild, lastChild.textContent?.length || 0); + this.savedSelectionRange = newRange; + } + this.fakeBackgroundElements = []; this.isFakeBackgroundEnabled = false; } @@ -506,8 +520,16 @@ export default class SelectionUtils { */ private collectTextNodes(range: Range): Text[] { const nodes: Text[] = []; + const { commonAncestorContainer } = range; + + if (commonAncestorContainer.nodeType === Node.TEXT_NODE) { + nodes.push(commonAncestorContainer as Text); + + return nodes; + } + const walker = document.createTreeWalker( - range.commonAncestorContainer, + commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode: (node: Node): number => { @@ -578,7 +600,6 @@ export default class SelectionUtils { } parent.removeChild(element); - parent.normalize(); } /** diff --git a/src/components/tools/inline.ts b/src/components/tools/inline.ts index 374da3fa..185b6ba0 100644 --- a/src/components/tools/inline.ts +++ b/src/components/tools/inline.ts @@ -28,15 +28,6 @@ export default class InlineToolAdapter extends BaseToolAdapter typeof prototype[methodName] !== 'function'); } - /** - * Returns title for Inline Tool if specified by user - */ - public get title(): string { - const constructable = this.constructable as InlineToolConstructable | undefined; - - return constructable?.title ?? ''; - } - /** * Constructs new InlineTool instance from constructable */ diff --git a/src/components/utils.ts b/src/components/utils.ts index 3f4f896d..c9fbfe9f 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -52,16 +52,6 @@ export const getEditorVersion = (): string => { return fallbackEditorVersion; }; -/** - * @typedef {object} ChainData - * @property {object} data - data that will be passed to the success or fallback - * @property {Function} function - function's that must be called asynchronously - * @interface ChainData - */ -export interface ChainData { - data?: object; - function: (...args: unknown[]) => unknown; -} /** * Editor.js utils @@ -399,55 +389,6 @@ export const isPrintableKey = (keyCode: number): boolean => { (keyCode > keyCodes.BRACKET_KEY_MIN && keyCode < keyCodes.BRACKET_KEY_MAX); // [\]' (in order) }; -/** - * Fires a promise sequence asynchronously - * - * @param {ChainData[]} chains - list or ChainData's - * @param {Function} success - success callback - * @param {Function} fallback - callback that fires in case of errors - * @returns {Promise} - * @deprecated use PromiseQueue.ts instead - */ -export const sequence = async ( - chains: ChainData[], - success: (data: object) => void = (): void => {}, - fallback: (data: object) => void = (): void => {} -): Promise => { - /** - * Decorator - * - * @param {ChainData} chainData - Chain data - * @param {Function} successCallback - success callback - * @param {Function} fallbackCallback - fail callback - * @returns {Promise} - */ - const waitNextBlock = async ( - chainData: ChainData, - successCallback: (data: object) => void, - fallbackCallback: (data: object) => void - ): Promise => { - try { - await chainData.function(chainData.data); - await successCallback(!isUndefined(chainData.data) ? chainData.data : {}); - } catch (e) { - fallbackCallback(!isUndefined(chainData.data) ? chainData.data : {}); - } - }; - - /** - * pluck each element from queue - * First, send resolved Promise as previous value - * Each plugins "prepare" method returns a Promise, that's why - * reduce current element will not be able to continue while can't get - * a resolved Promise - */ - return chains.reduce(async (previousValue, currentValue) => { - await previousValue; - - return waitNextBlock(currentValue, success, fallback); - }, Promise.resolve()); -}; - /** * Make array from array-like collection * @@ -700,20 +641,6 @@ export const generateId = (prefix = ''): string => { return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`; }; -/** - * Common method for printing a warning about the usage of deprecated property or method. - * - * @param condition - condition for deprecation. - * @param oldProperty - deprecated property. - * @param newProperty - the property that should be used instead. - */ -export const deprecationAssert = (condition: boolean, oldProperty: string, newProperty: string): void => { - const message = `«${oldProperty}» is deprecated and will be removed in the next major release. Please use the «${newProperty}» instead.`; - - if (condition) { - logLabeled(message, 'warn'); - } -}; type CacheableAccessor = { get?: () => Value; diff --git a/src/components/utils/resolve-aliases.ts b/src/components/utils/resolve-aliases.ts deleted file mode 100644 index 03d8715e..00000000 --- a/src/components/utils/resolve-aliases.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Resolves aliases in specified object according to passed aliases info - * - * @example resolveAliases(obj, { label: 'title' }) - * here 'label' is alias for 'title' - * @param obj - object with aliases to be resolved - * @param aliases - object with aliases info where key is an alias property name and value is an aliased property name - */ -export const resolveAliases = ( - obj: ObjectType, - aliases: Partial> -): ObjectType => { - const result = {} as ObjectType; - - (Object.keys(obj) as Array).forEach((property) => { - const propertyKey = property as keyof ObjectType; - const propertyString = String(property); - const aliasedProperty = aliases[propertyString]; - - if (aliasedProperty === undefined) { - result[propertyKey] = obj[propertyKey]; - - return; - } - - if (!(aliasedProperty in obj)) { - result[aliasedProperty] = obj[propertyKey]; - } - }); - - return result; -}; diff --git a/src/styles/export.css b/src/styles/export.css index 6a9df2d5..091e4589 100644 --- a/src/styles/export.css +++ b/src/styles/export.css @@ -2,36 +2,36 @@ * Block Tool wrapper */ .cdx-block { - padding: var(--block-padding-vertical) 0; + padding: var(--block-padding-vertical) 0; - &::-webkit-input-placeholder { - line-height:normal!important; - } + &::-webkit-input-placeholder { + line-height: normal !important; + } } /** * Input */ .cdx-input { - border: 1px solid var(--color-gray-border); - box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06); - border-radius: 3px; - padding: 10px 12px; - outline: none; - width: 100%; - box-sizing: border-box; + border: 1px solid var(--color-line-gray); + box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06); + border-radius: 3px; + padding: 10px 12px; + outline: none; + width: 100%; + box-sizing: border-box; - /** + /** * Workaround Firefox bug with cursor position on empty content editable elements with ::before pseudo * https://bugzilla.mozilla.org/show_bug.cgi?id=904846 */ - &[data-placeholder]::before { - position: static !important; - display: inline-block; - width: 0; - white-space: nowrap; - pointer-events: none; - } + &[data-placeholder]::before { + position: static !important; + display: inline-block; + width: 0; + white-space: nowrap; + pointer-events: none; + } } /** @@ -39,112 +39,112 @@ * @deprecated - use tunes config instead of creating html element with controls */ .cdx-settings-button { - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + cursor: pointer; + border: 0; + outline: none; + background-color: transparent; + vertical-align: bottom; + color: inherit; + margin: 0; + min-width: var(--toolbox-buttons-size); + min-height: var(--toolbox-buttons-size); - border-radius: 3px; - cursor: pointer; - border: 0; - outline: none; - background-color: transparent; - vertical-align: bottom; - color: inherit; - margin: 0; - min-width: var(--toolbox-buttons-size); - min-height: var(--toolbox-buttons-size); + &--focused { + @apply --button-focused; - &--focused { - @apply --button-focused; - - &-animated { - animation-name: buttonClicked; - animation-duration: 250ms; + &-animated { + animation-name: buttonClicked; + animation-duration: 250ms; + } } - } - &--active { - color: var(--color-active-icon); - } + &--active { + color: var(--color-active-icon); + } - svg { - width: auto; - height: auto; + svg { + width: auto; + height: auto; + + @media (--mobile) { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } + } @media (--mobile) { - width: var(--icon-size--mobile); - height: var(--icon-size--mobile); + width: var(--toolbox-buttons-size--mobile); + height: var(--toolbox-buttons-size--mobile); + border-radius: 8px; } - } - @media (--mobile) { - width: var(--toolbox-buttons-size--mobile); - height: var(--toolbox-buttons-size--mobile); - border-radius: 8px; - } - - @media (--can-hover) { - &:hover { - background-color: var(--bg-light); + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } } - } } /** * Loader */ .cdx-loader { - position: relative; - border: 1px solid var(--color-gray-border); + position: relative; + border: 1px solid var(--color-line-gray); - &::before { - content: ''; - position: absolute; - left: 50%; - top: 50%; - width: 18px; - height: 18px; - margin: -11px 0 0 -11px; - border: 2px solid var(--color-gray-border); - border-left-color: var(--color-active-icon); - border-radius: 50%; - animation: cdxRotation 1.2s infinite linear; - } + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 18px; + height: 18px; + margin: -11px 0 0 -11px; + border: 2px solid var(--color-line-gray); + border-left-color: var(--color-active-icon); + border-radius: 50%; + animation: cdxRotation 1.2s infinite linear; + } } @keyframes cdxRotation { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } /** * Button */ .cdx-button { - padding: 13px; - border-radius: 3px; - border: 1px solid var(--color-gray-border); - font-size: 14.9px; - background: #fff; - box-shadow: 0 2px 2px 0 rgba(18,30,57,0.04); - color: var(--grayText); - text-align: center; - cursor: pointer; + padding: 13px; + border-radius: 3px; + border: 1px solid var(--color-line-gray); + font-size: 14.9px; + background: #fff; + box-shadow: 0 2px 2px 0 rgba(18, 30, 57, 0.04); + color: var(--grayText); + text-align: center; + cursor: pointer; - @media (--can-hover) { - &:hover { - background: #FBFCFE; - box-shadow: 0 1px 3px 0 rgba(18,30,57,0.08); + @media (--can-hover) { + &:hover { + background: #fbfcfe; + box-shadow: 0 1px 3px 0 rgba(18, 30, 57, 0.08); + } } - } - svg { - height: 20px; - margin-right: 0.2em; - margin-top: -2px; - } + svg { + height: 20px; + margin-right: 0.2em; + margin-top: -2px; + } } diff --git a/src/styles/inline-toolbar.css b/src/styles/inline-toolbar.css index f7ba7a30..e3b40503 100644 --- a/src/styles/inline-toolbar.css +++ b/src/styles/inline-toolbar.css @@ -1,161 +1,156 @@ .ce-inline-toolbar { - --y-offset: 8px; + --y-offset: 8px; - /** These variables duplicate the ones defined in popover. @todo move them to single place */ - --color-background-icon-active: rgba(56, 138, 229, 0.1); - --color-text-icon-active: #388AE5; - --color-text-primary: black; + /** These variables duplicate the ones defined in popover. @todo move them to single place */ + --color-background-icon-active: rgba(56, 138, 229, 0.1); + --color-text-icon-active: #388ae5; + --color-text-primary: black; - position: absolute; - visibility: hidden; - transition: opacity 250ms ease; - will-change: opacity, left, top; - top: 0; - left: 0; - z-index: 3; - opacity: 1; - visibility: visible; + position: absolute; + transition: opacity 250ms ease; + will-change: opacity, left, top; + top: 0; + left: 0; + z-index: 3; + opacity: 1; + visibility: visible; - [hidden] { - display: none !important; - } - - &__toggler-and-button-wrapper { - display: flex; - width: 100%; - padding: 0 6px; - } - - &__buttons { - display: flex; - } - - &__actions { - } - - &__dropdown { - display: flex; - padding: 6px; - margin: 0 6px 0 -6px; - align-items: center; - cursor: pointer; - border-right: 1px solid var(--color-gray-border); - box-sizing: border-box; - - @media (--can-hover) { - &:hover { - background: var(--bg-light); - } + [hidden] { + display: none !important; } - &--hidden { - display: none; + &__toggler-and-button-wrapper { + display: flex; + width: 100%; + padding: 0 6px; } - &-content, - &-arrow { - display: flex; - svg { - width: var(--icon-size); - height: var(--icon-size); - } + &__buttons { + display: flex; } - } - &__shortcut { - opacity: 0.6; - word-spacing: -3px; - margin-top: 3px; - } + &__dropdown { + display: flex; + padding: 6px; + margin: 0 6px 0 -6px; + align-items: center; + cursor: pointer; + border-right: 1px solid var(--color-line-gray); + box-sizing: border-box; + + @media (--can-hover) { + &:hover { + background: var(--bg-light); + } + } + + &--hidden { + display: none; + } + + &-content, + &-arrow { + display: flex; + + svg { + width: var(--icon-size); + height: var(--icon-size); + } + } + } + + &__shortcut { + opacity: 0.6; + word-spacing: -3px; + margin-top: 3px; + } } .ce-inline-tool { - color: var(--color-text-primary); - display: flex; - justify-content: center; - align-items: center; - - border: 0; - border-radius: 4px; - line-height: normal; - height: 100%; - padding: 0; - width: 28px; - background-color: transparent; - cursor: pointer; - - @media (--mobile) { - width: 36px; - height: 36px; - } - - @media (--can-hover) { - &:hover { - background-color: #F8F8F8; /* @todo replace with 'var(--color-background-item-hover)' */ - } - } - - svg { - display: block; - width: var(--icon-size); - height: var(--icon-size); + color: var(--color-text-primary); + display: flex; + justify-content: center; + align-items: center; + border: 0; + border-radius: 4px; + line-height: normal; + height: 100%; + padding: 0; + width: 28px; + background-color: transparent; + cursor: pointer; @media (--mobile) { - width: var(--icon-size--mobile); - height: var(--icon-size--mobile); - } - } - - &--link { - .icon--unlink { - display: none; - } - } - - &--unlink { - .icon--link { - display: none; - } - .icon--unlink { - display: inline-block; - margin-bottom: -1px; - } - } - - &-input { - background: #F8F8F8; - border: 1px solid rgba(226,226,229,0.20); - border-radius: 6px; - padding: 4px 8px; - font-size: 14px; - line-height: 22px; - - - outline: none; - margin: 0; - width: 100%; - box-sizing: border-box; - display: none; - font-weight: 500; - -webkit-appearance: none; - font-family: inherit; - - @media (--mobile){ - font-size: 15px; - font-weight: 500; + width: 36px; + height: 36px; } - &::placeholder { - color: var(--grayText); + @media (--can-hover) { + &:hover { + background-color: #f8f8f8; /* @todo replace with 'var(--color-background-item-hover)' */ + } } - &--showed { - display: block; - } - } + svg { + display: block; + width: var(--icon-size); + height: var(--icon-size); - &--active { - background: var(--color-background-icon-active); - color: var(--color-text-icon-active); - } + @media (--mobile) { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } + } + + &--link { + .icon--unlink { + display: none; + } + } + + &--unlink { + .icon--link { + display: none; + } + + .icon--unlink { + display: inline-block; + margin-bottom: -1px; + } + } + + &-input { + background: #f8f8f8; + border: 1px solid rgba(226, 226, 229, 0.2); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; + line-height: 22px; + outline: none; + margin: 0; + width: 100%; + box-sizing: border-box; + display: none; + font-weight: 500; + -webkit-appearance: none; + font-family: inherit; + + @media (--mobile) { + font-size: 15px; + font-weight: 500; + } + + &::placeholder { + color: var(--grayText); + } + + &--showed { + display: block; + } + } + + &--active { + background: var(--color-background-icon-active); + color: var(--color-text-icon-active); + } } diff --git a/src/styles/popover-inline.css b/src/styles/popover-inline.css index 89b2fa38..bc0953a9 100644 --- a/src/styles/popover-inline.css +++ b/src/styles/popover-inline.css @@ -92,6 +92,14 @@ transform: rotate(90deg); } + /** + * Hide chevron for the link tool — it renders a custom input instead of a dropdown list, + * so the arrow is misleading here but should stay for other tools like the text style switcher. + */ + [data-item-name="link"] .ce-popover-item__icon--chevron-right { + display: none; + } + .ce-popover--nested-level-1 { .ce-popover__container { --offset: 3px; diff --git a/src/styles/rtl.css b/src/styles/rtl.css index f65cce74..95349abf 100644 --- a/src/styles/rtl.css +++ b/src/styles/rtl.css @@ -1,82 +1,79 @@ .codex-editor.codex-editor--rtl { - direction: rtl; + direction: rtl; - .cdx-list { - padding-left: 0; - padding-right: 40px; - } - - .ce-toolbar { - &__plus { - right: calc(var(--toolbox-buttons-size) * -1); - left: auto; + .cdx-list { + padding-left: 0; + padding-right: 40px; } - &__actions { - right: auto; - left: calc(var(--toolbox-buttons-size) * -1); + .ce-toolbar { + &__plus { + right: calc(var(--toolbox-buttons-size) * -1); + left: auto; + } - @media (--mobile){ - margin-left: 0; - margin-right: auto; - padding-right: 0; - padding-left: 10px; - } - } - } + &__actions { + right: auto; + left: calc(var(--toolbox-buttons-size) * -1); - .ce-settings { - left: 5px; - right: auto; - - &::before{ - right: auto; - left: 25px; + @media (--mobile) { + margin-left: 0; + margin-right: auto; + padding-right: 0; + padding-left: 10px; + } + } } - &__button { - &:not(:nth-child(3n+3)) { - margin-left: 3px; - margin-right: 0; - } + .ce-settings { + left: 5px; + right: auto; + + &::before { + right: auto; + left: 25px; + } + + &__button { + &:not(:nth-child(3n+3)) { + margin-left: 3px; + margin-right: 0; + } + } } - } - .ce-conversion-tool { - &__icon { - margin-right: 0px; - margin-left: 10px; + .ce-conversion-tool { + &__icon { + margin-right: 0; + margin-left: 10px; + } } - } - .ce-inline-toolbar { - &__dropdown { - border-right: 0px solid transparent; - border-left: 1px solid var(--color-gray-border); - margin: 0 -6px 0 6px; + .ce-inline-toolbar { + &__dropdown { + border-right: 0 solid transparent; + border-left: 1px solid var(--color-line-gray); + margin: 0 -6px 0 6px; - .icon--toggler-down { - margin-left: 0px; - margin-right: 4px; - } + .icon--toggler-down { + margin-left: 0; + margin-right: 4px; + } + } } - } - } .codex-editor--narrow.codex-editor--rtl { - .ce-toolbar__plus { - @media (--not-mobile) { - left: 0px; - right: 5px; + .ce-toolbar__plus { + @media (--not-mobile) { + left: 0; + right: 5px; + } } - } - .ce-toolbar__actions { - @media (--not-mobile) { - left: -5px; + .ce-toolbar__actions { + @media (--not-mobile) { + left: -5px; + } } - } } - - diff --git a/src/styles/variables.css b/src/styles/variables.css index 61d8ab2b..9a8d14ad 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -6,173 +6,164 @@ @custom-media --can-hover (hover: hover); :root { - /** + /** * Selection color */ - --selectionColor: #e1f2ff; - --inlineSelectionColor: #d4ecff; + --selectionColor: #e1f2ff; + --inlineSelectionColor: #d4ecff; - /** + /** * Toolbar buttons */ - --bg-light: #eff2f5; + --bg-light: #eff2f5; - /** + /** * All gray texts: placeholders, settings */ - --grayText: #707684; + --grayText: #707684; - /** + /** * Gray icons hover */ - --color-dark: #1D202B; + --color-dark: #1d202b; - /** + /** * Blue icons */ - --color-active-icon: #388AE5; + --color-active-icon: #388ae5; - /** - * Gray border, loaders - * @deprecated — use --color-line-gray instead - */ - --color-gray-border: rgba(201, 201, 204, 0.48); - - /** + /** * Block content width * Should be set in a constant at the modules/ui.js */ - --content-width: 650px; + --content-width: 650px; - /** + /** * In narrow mode, we increase right zone contained Block Actions button */ - --narrow-mode-right-padding: 50px; + --narrow-mode-right-padding: 50px; - /** + /** * Toolbar Plus Button and Toolbox buttons height and width */ - --toolbox-buttons-size: 26px; - --toolbox-buttons-size--mobile: 36px; + --toolbox-buttons-size: 26px; + --toolbox-buttons-size--mobile: 36px; - /** + /** * Size of svg icons got from the CodeX Icons pack */ - --icon-size: 20px; - --icon-size--mobile: 28px; + --icon-size: 20px; + --icon-size--mobile: 28px; - - /** + /** * The main `.cdx-block` wrapper has such vertical paddings * And the Block Actions toggler too */ - --block-padding-vertical: 0.4em; + --block-padding-vertical: 0.4em; + --color-line-gray: #eff0f1; - --color-line-gray: #EFF0F1; + --overlay-pane { + position: absolute; + background-color: #fff; + border: 1px solid #e8e8eb; + box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13); + border-radius: 6px; + z-index: 2; - --overlay-pane { - position: absolute; - background-color: #FFFFFF; - border: 1px solid #E8E8EB; - box-shadow: 0 3px 15px -3px rgba(13,20,33,0.13); - border-radius: 6px; - z-index: 2; + &--left-oriented { + &::before { + left: 15px; + margin-left: 0; + } + } - &--left-oriented { - &::before { - left: 15px; - margin-left: 0; - } + &--right-oriented { + &::before { + left: auto; + right: 15px; + margin-left: 0; + } + } } - &--right-oriented { - &::before { - left: auto; - right: 15px; - margin-left: 0; - } + --button-focused { + box-shadow: inset 0 0 0 1px rgba(7, 161, 227, 0.08); + background: rgba(34, 186, 255, 0.08) !important; } - }; - --button-focused { - box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08); - background: rgba(34, 186, 255, 0.08) !important; - }; + --button-active { + background: rgba(56, 138, 229, 0.1); + color: var(--color-active-icon); + } - --button-active { - background: rgba(56, 138, 229, 0.1); - color: var(--color-active-icon); - }; + --button-disabled { + color: var(--grayText); + cursor: default; + pointer-events: none; + } - --button-disabled { - color: var(--grayText); - cursor: default; - pointer-events: none; - } - - /** + /** * Styles for Toolbox Buttons and Plus Button */ - --toolbox-button { - color: var(--color-dark); - cursor: pointer; - width: var(--toolbox-buttons-size); - height: var(--toolbox-buttons-size); - border-radius: 7px; - display: inline-flex; - justify-content: center; - align-items: center; - user-select: none; + --toolbox-button { + color: var(--color-dark); + cursor: pointer; + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); + border-radius: 7px; + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; - @media (--mobile){ - width: var(--toolbox-buttons-size--mobile); - height: var(--toolbox-buttons-size--mobile); + @media (--mobile) { + width: var(--toolbox-buttons-size--mobile); + height: var(--toolbox-buttons-size--mobile); + } + + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } + } + + &--active { + background-color: var(--bg-light); + animation: bounceIn 0.75s 1; + animation-fill-mode: forwards; + } } - @media (--can-hover) { - &:hover { - background-color: var(--bg-light); - } - } - - &--active { - background-color: var(--bg-light); - animation: bounceIn 0.75s 1; - animation-fill-mode: forwards; - } - }; - - /** + /** * Tool icon with border */ - --tool-icon { - display: inline-flex; - width: var(--toolbox-buttons-size); - height: var(--toolbox-buttons-size); - box-shadow: 0 0 0 1px var(--color-gray-border); - border-radius: 5px; - align-items: center; - justify-content: center; - background: #fff; - box-sizing: content-box; - flex-shrink: 0; - margin-right: 10px; + --tool-icon { + display: inline-flex; + width: var(--toolbox-buttons-size); + height: var(--toolbox-buttons-size); + box-shadow: 0 0 0 1px var(--color-line-gray); + border-radius: 5px; + align-items: center; + justify-content: center; + background: #fff; + box-sizing: content-box; + flex-shrink: 0; + margin-right: 10px; - svg { - width: var(--icon-size); - height: var(--icon-size); + svg { + width: var(--icon-size); + height: var(--icon-size); + } + + @media (--mobile) { + width: var(--toolbox-buttons-size--mobile); + height: var(--toolbox-buttons-size--mobile); + border-radius: 8px; + + svg { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } + } } - - @media (--mobile) { - width: var(--toolbox-buttons-size--mobile); - height: var(--toolbox-buttons-size--mobile); - border-radius: 8px; - - svg { - width: var(--icon-size--mobile); - height: var(--icon-size--mobile); - } - } - } } - diff --git a/test/playwright/tests/api/tools.spec.ts b/test/playwright/tests/api/tools.spec.ts index ad4ced9b..9db495dd 100644 --- a/test/playwright/tests/api/tools.spec.ts +++ b/test/playwright/tests/api/tools.spec.ts @@ -285,7 +285,7 @@ test.describe('api.tools', () => { test('should render single tune configured via renderSettings()', async ({ page }) => { const singleTuneToolSource = createTuneToolSource(` return { - label: 'Test tool tune', + title: 'Test tool tune', icon: '${ICON}', name: 'testToolTune', onActivate: () => {}, @@ -320,13 +320,13 @@ test.describe('api.tools', () => { const multipleTunesToolSource = createTuneToolSource(` return [ { - label: 'Test tool tune 1', + title: 'Test tool tune 1', icon: '${ICON}', name: 'testToolTune1', onActivate: () => {}, }, { - label: 'Test tool tune 2', + title: 'Test tool tune 2', icon: '${ICON}', name: 'testToolTune2', onActivate: () => {}, @@ -396,49 +396,6 @@ test.describe('api.tools', () => { ) ).toContainText(sampleText); }); - - test('should support title and label aliases for tune text', async ({ page }) => { - const labelAliasToolSource = createTuneToolSource(` - return [ - { - icon: '${ICON}', - name: 'testToolTune1', - onActivate: () => {}, - title: 'Test tool tune 1', - }, - { - icon: '${ICON}', - name: 'testToolTune2', - onActivate: () => {}, - label: 'Test tool tune 2', - }, - ]; - `); - - await createEditor(page, { - tools: [ - { - name: 'testTool', - classSource: labelAliasToolSource, - }, - ], - data: { - blocks: [ - { - type: 'testTool', - data: { - text: 'some text', - }, - }, - ], - }, - }); - - await openBlockSettings(page, 0); - - await expect(page.locator('[data-item-name="testToolTune1"]')).toContainText('Test tool tune 1'); - await expect(page.locator('[data-item-name="testToolTune2"]')).toContainText('Test tool tune 2'); - }); }); test.describe('pasteConfig', () => { diff --git a/test/playwright/tests/api/tunes.spec.ts b/test/playwright/tests/api/tunes.spec.ts index 796519f0..aad815c0 100644 --- a/test/playwright/tests/api/tunes.spec.ts +++ b/test/playwright/tests/api/tunes.spec.ts @@ -22,7 +22,6 @@ const SECOND_POPOVER_ITEM_SELECTOR = `${POPOVER_ITEM_SELECTOR}:nth-of-type(2)`; type SerializableTuneMenuItem = { icon?: string; title?: string; - label?: string; name: string; }; @@ -260,36 +259,13 @@ test.describe('api.tunes', () => { await expect(page.locator(POPOVER_SELECTOR)).toContainText(sampleText); }); - test('supports label alias when rendering tunes', async ({ page }) => { - await createEditorWithTune(page, { - type: 'multiple', - items: [ - { - icon: 'ICON1', - title: 'Tune entry 1', - name: 'testTune1', - }, - { - icon: 'ICON2', - label: 'Tune entry 2', - name: 'testTune2', - }, - ], - }); - - await focusBlockAndType(page, 'some text'); - await openBlockTunes(page); - - await expect(page.locator('[data-item-name="testTune1"]')).toContainText('Tune entry 1'); - await expect(page.locator('[data-item-name="testTune2"]')).toContainText('Tune entry 2'); - }); test('displays installed tunes above default tunes', async ({ page }) => { await createEditorWithTune(page, { type: 'single', item: { icon: 'ICON', - label: 'Tune entry', + title: 'Tune entry', name: 'test-tune', }, }); diff --git a/test/playwright/tests/inline-tools/italic.spec.ts b/test/playwright/tests/inline-tools/italic.spec.ts new file mode 100644 index 00000000..b0402c51 --- /dev/null +++ b/test/playwright/tests/inline-tools/italic.spec.ts @@ -0,0 +1,616 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type EditorJS from '@/types'; +import type { OutputData } from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; +import { EDITOR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"] .ce-paragraph`; +const INLINE_TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy=inline-toolbar]`; + +/** + * Reset the editor holder and destroy any existing instance + * + * @param page - The Playwright page object + */ +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 }); +}; + +/** + * Create editor with provided blocks + * + * @param page - The Playwright page object + * @param blocks - The blocks data to initialize the editor with + */ +const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise => { + await resetEditor(page); + await page.evaluate(async ({ holderId, blocks: editorBlocks }) => { + const editor = new window.EditorJS({ + holder: holderId, + data: { blocks: editorBlocks }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID, + blocks }); +}; + +/** + * Select text content within a locator by string match + * + * @param locator - The Playwright locator for the element containing the text + * @param text - The text string to select within the element + */ +const selectText = async (locator: Locator, text: string): Promise => { + await locator.evaluate((element, targetText) => { + // Walk text nodes to find the target text within the element + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode: Node | null = null; + let start = -1; + + while (walker.nextNode()) { + const node = walker.currentNode; + const content = node.textContent ?? ''; + const idx = content.indexOf(targetText); + + if (idx !== -1) { + textNode = node; + start = idx; + break; + } + } + + if (!textNode || start === -1) { + throw new Error(`Text "${targetText}" was not found in element`); + } + + const range = element.ownerDocument.createRange(); + + range.setStart(textNode, start); + range.setEnd(textNode, start + targetText.length); + + const selection = element.ownerDocument.getSelection(); + + selection?.removeAllRanges(); + selection?.addRange(range); + + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }, text); +}; + +test.describe('inline tool italic', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('detects italic state across multiple italic words', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'first second', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await paragraph.evaluate((el) => { + const paragraphEl = el as HTMLElement; + const doc = paragraphEl.ownerDocument; + const range = doc.createRange(); + const selection = doc.getSelection(); + + if (!selection) { + throw new Error('Selection not available'); + } + + const italics = paragraphEl.querySelectorAll('i'); + const firstItalic = italics[0]; + const secondItalic = italics[1]; + + if (!firstItalic || !secondItalic) { + throw new Error('Italic elements not found'); + } + + const firstItalicText = firstItalic.firstChild; + const secondItalicText = secondItalic.firstChild; + + if (!firstItalicText || !secondItalicText) { + throw new Error('Text nodes not found'); + } + + range.setStart(firstItalicText, 0); + range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0); + + selection.removeAllRanges(); + selection.addRange(range); + + doc.dispatchEvent(new Event('selectionchange')); + }); + + await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-popover-opened="true"]`)).toHaveCount(1); + await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).toHaveAttribute('data-popover-item-active', 'true'); + }); + + test('detects italic state within a single word', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'italic text', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'italic'); + + await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).toHaveAttribute('data-popover-item-active', 'true'); + }); + + test('does not detect italic state in normal text', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'normal text', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'normal'); + + await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`)).not.toHaveAttribute('data-popover-item-active', 'true'); + }); + + test('toggles italic across multiple italic elements', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'first second', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + // Select text spanning both italic elements + await paragraph.evaluate((el) => { + const paragraphEl = el as HTMLElement; + const doc = paragraphEl.ownerDocument; + const range = doc.createRange(); + const selection = doc.getSelection(); + + if (!selection) { + throw new Error('Selection not available'); + } + + const italics = paragraphEl.querySelectorAll('i'); + const firstItalic = italics[0]; + const secondItalic = italics[1]; + + if (!firstItalic || !secondItalic) { + throw new Error('Italic elements not found'); + } + + const firstItalicText = firstItalic.firstChild; + const secondItalicText = secondItalic.firstChild; + + if (!firstItalicText || !secondItalicText) { + throw new Error('Text nodes not found'); + } + + range.setStart(firstItalicText, 0); + range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0); + + selection.removeAllRanges(); + selection.addRange(range); + + doc.dispatchEvent(new Event('selectionchange')); + }); + + const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`); + + // Verify italic button is active (since all text is visually italic) + await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true'); + + // Click italic button - should remove italic on first click (since selection is visually italic) + await italicButton.click(); + + // Wait for the toolbar state to update (italic button should no longer be active) + await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true'); + + // Verify that italic has been removed + const html = await paragraph.innerHTML(); + + expect(html).toBe('first second'); + expect(html).not.toMatch(//); + }); + + test('makes mixed selection (italic and normal text) italic', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'italic normal italic2', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + // Select text spanning italic and non-italic + await paragraph.evaluate((el) => { + const paragraphEl = el as HTMLElement; + const doc = paragraphEl.ownerDocument; + const range = doc.createRange(); + const selection = doc.getSelection(); + + if (!selection) { + throw new Error('Selection not available'); + } + + const italics = paragraphEl.querySelectorAll('i'); + const firstItalic = italics[0]; + const secondItalic = italics[1]; + + if (!firstItalic || !secondItalic) { + throw new Error('Italic elements not found'); + } + + const firstItalicText = firstItalic.firstChild; + const secondItalicText = secondItalic.firstChild; + + if (!firstItalicText || !secondItalicText) { + throw new Error('Text nodes not found'); + } + + // Select from first italic through second italic (including the " normal " text) + range.setStart(firstItalicText, 0); + range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0); + + selection.removeAllRanges(); + selection.addRange(range); + + doc.dispatchEvent(new Event('selectionchange')); + }); + + // Click italic button (should unwrap existing italic, then wrap everything) + await page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`).click(); + + // Wait for all selected text to be wrapped in a single tag + await page.waitForFunction( + ({ selector }) => { + const element = document.querySelector(selector); + + return element && /italic normal italic2<\/i>/.test(element.innerHTML); + }, + { + selector: PARAGRAPH_SELECTOR, + } + ); + + // Verify that all selected text is now wrapped in a single tag + const html = await paragraph.innerHTML(); + + console.log('Mixed selection HTML:', html); + + // Allow for merged tags or separate tags + expect(html).toMatch(/.*italic.*normal.*italic2.*<\/i>/); + }); + + test('removes italic from fully italic selection', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'fully italic', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'fully italic'); + + const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`); + + await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true'); + + await italicButton.click(); + + await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true'); + + const html = await paragraph.innerHTML(); + + expect(html).toBe('fully italic'); + }); + + test('toggles italic with keyboard shortcut', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Keyboard shortcut', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'Keyboard'); + await paragraph.focus(); + + const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`); + + await page.keyboard.press(`${MODIFIER_KEY}+i`); + + await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true'); + + let html = await paragraph.innerHTML(); + + expect(html).toMatch(/Keyboard<\/i> shortcut/); + + await page.keyboard.press(`${MODIFIER_KEY}+i`); + + await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true'); + + html = await paragraph.innerHTML(); + + expect(html).toBe('Keyboard shortcut'); + }); + + test('applies italic to typed text', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Typing test', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await paragraph.evaluate((element) => { + const paragraphEl = element as HTMLElement; + const doc = paragraphEl.ownerDocument; + const textNode = paragraphEl.childNodes[paragraphEl.childNodes.length - 1]; + + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + throw new Error('Expected trailing text node'); + } + + const range = doc.createRange(); + const selection = doc.getSelection(); + + range.setStart(textNode, textNode.textContent?.length ?? 0); + range.collapse(true); + + selection?.removeAllRanges(); + selection?.addRange(range); + }); + + await paragraph.focus(); + + await page.keyboard.press(`${MODIFIER_KEY}+i`); + await page.keyboard.insertText(' Italic'); + await page.keyboard.press(`${MODIFIER_KEY}+i`); + await page.keyboard.insertText(' normal'); + + const html = await paragraph.innerHTML(); + + expect(html.replace(/ /g, ' ').replace(/\u200B/g, '')).toBe('Typing test Italic normal'); + }); + + test('persists italic in saved output', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'italic text', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'italic'); + + await page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`).click(); + + const savedData = await page.evaluate(async () => { + return window.editorInstance?.save(); + }); + + expect(savedData).toBeDefined(); + + const paragraphBlock = savedData?.blocks.find((block) => block.type === 'paragraph'); + + expect(paragraphBlock?.data.text).toMatch(/italic<\/i> text/); + }); + + test('removes italic from selection within italic text', async ({ page }) => { + // Step 1: Create editor with "Some text" + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + // Step 2: Select entire text and make it italic + await selectText(paragraph, 'Some text'); + + const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`); + + await italicButton.click(); + + // Wait for the text to be wrapped in italic tags + await page.waitForFunction( + ({ selector }) => { + const element = document.querySelector(selector); + + return element && /Some text<\/i>/.test(element.innerHTML); + }, + { + selector: PARAGRAPH_SELECTOR, + } + ); + + // Verify initial italic state + let html = await paragraph.innerHTML(); + + expect(html).toMatch(/Some text<\/i>/); + + // Step 3: Select only "Some" and remove italic formatting + await selectText(paragraph, 'Some'); + + // Verify italic button is active (since "Some" is italic) + await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true'); + + // Click to remove italic from "Some" + await italicButton.click(); + + // Wait for the toolbar state to update (italic button should no longer be active for "Some") + await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true'); + + // Step 4: Verify that "text" is still italic while "Some" is not + html = await paragraph.innerHTML(); + + // "text" should be wrapped in italic tags (with space before it) + expect(html).toMatch(/\s*text<\/i>/); + // "Some" should not be wrapped in italic tags + expect(html).not.toMatch(/Some<\/i>/); + }); + + test('removes italic from separately italic words', async ({ page }) => { + // Step 1: Start with normal text "some text" + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'some text', + }, + }, + ]); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + const italicButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="italic"]`); + + // Step 2: Make "some" italic + await selectText(paragraph, 'some'); + await italicButton.click(); + + // Verify "some" is now italic + let html = await paragraph.innerHTML(); + + expect(html).toMatch(/some<\/i> text/); + + // Step 3: Make "text" italic (now we have some text) + await selectText(paragraph, 'text'); + await italicButton.click(); + + // Verify both words are now italic with space between them + html = await paragraph.innerHTML(); + + expect(html).toMatch(/some<\/i> text<\/i>/); + + // Step 4: Select the whole phrase including the space + await paragraph.evaluate((el) => { + const paragraphEl = el as HTMLElement; + const doc = paragraphEl.ownerDocument; + const range = doc.createRange(); + const selection = doc.getSelection(); + + if (!selection) { + throw new Error('Selection not available'); + } + + const italics = paragraphEl.querySelectorAll('i'); + const firstItalic = italics[0]; + const secondItalic = italics[1]; + + if (!firstItalic || !secondItalic) { + throw new Error('Italic elements not found'); + } + + const firstItalicText = firstItalic.firstChild; + const secondItalicText = secondItalic.firstChild; + + if (!firstItalicText || !secondItalicText) { + throw new Error('Text nodes not found'); + } + + // Select from start of first italic to end of second italic (including the space) + range.setStart(firstItalicText, 0); + range.setEnd(secondItalicText, secondItalicText.textContent?.length ?? 0); + + selection.removeAllRanges(); + selection.addRange(range); + + doc.dispatchEvent(new Event('selectionchange')); + }); + + // Step 5: Verify the editor indicates the selection is italic (button is active) + await expect(italicButton).toHaveAttribute('data-popover-item-active', 'true'); + + // Step 6: Click italic button - should remove italic on first click (not wrap again) + await italicButton.click(); + + // Verify italic button is no longer active + await expect(italicButton).not.toHaveAttribute('data-popover-item-active', 'true'); + + // Verify that italic has been removed from both words on first click + html = await paragraph.innerHTML(); + + expect(html).toBe('some text'); + expect(html).not.toMatch(//); + }); +}); + +declare global { + interface Window { + editorInstance?: EditorJS; + EditorJS: new (...args: unknown[]) => EditorJS; + } +} diff --git a/test/playwright/tests/inline-tools/link-edge-cases.spec.ts b/test/playwright/tests/inline-tools/link-edge-cases.spec.ts new file mode 100644 index 00000000..4144d6f2 --- /dev/null +++ b/test/playwright/tests/inline-tools/link-edge-cases.spec.ts @@ -0,0 +1,370 @@ +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 { OutputData } from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; +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_CONTENT_SELECTOR = '[data-block-tool="paragraph"] .ce-paragraph'; +const INLINE_TOOLBAR_SELECTOR = INLINE_TOOLBAR_INTERFACE_SELECTOR; +// The link tool renders the item itself as a button, not a nested button +const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"]`; +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_CONTENT_SELECTOR, { hasText: text }); +}; + +const ensureLinkInputOpen = async (page: Page): Promise => { + // Wait for toolbar to be visible first + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible(); + + const linkButton = page.locator(LINK_BUTTON_SELECTOR); + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + // If input is already visible + if (await linkInput.isVisible()) { + return linkInput; + } + + // Check if button is active (meaning we are on a link) + // If active, clicking it will Unlink, which we usually don't want when "ensuring input open" for editing. + // We should just wait for input to appear (checkState opens it). + const isActive = await linkButton.getAttribute('data-link-tool-active') === 'true'; + + if (isActive) { + await expect(linkInput).toBeVisible(); + + return linkInput; + } + + // Otherwise click the button to open input + if (await linkButton.isVisible()) { + await linkButton.click(); + await expect(linkInput).toBeVisible(); + + return linkInput; + } + + throw new Error('Link input could not be opened'); +}; + +const selectText = async (locator: Locator, text: string): Promise => { + await locator.evaluate((element, targetText) => { + const root = element as HTMLElement; + const doc = root.ownerDocument; + + if (!doc) { + throw new Error('OwnerDocument not found'); + } + + const fullText = root.textContent ?? ''; + const startIndex = fullText.indexOf(targetText); + + if (startIndex === -1) { + throw new Error(`Text "${targetText}" not found`); + } + const endIndex = startIndex + targetText.length; + + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + 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('Nodes not found'); + } + + const range = doc.createRange(); + + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + + const selection = doc.getSelection(); + + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + root.focus(); + doc.dispatchEvent(new Event('selectionchange')); + }, text); +}; + +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; + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise => { + await resetEditor(page); + await page.evaluate(async ({ holderId, blocks: editorBlocks }) => { + const editor = new window.EditorJS({ + holder: holderId, + data: { blocks: editorBlocks }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID, + blocks }); +}; + +test.describe('inline tool link - edge cases', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('should expand selection to whole link when editing partially selected link', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Click here to go.' }, + } ]); + + const paragraph = getParagraphByText(page, 'Click here to go'); + + // Select "here" fully to verify update logic works with full selection first + await selectText(paragraph, 'here'); + + // Trigger toolbar or shortcut + await ensureLinkInputOpen(page); + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + // Verify input has full URL + await expect(linkInput).toHaveValue('https://google.com'); + + // Change URL + await linkInput.fill('https://very-distinct-url.com'); + await expect(linkInput).toHaveValue('https://very-distinct-url.com'); + await linkInput.press('Enter'); + + // Check the result - entire "here" should be linked to very-distinct-url.com + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveAttribute('href', 'https://very-distinct-url.com'); + await expect(anchor).toHaveText('here'); + await expect(anchor).toHaveCount(1); + }); + + test('should handle spaces in URL correctly (reject unencoded)', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Space test' }, + } ]); + + const paragraph = getParagraphByText(page, 'Space test'); + + await selectText(paragraph, 'Space'); + await ensureLinkInputOpen(page); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await linkInput.fill('http://example.com/foo bar'); + await linkInput.press('Enter'); + + // Expect error notification + await expect(page.locator(NOTIFIER_SELECTOR)).toContainText('Pasted link is not valid'); + // Link should not be created + await expect(paragraph.locator('a')).toHaveCount(0); + }); + + test('should accept encoded spaces in URL', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Encoded space test' }, + } ]); + + const paragraph = getParagraphByText(page, 'Encoded space test'); + + await selectText(paragraph, 'Encoded'); + await ensureLinkInputOpen(page); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await linkInput.fill('http://example.com/foo%20bar'); + await linkInput.press('Enter'); + + await expect(paragraph.locator('a')).toHaveAttribute('href', 'http://example.com/foo%20bar'); + }); + + test('should preserve target="_blank" on existing links after edit', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Target link' }, + } ]); + + const paragraph = getParagraphByText(page, 'Target link'); + + await selectText(paragraph, 'Target link'); + await ensureLinkInputOpen(page); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await linkInput.fill('https://bing.com'); + await linkInput.press('Enter'); + + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveAttribute('href', 'https://bing.com'); + }); + + test('should sanitize javascript: URLs on save', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'XSS test' }, + } ]); + + const paragraph = getParagraphByText(page, 'XSS test'); + + await selectText(paragraph, 'XSS'); + await ensureLinkInputOpen(page); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await linkInput.fill('javascript:alert(1)'); + await linkInput.press('Enter'); + + // In the DOM, it might exist + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveAttribute('href', 'javascript:alert(1)'); + + const savedData = await page.evaluate(async () => { + return window.editorInstance?.save(); + }); + + const blockData = savedData?.blocks[0].data.text; + + // Editor.js sanitizer should strip javascript: hrefs + expect(blockData).not.toContain('href="javascript:alert(1)"'); + }); + + test('should handle multiple links in one block', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Link1 and Link2' }, + } ]); + + const paragraph = getParagraphByText(page, 'Link1 and Link2'); + + // Create first link + await selectText(paragraph, 'Link1'); + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible(); + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator(LINK_INPUT_SELECTOR)).toBeVisible(); + await page.locator(LINK_INPUT_SELECTOR).fill('http://link1.com'); + await page.keyboard.press('Enter'); + + // Create second link + await selectText(paragraph, 'Link2'); + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible(); + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator(LINK_INPUT_SELECTOR)).toBeVisible(); + await page.locator(LINK_INPUT_SELECTOR).fill('http://link2.com'); + await page.keyboard.press('Enter'); + + await expect(paragraph.locator('a[href="http://link1.com"]')).toBeVisible(); + await expect(paragraph.locator('a[href="http://link2.com"]')).toBeVisible(); + }); + + test('cMD+K on collapsed selection in plain text should NOT open tool', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Empty selection' }, + } ]); + + const paragraph = getParagraphByText(page, 'Empty selection'); + + await paragraph.click(); + + await page.evaluate(() => { + const sel = window.getSelection(); + + sel?.collapseToStart(); + }); + + await page.keyboard.press('ControlOrMeta+k'); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await expect(linkInput).toBeHidden(); + }); + + test('cMD+K on collapsed selection INSIDE a link should unlink', async ({ page }) => { + await createEditorWithBlocks(page, [ { + type: 'paragraph', + data: { text: 'Click inside me' }, + } ]); + + const paragraph = getParagraphByText(page, 'Click inside me'); + + await paragraph.evaluate((el) => { + const anchor = el.querySelector('a'); + + if (!anchor || !anchor.firstChild) { + return; + } + const range = document.createRange(); + + range.setStart(anchor.firstChild, 2); + range.setEnd(anchor.firstChild, 2); + const sel = window.getSelection(); + + sel?.removeAllRanges(); + sel?.addRange(range); + }); + + await page.keyboard.press('ControlOrMeta+k'); + + // Based on logic: shortcut typically ignores collapsed selection, so nothing happens. + // The anchor should remain, and input should not appear. + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveCount(1); + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await expect(linkInput).toBeHidden(); + }); +}); diff --git a/test/playwright/tests/inline-tools/link.spec.ts b/test/playwright/tests/inline-tools/link.spec.ts index 947ccbcb..82ac1a6f 100644 --- a/test/playwright/tests/inline-tools/link.spec.ts +++ b/test/playwright/tests/inline-tools/link.spec.ts @@ -14,7 +14,7 @@ const TEST_PAGE_URL = pathToFileURL( const HOLDER_ID = 'editorjs'; 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_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"]`; const LINK_INPUT_SELECTOR = `input[data-link-tool-input-opened]`; const NOTIFIER_SELECTOR = '.cdx-notifies'; @@ -368,7 +368,7 @@ test.describe('inline tool link', () => { const paragraphBlock = savedData?.blocks.find((block) => block.type === 'paragraph'); - expect(paragraphBlock?.data.text).toContain('Persist me'); + expect(paragraphBlock?.data.text).toContain('Persist me'); }); test('should work in read-only mode', async ({ page }) => { @@ -425,6 +425,344 @@ test.describe('inline tool link', () => { expect(isDisabled).toBe(false); }); + + test('should open link input via Shortcut (CMD+K)', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Shortcut text', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Shortcut text'); + + await selectText(paragraph, 'Shortcut'); + + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeVisible(); + + await page.keyboard.press('ControlOrMeta+k'); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await expect(linkInput).toBeVisible(); + await expect(linkInput).toBeFocused(); + + await submitLink(page, 'https://shortcut.com'); + await expect(paragraph.locator('a')).toHaveAttribute('href', 'https://shortcut.com'); + }); + + test('should unlink if input is cleared and Enter is pressed', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Link to remove', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Link to remove'); + + await selectAll(paragraph); + // Opening link tool on existing link opens the input pre-filled + const linkInput = await ensureLinkInputOpen(page); + + await expect(linkInput).toHaveValue('https://codex.so'); + + await linkInput.fill(''); + await linkInput.press('Enter'); + + await expect(paragraph.locator('a')).toHaveCount(0); + }); + + test('should auto-prepend http:// to domain-only links', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Auto-prepend protocol', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Auto-prepend protocol'); + + await selectText(paragraph, 'Auto-prepend'); + await ensureLinkInputOpen(page); + await submitLink(page, 'google.com'); + + await expect(paragraph.locator('a')).toHaveAttribute('href', 'http://google.com'); + }); + + test('should NOT prepend protocol to internal links', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Internal link', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Internal link'); + + await selectText(paragraph, 'Internal'); + await ensureLinkInputOpen(page); + await submitLink(page, '/about-us'); + + await expect(paragraph.locator('a')).toHaveAttribute('href', '/about-us'); + }); + + test('should NOT prepend protocol to anchors', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Anchor link', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Anchor link'); + + await selectText(paragraph, 'Anchor'); + await ensureLinkInputOpen(page); + await submitLink(page, '#section-1'); + + await expect(paragraph.locator('a')).toHaveAttribute('href', '#section-1'); + }); + + test('should NOT prepend protocol to protocol-relative URLs', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Protocol relative', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Protocol relative'); + + await selectText(paragraph, 'Protocol'); + await ensureLinkInputOpen(page); + await submitLink(page, '//cdn.example.com/lib.js'); + + await expect(paragraph.locator('a')).toHaveAttribute('href', '//cdn.example.com/lib.js'); + }); + + test('should close input when Escape is pressed', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Escape me', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Escape me'); + + await selectText(paragraph, 'Escape'); + await ensureLinkInputOpen(page); + + const linkInput = page.locator(LINK_INPUT_SELECTOR); + + await expect(linkInput).toBeVisible(); + await expect(linkInput).toBeFocused(); + + await page.keyboard.press('Escape'); + + await expect(linkInput).toBeHidden(); + // Inline toolbar might also close or just the input. + // Usually Escape closes the whole Inline Toolbar or just the tool actions depending on implementation. + // In LinkTool, clear() calls closeActions(). + // But Escape is handled by InlineToolbar which closes itself and calls clear() on tools. + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden(); + }); + + test('should not create link if input is empty and Enter is pressed (new link)', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Empty link test', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Empty link test'); + + await selectText(paragraph, 'Empty link'); + const linkInput = await ensureLinkInputOpen(page); + + await linkInput.fill(''); + await linkInput.press('Enter'); + + await expect(linkInput).toBeHidden(); + await expect(paragraph.locator('a')).toHaveCount(0); + }); + + test('should restore selection after Escape', async ({ page }) => { + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Selection restoration', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Selection restoration'); + const textToSelect = 'Selection'; + + await selectText(paragraph, textToSelect); + await ensureLinkInputOpen(page); + + await page.keyboard.press('Escape'); + + // Verify text is still selected + const selection = await page.evaluate(() => { + const sel = window.getSelection(); + + return sel ? sel.toString() : ''; + }); + + expect(selection).toBe(textToSelect); + }); + + test('should unlink when button is clicked while input is open', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Unlink me', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Unlink me'); + + await selectAll(paragraph); + const linkInput = await ensureLinkInputOpen(page); + + await expect(linkInput).toBeVisible(); + await expect(linkInput).toHaveValue('https://example.com'); + + // Click the button again (it should be in unlink state) + const linkButton = page.locator('button[data-link-tool-unlink="true"]'); + + await expect(linkButton).toBeVisible(); + await linkButton.click(); + + await expect(paragraph.locator('a')).toHaveCount(0); + }); + + test('should support IDN URLs', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'IDN Link', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'IDN Link'); + const url = 'https://пример.рф'; + + await selectText(paragraph, 'IDN Link'); + await ensureLinkInputOpen(page); + await submitLink(page, url); + + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveAttribute('href', url); + }); + + test('should allow pasting URL into input', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Paste Link', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Paste Link'); + const url = 'https://pasted-example.com'; + + await selectText(paragraph, 'Paste Link'); + const linkInput = await ensureLinkInputOpen(page); + + // Simulate paste + await linkInput.evaluate((el, text) => { + const input = el as HTMLInputElement; + + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, url); + + await linkInput.press('Enter'); + + const anchor = paragraph.locator('a'); + + await expect(anchor).toHaveAttribute('href', url); + }); + + test('should not open tool via Shortcut (CMD+K) when selection is collapsed', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Collapsed selection', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'Collapsed selection'); + + // Place caret without selection + await paragraph.click(); + + // Ensure inline toolbar is not visible initially + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden(); + + await page.keyboard.press('ControlOrMeta+k'); + + // Should still be hidden because there is no range + await expect(page.locator(INLINE_TOOLBAR_SELECTOR)).toBeHidden(); + await expect(page.locator(LINK_INPUT_SELECTOR)).toBeHidden(); + }); + + test('should allow javascript: links (security check)', async ({ page }) => { + // This test documents current behavior. + // If the policy changes to disallow javascript: links, this test should be updated to expect failure/sanitization. + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'XSS Link', + }, + }, + ]); + + const paragraph = getParagraphByText(page, 'XSS Link'); + const url = 'javascript:alert(1)'; + + await selectText(paragraph, 'XSS Link'); + await ensureLinkInputOpen(page); + await submitLink(page, url); + + const anchor = paragraph.locator('a'); + + // Current implementation does not strip javascript: protocol + await expect(anchor).toHaveAttribute('href', url); + }); }); declare global { diff --git a/test/playwright/tests/modules/Saver.spec.ts b/test/playwright/tests/modules/Saver.spec.ts index fd03a1d8..5e23e5c4 100644 --- a/test/playwright/tests/modules/Saver.spec.ts +++ b/test/playwright/tests/modules/Saver.spec.ts @@ -210,7 +210,8 @@ test.describe('saver module', () => { await expect(settingsButton).toBeVisible(); await settingsButton.click(); - const headerLevelOption = page.locator(SETTINGS_ITEM_SELECTOR).filter({ hasText: /^Heading 3$/ }); + // eslint-disable-next-line playwright/no-nth-methods -- The Header tool settings items do not have distinctive text or attributes, so we rely on the order (Level 1, 2, 3...) + const headerLevelOption = page.locator(SETTINGS_ITEM_SELECTOR).nth(2); await headerLevelOption.waitFor({ state: 'visible' }); await headerLevelOption.click(); diff --git a/test/playwright/tests/tools/inline-tool.spec.ts b/test/playwright/tests/tools/inline-tool.spec.ts index 7cb482fe..3784e63f 100644 --- a/test/playwright/tests/tools/inline-tool.spec.ts +++ b/test/playwright/tests/tools/inline-tool.spec.ts @@ -62,13 +62,6 @@ test.describe('inlineToolAdapter', () => { expect(tool.name).toBe(options.name); }); - test('.title returns correct title', () => { - const options = createInlineToolOptions(); - const tool = new InlineToolAdapter(options as any); - - expect(tool.title).toBe(options.constructable.title); - }); - test('.isInternal returns correct value', () => { const options = createInlineToolOptions(); @@ -187,32 +180,38 @@ test.describe('inlineToolAdapter', () => { ...options, constructable: {} as typeof options.constructable, } as any); - const requiredMethods = ['render', 'surround']; + const requiredMethods = [ 'render' ]; expect(tool.getMissingMethods(requiredMethods)).toStrictEqual(requiredMethods); }); test('returns only methods that are not implemented on the prototype', () => { const options = createInlineToolOptions(); + const Parent = options.constructable; - class ConstructableWithRender extends options.constructable { - public render(): void {} + class ConstructableWithRender extends Parent { + public render(): object { + return {}; + } } const tool = new InlineToolAdapter({ ...options, constructable: ConstructableWithRender, } as any); - const requiredMethods = ['render', 'surround']; + const requiredMethods = ['render', 'fakeMethod']; - expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'surround' ]); + expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'fakeMethod' ]); }); test('returns an empty array when all required methods are implemented', () => { const options = createInlineToolOptions(); + const Parent = options.constructable; - class ConstructableWithAllMethods extends options.constructable { - public render(): void {} + class ConstructableWithAllMethods extends Parent { + public render(): object { + return {}; + } public surround(): void {} } @@ -220,7 +219,7 @@ test.describe('inlineToolAdapter', () => { ...options, constructable: ConstructableWithAllMethods, } as any); - const requiredMethods = ['render', 'surround']; + const requiredMethods = [ 'render' ]; expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([]); }); diff --git a/test/playwright/tests/utils/popover.spec.ts b/test/playwright/tests/utils/popover.spec.ts index 742ddf7d..b7d6e15e 100644 --- a/test/playwright/tests/utils/popover.spec.ts +++ b/test/playwright/tests/utils/popover.spec.ts @@ -1142,13 +1142,13 @@ test.describe('popover', () => { await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="convert-to"].ce-popover-item--focused`)).toBeVisible(); // Check second item is NOT focused - await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"] .ce-popover-item--focused`)).toBeHidden(); + await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"].ce-popover-item--focused`)).toBeHidden(); // Press Tab await page.keyboard.press('Tab'); // Check second item became focused after tab - await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"] .ce-popover-item--focused`)).toBeVisible(); + await expect(page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-inline-toolbar .ce-popover__container [data-item-name="link"].ce-popover-item--focused`)).toBeVisible(); }); test('should allow to reach nested popover via keyboard', async ({ page }) => { @@ -1196,4 +1196,3 @@ test.describe('popover', () => { }); }); }); - diff --git a/test/unit/components/core.test.ts b/test/unit/components/core.test.ts index 883eb0c7..62381a48 100644 --- a/test/unit/components/core.test.ts +++ b/test/unit/components/core.test.ts @@ -14,7 +14,6 @@ const mockRegistry = vi.hoisted(() => ({ isEmpty: vi.fn(), setLogLevel: vi.fn(), log: vi.fn(), - deprecationAssert: vi.fn(), }, i18n: { setDictionary: vi.fn(), @@ -53,7 +52,6 @@ vi.mock('../../../src/components/utils', () => ({ isEmpty: mockRegistry.utils.isEmpty, setLogLevel: mockRegistry.utils.setLogLevel, log: mockRegistry.utils.log, - deprecationAssert: mockRegistry.utils.deprecationAssert, LogLevels: { VERBOSE: 'VERBOSE', INFO: 'INFO', @@ -194,7 +192,6 @@ const { isObject: mockIsObject, isString: mockIsString, isEmpty: mockIsEmpty, - setLogLevel: mockSetLogLevel, log: mockLog, } = utils; const { setDictionary: mockSetDictionary } = i18n; @@ -256,19 +253,6 @@ describe('Core', () => { }); describe('configuration', () => { - it('normalizes holderId and sets default values', async () => { - const core = await createReadyCore({ holderId: 'my-holder' }); - - expect(core.configuration.holder).toBe('my-holder'); - expect(core.configuration.holderId).toBeUndefined(); - expect(core.configuration.defaultBlock).toBe('paragraph'); - expect(core.configuration.minHeight).toBe(300); - expect(core.configuration.data?.blocks).toHaveLength(1); - expect(core.configuration.data?.blocks?.[0]?.type).toBe('paragraph'); - expect(core.configuration.readOnly).toBe(false); - expect(mockSetLogLevel).toHaveBeenCalledWith('VERBOSE'); - }); - it('retains provided data and applies i18n dictionary', async () => { const config: EditorConfig = { holder: 'holder', @@ -302,17 +286,6 @@ describe('Core', () => { }); describe('validate', () => { - it('throws when both holder and holderId are provided', async () => { - const core = await createReadyCore(); - - core.configuration = { - holder: 'element', - holderId: 'other', - } as EditorConfig; - - expect(() => core.validate()).toThrow('«holderId» and «holder» param can\'t assign at the same time.'); - }); - it('throws when holder element is missing', async () => { const core = await createReadyCore(); diff --git a/test/unit/components/inline-tools/inline-tool-italic.test.ts b/test/unit/components/inline-tools/inline-tool-italic.test.ts index 6fbcc86c..f92f1f78 100644 --- a/test/unit/components/inline-tools/inline-tool-italic.test.ts +++ b/test/unit/components/inline-tools/inline-tool-italic.test.ts @@ -1,33 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { IconItalic } from '@codexteam/icons'; import ItalicInlineTool from '../../../../src/components/inline-tools/inline-tool-italic'; -type DocumentCommandKey = 'execCommand' | 'queryCommandState'; - -const setDocumentCommand = ( - key: K, - implementation: Document[K] -): void => { - Object.defineProperty(document, key, { - configurable: true, - value: implementation, - writable: true, - }); -}; - describe('ItalicInlineTool', () => { let tool: ItalicInlineTool; - let execCommandMock: ReturnType>; - let queryCommandStateMock: ReturnType>; beforeEach(() => { - execCommandMock = vi.fn(() => true); - queryCommandStateMock = vi.fn(() => false); - - setDocumentCommand('execCommand', execCommandMock as Document['execCommand']); - setDocumentCommand('queryCommandState', queryCommandStateMock as Document['queryCommandState']); - tool = new ItalicInlineTool(); }); @@ -38,45 +18,45 @@ describe('ItalicInlineTool', () => { it('exposes inline metadata and sanitizer config', () => { expect(ItalicInlineTool.isInline).toBe(true); expect(ItalicInlineTool.title).toBe('Italic'); - expect(ItalicInlineTool.sanitize).toStrictEqual({ i: {} }); + expect(ItalicInlineTool.sanitize).toStrictEqual({ i: {}, + em: {} }); }); - it('renders an inline toolbar button with italic icon', () => { - const element = tool.render(); - const button = element as HTMLButtonElement; - const expectedIcon = document.createElement('div'); + it('renders menu config with italic icon and callbacks', () => { + const config = tool.render() as any; - expectedIcon.innerHTML = IconItalic; - - expect(button).toBeInstanceOf(HTMLButtonElement); - expect(button.type).toBe('button'); - expect(button.classList.contains('ce-inline-tool')).toBe(true); - expect(button.classList.contains('ce-inline-tool--italic')).toBe(true); - expect(button.innerHTML).toBe(expectedIcon.innerHTML); - }); - - it('executes italic command when surround is called', () => { - tool.surround(); - - expect(execCommandMock).toHaveBeenCalledWith('italic'); - }); - - it('synchronizes button active state with document command state', () => { - const button = tool.render(); - - queryCommandStateMock.mockReturnValue(true); - - expect(tool.checkState()).toBe(true); - expect(button.classList.contains('ce-inline-tool--active')).toBe(true); - - queryCommandStateMock.mockReturnValue(false); - - expect(tool.checkState()).toBe(false); - expect(button.classList.contains('ce-inline-tool--active')).toBe(false); + expect(config).toHaveProperty('icon'); + expect(config.icon).toBe(IconItalic); + expect(config.name).toBe('italic'); + expect(config.onActivate).toBeInstanceOf(Function); + expect(config.isActive).toBeInstanceOf(Function); }); it('exposes CMD+I shortcut', () => { expect(tool.shortcut).toBe('CMD+I'); }); + + describe('isActive', () => { + it('should return false if no selection', () => { + vi.spyOn(window, 'getSelection').mockReturnValue(null); + + const config = tool.render() as any; + + expect(config.isActive && config.isActive()).toBe(false); + }); + + it('should return false if range count is 0', () => { + const mockSelection = { + rangeCount: 0, + getRangeAt: vi.fn(), + } as unknown as Selection; + + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); + + const config = tool.render() as any; + + expect(config.isActive && config.isActive()).toBe(false); + }); + }); }); diff --git a/test/unit/components/inline-tools/inline-tool-link.test.ts b/test/unit/components/inline-tools/inline-tool-link.test.ts index 95ff8c94..71fc1717 100644 --- a/test/unit/components/inline-tools/inline-tool-link.test.ts +++ b/test/unit/components/inline-tools/inline-tool-link.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; -import { IconLink, IconUnlink } from '@codexteam/icons'; +import { IconLink } from '@codexteam/icons'; import LinkInlineTool from '../../../../src/components/inline-tools/inline-tool-link'; import type SelectionUtils from '../../../../src/components/selection'; @@ -41,13 +41,26 @@ const createSelectionMock = (): SelectionMock => { }; type ToolSetup = { - tool: LinkInlineTool; + tool: InstanceType; toolbar: { close: ReturnType }; inlineToolbar: { close: ReturnType }; notifier: { show: ReturnType }; selection: SelectionMock; }; +type LinkToolRenderResult = { + icon: string; + title: string; + isActive: () => boolean; + children: { + items: { + element: HTMLElement; + }[]; + onOpen: () => void; + onClose: () => void; + }; +}; + const createTool = (): ToolSetup => { const toolbar = { close: vi.fn() }; const inlineToolbar = { close: vi.fn() }; @@ -103,25 +116,6 @@ const createEnterEventStubs = (): KeyboardEventStub => { }; }; -/** - * Normalizes HTML string by parsing and re-serializing it. - * This ensures consistent comparison when browsers serialize SVG differently. - * - * @param html - The HTML string to normalize - * @returns The normalized HTML string - */ -const normalizeHTML = (html: string): string => { - const temp = document.createElement('div'); - - temp.innerHTML = html; - - return temp.innerHTML; -}; - -const expectButtonIcon = (button: HTMLElement, iconHTML: string): void => { - expect(normalizeHTML(button.innerHTML)).toBe(normalizeHTML(iconHTML)); -}; - describe('LinkInlineTool', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -145,25 +139,23 @@ describe('LinkInlineTool', () => { expect(tool.shortcut).toBe('CMD+K'); }); - it('renders toolbar button with initial state persisted in data attributes', () => { + it('renders menu config with correct properties', () => { const { tool } = createTool(); - const button = tool.render() as HTMLButtonElement; + const renderResult = tool.render() as unknown as LinkToolRenderResult; - expect(button).toBeInstanceOf(HTMLButtonElement); - expect(button.type).toBe('button'); - expect(button.classList.contains('ce-inline-tool')).toBe(true); - expect(button.classList.contains('ce-inline-tool--link')).toBe(true); - expect(button.getAttribute('data-link-tool-active')).toBe('false'); - expect(button.getAttribute('data-link-tool-unlink')).toBe('false'); - expectButtonIcon(button, IconLink); + expect(renderResult).toHaveProperty('icon', IconLink); + expect(renderResult).toHaveProperty('isActive'); + expect(typeof renderResult.isActive).toBe('function'); + expect(renderResult).toHaveProperty('children'); }); it('renders actions input and invokes enter handler when Enter key is pressed', () => { const { tool } = createTool(); const enterSpy = vi.spyOn(tool as unknown as { enterPressed(event: KeyboardEvent): void }, 'enterPressed'); - const input = tool.renderActions() as HTMLInputElement; + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const input = renderResult.children.items[0].element as HTMLInputElement; expect(input.placeholder).toBe('Add a link'); expect(input.classList.contains('ce-inline-tool-input')).toBe(true); @@ -176,46 +168,53 @@ describe('LinkInlineTool', () => { expect(enterSpy).toHaveBeenCalledWith(event); }); - it('activates unlink state when selection already contains anchor', () => { + it('returns true from isActive when selection contains anchor', () => { const { tool, selection } = createTool(); - const button = tool.render() as HTMLButtonElement; - const input = tool.renderActions() as HTMLInputElement; - const openActionsSpy = vi.spyOn(tool as unknown as { openActions(needFocus?: boolean): void }, 'openActions'); const anchor = document.createElement('a'); anchor.setAttribute('href', 'https://codex.so'); selection.findParentTag.mockReturnValue(anchor); - const result = tool.checkState(); + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const isActive = renderResult.isActive(); - expect(result).toBe(true); - expectButtonIcon(button, IconUnlink); - expect(button.classList.contains('ce-inline-tool--active')).toBe(true); - expect(button.getAttribute('data-link-tool-unlink')).toBe('true'); - expect(input.value).toBe('https://codex.so'); - expect(openActionsSpy).toHaveBeenCalled(); - expect(selection.save).toHaveBeenCalled(); + expect(isActive).toBe(true); }); - it('deactivates button when selection leaves anchor', () => { + it('returns false from isActive when selection does not contain anchor', () => { const { tool, selection } = createTool(); - const button = tool.render() as HTMLButtonElement; - button.classList.add('ce-inline-tool--active'); - tool.renderActions(); selection.findParentTag.mockReturnValue(null); - const result = tool.checkState(); + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const isActive = renderResult.isActive(); - expect(result).toBe(false); - expectButtonIcon(button, IconLink); - expect(button.classList.contains('ce-inline-tool--active')).toBe(false); - expect(button.getAttribute('data-link-tool-unlink')).toBe('false'); + expect(isActive).toBe(false); + }); + + it('populates input when opened on an existing link', () => { + const { tool, selection } = createTool(); + const anchor = document.createElement('a'); + + anchor.setAttribute('href', 'https://codex.so'); + + selection.findParentTag.mockReturnValue(anchor); + + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const input = renderResult.children.items[0].element as HTMLInputElement; + + // Simulate onOpen + renderResult.children.onOpen(); + + expect(input.value).toBe('https://codex.so'); + expect(selection.save).toHaveBeenCalled(); }); it('removes link when input is submitted empty', () => { const { tool, selection } = createTool(); - const input = tool.renderActions() as HTMLInputElement; + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const input = renderResult.children.items[0].element as HTMLInputElement; + const unlinkSpy = vi.spyOn(tool as unknown as { unlink(): void }, 'unlink'); const closeActionsSpy = vi.spyOn(tool as unknown as { closeActions(clearSavedSelection?: boolean): void }, 'closeActions'); @@ -233,7 +232,8 @@ describe('LinkInlineTool', () => { it('shows notifier when URL validation fails', () => { const { tool, notifier } = createTool(); - const input = tool.renderActions() as HTMLInputElement; + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const input = renderResult.children.items[0].element as HTMLInputElement; const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink'); input.value = 'https://codex .so'; @@ -249,7 +249,8 @@ describe('LinkInlineTool', () => { it('inserts prepared link and collapses selection when URL is valid', () => { const { tool, selection, inlineToolbar } = createTool(); - const input = tool.renderActions() as HTMLInputElement; + const renderResult = tool.render() as unknown as LinkToolRenderResult; + const input = renderResult.children.items[0].element as HTMLInputElement; const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink'); const removeFakeBackgroundSpy = selection.removeFakeBackground as unknown as ReturnType; @@ -275,19 +276,51 @@ describe('LinkInlineTool', () => { expect(addProtocol.addProtocol('//cdn.codex.so')).toBe('//cdn.codex.so'); }); - it('delegates to document.execCommand when inserting and removing links', () => { - const execSpy = vi.fn(); - - setDocumentCommand(execSpy as Document['execCommand']); - + it('inserts anchor tag with correct attributes when inserting link', () => { const { tool } = createTool(); - (tool as unknown as { insertLink(link: string): void }).insertLink('https://codex.so'); - expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://codex.so'); + const range = document.createRange(); + const textNode = document.createTextNode('selected text'); - execSpy.mockClear(); + document.body.appendChild(textNode); + range.selectNodeContents(textNode); + + const selectionMock = { + getRangeAt: vi.fn().mockReturnValue(range), + rangeCount: 1, + removeAllRanges: vi.fn(), + addRange: vi.fn(), + }; + + vi.spyOn(window, 'getSelection').mockReturnValue(selectionMock as unknown as Selection); + + (tool as unknown as { insertLink(link: string): void }).insertLink('https://codex.so'); + + const anchor = document.querySelector('a'); + + expect(anchor).not.toBeNull(); + expect(anchor?.href).toBe('https://codex.so/'); + expect(anchor?.target).toBe('_blank'); + expect(anchor?.rel).toBe('nofollow'); + expect(anchor?.textContent).toBe('selected text'); + }); + + it('unwraps anchor tag when unlinking', () => { + const { tool, selection } = createTool(); + + const anchor = document.createElement('a'); + + anchor.href = 'https://codex.so'; + anchor.textContent = 'link text'; + document.body.appendChild(anchor); + + selection.findParentTag.mockReturnValue(anchor); (tool as unknown as { unlink(): void }).unlink(); - expect(execSpy).toHaveBeenCalledWith('unlink'); + + const anchorCheck = document.querySelector('a'); + + expect(anchorCheck).toBeNull(); + expect(document.body.textContent).toBe('link text'); }); }); diff --git a/test/unit/components/modules/api/blocks.test.ts b/test/unit/components/modules/api/blocks.test.ts index 645cd81c..306f74c2 100644 --- a/test/unit/components/modules/api/blocks.test.ts +++ b/test/unit/components/modules/api/blocks.test.ts @@ -553,39 +553,6 @@ describe('BlocksAPI', () => { }); }); - describe('block stretching', () => { - it('sets stretched state on block', () => { - const block = createBlockStub({ stretched: false }); - const { blocksApi, blockManager } = createBlocksApi({ blocks: [ block ] }); - const assertSpy = vi.spyOn(utils, 'deprecationAssert').mockImplementation(() => {}); - - blocksApi.stretchBlock(0, true); - - expect(assertSpy).toHaveBeenCalledWith( - true, - 'blocks.stretchBlock()', - 'BlockAPI' - ); - expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(0); - expect(block.stretched).toBe(true); - - assertSpy.mockRestore(); - }); - - it('does nothing when block for stretch is missing', () => { - const { blocksApi, blockManager } = createBlocksApi({ blocks: [] }); - const assertSpy = vi.spyOn(utils, 'deprecationAssert').mockImplementation(() => {}); - - blockManager.getBlockByIndex.mockReturnValueOnce(undefined); - - blocksApi.stretchBlock(0, false); - - expect(assertSpy).toHaveBeenCalled(); - expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(0); - - assertSpy.mockRestore(); - }); - }); describe('block insertion APIs', () => { it('inserts a new block and wraps it with BlockAPI', () => { @@ -618,22 +585,6 @@ describe('BlocksAPI', () => { })); }); - it('logs deprecation when insertNewBlock is called', () => { - const { blocksApi, blockManager } = createBlocksApi(); - const logSpy = vi.spyOn(utils, 'log').mockImplementation(() => {}); - - blocksApi.insertNewBlock(); - - expect(logSpy).toHaveBeenCalledWith( - 'Method blocks.insertNewBlock() is deprecated and it will be removed in the next major release. ' + - 'Use blocks.insert() instead.', - 'warn' - ); - expect(blockManager.insert).toHaveBeenCalled(); - - logSpy.mockRestore(); - }); - it('composes block data through Block constructor', async () => { const toolName = 'custom-tool'; const tool = { name: toolName }; diff --git a/test/unit/components/modules/toolbar/blockSettings.test.ts b/test/unit/components/modules/toolbar/blockSettings.test.ts index a3e4d55d..53737bc3 100644 --- a/test/unit/components/modules/toolbar/blockSettings.test.ts +++ b/test/unit/components/modules/toolbar/blockSettings.test.ts @@ -467,53 +467,6 @@ describe('BlockSettings', () => { selectionAtEditorSpy.mockRestore(); }); - it('resolves tune aliases including nested children', () => { - const resolveTuneAliases = (blockSettings as unknown as { - resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams; - }).resolveTuneAliases.bind(blockSettings); - - const item: MenuConfigItem = { - name: 'duplicate', - label: 'Duplicate', - confirmation: { - label: 'Confirm', - onActivate: vi.fn(), - }, - children: { - items: [ - { - name: 'child', - label: 'Child label', - onActivate: vi.fn(), - } as MenuConfigItem, - ], - }, - }; - - const resolved = resolveTuneAliases(item); - - if ('title' in resolved) { - expect(resolved.title).toBe('Duplicate'); - } - if ('confirmation' in resolved && resolved.confirmation && 'title' in resolved.confirmation) { - expect(resolved.confirmation.title).toBe('Confirm'); - } - if ('children' in resolved && resolved.children?.items?.[0] && 'title' in resolved.children.items[0]) { - expect(resolved.children.items[0].title).toBe('Child label'); - } - }); - - it('returns separator items unchanged when resolving aliases', () => { - const resolveTuneAliases = (blockSettings as unknown as { - resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams; - }).resolveTuneAliases.bind(blockSettings); - - const separatorItem: MenuConfigItem = { - type: PopoverItemType.Separator, - } as MenuConfigItem; - - expect(resolveTuneAliases(separatorItem)).toBe(separatorItem); - }); it('merges tool tunes, convert-to menu and common tunes', async () => { const block = createBlock(); @@ -525,7 +478,7 @@ describe('BlockSettings', () => { const toolTunes: MenuConfigItem[] = [ { name: 'duplicate', - label: 'Duplicate', + title: 'Duplicate', onActivate: vi.fn(), }, ]; @@ -590,17 +543,12 @@ describe('BlockSettings', () => { getConvertibleToolsForBlockMock.mockResolvedValueOnce([]); - const resolveSpy = vi.spyOn(blockSettings as unknown as { resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams }, 'resolveTuneAliases'); - const items = await (blockSettings as unknown as { getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise; }).getTunesItems(block, commonTunes); expect(items).toHaveLength(2); expect(items.every((item) => item.type !== PopoverItemType.Separator)).toBe(true); - expect(resolveSpy).toHaveBeenCalledTimes(commonTunes.length); - - resolveSpy.mockRestore(); }); it('forwards popover close event to block settings close', () => { diff --git a/test/unit/components/modules/tools.test.ts b/test/unit/components/modules/tools.test.ts index 1d66de85..f6bb5460 100644 --- a/test/unit/components/modules/tools.test.ts +++ b/test/unit/components/modules/tools.test.ts @@ -117,17 +117,9 @@ describe('tools module', () => { /** * */ - public render(): void {} - - /** - * - */ - public surround(): void {} - - /** - * - */ - public checkState(): void {} + public render(): object { + return {}; + } } /** @@ -139,17 +131,9 @@ describe('tools module', () => { /** * */ - public render(): void {} - - /** - * - */ - public surround(): void {} - - /** - * - */ - public checkState(): void {} + public render(): object { + return {}; + } } /** @@ -385,17 +369,9 @@ describe('tools module', () => { /** * */ - public render(): void {} - - /** - * - */ - public surround(): void {} - - /** - * - */ - public checkState(): void {} + public render(): object { + return {}; + } } /** @@ -413,17 +389,9 @@ describe('tools module', () => { /** * */ - public render(): void {} - - /** - * - */ - public surround(): void {} - - /** - * - */ - public checkState(): void {} + public render(): object { + return {}; + } } const module = createModule({ diff --git a/test/unit/components/selection.test.ts b/test/unit/components/selection.test.ts index 85c33cc6..c18beb1e 100644 --- a/test/unit/components/selection.test.ts +++ b/test/unit/components/selection.test.ts @@ -377,6 +377,42 @@ describe('SelectionUtils', () => { expect(paragraph.textContent).toBe('Highlighted text'); }); + it('restores selection correctly after using fake background', () => { + const utilsInstance = new SelectionUtils(); + const { zone, paragraph } = createEditorZone('Text to select'); + + // Create a text node and select a part of it + const textNode = paragraph.firstChild as Text; + + setSelectionRange(textNode, 0, 4); // Select "Text" + + // Save selection + utilsInstance.save(); + + // Set fake background + utilsInstance.setFakeBackground(); + + // Check that fake background is enabled + expect(utilsInstance.isFakeBackgroundEnabled).toBe(true); + expect(zone.querySelectorAll('.codex-editor__fake-background').length).toBeGreaterThan(0); + + // Remove fake background + utilsInstance.removeFakeBackground(); + expect(utilsInstance.isFakeBackgroundEnabled).toBe(false); + expect(paragraph.querySelector('.codex-editor__fake-background')).toBeNull(); + + // Clear current selection to simulate focus change or similar + window.getSelection()?.removeAllRanges(); + + // Restore selection + utilsInstance.restore(); + + // Verify selection is restored + const currentSelection = window.getSelection(); + + expect(currentSelection?.toString()).toBe('Text'); + }); + it('does not enable fake background when selection is collapsed', () => { const utilsInstance = new SelectionUtils(); const { paragraph } = createEditorZone('Single word'); @@ -501,5 +537,3 @@ describe('SelectionUtils', () => { logSpy.mockRestore(); }); }); - - diff --git a/test/unit/components/utils/resolve-aliases.test.ts b/test/unit/components/utils/resolve-aliases.test.ts deleted file mode 100644 index 46b9ad12..00000000 --- a/test/unit/components/utils/resolve-aliases.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveAliases } from '../../../../src/components/utils/resolve-aliases'; - -type MenuItem = { - title?: string; - label?: string; - tooltip?: string; - caption?: string; - description?: string; -}; - -describe('resolveAliases', () => { - it('maps alias value to the target property and omits the alias key', () => { - const item: MenuItem = { label: 'Alias title' }; - - const resolved = resolveAliases(item, { label: 'title' }); - - expect(resolved).not.toBe(item); - expect(resolved.title).toBe('Alias title'); - expect(resolved.label).toBeUndefined(); - expect(item).toEqual({ label: 'Alias title' }); - }); - - it('does not override explicitly set target property', () => { - const item: MenuItem = { - title: 'Preferred', - label: 'Fallback', - }; - - const resolved = resolveAliases(item, { label: 'title' }); - - expect(resolved.title).toBe('Preferred'); - expect(resolved.label).toBeUndefined(); - }); - - it('resolves multiple aliases while keeping other properties intact', () => { - const item: MenuItem = { - label: 'Title alias', - caption: 'Tooltip alias', - description: 'Keep me', - }; - - const resolved = resolveAliases(item, { - label: 'title', - caption: 'tooltip', - }); - - expect(resolved).toEqual({ - title: 'Title alias', - tooltip: 'Tooltip alias', - description: 'Keep me', - }); - }); - - it('ignores alias entries that are absent on the object', () => { - const item: MenuItem = { description: 'Only field' }; - - const resolved = resolveAliases(item, { label: 'title' }); - - expect(resolved).toEqual({ description: 'Only field' }); - }); -}); diff --git a/test/unit/modules/toolbar/inline.test.ts b/test/unit/modules/toolbar/inline.test.ts index cfc813e5..22fc73d5 100644 --- a/test/unit/modules/toolbar/inline.test.ts +++ b/test/unit/modules/toolbar/inline.test.ts @@ -5,6 +5,7 @@ import SelectionUtils from '../../../../src/components/selection'; import type { Popover } from '../../../../src/components/utils/popover'; import Shortcuts from '../../../../src/components/utils/shortcuts'; import type { InlineTool } from '../../../../types'; +import type { MenuConfig } from '../../../../types/tools'; // Mock dependencies at module level const mockPopoverInstance = { @@ -134,7 +135,7 @@ describe('InlineToolbar', () => { title?: string; shortcut?: string; isReadOnlySupported?: boolean; - render?: () => HTMLElement | { type: string; [key: string]: unknown }; + render?: () => HTMLElement | MenuConfig; checkState?: (selection: Selection) => boolean; surround?: (range: Range) => void; clear?: () => void; @@ -210,14 +211,25 @@ describe('InlineToolbar', () => { // Reset SelectionUtils spies if they exist vi.restoreAllMocks(); - // Mock requestIdleCallback to execute immediately + // Mock requestIdleCallback and setTimeout to execute immediately but asynchronously to avoid recursion in constructor + vi.useFakeTimers(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).requestIdleCallback = vi.fn((callback: () => void) => { - callback(); + setTimeout(callback, 0); return 1; }); + // Ensure window exists for the module logic + if (typeof window === 'undefined') { + vi.stubGlobal('window', { + setTimeout: setTimeout, + clearTimeout: clearTimeout, + requestIdleCallback: (global as unknown as { requestIdleCallback: (callback: () => void) => number }).requestIdleCallback, + }); + } + // Create mock UI nodes const wrapper = document.createElement('div'); @@ -388,9 +400,7 @@ describe('InlineToolbar', () => { it('should clear tool instances and reset state when closing', () => { inlineToolbar.opened = true; - const toolInstance = createMockInlineTool('bold', { - clear: vi.fn(), - }); + const toolInstance = createMockInlineTool('bold'); (inlineToolbar as unknown as { tools: Map }).tools = new Map([ [createMockInlineToolAdapter('bold'), toolInstance], @@ -400,7 +410,6 @@ describe('InlineToolbar', () => { inlineToolbar.close(); - expect(toolInstance.clear).toHaveBeenCalled(); expect(inlineToolbar.opened).toBe(false); expect(mockPopoverInstance.hide).toHaveBeenCalled(); expect(mockPopoverInstance.destroy).toHaveBeenCalled(); @@ -726,38 +735,32 @@ describe('InlineToolbar', () => { }); }); - describe('toolClicked', () => { - it('should call surround on tool with range', () => { - const range = document.createRange(); - const tool = createMockInlineTool('bold', { - surround: vi.fn(), + describe('Modern Inline Tool', () => { + it('should use onActivate callback from MenuConfig', async () => { + const onActivateSpy = vi.fn(); + const tool = createMockInlineTool('modernTool', { + render: () => ({ + icon: 'svg', + onActivate: onActivateSpy, + }), }); - Object.defineProperty(SelectionUtils, 'range', { - value: range, - writable: true, - }); + const adapter = { name: 'modernTool', + title: 'Modern Tool' }; - (inlineToolbar as unknown as { toolClicked: (tool: InlineTool) => void }).toolClicked(tool); + (inlineToolbar as unknown as { tools: Map }).tools = new Map([ [adapter, tool] ]); - expect(tool.surround).toHaveBeenCalledWith(range); - }); + // Mock getToolShortcut to avoid errors + vi.spyOn(inlineToolbar as unknown as { getToolShortcut: (name: string) => string | undefined }, 'getToolShortcut').mockReturnValue(undefined); - it('should check tools state after clicking', () => { - const tool = createMockInlineTool('bold', { - surround: vi.fn(), - }); + const popoverItems = await (inlineToolbar as unknown as { getPopoverItems: () => Promise void }>> }).getPopoverItems(); - const checkToolsStateSpy = vi.spyOn(inlineToolbar as unknown as { checkToolsState: () => void }, 'checkToolsState'); + expect(popoverItems).toHaveLength(1); + expect(popoverItems[0].onActivate).toBeDefined(); - Object.defineProperty(SelectionUtils, 'range', { - value: document.createRange(), - writable: true, - }); + popoverItems[0].onActivate?.(); - (inlineToolbar as unknown as { toolClicked: (tool: InlineTool) => void }).toolClicked(tool); - - expect(checkToolsStateSpy).toHaveBeenCalled(); + expect(onActivateSpy).toHaveBeenCalled(); }); }); @@ -808,7 +811,7 @@ describe('InlineToolbar', () => { expect(mockPopoverInstance.activateItemByName).toHaveBeenCalledWith('bold'); }); - it('should not activate tool when tool is already active', async () => { + it('should activate item by name regardless of tool state', async () => { inlineToolbar.opened = true; const toolInstance = createMockInlineTool('bold', { checkState: vi.fn(() => true), @@ -823,7 +826,7 @@ describe('InlineToolbar', () => { await (inlineToolbar as unknown as { activateToolByShortcut: (toolName: string) => Promise }).activateToolByShortcut('bold'); - expect(mockPopoverInstance.activateItemByName).not.toHaveBeenCalled(); + expect(mockPopoverInstance.activateItemByName).toHaveBeenCalledWith('bold'); }); it('should activate tool when tool is not active', async () => { @@ -845,64 +848,6 @@ describe('InlineToolbar', () => { }); }); - describe('checkToolsState', () => { - it('should do nothing when selection is null', () => { - Object.defineProperty(SelectionUtils, 'instance', { - value: null, - writable: true, - configurable: true, - }); - Object.defineProperty(SelectionUtils, 'selection', { - value: null, - writable: true, - configurable: true, - }); - - const toolInstance = createMockInlineTool('bold', { - checkState: vi.fn(), - }); - - (inlineToolbar as unknown as { tools: Map }).tools = new Map([ - [createMockInlineToolAdapter('bold'), toolInstance], - ]); - - (inlineToolbar as unknown as { checkToolsState: () => void }).checkToolsState(); - - expect(toolInstance.checkState).not.toHaveBeenCalled(); - }); - - it('should call checkState on all tool instances', () => { - const selection = createMockSelection(); - const tool1 = createMockInlineTool('bold', { - checkState: vi.fn(), - }); - const tool2 = createMockInlineTool('italic', { - checkState: vi.fn(), - }); - - Object.defineProperty(SelectionUtils, 'instance', { - value: selection, - writable: true, - configurable: true, - }); - Object.defineProperty(SelectionUtils, 'selection', { - value: selection, - writable: true, - configurable: true, - }); - - (inlineToolbar as unknown as { tools: Map }).tools = new Map([ - [createMockInlineToolAdapter('bold'), tool1], - [createMockInlineToolAdapter('italic'), tool2], - ]); - - (inlineToolbar as unknown as { checkToolsState: () => void }).checkToolsState(); - - expect(tool1.checkState).toHaveBeenCalledWith(selection); - expect(tool2.checkState).toHaveBeenCalledWith(selection); - }); - }); - describe('shortcut registration', () => { it('should register shortcuts for tools with shortcuts', () => { const toolAdapter = createMockInlineToolAdapter('bold', { diff --git a/test/unit/polyfills.test.ts b/test/unit/polyfills.test.ts index f3a0f35b..2ffd8ab2 100644 --- a/test/unit/polyfills.test.ts +++ b/test/unit/polyfills.test.ts @@ -99,7 +99,7 @@ describe('polyfills', () => { } as unknown as CSSStyleDeclaration) ); - child.scrollIntoViewIfNeeded(); + child.scrollIntoViewIfNeeded?.(); expect(scrollTop).toBe(110); expect(scrollLeft).toBe(155); @@ -165,7 +165,7 @@ describe('polyfills', () => { } as unknown as CSSStyleDeclaration) ); - child.scrollIntoViewIfNeeded(false); + child.scrollIntoViewIfNeeded?.(false); expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); expect(scrollIntoViewMock).toHaveBeenCalledWith(true); diff --git a/test/unit/tools/inline.test.ts b/test/unit/tools/inline.test.ts index 35ba1703..3215d8e8 100644 --- a/test/unit/tools/inline.test.ts +++ b/test/unit/tools/inline.test.ts @@ -78,34 +78,6 @@ describe('InlineToolAdapter', () => { }); }); - describe('.title', () => { - it('returns constructable title', () => { - const options = createInlineToolOptions(); - const tool = new InlineToolAdapter(options); - const constructable = options.constructable as { title?: string }; - - expect(tool.title).toBe(constructable.title); - }); - - it('returns empty string when constructable is undefined', () => { - const tool = new InlineToolAdapter({ - ...createInlineToolOptions(), - constructable: undefined as unknown as InlineToolAdapterOptions['constructable'], - }); - - expect(tool.title).toBe(''); - }); - - it('returns empty string when constructable title is undefined', () => { - const tool = new InlineToolAdapter({ - ...createInlineToolOptions(), - constructable: {} as InlineToolAdapterOptions['constructable'], - }); - - expect(tool.title).toBe(''); - }); - }); - describe('.isInternal', () => { it('reflects provided value', () => { const tool = new InlineToolAdapter(createInlineToolOptions()); @@ -219,7 +191,7 @@ describe('InlineToolAdapter', () => { ...createInlineToolOptions(), constructable: {} as InlineToolAdapterOptions['constructable'], }); - const requiredMethods = ['render', 'surround']; + const requiredMethods = [ 'render' ]; expect(tool.getMissingMethods(requiredMethods)).toStrictEqual(requiredMethods); }); @@ -234,16 +206,18 @@ describe('InlineToolAdapter', () => { /** * */ - public render(): void {} + public render(): object { + return {}; + } } const tool = new InlineToolAdapter({ ...options, constructable: ConstructableWithRender as unknown as InlineToolAdapterOptions['constructable'], }); - const requiredMethods = ['render', 'surround']; + const requiredMethods = ['render', 'fakeMethod']; - expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'surround' ]); + expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([ 'fakeMethod' ]); }); it('returns empty array when all methods are implemented', () => { @@ -256,7 +230,9 @@ describe('InlineToolAdapter', () => { /** * */ - public render(): void {} + public render(): object { + return {}; + } /** * @@ -268,7 +244,7 @@ describe('InlineToolAdapter', () => { ...options, constructable: ConstructableWithAllMethods as unknown as InlineToolAdapterOptions['constructable'], }); - const requiredMethods = ['render', 'surround']; + const requiredMethods = [ 'render' ]; expect(tool.getMissingMethods(requiredMethods)).toStrictEqual([]); }); diff --git a/test/unit/utils/utils.test.ts b/test/unit/utils/utils.test.ts index 1b62f0c8..e920a1d1 100644 --- a/test/unit/utils/utils.test.ts +++ b/test/unit/utils/utils.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { - ChainData } from '../../../src/components/utils'; import { isFunction, isObject, @@ -20,7 +18,6 @@ import { delay, debounce, throttle, - sequence, getEditorVersion, getFileExtension, isValidMimeType, @@ -32,7 +29,6 @@ import { generateBlockId, generateId, openTab, - deprecationAssert, cacheable, mobileScreenBreakpoint, isMobileScreen, @@ -539,63 +535,6 @@ describe('utils', () => { }); }); - describe('sequence', () => { - it('should execute functions in sequence', async () => { - const results: number[] = []; - const chains: ChainData[] = [ - { - data: { order: 1 }, - function: async (...args: unknown[]) => { - const data = args[0] as { order: number }; - - results.push(data.order); - }, - }, - { - data: { order: 2 }, - function: async (...args: unknown[]) => { - const data = args[0] as { order: number }; - - results.push(data.order); - }, - }, - ]; - - await sequence(chains); - - expect(results).toEqual([1, 2]); - }); - - it('should call success callback after each chain', async () => { - const successCallback = vi.fn(); - const chains: ChainData[] = [ - { - data: { test: 'data' }, - function: async () => {}, - }, - ]; - - await sequence(chains, successCallback); - - expect(successCallback).toHaveBeenCalledWith({ test: 'data' }); - }); - - it('should call fallback callback on error', async () => { - const fallbackCallback = vi.fn(); - const chains: ChainData[] = [ - { - data: { test: 'data' }, - function: async () => { - throw new Error('test error'); - }, - }, - ]; - - await sequence(chains, () => {}, fallbackCallback); - - expect(fallbackCallback).toHaveBeenCalledWith({ test: 'data' }); - }); - }); describe('getFileExtension', () => { it('should return file extension', () => { @@ -910,31 +849,6 @@ describe('utils', () => { }); }); - describe('deprecationAssert', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - beforeEach(() => { - consoleWarnSpy.mockClear(); - }); - - afterEach(() => { - consoleWarnSpy.mockClear(); - }); - - it('should log warning when condition is true', () => { - deprecationAssert(true, 'oldProperty', 'newProperty'); - - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(consoleWarnSpy.mock.calls[0]?.[0]).toContain('oldProperty'); - expect(consoleWarnSpy.mock.calls[0]?.[0]).toContain('newProperty'); - }); - - it('should not log warning when condition is false', () => { - deprecationAssert(false, 'oldProperty', 'newProperty'); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); describe('cacheable', () => { it('should cache method result', () => { diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index f10027cf..3348e61d 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -71,28 +71,12 @@ export interface Blocks { */ getBlockByElement(element: HTMLElement): BlockAPI | undefined; - /** - * Mark Block as stretched - * @param {number} index - Block to mark - * @param {boolean} status - stretch status - * - * @deprecated Use BlockAPI interface to stretch Blocks - */ - stretchBlock(index: number, status?: boolean): void; - /** * Returns Blocks count * @return {number} */ getBlocksCount(): number; - /** - * Insert new Initial Block after current Block - * - * @deprecated - */ - insertNewBlock(): void; - /** * Insert new Block and return inserted Block API * diff --git a/types/configs/editor-config.d.ts b/types/configs/editor-config.d.ts index 0f60a3fd..dc7c158d 100644 --- a/types/configs/editor-config.d.ts +++ b/types/configs/editor-config.d.ts @@ -5,12 +5,6 @@ import {I18nConfig} from './i18n-config'; import { BlockMutationEvent } from '../events/block'; export interface EditorConfig { - /** - * Element where Editor will be append - * @deprecated property will be removed in the next major release, use holder instead - */ - holderId?: string | HTMLElement; - /** * Element where Editor will be appended */ @@ -28,12 +22,7 @@ export interface EditorConfig { */ defaultBlock?: string; - /** - * @deprecated - * This property will be deprecated in the next major release. - * Use the 'defaultBlock' property instead. - */ - initialBlock?: string; + /** * First Block placeholder diff --git a/types/tools/adapters/inline-tool-adapter.d.ts b/types/tools/adapters/inline-tool-adapter.d.ts index 6c55f1c7..b00b5f6a 100644 --- a/types/tools/adapters/inline-tool-adapter.d.ts +++ b/types/tools/adapters/inline-tool-adapter.d.ts @@ -3,11 +3,6 @@ import { BaseToolAdapter } from './base-tool-adapter'; import { ToolType } from './tool-type'; interface InlineToolAdapter extends BaseToolAdapter { - /** - * Returns title for Inline Tool if specified by user - */ - title: string; - /** * Constructs new InlineTool instance from constructable */ diff --git a/types/tools/inline-tool.d.ts b/types/tools/inline-tool.d.ts index e9b8b609..354bc9f7 100644 --- a/types/tools/inline-tool.d.ts +++ b/types/tools/inline-tool.d.ts @@ -4,41 +4,12 @@ import { MenuConfig } from './menu-config'; /** * Base structure for the Inline Toolbar Tool */ -export interface InlineTool extends BaseTool { +export interface InlineTool extends BaseTool { /** * Shortcut for Tool * @type {string} */ shortcut?: string; - - /** - * Method that accepts selected range and wrap it somehow - * @param range - selection's range. If no active selection, range is null - * @deprecated use {@link MenuConfig} item onActivate property instead - */ - surround?(range: Range | null): void; - - /** - * Get SelectionUtils and detect if Tool was applied - * For example, after that Tool can highlight button or show some details - * @param {Selection} selection - current Selection - * @deprecated use {@link MenuConfig} item isActive property instead - */ - checkState?(selection: Selection): boolean; - - /** - * Make additional element with actions - * For example, input for the 'link' tool or textarea for the 'comment' tool - * @deprecated use {@link MenuConfig} item children to set item actions instead - */ - renderActions?(): HTMLElement; - - /** - * Function called with Inline Toolbar closing - * @deprecated 2020 10/02 - The new instance will be created each time the button is rendered. So clear is not needed. - * Better to create the 'destroy' method in a future. - */ - clear?(): void; } @@ -56,7 +27,7 @@ export interface InlineToolConstructable extends BaseToolConstructable { * * @param {InlineToolConstructorOptions} config - constructor parameters */ - new(config: InlineToolConstructorOptions): BaseTool; + new(config: InlineToolConstructorOptions): InlineTool; /** * Allows inline tool to be available in read-only mode diff --git a/types/tools/menu-config.d.ts b/types/tools/menu-config.d.ts index a33d751d..a9596c41 100644 --- a/types/tools/menu-config.d.ts +++ b/types/tools/menu-config.d.ts @@ -9,15 +9,7 @@ export type MenuConfig = MenuConfigItem | MenuConfigItem[]; /** * Common parameters for all kinds of default Menu Config items: with or without confirmation */ -type MenuConfigDefaultBaseParams = PopoverItemDefaultBaseParams & { - /** - * Displayed text. - * Alias for title property - * - * @deprecated - use title property instead - */ - label?: string -}; +type MenuConfigDefaultBaseParams = PopoverItemDefaultBaseParams; /** * Menu Config item with confirmation @@ -39,7 +31,7 @@ type MenuConfigItemDefaultWithConfirmationParams = Omit; @@ -47,7 +39,7 @@ type MenuConfigItemDefaultParams = /** * Single Menu Config item */ -type MenuConfigItem = +type MenuConfigItem = MenuConfigItemDefaultParams | PopoverItemSeparatorParams | PopoverItemHtmlParams | diff --git a/types/tools/tool.d.ts b/types/tools/tool.d.ts index 9cdb44e1..a2028d9f 100644 --- a/types/tools/tool.d.ts +++ b/types/tools/tool.d.ts @@ -10,7 +10,7 @@ export interface BaseTool { /** * Tool`s render method * - * For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig} + * For Inline Tools returns {@link MenuConfig} * @see https://editorjs.io/menu-config * * For Block Tools returns tool`s wrapper html element