From ca4e0adf0f7187a497dd1fdfdbfb5c6d533a7c0f Mon Sep 17 00:00:00 2001 From: Eugeny Date: Mon, 20 Apr 2026 01:28:00 +0300 Subject: [PATCH 01/11] fix(link-tool): call clear function to reset link-tool state --- .../inline-tools/inline-tool-link.ts | 28 +++++++------------ src/components/modules/toolbar/inline.ts | 3 ++ .../components/popover-item/popover-item.ts | 15 ++++++++++ .../utils/popover/popover-inline.ts | 4 +-- types/utils/popover/popover-item.d.ts | 7 +++++ 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 0bef25c73..3ae5f835e 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -172,21 +172,11 @@ export default class LinkInlineTool implements InlineTool { * Unlink icon pressed */ if (parentAnchor) { - /** - * If input is not opened, treat click as explicit unlink action. - * If input is opened (e.g., programmatic close when switching tools), avoid unlinking. - */ - if (!this.inputOpened) { - this.selection.expandToTag(parentAnchor); - this.unlink(); - this.closeActions(); - this.checkState(); - this.toolbar.close(); - } else { - /** Only close actions without clearing saved selection to preserve user state */ - this.closeActions(false); - this.checkState(); - } + this.selection.expandToTag(parentAnchor); + this.unlink(); + this.closeActions(); + this.checkState(); + this.toolbar.close(); return; } @@ -270,14 +260,16 @@ export default class LinkInlineTool implements InlineTool { if (this.selection.isFakeBackgroundEnabled) { // if actions is broken by other selection We need to save new selection const currentSelection = new SelectionUtils(); - currentSelection.save(); this.selection.restore(); this.selection.removeFakeBackground(); - // and recover new selection after removing fake background - currentSelection.restore(); + // check if other selection happend + if (!currentSelection.savedSelectionRange.collapsed) { + // and recover new selection after removing fake background + currentSelection.restore(); + } } this.nodes.input.classList.remove(this.CSS.inputShowed); diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 5aa5f7dab..cf938e57a 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -391,6 +391,9 @@ export default class InlineToolbar extends Module { onActivate: () => { this.toolClicked(instance); }, + onClear: () => { + instance?.clear?.(); + }, hint: { title: toolTitle, description: shortcutBeautified, diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index b211cab99..c6a93c628 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -25,6 +25,21 @@ export abstract class PopoverItem { } } + /** + * Calls instance clear function + */ + public clear(): void { + if (this.params === undefined) { + return; + } + + if (!('onClear' in this.params)) { + return; + } + + this.params.onClear?.(this.params); + } + /** * Destroys the instance */ diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts index ebe91223c..6a3e7b471 100644 --- a/src/components/utils/popover/popover-inline.ts +++ b/src/components/utils/popover/popover-inline.ts @@ -167,9 +167,9 @@ export class PopoverInline extends PopoverDesktop { if (item !== this.nestedPopoverTriggerItem) { /** * In case tool had special handling for toggling button (like link tool which modifies selection) - * we need to call handleClick on nested popover trigger item + * we need to call clear on nested popover trigger item to restore initial state */ - this.nestedPopoverTriggerItem?.handleClick(); + this.nestedPopoverTriggerItem?.clear(); /** * Then close the nested popover diff --git a/types/utils/popover/popover-item.d.ts b/types/utils/popover/popover-item.d.ts index 3957fdae0..31d76ce72 100644 --- a/types/utils/popover/popover-item.d.ts +++ b/types/utils/popover/popover-item.d.ts @@ -178,6 +178,13 @@ export interface PopoverItemDefaultBaseParams { * @param event - event that initiated item activation */ onActivate: (item: PopoverItemParams, event?: PointerEvent) => void; + + /** + * Popover item clear handler + * + * @param item - item to be cleared + */ + onClear: (item: PopoverItemParams) => void; } /** From 684c3ab8dd0726cd2d0c3759f3037813c61b143a Mon Sep 17 00:00:00 2001 From: Eugeny Date: Tue, 21 Apr 2026 23:55:57 +0300 Subject: [PATCH 02/11] refactor(link-tool): add JSDoc. Add function to check selectionTarget --- .../inline-tools/inline-tool-link.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 3ae5f835e..88825191c 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -51,6 +51,12 @@ export default class LinkInlineTool implements InlineTool { */ private readonly ENTER_KEY: number = 13; + /** + * Popover item block CSS class + */ + private readonly POPOVER_ITEM_CLASSNAME = "ce-popover-item-html"; + + /** * Styles */ @@ -264,12 +270,12 @@ export default class LinkInlineTool implements InlineTool { this.selection.restore(); this.selection.removeFakeBackground(); - - // check if other selection happend - if (!currentSelection.savedSelectionRange.collapsed) { - // and recover new selection after removing fake background + + // check if other selection happend outside popover element + if (this.checkSelectionTarget(currentSelection.savedSelectionRange)) { + // and recover new selection currentSelection.restore(); - } + } } this.nodes.input.classList.remove(this.CSS.inputShowed); @@ -406,4 +412,22 @@ export default class LinkInlineTool implements InlineTool { private unlink(): void { document.execCommand(this.commandUnlink); } + + /** + * Checks if the current selection range starts outside + * of a popover item element + * + * @param range - The DOM Range to evaluate. + * @returns `true` if popover item block is selection target otherwise - `false`. + */ + private checkSelectionTarget(range: Range): boolean { + const container = range.startContainer; + if (container instanceof HTMLElement) { + if (container.classList.contains(this.POPOVER_ITEM_CLASSNAME)) { + return false; + } + } + + return true; + } } From 3a4c71a3b0c3ddce4430790e513949148783275b Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 22 Apr 2026 00:29:17 +0300 Subject: [PATCH 03/11] add tests --- test/cypress/tests/inline-tools/link.cy.ts | 104 +++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/test/cypress/tests/inline-tools/link.cy.ts b/test/cypress/tests/inline-tools/link.cy.ts index 7d3fa121a..cd96f83c0 100644 --- a/test/cypress/tests/inline-tools/link.cy.ts +++ b/test/cypress/tests/inline-tools/link.cy.ts @@ -238,4 +238,108 @@ describe('Inline Tool Link', () => { cy.get('@windowOpen').should('be.calledWith', 'https://test.io/'); }); + + it('should unlink on popover item block click', () => { + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Link text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Link text'); + + cy.get('[data-cy=editorjs]') + .find('[data-item-name=link]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('.ce-inline-tool-input') + .type('https://test.io/') + .type('{enter}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .find('a') + .selectText('Link text'); + + cy.get('[data-cy=editorjs]') + .find('[data-item-name=link]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .find('a') + .should("not.exist"); + }); + + it('should hide popover on selection change', () => { + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Link text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Link text'); + + cy.get('[data-cy=editorjs]') + .find('[data-item-name=link]') + .click(); + + cy.get('[data-cy=editorjs]').click(0, 0); + + cy.get('[data-cy=editorjs]') + .find('.ce-popover') + .should('not.be.visible'); + }); + + it('should restore selection and apply formatting on other popover item block click', () => { + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Link text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Link text'); + + cy.get('[data-cy=editorjs]') + .find('[data-item-name=link]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('[data-item-name=bold]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .find('b') + .should("exist") + .and('have.text', 'Link text'); + }); }); From df3993e1db5e74b40277badea001728325147ade Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 22 Apr 2026 00:29:30 +0300 Subject: [PATCH 04/11] fix lint --- src/components/inline-tools/inline-tool-link.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 88825191c..2cdd87ae7 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -270,10 +270,10 @@ export default class LinkInlineTool implements InlineTool { this.selection.restore(); this.selection.removeFakeBackground(); - + // check if other selection happend outside popover element if (this.checkSelectionTarget(currentSelection.savedSelectionRange)) { - // and recover new selection + // and recover new selection currentSelection.restore(); } } From 9eb5f5402f1de26d77a1cd8476e5d2f43aa7c9e9 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 22 Apr 2026 01:06:25 +0300 Subject: [PATCH 05/11] fix tests --- test/cypress/tests/inline-tools/link.cy.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/cypress/tests/inline-tools/link.cy.ts b/test/cypress/tests/inline-tools/link.cy.ts index cd96f83c0..8ea5d0cfb 100644 --- a/test/cypress/tests/inline-tools/link.cy.ts +++ b/test/cypress/tests/inline-tools/link.cy.ts @@ -303,11 +303,18 @@ describe('Inline Tool Link', () => { .find('[data-item-name=link]') .click(); - cy.get('[data-cy=editorjs]').click(0, 0); + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click(); cy.get('[data-cy=editorjs]') - .find('.ce-popover') - .should('not.be.visible'); + .find('[data-item-name=link]') + .should('not.exist'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .find('span') + .should('not.exist'); }); it('should restore selection and apply formatting on other popover item block click', () => { From 7085e98c55a01e9af997792196d938c3dbafb452 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 27 May 2026 00:48:48 +0300 Subject: [PATCH 06/11] Revert "refactor(link-tool): add JSDoc. Add function to check selectionTarget" This reverts commit 684c3ab8dd0726cd2d0c3759f3037813c61b143a. --- .../inline-tools/inline-tool-link.ts | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 2cdd87ae7..3ae5f835e 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -51,12 +51,6 @@ export default class LinkInlineTool implements InlineTool { */ private readonly ENTER_KEY: number = 13; - /** - * Popover item block CSS class - */ - private readonly POPOVER_ITEM_CLASSNAME = "ce-popover-item-html"; - - /** * Styles */ @@ -270,12 +264,12 @@ export default class LinkInlineTool implements InlineTool { this.selection.restore(); this.selection.removeFakeBackground(); - - // check if other selection happend outside popover element - if (this.checkSelectionTarget(currentSelection.savedSelectionRange)) { - // and recover new selection + + // check if other selection happend + if (!currentSelection.savedSelectionRange.collapsed) { + // and recover new selection after removing fake background currentSelection.restore(); - } + } } this.nodes.input.classList.remove(this.CSS.inputShowed); @@ -412,22 +406,4 @@ export default class LinkInlineTool implements InlineTool { private unlink(): void { document.execCommand(this.commandUnlink); } - - /** - * Checks if the current selection range starts outside - * of a popover item element - * - * @param range - The DOM Range to evaluate. - * @returns `true` if popover item block is selection target otherwise - `false`. - */ - private checkSelectionTarget(range: Range): boolean { - const container = range.startContainer; - if (container instanceof HTMLElement) { - if (container.classList.contains(this.POPOVER_ITEM_CLASSNAME)) { - return false; - } - } - - return true; - } } From 579866d940ae2e46687eb2b7ca60847e51c9855f Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 27 May 2026 00:49:12 +0300 Subject: [PATCH 07/11] Revert "fix(link-tool): call clear function to reset link-tool state" This reverts commit ca4e0adf0f7187a497dd1fdfdbfb5c6d533a7c0f. --- .../inline-tools/inline-tool-link.ts | 28 ++++++++++++------- src/components/modules/toolbar/inline.ts | 3 -- .../components/popover-item/popover-item.ts | 15 ---------- .../utils/popover/popover-inline.ts | 4 +-- types/utils/popover/popover-item.d.ts | 7 ----- 5 files changed, 20 insertions(+), 37 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 3ae5f835e..0bef25c73 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -172,11 +172,21 @@ export default class LinkInlineTool implements InlineTool { * Unlink icon pressed */ if (parentAnchor) { - this.selection.expandToTag(parentAnchor); - this.unlink(); - this.closeActions(); - this.checkState(); - this.toolbar.close(); + /** + * If input is not opened, treat click as explicit unlink action. + * If input is opened (e.g., programmatic close when switching tools), avoid unlinking. + */ + if (!this.inputOpened) { + this.selection.expandToTag(parentAnchor); + this.unlink(); + this.closeActions(); + this.checkState(); + this.toolbar.close(); + } else { + /** Only close actions without clearing saved selection to preserve user state */ + this.closeActions(false); + this.checkState(); + } return; } @@ -260,16 +270,14 @@ export default class LinkInlineTool implements InlineTool { if (this.selection.isFakeBackgroundEnabled) { // if actions is broken by other selection We need to save new selection const currentSelection = new SelectionUtils(); + currentSelection.save(); this.selection.restore(); this.selection.removeFakeBackground(); - // check if other selection happend - if (!currentSelection.savedSelectionRange.collapsed) { - // and recover new selection after removing fake background - currentSelection.restore(); - } + // and recover new selection after removing fake background + currentSelection.restore(); } this.nodes.input.classList.remove(this.CSS.inputShowed); diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index cf938e57a..5aa5f7dab 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -391,9 +391,6 @@ export default class InlineToolbar extends Module { onActivate: () => { this.toolClicked(instance); }, - onClear: () => { - instance?.clear?.(); - }, hint: { title: toolTitle, description: shortcutBeautified, diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index c6a93c628..b211cab99 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -25,21 +25,6 @@ export abstract class PopoverItem { } } - /** - * Calls instance clear function - */ - public clear(): void { - if (this.params === undefined) { - return; - } - - if (!('onClear' in this.params)) { - return; - } - - this.params.onClear?.(this.params); - } - /** * Destroys the instance */ diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts index 6a3e7b471..ebe91223c 100644 --- a/src/components/utils/popover/popover-inline.ts +++ b/src/components/utils/popover/popover-inline.ts @@ -167,9 +167,9 @@ export class PopoverInline extends PopoverDesktop { if (item !== this.nestedPopoverTriggerItem) { /** * In case tool had special handling for toggling button (like link tool which modifies selection) - * we need to call clear on nested popover trigger item to restore initial state + * we need to call handleClick on nested popover trigger item */ - this.nestedPopoverTriggerItem?.clear(); + this.nestedPopoverTriggerItem?.handleClick(); /** * Then close the nested popover diff --git a/types/utils/popover/popover-item.d.ts b/types/utils/popover/popover-item.d.ts index 31d76ce72..3957fdae0 100644 --- a/types/utils/popover/popover-item.d.ts +++ b/types/utils/popover/popover-item.d.ts @@ -178,13 +178,6 @@ export interface PopoverItemDefaultBaseParams { * @param event - event that initiated item activation */ onActivate: (item: PopoverItemParams, event?: PointerEvent) => void; - - /** - * Popover item clear handler - * - * @param item - item to be cleared - */ - onClear: (item: PopoverItemParams) => void; } /** From 1864f1a224338645cc5ed4bd8b7bc0a5fd7e6b42 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 1 Jul 2026 20:57:47 +0300 Subject: [PATCH 08/11] fix(inline-tool-link): correct unlink action handling --- .../inline-tools/inline-tool-link.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 0bef25c73..e448d9755 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -70,9 +70,14 @@ export default class LinkInlineTool implements InlineTool { button: HTMLButtonElement; input: HTMLInputElement; } = { - button: null, - input: null, - }; + button: null, + input: null, + }; + + /** + * Indicates whether the button has been clicked + */ + private BUTTON_CLICKED: boolean = false; /** * SelectionUtils instance @@ -125,6 +130,10 @@ export default class LinkInlineTool implements InlineTool { this.nodes.button.innerHTML = IconLink; + this.nodes.button.addEventListener("click", () => { + this.BUTTON_CLICKED = true; + }); + return this.nodes.button; } @@ -171,27 +180,18 @@ export default class LinkInlineTool implements InlineTool { /** * Unlink icon pressed */ - if (parentAnchor) { - /** - * If input is not opened, treat click as explicit unlink action. - * If input is opened (e.g., programmatic close when switching tools), avoid unlinking. - */ - if (!this.inputOpened) { - this.selection.expandToTag(parentAnchor); - this.unlink(); - this.closeActions(); - this.checkState(); - this.toolbar.close(); - } else { - /** Only close actions without clearing saved selection to preserve user state */ - this.closeActions(false); - this.checkState(); - } + if (parentAnchor && this.BUTTON_CLICKED) { + this.selection.expandToTag(parentAnchor); + this.unlink(); + this.closeActions(); + this.checkState(); + this.toolbar.close(); return; } } + this.BUTTON_CLICKED = false; this.toggleActions(); } From e614114bd5833837068ac7b6947ca9963ed9d0af Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 1 Jul 2026 21:01:58 +0300 Subject: [PATCH 09/11] fix lint --- src/components/inline-tools/inline-tool-link.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index e448d9755..7e7c80ce1 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -70,14 +70,14 @@ export default class LinkInlineTool implements InlineTool { button: HTMLButtonElement; input: HTMLInputElement; } = { - button: null, - input: null, - }; + button: null, + input: null, + }; /** * Indicates whether the button has been clicked */ - private BUTTON_CLICKED: boolean = false; + private BUTTON_CLICKED = false; /** * SelectionUtils instance From 0e6605fdc2d59bcbf251adbc19105f07c5fe1bd6 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 1 Jul 2026 23:30:55 +0300 Subject: [PATCH 10/11] bump version --- docs/CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bca6f9236..0adb2fa2b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.31.7 + +- `Fix` - Inability to unlink text using the inline toolbar unlink action + ### 2.31.6 - `Fix` - Widen `sanitize` type on `BlockTool` and `BaseToolConstructable` to accept per-field `SanitizerConfig` diff --git a/package.json b/package.json index 3d964610b..2bc48489f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.31.6", + "version": "2.31.7", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 4d9f4ef394f30d3c09a591495edee6592bc561c0 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Wed, 8 Jul 2026 23:15:33 +0300 Subject: [PATCH 11/11] fix(inline-link-tool): reset BUTTON_CLICKED --- src/components/inline-tools/inline-tool-link.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 7e7c80ce1..42285b53d 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -176,11 +176,14 @@ export default class LinkInlineTool implements InlineTool { this.selection.removeFakeBackground(); } const parentAnchor = this.selection.findParentTag('A'); + const buttonClicked = this.BUTTON_CLICKED; + + this.BUTTON_CLICKED = false; /** * Unlink icon pressed */ - if (parentAnchor && this.BUTTON_CLICKED) { + if (parentAnchor && buttonClicked) { this.selection.expandToTag(parentAnchor); this.unlink(); this.closeActions(); @@ -191,7 +194,6 @@ export default class LinkInlineTool implements InlineTool { } } - this.BUTTON_CLICKED = false; this.toggleActions(); }