Compare commits

...

19 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
Peter
7da61e98ff
improvement(caret): caret.setToBlock() offset argument improved (#2922)
* chore(caret): caret.setToBlock offset improved

* handle empty block

* Update caret.cy.ts

* fix eslint
2025-04-30 19:48:20 +03:00
github-actions[bot]
fdaef55e5f
Bump version (#2919)
Co-authored-by: github-actions <action@github.com>
2025-03-11 11:17:40 +03:00
Peter
cd65d8cd29
imrovement(flipper): allow to select next/prev line by shift arrows (#2918)
* fix: prevent flipper navigation when shift key is pressed

* rm logs

* feat: improve line selection with Shift + Up/Down

* fix lint action

* fix action

* upd
2025-03-11 10:01:46 +03:00
github-actions[bot]
7399e55f7e
Bump version (#2899)
Co-authored-by: github-actions <action@github.com>
2025-01-11 16:25:32 +03:00
Omotayo Obafemi
d15a8c2b2f
Added fix for memory leak issue (#2893)
* Added fix for memory leak issue

* Documented the fix in docs/CHANGELOG.md

* v2.31.0

* Documented the fix in docs/CHANGELOG.md

* Documented the fix in docs/CHANGELOG.md

* Documented the fix in docs/CHANGELOG.md

* Documented the fix in docs/CHANGELOG.md

---------

Co-authored-by: Omotayo Obafemi <omotayo@testlio.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
2025-01-08 20:52:40 +03:00
Omotayo Obafemi
d950a11b8e
Bug Fix For When "/" Overides external text (#2894)
* Bug Fix For When / Overides external text

* Moved fix to blockEvents

* Moved fix to blockEvents

* Moved fix to blockEvents

* Refactored test to simulate behaviour

* Added fix to change log

* Refactored test to mimick exact behaviour of the bug

---------

Co-authored-by: Omotayo Obafemi <omotayo@testlio.com>
Co-authored-by: Peter <specc.dev@gmail.com>
2025-01-08 18:23:38 +03:00
Peter
c9a6cfbe8f
dx(submodules): remove tools submodules from the repo (#2898)
* rm all submodules

* rm commands

* Update package.json
2025-01-07 19:34:59 +03:00
44 changed files with 778 additions and 801 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,19 +5,21 @@ on: [pull_request]
jobs:
lint:
name: ESlint
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Cache node modules
uses: actions/cache@v1
- uses: actions/setup-node@v3
with:
path: node_modules
node-version: 18
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.OS }}-build-${{ env.cache-name }}-
${{ runner.OS }}-build-
${{ runner.OS }}-
${{ runner.os }}-node-
- run: yarn
- run: yarn lint

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

48
.gitmodules vendored
View file

@ -1,48 +0,0 @@
[submodule "example/tools/inline-code"]
path = example/tools/inline-code
url = https://github.com/editor-js/inline-code
[submodule "example/tools/header"]
path = example/tools/header
url = https://github.com/editor-js/header
[submodule "example/tools/delimiter"]
path = example/tools/delimiter
url = https://github.com/editor-js/delimiter
[submodule "example/tools/quote"]
path = example/tools/quote
url = https://github.com/editor-js/quote
[submodule "example/tools/simple-image"]
path = example/tools/simple-image
url = https://github.com/editor-js/simple-image
[submodule "example/tools/marker"]
path = example/tools/marker
url = https://github.com/editor-js/marker
[submodule "example/tools/code"]
path = example/tools/code
url = https://github.com/editor-js/code
[submodule "example/tools/image"]
path = example/tools/image
url = https://github.com/editor-js/image
[submodule "example/tools/embed"]
path = example/tools/embed
url = https://github.com/editor-js/embed
[submodule "example/tools/table"]
path = example/tools/table
url = https://github.com/editor-js/table
[submodule "example/tools/link"]
path = example/tools/link
url = https://github.com/editor-js/link
[submodule "example/tools/raw"]
path = example/tools/raw
url = https://github.com/editor-js/raw
[submodule "example/tools/warning"]
path = example/tools/warning
url = https://github.com/editor-js/warning
[submodule "example/tools/underline"]
path = example/tools/underline
url = https://github.com/editor-js/underline
[submodule "example/tools/text-variant-tune"]
path = example/tools/text-variant-tune
url = https://github.com/editor-js/text-variant-tune
[submodule "example/tools/list"]
path = example/tools/list
url = https://github.com/editor-js/list

1
.nvmrc Normal file
View file

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

View file

@ -1,128 +0,0 @@
/**
* Server for testing example page on mobile devices.
*
* Usage:
* 1. run `yarn devserver:start`
* 2. Open `http://{ip_address}:3000/example/example-dev.html`
* where {ip_address} is IP of your machine.
*
* Also, can serve static files from `/example` or `/dist` on any device in local network.
*/
const path = require('path');
const fs = require('fs');
const http = require('http');
const { networkInterfaces } = require('os');
const port = 3000;
const localhost = '127.0.0.1';
const nonRoutableAddress = '0.0.0.0';
const host = getHost();
const server = http.createServer(serveStatic([
'/example',
'/dist',
]));
server.listen(port, nonRoutableAddress, () => {
console.log(`
${wrapInColor('Editor.js 💖', consoleColors.hiColor)} devserver is running (_)
---------------------------------------------
${wrapInColor('http://' + host + ':' + port + '/example/example-dev.html', consoleColors.fgGreen)}
---------------------------------------------
Page can be opened from any device connected to the same local network.
`);
if (host === localhost) {
console.log(wrapInColor('Looks like you are not connected to any Network so you couldn\'t debug the Editor on your mobile device at the moment.', consoleColors.fgRed));
}
});
/**
* Serves files from specified directories
*
* @param {string[]} paths - directories files from which should be served
* @returns {Function}
*/
function serveStatic(paths) {
return (request, response) => {
const resource = request.url;
const isPathAllowed = paths.find(p => resource.startsWith(p));
if (!isPathAllowed) {
response.writeHead(404);
response.end();
return;
}
const filePath = path.join(__dirname, resource);
try {
const stat = fs.statSync(filePath);
response.writeHead(200, {
'Content-Length': stat.size,
});
const readStream = fs.createReadStream(filePath);
readStream.on('error', e => {
throw e;
});
readStream.pipe(response);
} catch (e) {
response.writeHead(500);
response.end(e.toString());
}
};
}
/**
* Returns IP address of a machine
*
* @returns {string}
*/
function getHost() {
const nets = networkInterfaces();
const results = {};
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
if (net.family === 'IPv4' && !net.internal) {
if (!results[name]) {
results[name] = [];
}
results[name].push(net.address);
}
}
}
/**
* Offline case
*/
if (Object.keys(results).length === 0) {
return localhost;
}
return results['en0'][0];
}
/**
* Terminal output colors
*/
const consoleColors = {
fgMagenta: 35,
fgRed: 31,
fgGreen: 32,
hiColor: 1,
};
/**
* Set a terminal color to the message
*
* @param {string} msg - text to wrap
* @param {string} color - color
* @returns {string}
*/
function wrapInColor(msg, color) {
return '\x1b[' + color + 'm' + msg + '\x1b[0m';
}

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
@ -11,6 +31,16 @@
- `Improvement` - The current block reference will be updated in read-only mode when blocks are clicked
- `Fix` - codex-notifier and codex-tooltip moved from devDependencies to dependencies in package.json to solve type errors
- `Fix` - Handle whitespace input in empty placeholder elements to prevent caret from moving unexpectedly to the end of the placeholder
- `Fix` - Fix the memory leak issue in `Shortcuts` class
- `Fix` - Fix when / overides selected text outside of the editor
- `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

@ -1,422 +0,0 @@
<!--
Use this page is for debugging purposes.
Editor Tools are loaded as git-submodules.
You can pull modules by running `yarn pull_tools` and start experimenting.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Editor.js 🤩🧦🤨 example</title>
<link href="https://fonts.googleapis.com/css?family=PT+Mono" rel="stylesheet">
<link href="../public/assets/demo.css" rel="stylesheet">
<script src="../public/assets/json-preview.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</head>
<body>
<script>
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add("dark-mode");
}
</script>
<div class="ce-example">
<div class="ce-example__header">
<a class="ce-example__header-logo" href="https://codex.so/editor">Editor.js 🤩🧦🤨</a>
<div class="ce-example__header-menu">
<a href="https://github.com/editor-js" target="_blank">Plugins</a>
<a href="https://editorjs.io/usage" target="_blank">Usage</a>
<a href="https://editorjs.io/configuration" target="_blank">Configuration</a>
<a href="https://editorjs.io/creating-a-block-tool" target="_blank">API</a>
</div>
</div>
<div class="ce-example__content">
<div id="editorjs"></div>
<div id="hint-core" style="text-align: center;">
No core bundle file found. Run <code class="inline-code">yarn build</code>
</div>
<div id="hint-tools" style="text-align: center;">
No submodules found. Run <code class="inline-code">yarn pull_tools && yarn tools:update</code>
</div>
<div class="ce-example__button" id="saveButton">
editor.save()
</div>
<div class="ce-example__statusbar">
<div class="ce-example__statusbar-item">
Readonly:
<b id="readonly-state">
Off
</b>
&nbsp;
<div class="ce-example__statusbar-button" id="toggleReadOnlyButton">
toggle
</div>
</div>
<div class="ce-example__statusbar-item">
<div class="ce-example__statusbar-button" id="showBlocksBoundariesButton">
<span data-toggled-text="Hide">Show</span>
blocks boundaries
</div>
</div>
<div class="ce-example__statusbar-item">
<div class="ce-example__statusbar-button" id="enableThinModeButton">
<span data-toggled-text="Disable">Enable</span>
thin mode
</div>
</div>
<div class="ce-example__statusbar-item ce-example__statusbar-item--right">
<div class="ce-example__statusbar-toggler" id="darkThemeToggler">
</div>
</div>
</div>
</div>
<div class="ce-example__output">
<pre class="ce-example__output-content" id="output"></pre>
<div class="ce-example__output-footer">
<a href="https://codex.so" style="font-weight: bold;">Made by CodeX</a>
</div>
</div>
</div>
<!-- Load Tools -->
<!--
You can upload Tools to your project's directory and use as in example below.
Also you can load each Tool from CDN or use NPM/Yarn packages.
Read more in Tool's README file. For example:
https://github.com/editor-js/header#installation
-->
<script src="./tools/header/dist/header.umd.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/simple-image.umd.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/delimiter.umd.js"></script><!-- Delimiter -->
<!-- <script src="./tools/list/dist/list.umd.js"></script> List-->
<script src="./tools/nested-list/dist/nested-list.umd.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/checklist.umd.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/quote.umd.js"></script><!-- Quote -->
<script src="./tools/code/dist/code.umd.js"></script><!-- Code -->
<script src="./tools/embed/dist/embed.umd.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.umd.js"></script><!-- Table -->
<script src="./tools/link/dist/link.umd.js"></script><!-- Link -->
<script src="./tools/raw/dist/raw.umd.js"></script><!-- Raw -->
<script src="./tools/warning/dist/warning.umd.js"></script><!-- Warning -->
<script src="./tools/marker/dist/marker.umd.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/inline-code.umd.js"></script><!-- Inline Code -->
<!-- Load Editor.js's Core -->
<script src="../dist/editorjs.umd.js" onload="document.getElementById('hint-core').hidden = true;"></script>
<!-- Initialization -->
<script>
/**
* Editor init config
* @see https://editorjs.io/configuration
*/
const editorConfig = {
/**
* Enable/Disable the read only mode
*/
readOnly: false,
/**
* Wrapper of Editor
*/
holder: 'editorjs',
/**
* Common Inline Toolbar settings
* - if true (or not specified), the order from 'tool' property will be used
* - if an array of tool names, this order will be used
*/
// inlineToolbar: ['link', 'marker', 'bold', 'italic'],
// inlineToolbar: true,
/**
* Tools list
*/
tools: {
/**
* Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md}
*/
header: {
class: Header,
inlineToolbar: ['marker', 'link'],
config: {
placeholder: 'Header'
},
shortcut: 'CMD+SHIFT+H'
},
/**
* Or pass class directly without any configuration
*/
image: SimpleImage,
list: {
class: NestedList,
inlineToolbar: true,
shortcut: 'CMD+SHIFT+L'
},
checklist: {
class: Checklist,
inlineToolbar: true,
},
quote: {
class: Quote,
inlineToolbar: true,
config: {
quotePlaceholder: 'Enter a quote',
captionPlaceholder: 'Quote\'s author',
},
shortcut: 'CMD+SHIFT+O'
},
warning: Warning,
marker: {
class: Marker,
shortcut: 'CMD+SHIFT+M'
},
code: {
class: CodeTool,
shortcut: 'CMD+SHIFT+C'
},
delimiter: Delimiter,
inlineCode: {
class: InlineCode,
shortcut: 'CMD+SHIFT+C'
},
linkTool: LinkTool,
raw: RawTool,
embed: Embed,
table: {
class: Table,
inlineToolbar: true,
shortcut: 'CMD+ALT+T'
},
},
/**
* This Tool will be used as default
*/
// defaultBlock: 'paragraph',
/**
* Initial Editor data
*/
data: {
blocks: [
{
id: "zcKCF1S7X8",
type: "header",
data: {
text: "Editor.js",
level: 1
}
},
{
"id": "b6ji-DvaKb",
"type": "paragraph",
"data": {
"text": "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration."
}
},
{
type: "header",
id: "7ItVl5biRo",
data: {
text: "Key features",
level: 2
}
},
{
type : 'list',
id: "SSBSguGvP7",
data : {
items : [
{
content: 'It is a block-styled editor',
items: []
},
{
content: 'It returns clean data output in JSON',
items: []
},
{
content: 'Designed to be extendable and pluggable with a simple API',
items: []
}
],
style: 'unordered'
}
},
{
type: "header",
id: "QZFox1m_ul",
data: {
text: "What does it mean «block-styled editor»",
level: 2
}
},
{
type : 'paragraph',
id: "bwnFX5LoX7",
data : {
text : 'Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js <mark class=\"cdx-marker\">workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc</mark>. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor\'s Core.'
}
},
{
type : 'paragraph',
id: "mTrPOHAQTe",
data : {
text : `There are dozens of <a href="https://github.com/editor-js">ready-to-use Blocks</a> and the <a href="https://editorjs.io/creating-a-block-tool">simple API</a> for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games.`
}
},
{
type: "header",
id: "1sYMhUrznu",
data: {
text: "What does it mean clean data output",
level: 2
}
},
{
type : 'paragraph',
id: "jpd7WEXrJG",
data : {
text : 'Classic WYSIWYG-editors produce raw HTML-markup with both content data and content appearance. On the contrary, Editor.js outputs JSON object with data of each Block. You can see an example below'
}
},
{
type : 'paragraph',
id: "0lOGNUKxqt",
data : {
text : `Given data can be used as you want: render with HTML for <code class="inline-code">Web clients</code>, render natively for <code class="inline-code">mobile apps</code>, create markup for <code class="inline-code">Facebook Instant Articles</code> or <code class="inline-code">Google AMP</code>, generate an <code class="inline-code">audio version</code> and so on.`
}
},
{
type : 'paragraph',
id: "WvX7kBjp0I",
data : {
text : 'Clean data is useful to sanitize, validate and process on the backend.'
}
},
{
type : 'delimiter',
id: "H9LWKQ3NYd",
data : {}
},
{
type : 'paragraph',
id: "h298akk2Ad",
data : {
text : 'We have been working on this project more than three years. Several large media projects help us to test and debug the Editor, to make its core more stable. At the same time we significantly improved the API. Now, it can be used to create any plugin for any task. Hope you enjoy. 😏'
}
},
{
type: 'image',
id: "9802bjaAA2",
data: {
url: 'assets/codex2x.png',
caption: '',
stretched: false,
withBorder: true,
withBackground: false,
}
},
]
},
onReady: function(){
saveButton.click();
},
onChange: function(api, event) {
console.log('something changed', event);
},
}
/**
* To initialize the Editor, create a new instance with configuration object
* @see docs/installation.md for mode details
*/
var editor = new EditorJS(editorConfig);
/**
* Saving button
*/
const saveButton = document.getElementById('saveButton');
/**
* Toggle read-only button
*/
const toggleReadOnlyButton = document.getElementById('toggleReadOnlyButton');
const readOnlyIndicator = document.getElementById('readonly-state');
/**
* Saving example
*/
saveButton.addEventListener('click', function () {
editor.save()
.then((savedData) => {
cPreview.show(savedData, document.getElementById("output"));
})
.catch((error) => {
console.error('Saving error', error);
});
});
/**
* Toggle read-only example
*/
toggleReadOnlyButton.addEventListener('click', async () => {
const readOnlyState = await editor.readOnly.toggle();
readOnlyIndicator.textContent = readOnlyState ? 'On' : 'Off';
});
/**
* Button for displaying blocks borders. Useful for UI development
*/
const showBlocksBoundariesButton = document.getElementById("showBlocksBoundariesButton");
showBlocksBoundariesButton.addEventListener('click', () => {
document.body.classList.toggle("show-block-boundaries")
})
/**
* Button for enabling the 'Thin' mode
*/
const enableThinModeButton = document.getElementById("enableThinModeButton");
enableThinModeButton.addEventListener('click', () => {
document.body.classList.toggle("thin-mode")
editor.destroy();
editor = new EditorJS(editorConfig);
})
/**
* Toggler for toggling the dark mode
*/
const darkThemeToggler = document.getElementById("darkThemeToggler");
darkThemeToggler.addEventListener('click', () => {
document.body.classList.toggle("dark-mode");
localStorage.setItem('theme', document.body.classList.contains("dark-mode") ? 'dark' : 'default');
})
</script>
</body>
</html>

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

@ -53,21 +53,21 @@
Read more in Tool's README file. For example:
https://github.com/editor-js/header#installation
-->
<script src="./tools/header/dist/bundle.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/bundle.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/bundle.js"></script><!-- Delimiter -->
<script src="./tools/list/dist/bundle.js"></script><!-- List -->
<script src="./tools/checklist/dist/bundle.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/bundle.js"></script><!-- Quote -->
<script src="./tools/code/dist/bundle.js"></script><!-- Code -->
<script src="./tools/embed/dist/bundle.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.js"></script><!-- Table -->
<script src="./tools/link/dist/bundle.js"></script><!-- Link -->
<script src="./tools/raw/dist/bundle.js"></script><!-- Raw -->
<script src="./tools/warning/dist/bundle.js"></script><!-- Warning -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest"></script><!-- Header -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"></script><!-- Image -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@latest"></script><!-- Delimiter -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/nested-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 -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/table@latest"></script><!-- Table -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/link@latest"></script><!-- Link -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@latest"></script><!-- Warning -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@latest"></script><!-- Raw -->
<script src="./tools/marker/dist/bundle.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/bundle.js"></script><!-- Inline Code -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@latest"></script><!-- Marker -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@latest"></script><!-- Inline Code -->
<!-- Load Editor.js's Core -->
<script src="../dist/editorjs.umd.js" onload="document.getElementById('hint-core').hidden = true"></script>
@ -120,12 +120,6 @@
inlineToolbar: ['link'],
},
list: {
class: List,
inlineToolbar: true,
shortcut: 'CMD+SHIFT+L'
},
checklist: {
class: Checklist,
inlineToolbar: true,

@ -1 +0,0 @@
Subproject commit 82402cb86a327e3c64bef653d953533e2c3613dd

@ -1 +0,0 @@
Subproject commit 95a5eb90dd2e2e8ab153eb66b59a70cdafdf2d7f

@ -1 +0,0 @@
Subproject commit 801580fbdb7ab0ad1e975cfdaab38ada6625e301

@ -1 +0,0 @@
Subproject commit 3e457cbac2c5da53fff1b02b99ddaccaa577f401

@ -1 +0,0 @@
Subproject commit c8236e5765294f6b6590573910a68d3826671838

@ -1 +0,0 @@
Subproject commit 31a086d7dc97169de34b9c191735cba7d63562d6

@ -1 +0,0 @@
Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be

@ -1 +0,0 @@
Subproject commit bbc46d557bb5711dd27517272ae2754e1da04697

@ -1 +0,0 @@
Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07

@ -1 +0,0 @@
Subproject commit 78f70cf2391cc8aaf2d2e59615de3ad833d180c3

@ -1 +0,0 @@
Subproject commit 84b7d56b26a66d121edb6682ca205bf995d39034

@ -1 +0,0 @@
Subproject commit 963883520c7bbe5040366335c9a37bbdc7cf60fd

@ -1 +0,0 @@
Subproject commit 5a57621c4e1abb884fd03e70862cb05b10bfe405

@ -1 +0,0 @@
Subproject commit 7f51a16d4ab46eff9364f09cea52e09518896d2a

@ -1 +0,0 @@
Subproject commit 0f3ec98b9a3b0ea8a9a71cffcb4e596e5c98aecb

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.31.0-rc.7",
"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",
@ -21,16 +21,8 @@
"lint:errors": "eslint src/ --ext .ts --quiet",
"lint:fix": "eslint src/ --ext .ts --fix",
"lint:tests": "eslint test/ --ext .ts",
"pull_tools": "git submodule update --init --recursive",
"_tools:checkout": "git submodule foreach \"git checkout master || git checkout main\"",
"_tools:pull": "git submodule foreach git pull",
"_tools:yarn": "git submodule foreach yarn",
"_tools:build": "git submodule foreach yarn build",
"_tools:make": "yarn _tools:yarn && yarn _tools:build",
"tools:update": "yarn _tools:checkout && yarn _tools:pull && yarn _tools:make",
"test:e2e": "yarn build:test && cypress run",
"test:e2e:open": "yarn build:test && cypress open",
"devserver:start": "yarn build && node ./devserver.js"
"test:e2e:open": "yarn build:test && cypress open"
},
"author": "CodeX",
"license": "Apache-2.0",

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
*
@ -587,6 +597,80 @@ export default class Dom {
right: left + rect.width,
};
}
/**
* Find text node and offset by total content offset
*
* @param {Node} root - root node to start search from
* @param {number} totalOffset - offset relative to the root node content
* @returns {{node: Node | null, offset: number}} - node and offset inside node
*/
public static getNodeByOffset(root: Node, totalOffset: number): {node: Node | null; offset: number} {
let currentOffset = 0;
let lastTextNode: Node | null = null;
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
null
);
let node: Node | null = walker.nextNode();
while (node) {
const textContent = node.textContent;
const nodeLength = textContent === null ? 0 : textContent.length;
lastTextNode = node;
if (currentOffset + nodeLength >= totalOffset) {
break;
}
currentOffset += nodeLength;
node = walker.nextNode();
}
/**
* 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 {
node: null,
offset: 0,
};
}
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,
offset: 0,
};
}
/**
* Calculate offset inside found node
*/
const nodeOffset = Math.min(totalOffset - currentOffset, textContent.length);
return {
node: lastTextNode,
offset: nodeOffset,
};
}
}
/**

View file

@ -199,13 +199,23 @@ export default class Flipper {
*
* @param event - keydown event
*/
private onKeyDown = (event): void => {
private onKeyDown = (event: KeyboardEvent): void => {
const isReady = this.isEventReadyForHandling(event);
if (!isReady) {
return;
}
const isShiftKey = event.shiftKey;
/**
* If shift key is pressed, do nothing
* Allows to select next/prev lines of text using keyboard
*/
if (isShiftKey === true) {
return;
}
/**
* Prevent only used keys default behaviour
* (allows to navigate by ARROW DOWN, for example)

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

@ -237,6 +237,12 @@ export default class BlockEvents extends Module {
* @param event - keydown
*/
private slashPressed(event: KeyboardEvent): void {
const wasEventTriggeredInsideEditor = this.Editor.UI.nodes.wrapper.contains(event.target as Node);
if (!wasEventTriggeredInsideEditor) {
return;
}
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;

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

@ -43,7 +43,7 @@ export default class Caret extends Module {
* @param {Block} block - Block class
* @param {string} position - position where to set caret.
* If default - leave default behaviour and apply offset if it's passed
* @param {number} offset - caret offset regarding to the text node
* @param {number} offset - caret offset regarding to the block content
*/
public setToBlock(block: Block, position: string = this.positions.DEFAULT, offset = 0): void {
const { BlockManager, BlockSelection } = this.Editor;
@ -88,23 +88,32 @@ export default class Caret extends Module {
return;
}
const nodeToSet = $.getDeepestNode(element, position === this.positions.END);
const contentLength = $.getContentLength(nodeToSet);
let nodeToSet: Node;
let offsetToSet = offset;
switch (true) {
case position === this.positions.START:
offset = 0;
break;
case position === this.positions.END:
case offset > contentLength:
offset = contentLength;
break;
if (position === this.positions.START) {
nodeToSet = $.getDeepestNode(element, false) as Node;
offsetToSet = 0;
} else if (position === this.positions.END) {
nodeToSet = $.getDeepestNode(element, true) as Node;
offsetToSet = $.getContentLength(nodeToSet);
} else {
const { node, offset: nodeOffset } = $.getNodeByOffset(element, offset);
if (node) {
nodeToSet = node;
offsetToSet = nodeOffset;
} else { // case for empty block's input
nodeToSet = $.getDeepestNode(element, false) as Node;
offsetToSet = 0;
}
}
this.set(nodeToSet as HTMLElement, offset);
this.set(nodeToSet as HTMLElement, offsetToSet);
BlockManager.setCurrentBlockByChildNode(block.holder);
BlockManager.currentBlock.currentInput = element;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
BlockManager.currentBlock!.currentInput = element;
}
/**

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

@ -86,7 +86,15 @@ class Shortcuts {
const shortcuts = this.registeredShortcuts.get(element);
this.registeredShortcuts.set(element, shortcuts.filter(el => el !== shortcut));
const filteredShortcuts = shortcuts.filter(el => el !== shortcut);
if (filteredShortcuts.length === 0) {
this.registeredShortcuts.delete(element);
return;
}
this.registeredShortcuts.set(element, filteredShortcuts);
}
/**

View file

@ -0,0 +1,19 @@
import { nanoid } from 'nanoid';
/**
* Creates a paragraph mock
*
* @param text - text for the paragraph
* @returns paragraph mock
*/
export function createParagraphMock(text: string): {
id: string;
type: string;
data: { text: string };
} {
return {
id: nanoid(),
type: 'paragraph',
data: { text },
};
}

View file

@ -1,113 +1,249 @@
import { createParagraphMock } from '../../support/utils/createParagraphMock';
import type EditorJS from '../../../../types';
/**
* Test cases for Caret API
*/
describe('Caret API', () => {
const paragraphDataMock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
describe('.setToBlock()', () => {
/**
* The arrange part of the following tests are the same:
* - create an editor
* - move caret out of the block by default
*/
beforeEach(() => {
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
describe('first argument', () => {
const paragraphDataMock = createParagraphMock('The first block content mock.');
/**
* Blur caret from the block before setting via api
* The arrange part of the following tests are the same:
* - create an editor
* - move caret out of the block by default
*/
cy.get('[data-cy=editorjs]')
.click();
beforeEach(() => {
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
/**
* Blur caret from the block before setting via api
*/
cy.get('[data-cy=editorjs]')
.click();
});
it('should set caret to a block (and return true) if block index is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(0);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if block id is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(paragraphDataMock.id);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
const returnedValue = editor.caret.setToBlock(block);
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(returnedValue).to.be.true;
});
});
});
it('should set caret to a block (and return true) if block index is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(0);
describe('offset', () => {
it('should set caret at specific offset in text content', () => {
const paragraphDataMock = createParagraphMock('Plain text content.');
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
expect(returnedValue).to.be.true;
});
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor.caret.setToBlock(block!, 'default', 5);
it('should set caret to a block (and return true) if block id is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const returnedValue = editor.caret.setToBlock(paragraphDataMock.id);
cy.window()
.then((window) => {
const selection = window.getSelection();
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
if (!selection) {
throw new Error('Selection not found');
}
const range = selection.getRangeAt(0);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
expect(range.startOffset).to.equal(5);
});
});
});
expect(returnedValue).to.be.true;
});
});
it('should set caret at correct offset when text contains HTML elements', () => {
const paragraphDataMock = createParagraphMock('1234<b>567</b>!');
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
const returnedValue = editor.caret.setToBlock(block);
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
/**
* Check that caret belongs block
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
cy.get('[data-cy=editorjs]')
.find('.ce-block')
.first()
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
// Set caret after "12345"
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor.caret.setToBlock(block!, 'default', 6);
expect(returnedValue).to.be.true;
});
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection not found');
}
const range = selection.getRangeAt(0);
expect(range.startContainer.textContent).to.equal('567');
expect(range.startOffset).to.equal(2);
});
});
});
it('should handle offset beyond content length', () => {
const paragraphDataMock = createParagraphMock('1234567890');
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const contentLength = block!.holder.textContent?.length ?? 0;
// Set caret beyond content length
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor.caret.setToBlock(block!, 'default', contentLength + 10);
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection not found');
}
const range = selection.getRangeAt(0);
// Should be at the end of content
expect(range.startOffset).to.equal(contentLength);
});
});
});
it('should handle offset in nested HTML structure', () => {
const paragraphDataMock = createParagraphMock('123<b>456<i>789</i></b>!');
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(paragraphDataMock.id);
// Set caret after "8"
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor.caret.setToBlock(block!, 'default', 8);
cy.window()
.then((window) => {
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection not found');
}
const range = selection.getRangeAt(0);
expect(range.startContainer.textContent).to.equal('789');
expect(range.startOffset).to.equal(2);
});
});
});
});
});
});

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

View file

@ -92,6 +92,60 @@ describe('Slash keydown', function () {
.should('eq', 'Hello/');
});
});
describe('pressed outside editor', function () {
it('should not modify any text outside editor when text block is selected', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: '',
},
},
],
},
});
cy.document().then((doc) => {
const title = doc.querySelector('h1');
if (title) {
title.setAttribute('data-cy', 'page-title');
}
});
// Step 1
// Click on the plus button and select the text option
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.click();
cy.get('[data-cy=editorjs]')
.find('.ce-toolbar__plus')
.click({ force: true });
cy.get('[data-cy="toolbox"] .ce-popover__container')
.contains('Text')
.click();
// Step 2
// Select the 'Editor.js test page' text
cy.get('[data-cy=page-title]')
.invoke('attr', 'contenteditable', 'true')
.click()
.type('{selectall}')
.invoke('removeAttr', 'contenteditable');
// Step 3
// Press the Slash key
cy.get('[data-cy=page-title]')
.trigger('keydown', { key: '/',
code: 'Slash',
which: 191 });
cy.get('[data-cy=page-title]').should('have.text', 'Editor.js test page');
});
});
});
describe('CMD+Slash keydown', function () {

View file

@ -46,10 +46,10 @@ class SomePlugin {
}
describe('Flipper', () => {
it('should prevent plugins event handlers from being called while keyboard navigation', () => {
const ARROW_DOWN_KEY_CODE = 40;
const ENTER_KEY_CODE = 13;
const ARROW_DOWN_KEY_CODE = 40;
const ENTER_KEY_CODE = 13;
it('should prevent plugins event handlers from being called while keyboard navigation', () => {
const sampleText = 'sample text';
cy.createEditor({
@ -101,4 +101,40 @@ describe('Flipper', () => {
expect(SomePlugin.pluginInternalKeydownHandler).to.have.not.been.called;
});
it('should not flip when shift key is pressed', () => {
cy.createEditor({
data: {
blocks: [
{
type: 'paragraph',
data: {
text: 'Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor\'s Core.',
},
},
],
},
autofocus: true,
});
cy.get('[data-cy=editorjs]')
.get('.ce-paragraph')
.as('paragraph')
.selectTextByOffset([0, 10])
.wait(200);
cy.get('@paragraph')
.trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE,
shiftKey: true });
// eslint-disable-next-line cypress/require-data-selectors
cy.get('[data-cy="inline-toolbar"]')
.get('.ce-popover--opened')
.as('popover')
.should('exist');
cy.get('@popover')
.get('.ce-popover-item--focused')
.should('not.exist');
});
});