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