diff --git a/.github/workflows/bump-version-on-merge-next.yml b/.github/workflows/bump-version-on-merge-next.yml
index 2a592e3c..023bf809 100644
--- a/.github/workflows/bump-version-on-merge-next.yml
+++ b/.github/workflows/bump-version-on-merge-next.yml
@@ -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:
diff --git a/.github/workflows/create-a-release-draft.yml b/.github/workflows/create-a-release-draft.yml
index ec728b8a..02ffb1b3 100644
--- a/.github/workflows/create-a-release-draft.yml
+++ b/.github/workflows/create-a-release-draft.yml
@@ -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:
diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml
index 51718006..e9ed6ae2 100644
--- a/.github/workflows/cypress.yml
+++ b/.github/workflows/cypress.yml
@@ -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
diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml
index 973af889..c85e5ca9 100644
--- a/.github/workflows/eslint.yml
+++ b/.github/workflows/eslint.yml
@@ -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:
diff --git a/.github/workflows/publish-package-to-npm.yml b/.github/workflows/publish-package-to-npm.yml
index b6ec6935..e867a7ed 100644
--- a/.github/workflows/publish-package-to-npm.yml
+++ b/.github/workflows/publish-package-to-npm.yml
@@ -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
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..ef33d651
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v18.20.1
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 5770a1e7..3367bec6 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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
diff --git a/example/example-i18n.html b/example/example-i18n.html
index bc1aaf7a..1c40d370 100644
--- a/example/example-i18n.html
+++ b/example/example-i18n.html
@@ -50,7 +50,6 @@
-
@@ -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": "Переместить вниз"
- }
+ },
},
}
},
diff --git a/example/example-rtl.html b/example/example-rtl.html
index 1a347316..548c4f4e 100644
--- a/example/example-rtl.html
+++ b/example/example-rtl.html
@@ -120,12 +120,6 @@
inlineToolbar: ['link'],
},
- list: {
- class: List,
- inlineToolbar: true,
- shortcut: 'CMD+SHIFT+L'
- },
-
checklist: {
class: Checklist,
inlineToolbar: true,
diff --git a/package.json b/package.json
index 8f60f1a0..ee5f1499 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/dom.ts b/src/components/dom.ts
index 67573555..2f67e49a 100644
--- a/src/components/dom.ts
+++ b/src/components/dom.ts
@@ -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 ()
+ * - Nodes with only whitespace
+ * - Nodes that were cleared but not removed
+ */
if (textContent === null || textContent.length === 0) {
return {
node: null,
diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts
index 9b413a56..0bef25c7 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;
}
@@ -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 {
diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts
index f9297d5d..9ad22176 100644
--- a/src/components/modules/api/blocks.ts
+++ b/src/components/modules/api/blocks.ts
@@ -224,8 +224,8 @@ export default class BlocksAPI extends Module {
* @param {string} data - HTML string to render
* @returns {Promise}
*/
- public renderFromHTML(data: string): Promise {
- this.Editor.BlockManager.clear();
+ public async renderFromHTML(data: string): Promise {
+ await this.Editor.BlockManager.clear();
return this.Editor.Paste.processText(data, true);
}
diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts
index 48fec049..be8e1e24 100644
--- a/src/components/modules/blockManager.ts
+++ b/src/components/modules/blockManager.ts
@@ -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 {
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);
});
diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts
index 88f861b4..2b1e4035 100644
--- a/src/components/modules/toolbar/blockSettings.ts
+++ b/src/components/modules/toolbar/blockSettings.ts
@@ -68,6 +68,11 @@ export default class BlockSettings extends Module {
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 {
}
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
+ this.hasMobileLayoutToggleListener = true;
}
/**
@@ -100,7 +106,11 @@ export default class BlockSettings extends Module {
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;
+ }
}
/**
diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts
index a4d3baad..d8dc3798 100644
--- a/src/components/modules/ui.ts
+++ b/src/components/modules/ui.ts
@@ -773,12 +773,13 @@ export default class UI extends Module {
*/
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);
diff --git a/src/components/selection.ts b/src/components/selection.ts
index 1ab2e568..40cceaeb 100644
--- a/src/components/selection.ts
+++ b/src/components/selection.ts
@@ -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);
}
/**
diff --git a/test/cypress/tests/inline-tools/link.cy.ts b/test/cypress/tests/inline-tools/link.cy.ts
index 3077e7d8..7d3fa121 100644
--- a/test/cypress/tests/inline-tools/link.cy.ts
+++ b/test/cypress/tests/inline-tools/link.cy.ts
@@ -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/');
+ });
});