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.
This commit is contained in:
JackUait 2025-11-22 02:46:08 +03:00
commit c48898bb5b
47 changed files with 2739 additions and 1771 deletions

View file

@ -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

View file

@ -13,5 +13,5 @@
"source.fixAll.eslint": "always"
},
"eslint.useFlatConfig": true,
"editor.formatOnSave": false
}

View file

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

View file

@ -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

View file

@ -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());

View file

@ -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';

View file

@ -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 <i> tags
* Leave <i> and <em> 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 <i> tag
*/
public surround(): void {
document.execCommand(this.commandName);
}
/**
* Check selection and set activated state to button if there are <i> 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 <i> 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, `<i>${html}</i>`);
const selection = window.getSelection();
if (selection && insertedRange) {
selection.removeAllRanges();
selection.addRange(insertedRange);
}
}
/**
* Remove italic tags (<i>/<em>) 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 (<i> or <em>)
*
* @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<HTMLElement>();
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);
}
}

View file

@ -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 <a> 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 <a> 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<HTMLButtonElement>(
`${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 <a> 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;

View file

@ -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
*

View file

@ -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<BlockSettingsNodes> {
items.push(...commonTunes);
return items.map(tune => this.resolveTuneAliases(tune));
return items;
}
/**
@ -309,43 +308,6 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
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
*

View file

@ -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<InlineToolbarNodes> {
*/
private registeredShortcuts: Map<string, string> = 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<InlineToolbarNodes> {
}
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<InlineToolbarNodes> {
}
this.popover = null;
this.savedShortcutRange = null;
}
/**
@ -362,8 +355,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.popover.show?.();
this.checkToolsState();
}
/**
@ -640,9 +631,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
): void {
const commonPopoverItemParams = {
name: toolName,
onActivate: () => {
this.toolClicked(instance);
},
hint: {
title: toolTitle,
description: shortcutBeautified,
@ -650,8 +638,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
} as PopoverItemParams;
if ($.isElement(item)) {
this.processElementItem(item, instance, commonPopoverItemParams, popoverItems);
return;
}
@ -682,71 +668,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
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<PopoverItemHtmlParams>).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<InlineToolbarNodes> {
}
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<InlineToolbarNodes> {
});
}
/**
* 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<void> {
const initialRange = SelectionUtils.range;
if (!this.opened) {
await this.tryToShow();
}
@ -917,68 +814,14 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
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

View file

@ -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;

View file

@ -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();
}
/**

View file

@ -28,15 +28,6 @@ export default class InlineToolAdapter extends BaseToolAdapter<ToolType.Inline,
return requiredMethods.filter((methodName) => 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
*/

View file

