Compare commits

..

12 commits

Author SHA1 Message Date
KoshaevEugeny
530ec56bb8
fix(link-tool): open new window with url when formatted link clicked via ctrl key (#2996)
* fix(link-tool): open new window with url when formatted link clicked via ctrl key

* add test

* fix lint

* bump version and add changelog

* Apply suggestions from code review

Co-authored-by: Peter <specc.dev@gmail.com>
Co-authored-by: KoshaevEugeny <103786108+akulistus@users.noreply.github.com>

* Update test/cypress/tests/inline-tools/link.cy.ts

Co-authored-by: Peter <specc.dev@gmail.com>

---------

Co-authored-by: Peter <specc.dev@gmail.com>
2026-03-11 20:58:30 +03:00
KoshaevEugeny
b69aa1ed25
fix(inline-tool-link): use defaultValue to prevent selectionchange event (#2993)
* fix(inline-tool-link): use defaultValue to prevent selectionchange event

* fix(link-tool): handle formatted linked text clicks

* fix test errors

* Revert "fix test errors"

This reverts commit 582e137b77.

* Revert "fix(link-tool): handle formatted linked text clicks"

This reverts commit ae90e03c60.
2026-03-04 23:45:11 +03:00
KoshaevEugeny
a89f3d0eda
Fix link tool interaction with other tools (#2977)
* fix(selection): removeFakeBackground no longer removes text formatting

* fix(selection): fix jsdoc

* fix(linkTool): add test case to ensure text formatting preservation when applying link

* Add fix entry to changelog

* bump version
2026-02-18 21:09:54 +03:00
Peter Savchenko
cc624cad2e i18n and rtl examples updated 2026-02-18 15:32:29 +03:00
Alex Gaillard
9f942ca72a
fix(LinkInlineTool): improve unlink behavior based on input state (#2979)
* fix(LinkInlineTool): improve unlink behavior based on input state

* 2.31.2-hotfix.0

* fix(linkTool): Add test case to ensure link preservation when applying bold to linked text

* Revert "2.31.2-hotfix.0"

This reverts commit c68ae54c77.

* Add fix entry to changelog

* Bump version

* Revert "Add fix entry to changelog"

This reverts commit 7e537d662a.

* Add fix entry to changelog without formatting

* Refactor test for compatibility with firefox
2026-02-12 18:45:21 +03:00
KoshaevEugeny
90d6dec90e
fix(blockSettings): prevent warning on initial read-only mode toggle (#2969)
Co-authored-by: Peter <specc.dev@gmail.com>
2026-01-08 01:44:19 +03:00
Kuchizu
fb2bf8f116
ci: update deprecated GitHub Actions runners (#2974)
* ci: update deprecated GitHub Actions runners

ci: update GitHub Actions runners from ubuntu-20.04 to ubuntu-latest

* fix: pin Node 18 in CI workflows for eslint compatibility

* fix(ci): pin Firefox 115 ESR for Cypress compatibility
2026-01-07 21:54:34 +03:00
Peter Savchenko
a04c37b30b add example of how to use i18n for Convert To buttons 2025-09-15 20:14:25 +03:00
Peter
9612e0d247
release 2.31 (#2956) 2025-09-06 16:31:44 +03:00
Peter Savchenko
7b6b78235a add changelog 2025-09-06 16:20:35 +03:00
narpat-ps
df7d3a7883
resolve "Can't find a Block to remove" error in renderFromHTML (#2941)
* fix(blocks):Error occurred when calling renderFromHTML: Can't find a Block to remove.

* fix: resolve "Can't find a Block to remove" error in renderFromHTML

- Make renderFromHTML async and await BlockManager.clear() to prevent race condition
- Change removeBlock order: remove from array before destroy to prevent index invalidation
- Fix clear() method to copy blocks array before iteration to avoid modification during loop

Fixes issue where renderFromHTML would fail with "Can't find a Block to remove" error
due to concurrent block removal operations and array modification during iteration.

Resolves #2518
2025-09-06 16:20:08 +03:00
Peter Savchenko
628f2188e7 Create .nvmrc 2025-09-06 16:14:27 +03:00
18 changed files with 298 additions and 44 deletions

View file

@ -17,7 +17,7 @@ jobs:
# If pull request was merged then we should check for a package version update
check-for-no-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
permissions:
actions: write
steps:

View file

@ -17,7 +17,7 @@ jobs:
# If pull request was merged then we should check for a package version update
check-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
permissions:
actions: write
steps:

View file

@ -8,12 +8,19 @@ jobs:
matrix:
browser: [firefox, chrome, edge]
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: actions/checkout@v4
- name: Setup Firefox
if: matrix.browser == 'firefox'
uses: browser-actions/setup-firefox@v1
with:
firefox-version: '115.0esr'
- uses: cypress-io/github-action@v6
with:
config: video=false

View file

@ -5,10 +5,14 @@ on: [pull_request]
jobs:
lint:
name: ESlint
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Cache dependencies
uses: actions/cache@v4
with:

View file

@ -7,7 +7,7 @@ on:
jobs:
publish:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
# Checkout to target branch
- uses: actions/checkout@v4

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v18.20.1

View file

@ -1,5 +1,25 @@
# Changelog
### 2.31.5
- `Fix` - Handle __Ctrl + click__ on links with inline styles applied (e.g., bold, italic)
### 2.31.4
- `Fix` - Prevent inline-toolbar re-renders when linked text is selected
### 2.31.3
- `Fix` - Prevent text formatting removal when applying link
### 2.31.2
- `Fix` - Prevent link removal when applying bold to linked text
### 2.31.1
- `Fix` - Prevent the warning from appearing when `readOnly` mode is initially set to `true`
### 2.31.0
- `New` - Inline tools (those with `isReadOnlySupported` specified) can now be used in read-only mode
@ -16,6 +36,11 @@
- `DX` - Tools submodules removed from the repository
- `Improvement` - Shift + Down/Up will allow to select next/previous line instead of Inline Toolbar flipping
- `Improvement` - The API `caret.setToBlock()` offset now works across the entire block content, not just the first or last node.
- `Improvement` - The API `blocks.renderFromHTML()` became async and now can be awaited.
- `Fix` - `blocks.renderFromHTML()` — Error "Can't find a Block to remove." fixed
- `Fix` - The API `.clear()` index invalidation fixed
### 2.30.7

View file

@ -50,7 +50,6 @@
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@latest"></script><!-- Image -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@latest"></script><!-- Delimiter -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest"></script><!-- List -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/checklist@latest"></script><!-- Checklist -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@latest"></script><!-- Quote -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/code@latest"></script><!-- Code -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@latest"></script><!-- Embed -->
@ -107,16 +106,11 @@
image: ImageTool,
list: {
class: List,
class: EditorjsList,
inlineToolbar: true,
shortcut: 'CMD+SHIFT+L'
},
checklist: {
class: Checklist,
inlineToolbar: true,
},
quote: {
class: Quote,
inlineToolbar: true,
@ -198,7 +192,11 @@
},
"popover": {
"Filter": "Поиск",
"Nothing found": "Ничего не найдено"
"Nothing found": "Ничего не найдено",
/**
* Translation of "Convert To" at the Block Tunes Popover
*/
"Convert to": "Конвертировать в",
}
},
@ -208,7 +206,8 @@
"toolNames": {
"Text": "Параграф",
"Heading": "Заголовок",
"List": "Список",
"Ordered List": "Нумерованный список",
"Unordered List": "Маркированный список",
"Warning": "Примечание",
"Checklist": "Чеклист",
"Quote": "Цитата",
@ -221,7 +220,7 @@
"Bold": "Полужирный",
"Italic": "Курсив",
"InlineCode": "Моноширинный",
"Image": "Картинка"
"Image": "Картинка",
},
/**
@ -266,7 +265,12 @@
"Wrong response format from the server": "Неполадки на сервере",
},
"header": {
"Header": "Заголовок",
"Heading 1": "Заголовок 1",
"Heading 2": "Заголовок 2",
"Heading 3": "Заголовок 3",
"Heading 4": "Заголовок 4",
"Heading 5": "Заголовок 5",
"Heading 6": "Заголовок 6",
},
"paragraph": {
"Enter something": "Введите текст"
@ -274,7 +278,14 @@
"list": {
"Ordered": "Нумерованный",
"Unordered": "Маркированный",
}
"Checklist": "Чеклист",
},
/**
* Translation of "Convert To" at the Inline Toolbar hint
*/
"convertTo": {
"Convert to": "Конвертировать в"
},
},
/**
@ -288,14 +299,15 @@
* Also, there are few internal block tunes: "delete", "moveUp" and "moveDown"
*/
"delete": {
"Delete": "Удалить"
"Delete": "Удалить",
"Click to delete": "Подтвердить удаление"
},
"moveUp": {
"Move up": "Переместить вверх"
},
"moveDown": {
"Move down": "Переместить вниз"
}
},
},
}
},

View file

@ -120,12 +120,6 @@
inlineToolbar: ['link'],
},
list: {
class: List,
inlineToolbar: true,
shortcut: 'CMD+SHIFT+L'
},
checklist: {
class: Checklist,
inlineToolbar: true,

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.31.0-rc.10",
"version": "2.31.5",
"description": "Editor.js — open source block-style WYSIWYG editor with JSON output",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",

View file

@ -566,6 +566,16 @@ export default class Dom {
return element.tagName.toLowerCase() === 'a';
}
/**
* Returns the closest ancestor anchor (A tag) of the given element (including itself)
*
* @param element - element to check
* @returns {HTMLAnchorElement | null}
*/
public static getClosestAnchor(element: Element): HTMLAnchorElement | null {
return element.closest("a");
}
/**
* Return element's offset related to the document
*
@ -623,6 +633,9 @@ export default class Dom {
/**
* If no node found or last node is empty, return null
* - The root node has no text nodes at all
* - The TreeWalker couldn't find any text nodes in the DOM tree
* - The root node itself is null or invalid
*/
if (!lastTextNode) {
return {
@ -633,6 +646,14 @@ export default class Dom {
const textContent = lastTextNode.textContent;
/**
* - The text node exists but has no content (textContent is null)
* - The text node exists but has empty content (textContent.length === 0)
* This could be due to:
* - Empty text nodes (<span></span>)
* - Nodes with only whitespace
* - Nodes that were cleared but not removed
*/
if (textContent === null || textContent.length === 0) {
return {
node: null,

View file

@ -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;
}
@ -202,7 +212,7 @@ export default class LinkInlineTool implements InlineTool {
*/
const hrefAttr = anchorTag.getAttribute('href');
this.nodes.input.value = hrefAttr !== 'null' ? hrefAttr : '';
this.nodes.input.defaultValue = hrefAttr !== 'null' ? hrefAttr : '';
this.selection.save();
} else {

View file

@ -224,8 +224,8 @@ export default class BlocksAPI extends Module {
* @param {string} data - HTML string to render
* @returns {Promise<void>}
*/
public renderFromHTML(data: string): Promise<void> {
this.Editor.BlockManager.clear();
public async renderFromHTML(data: string): Promise<void> {
await this.Editor.BlockManager.clear();
return this.Editor.Paste.processText(data, true);
}

View file

@ -533,8 +533,8 @@ export default class BlockManager extends Module {
throw new Error('Can\'t find a Block to remove');
}
block.destroy();
this._blocks.remove(index);
block.destroy();
/**
* Force call of didMutated event on Block removal
@ -894,7 +894,10 @@ export default class BlockManager extends Module {
public async clear(needToAddDefaultBlock = false): Promise<void> {
const queue = new PromiseQueue();
this.blocks.forEach((block) => {
// Create a copy of the blocks array to avoid issues with array modification during iteration
const blocksToRemove = [...this.blocks];
blocksToRemove.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);
});

View file

@ -68,6 +68,11 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
return 'flipper' in this.popover ? this.popover?.flipper : undefined;
}
/**
* Flag that indicates whether the `EditorMobileLayoutToggled` event listener is attached.
*/
private hasMobileLayoutToggleListener = false;
/**
* Page selection utils
*/
@ -92,6 +97,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
}
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
this.hasMobileLayoutToggleListener = true;
}
/**
@ -100,7 +106,11 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
public destroy(): void {
this.removeAllNodes();
this.listeners.destroy();
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
if (this.hasMobileLayoutToggleListener) {
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
this.hasMobileLayoutToggleListener = false;
}
}
/**

View file

@ -773,12 +773,13 @@ export default class UI extends Module<UINodes> {
*/
const element = event.target as Element;
const ctrlKey = event.metaKey || event.ctrlKey;
if ($.isAnchor(element) && ctrlKey) {
const anchor = $.getClosestAnchor(element);
if (anchor && ctrlKey) {
event.stopImmediatePropagation();
event.stopPropagation();
const href = element.getAttribute('href');
const href = anchor.getAttribute('href');
const validUrl = _.getValidUrl(href);
_.openTab(validUrl);

View file

@ -57,10 +57,9 @@ export default class SelectionUtils {
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
* Native Document's command for fake background
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
/**
* Editor styles
@ -416,9 +415,9 @@ export default class SelectionUtils {
if (!this.isFakeBackgroundEnabled) {
return;
}
document.execCommand(this.commandBackground, false, 'transparent');
this.isFakeBackgroundEnabled = false;
document.execCommand(this.commandRemoveFormat);
}
/**

View file

@ -71,4 +71,171 @@ describe('Inline Tool Link', () => {
.find('.ce-paragraph span[style]')
.should('not.exist');
});
it('should preserve link when applying bold to linked text', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Text with link',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectText('Text with link');
cy.get('[data-cy=editorjs]')
.find('[data-item-name=link]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-inline-tool-input')
.type('https://editorjs.io')
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('a')
.should('have.attr', 'href', 'https://editorjs.io');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('a')
.selectText('Text with link');
cy.get('[data-cy=editorjs]')
.find('[data-item-name=bold]')
.click();
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('a')
.should('have.attr', 'href', 'https://editorjs.io')
.find('b')
.should('exist')
.should('contain', 'Text with link');
});
it('should preserve bold and italic when applying link', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Bold and italic text',
},
},
],
},
});
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.selectText('Bold and italic text');
cy.get('[data-cy=editorjs]')
.find('[data-item-name=bold]')
.click();
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('b')
.should('exist')
.should('contain', 'Bold and italic text');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('b')
.selectText('Bold and italic text');
cy.get('[data-cy=editorjs]')
.find('[data-item-name=italic]')
.click();
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('b')
.should('exist')
.find('i')
.should('exist')
.should('contain', 'Bold and italic text');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('b')
.find('i')
.selectText('Bold and italic text');
cy.get('[data-cy=editorjs]')
.find('[data-item-name=link]')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-inline-tool-input')
.type('https://editorjs.io')
.type('{enter}');
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.find('b')
.should('exist')
.find('i')
.should('exist')
.find('a')
.should('have.attr', 'href', 'https://editorjs.io')
.should('contain', 'Bold and italic text');
});
it('should open a link if it is wrapped in another formatting', () => {
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=italic]')
.click();
cy.window().then((win) => {
cy.stub(win, 'open').as('windowOpen');
});
cy.contains('[data-cy=editorjs] div.ce-block i', 'Link text')
.click({ ctrlKey: true });
cy.get('@windowOpen').should('be.calledWith', 'https://test.io/');
});
});