@ -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<void> => {
/**
* 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<void> => {
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<Value> = {
get?: () => Value;

View file

@ -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 = <ObjectType extends object>(
obj: ObjectType,
aliases: Partial<Record<string, keyof ObjectType>>
): ObjectType => {
const result = {} as ObjectType;
(Object.keys(obj) as Array<keyof ObjectType | string>).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;
};

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}
}

View file

@ -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', () => {

View file

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

View file

@ -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<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
/**
* 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<void> => {
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<void> => {
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: '<i>first</i> <i>second</i>',
},
},
]);
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: '<i>italic text</i>',
},
},
]);
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: '<i>first</i> <i>second</i>',
},
},
]);
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(/<i>/);
});
test('makes mixed selection (italic and normal text) italic', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: '<i>italic</i> normal <i>italic2</i>',
},
},
]);
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 <i> tag
await page.waitForFunction(
({ selector }) => {
const element = document.querySelector(selector);
return element && /<i>italic normal italic2<\/i>/.test(element.innerHTML);
},
{
selector: PARAGRAPH_SELECTOR,
}
);
// Verify that all selected text is now wrapped in a single <i> tag
const html = await paragraph.innerHTML();
console.log('Mixed selection HTML:', html);
// Allow for merged tags or separate tags
expect(html).toMatch(/<i>.*italic.*normal.*italic2.*<\/i>/);
});
test('removes italic from fully italic selection', async ({ page }) => {
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: '<i>fully italic</i>',
},
},
]);
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(/<i>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(/&nbsp;/g, ' ').replace(/\u200B/g, '')).toBe('Typing test<i> Italic</i> 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<OutputData | undefined>(async () => {
return window.editorInstance?.save();
});
expect(savedData).toBeDefined();
const paragraphBlock = savedData?.blocks.find((block) => block.type === 'paragraph');
expect(paragraphBlock?.data.text).toMatch(/<i>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 && /<i>Some text<\/i>/.test(element.innerHTML);
},
{
selector: PARAGRAPH_SELECTOR,
}
);
// Verify initial italic state
let html = await paragraph.innerHTML();
expect(html).toMatch(/<i>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(/<i>\s*text<\/i>/);
// "Some" should not be wrapped in italic tags
expect(html).not.toMatch(/<i>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(/<i>some<\/i> text/);
// Step 3: Make "text" italic (now we have <i>some</i> <i>text</i>)
await selectText(paragraph, 'text');
await italicButton.click();
// Verify both words are now italic with space between them
html = await paragraph.innerHTML();
expect(html).toMatch(/<i>some<\/i> <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(/<i>/);
});
});
declare global {
interface Window {
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -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<Locator> => {
// 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<void> => {
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<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise<void> => {
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 <a href="https://google.com">here</a> 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: '<a href="https://google.com" target="_blank">Target link</a>' },
} ]);
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 <a href="https://inside.com">inside</a> 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();
});
});

View file

@ -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('<a href="https://codex.so">Persist me</a>');
expect(paragraphBlock?.data.text).toContain('<a href="https://codex.so" target="_blank" rel="nofollow">Persist me</a>');
});
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: '<a href="https://codex.so">Link to remove</a>',
},
},
]);
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: '<a href="https://example.com">Unlink me</a>',
},
},
]);
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 {

View file

@ -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();

View file

@ -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([]);
});

View file

@ -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', () => {
});
});
});

View file

@ -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();

View file

@ -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 = <K extends DocumentCommandKey>(
key: K,
implementation: Document[K]
): void => {
Object.defineProperty(document, key, {
configurable: true,
value: implementation,
writable: true,
});
};
describe('ItalicInlineTool', () => {
let tool: ItalicInlineTool;
let execCommandMock: ReturnType<typeof vi.fn<[], boolean>>;
let queryCommandStateMock: ReturnType<typeof vi.fn<[], boolean>>;
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);
});
});
});

View file

@ -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<typeof LinkInlineTool>;
toolbar: { close: ReturnType<typeof vi.fn> };
inlineToolbar: { close: ReturnType<typeof vi.fn> };
notifier: { show: ReturnType<typeof vi.fn> };
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<typeof vi.fn>;
@ -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');
});
});

View file

@ -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 };

View file

@ -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<PopoverItemParams[]>;
}).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', () => {

View file

@ -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({

View file

@ -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();
});
});

View file

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

View file

@ -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<InlineToolAdapter, InlineTool> }).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<unknown, unknown> }).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<Array<{ onActivate?: () => 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<void> }).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<InlineToolAdapter, InlineTool> }).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<InlineToolAdapter, InlineTool> }).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', {

View file

@ -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);

View file

@ -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([]);
});

View file

@ -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', () => {

16
types/api/blocks.d.ts vendored
View file

@ -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
*

View file

@ -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

View file

@ -3,11 +3,6 @@ import { BaseToolAdapter } from './base-tool-adapter';
import { ToolType } from './tool-type';
interface InlineToolAdapter extends BaseToolAdapter<ToolType.Inline, InlineTool> {
/**
* Returns title for Inline Tool if specified by user
*/
title: string;
/**
* Constructs new InlineTool instance from constructable
*/

View file

@ -4,41 +4,12 @@ import { MenuConfig } from './menu-config';
/**
* Base structure for the Inline Toolbar Tool
*/
export interface InlineTool extends BaseTool<HTMLElement | MenuConfig> {
export interface InlineTool extends BaseTool<MenuConfig> {
/**
* 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

View file

@ -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<MenuConfigDefaultBasePar
/**
* Default, non-separator and non-html Menu Config items type
*/
type MenuConfigItemDefaultParams =
type MenuConfigItemDefaultParams =
MenuConfigItemDefaultWithConfirmationParams |
MenuConfigDefaultBaseParams |
WithChildren<MenuConfigDefaultBaseParams>;
@ -47,7 +39,7 @@ type MenuConfigItemDefaultParams =
/**
* Single Menu Config item
*/
type MenuConfigItem =
type MenuConfigItem =
MenuConfigItemDefaultParams |
PopoverItemSeparatorParams |
PopoverItemHtmlParams |

View file

@ -10,7 +10,7 @@ export interface BaseTool<RenderReturnType = HTMLElement> {
/**
* 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