Merge branch 'next' of github.com:codex-team/editor.js into feature/collaborative-editing

 Conflicts:
	src/components/utils.ts
This commit is contained in:
ilyamore88 2022-09-01 00:13:23 +01:00
commit 0f77b7586d
No known key found for this signature in database
GPG key ID: 3C27F3A104EBCE48
59 changed files with 2644 additions and 523 deletions

3
.gitmodules vendored
View file

@ -52,3 +52,6 @@
[submodule "example/tools/nested-list"]
path = example/tools/nested-list
url = https://github.com/editor-js/nested-list
[submodule "example/tools/text-variant-tune"]
path = example/tools/text-variant-tune
url = https://github.com/editor-js/text-variant-tune

View file

@ -19,3 +19,4 @@ tsconfig.json
tslint.json
webpack.config.js
yarn.lock
devserver.js

View file

@ -1 +1,2 @@
* @neSpecc @gohabereg @khaydarov
* @neSpecc @gohabereg @TatianaFomina @ilyamore88

View file

@ -27,18 +27,18 @@
- Unified Toolbox
- [x] Block Tunes moved left [#1815](https://github.com/codex-team/editor.js/pull/1815)
- [ ] Toolbox become vertical `In progress`
- [ ] Ability to display several Toolbox buttons by the single Tool
- [ ] Conversion Toolbar uses Unified Toolbox
- [x] Toolbox become vertical [#2014](https://github.com/codex-team/editor.js/pull/2014)
- [x] Ability to display several Toolbox buttons by the single Tool [#2050](https://github.com/codex-team/editor.js/pull/2050)
- [ ] Conversion Toolbar uses Unified Toolbox `In progress`
- [ ] Block Tunes become vertical
- [ ] Conversion Toolbar added to the Block Tunes
- Ecosystem improvements
<a href="https://opencollective.com/editorjs/donate" target="_blank">
<img width="459" alt="image" src="https://user-images.githubusercontent.com/3684889/152159639-f5e89362-19a1-4c71-a089-422e875fab7d.png">
<img width="432" alt="image" src="https://user-images.githubusercontent.com/3684889/166146666-e6f5fcc3-bccf-43b0-b48c-e8f1b25b2896.png">
</a>
##
##
## If you like a project 💗💗💗
@ -85,6 +85,8 @@ You can join a [Gitter-channel](https://gitter.im/codex-team/editor.js) or [Tele
See the whole [Changelog](/docs/CHANGELOG.md)
If you want to follow Editor.js updates, [subscribe to our Newsletter](http://digest.editorjs.io/).
## How to use Editor.js
### Basics

128
devserver.js Normal file
View file

@ -0,0 +1,128 @@
/**
* 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,41 @@
# Changelog
### 2.25.0
- `New`*Tools API* — Introducing new feature — toolbox now can have multiple entries for one tool! <br>
Due to that API changes: tool's `toolbox` getter now can return either a single config item or an array of config items
- `New`*Blocks API*`composeBlockData()` method was added.
### 2.24.4
- `Fix` — Keyboard selection by word [2045](https://github.com/codex-team/editor.js/issues/2045)
### 2.24.3
- `Fix` — Issue with toolbox preventing text selection fixed
### 2.24.2
- `Fix` — Scrolling issue when opening toolbox on mobile fixed
- `Fix` — Typo in toolbox empty placeholder fixed
- `Fix` — The issue with scroll jumping on block hovering have fixed [2036](https://github.com/codex-team/editor.js/issues/2036)
- `Improvement`*Dev Example Page* - Add popup example page
- `Improvement`*UI* - The Toolbox will restore the internal scroll on every opening
### 2.24.1
`Fix` — The I18n of Tools` titles at the Toolbox now works correctly [#2030](https://github.com/codex-team/editor.js/issues/2030)
### 2.24.0
- `New`*UI* — The Toolbox became vertical 🥳
- `Improvement`*UI* — the Plus button will always be shown (previously, it appears only for empty blocks)
- `Improvement`*Dev Example Page* - Server added to allow opening example page on other devices in network.
- `Fix``UI` — the Toolbar won't move on hover at mobile viewports. Resolves [#1972](https://github.com/codex-team/editor.js/issues/1972)
- `Fix``OnChange` event invocation after block insertion. [#1997](https://github.com/codex-team/editor.js/issues/1997)
- `Fix``ReadOnly` — the `readonly.isEnabled` API getter now works correctly after `readonly.toggle()` calling. Resolves [#1822](https://github.com/codex-team/editor.js/issues/1822)
- `Fix``Paste` — the inline HTML tags now will be preserved on pasting. [#1686](https://github.com/codex-team/editor.js/pull/1686)
### 2.23.2
`Fix` — Crash on initialization in the read-only mode [#1968](https://github.com/codex-team/editor.js/issues/1968)

View file

@ -56,7 +56,7 @@ Options that Tool can specify. All settings should be passed as static propertie
| Name | Type | Default Value | Description |
| -- | -- | -- | -- |
| `toolbox` | _Object_ | `undefined` | Pass here `icon` and `title` to display this `Tool` in the Editor's `Toolbox` <br /> `icon` - HTML string with icon for Toolbox <br /> `title` - optional title to display in Toolbox |
| `toolbox` | _Object_ | `undefined` | Pass the `icon` and the `title` there to display this `Tool` in the Editor's `Toolbox` <br /> `icon` - HTML string with icon for the Toolbox <br /> `title` - title to be displayed at the Toolbox. <br /><br />May contain an array of `{icon, title, data}` to display the several variants of the tool, for example "Ordered list", "Unordered list". See details at [the documentation](https://editorjs.io/tools-api#toolbox) |
| `enableLineBreaks` | _Boolean_ | `false` | With this option, Editor.js won't handle Enter keydowns. Can be helpful for Tools like `<code>` where line breaks should be handled by default behaviour. |
| `isInline` | _Boolean_ | `false` | Describes Tool as a [Tool for the Inline Toolbar](tools-inline.md) |
| `isTune` | _Boolean_ | `false` | Describes Tool as a [Block Tune](block-tunes.md) |

View file

@ -270,6 +270,56 @@ body {
padding: 30px;
}
/**
* Styles for the popup example page
*/
.ce-example--popup {
height: 100vh;
display: flex;
flex-direction: column;
}
.ce-example--popup .ce-example__content {
flex-grow: 2;
}
.ce-example-popup__overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: #00000085;
}
.ce-example-popup__popup {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
width: 800px;
max-width: 100%;
max-height: 90vh;
background: white;
padding: 20px;
border-radius: 8px;
overflow: auto;
box-sizing: border-box;
}
@media all and (max-width: 730px){
.ce-example-popup__popup {
top: 10px;
left: 10px;
width: calc(100% - 20px);
height: calc(100% - 20px);
transform: none;
max-height: none;
}
}
.show-block-boundaries .ce-block {
box-shadow: inset 0 0 0 1px #eff2f5;
}

View file

@ -193,7 +193,9 @@
},
"toolbar": {
"toolbox": {
"Add": "Добавить"
"Add": "Добавить",
"Filter": "Поиск",
"Nothing found": "Ничего не найдено"
}
}
},

131
example/example-popup.html Normal file
View file

@ -0,0 +1,131 @@
<!--
Use this page for debugging purposes.
This page can be used for testing editor nested in a popup.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Editor.js 🤩🧦🤨 example: Popup</title>
<link href="assets/demo.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</head>
<body>
<div class="ce-example ce-example--popup">
<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 ce-example__content--with-bg _ce-example__content--small">
<div id="hint-core" style="text-align: center; padding-top: 20px">
No core bundle file found. Run <code class="inline-code">yarn build</code>
</div>
<div class="stub">
<h1>Base concepts</h1>
<p>
Editor.js is a block-style editor for rich media stories. It outputs clean data in JSON instead of heavy HTML markup. And more important thing is that Editor.js is designed to be API extendable and pluggable.
</p>
<p>
So there are a few key features:
</p>
<ul>
<li>Clean data output</li>
<li>API pluggable</li>
<li>Open source</li>
</ul>
<h2>
What does it mean block-styled
</h2>
<p>
In other editors, the workspace is provided by single contenteditable element in where you can create different HTML markup. All of us saw permanent bugs with moving text fragments or scaling images, while page parts are jumping and twitches. Or highlighting big parts of the text in the case when you just want to make few words to be a heading or bold.
</p>
<p>
The 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.
</p>
<p>
At the same time, most useful features as arrow-navigation, copy & paste, cross block selection, and others works almost as in the familiar editors.
</p>
<h2>
What is clean data
</h2>
<p>
But the more interesting thing is, as mentioned above, that Editor.js returns clean data instead of HTML-markup. Take a look at the example.
</p>
<p>
If our entry consists of few paragraphs and a heading, in popular Medium editor after saving we will have something like this:
</p>
<p>
As you can see, there are only data we need: a list of structural Blocks with their content description.
</p>
<p>
You can use this data to easily render in Web, native mobile/desktop application, pass to Audio Readers, create templates for Facebook Instant Articles, AMP, RSS, create chat-bots, and many others.
</p>
<p>
Also, the clean data can be useful for backend processing: sanitizing, validation, injecting an advertising or other stuff, extracting Headings, make covers for social networks from Image Blocks, and other.
</p>
<h2>
API pluggable?
</h2>
<p>
A key value of the Editor is the API. All main functional units of the editor — Blocks, Inline Formatting Tools, Block Tunes — are provided by external plugins that use Editor's API.
</p>
<p>
We decide to extract all these Tools to separate scripts to make Editor's Core more abstract and make API more powerful. Any challenges and tasks you are facing can be implemented by your own plugins using the API.
</p>
<p>
At the same time, API is created to be easy-to-understand and simple-to-use.
</p>
<h2>
Open Source, so?
</h2>
<p>
Editor.js is more than just an editor. It is a big open-source community of developers and contributors. Anyone can suggest an improvement or a bug fix. Anyone can create new cool API features and plugins.
</p>
<p>
We will support each developer of Editor.js plugins: the best solutions will be collected to the Awesome List and promoted to the community. Together we can create a big suite of different Blocks, Inline Tools, Block Tunes that can hit a wide specter of tasks.
</p>
<p>
Thanks for your interest. Hope you enjoy Editor.js.
</p>
</div>
<div class="ce-example-popup">
<div class="ce-example-popup__overlay"></div>
<div class="ce-example-popup__popup">
<div id="editorjs"></div>
</div>
</div>
</div>
<div class="ce-example__output">
<div class="ce-example__output-footer">
<a href="https://codex.so" style="font-weight: bold;">Made by CodeX</a>
</div>
</div>
</div>
<!-- Load Editor.js's Core -->
<script src="../dist/editor.js" onload="document.getElementById('hint-core').hidden = true"></script>
<script src="./tools/header/dist/bundle.js"></script><!-- Header -->
<!-- Initialization -->
<script>
var editor1 = new EditorJS({
holder: 'editorjs',
tools: {
header: {
class: Header,
shortcut: 'CMD+SHIFT+H'
}
}
});
</script>
</body>
</html>

@ -1 +1 @@
Subproject commit b3c87948d5a5926f2557c2029d64aa819ca61920
Subproject commit 35742f01ae5875d442b145121d3c9b71b23aea56

@ -1 +1 @@
Subproject commit d452b46106766b5fc4c5747e99888f613a3764fd
Subproject commit 0fc365ef256decb8f765fb72b060d5bef9254aa3

@ -1 +1 @@
Subproject commit 004a06458d3881e8d922506381c21847900e3c62
Subproject commit 58b5dc7072ad92a048ebe9488d0ca8e7bfa069d2

@ -1 +1 @@
Subproject commit 8375ae17756fa2677d57e716e12096437d01e8f8
Subproject commit 9add95389afca0711c05260a92283fae8eb209eb

@ -1 +1 @@
Subproject commit 84cc4f393db0939c6246c9a579377f2540dac289
Subproject commit 4f8010dd640d847778220d9b1947fa8235b17f87

@ -1 +1 @@
Subproject commit ddbc1147e127a727cfac4dbe0326b78e02dceb9e
Subproject commit ad0d9012d149e3ca4b41a5ce096b31767cc8c1fd

@ -0,0 +1 @@
Subproject commit 02538b1daea722c854cc61b6fbca01b746c21717

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.23.2",
"version": "2.25.0",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editor.js",
"types": "./types/index.d.ts",
@ -24,14 +24,15 @@
"svg": "svg-sprite-generate -d src/assets/ -o dist/sprite.svg",
"ci:pull_paragraph": "git submodule update --init ./src/tools/paragraph",
"pull_tools": "git submodule update --init --recursive",
"_tools:checkout": "git submodule foreach 'git checkout master || git checkout main'",
"_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 && cypress run",
"test:e2e:open": "yarn build && cypress open"
"test:e2e:open": "yarn build && cypress open",
"devserver:start": "yarn build && node ./devserver.js"
},
"author": "CodeX",
"license": "Apache-2.0",
@ -51,6 +52,7 @@
"@cypress/code-coverage": "^3.9.2",
"@cypress/webpack-preprocessor": "^5.6.0",
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.6.1",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^14.14.35",

3
src/assets/search.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M12.711 2.18a7.448 7.448 0 0 1 .79 9.603l2.143 2.144a1.214 1.214 0 1 1-1.717 1.717L11.783 13.5a7.446 7.446 0 1 1 .928-11.32ZM11.39 3.61a5.5 5.5 0 1 0-7.778 7.78 5.5 5.5 0 0 0 7.778-7.78Z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -4,7 +4,8 @@ import {
BlockToolData,
BlockTune as IBlockTune,
SanitizerConfig,
ToolConfig
ToolConfig,
ToolboxConfigEntry
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -734,6 +735,48 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
}
/**
* Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3")
* This method returns the entry that is related to the Block (depended on the Block data)
*/
public async getActiveToolboxEntry(): Promise<ToolboxConfigEntry | undefined> {
const toolboxSettings = this.tool.toolbox;
/**
* If Tool specifies just the single entry, treat it like an active
*/
if (toolboxSettings.length === 1) {
return Promise.resolve(this.tool.toolbox[0]);
}
/**
* If we have several entries with their own data overrides,
* find those who matches some current data property
*
* Example:
* Tools' toolbox: [
* {title: "Heading 1", data: {level: 1} },
* {title: "Heading 2", data: {level: 2} }
* ]
*
* the Block data: {
* text: "Heading text",
* level: 2
* }
*
* that means that for the current block, the second toolbox item (matched by "{level: 2}") is active
*/
const blockData = await this.data;
const toolboxItems = toolboxSettings;
return toolboxItems.find((item) => {
return Object.entries(item.data)
.some(([propName, propValue]) => {
return blockData[propName] && _.equals(blockData[propName], propValue);
});
});
}
/**
* Make default Block wrappers and put Tool`s content there
*

View file

@ -17,21 +17,23 @@ export interface FlipperOptions {
*/
items?: HTMLElement[];
/**
* Defines arrows usage. By default Flipper leafs items also via RIGHT/LEFT.
*
* true by default
*
* Pass 'false' if you don't need this behaviour
* (for example, Inline Toolbar should be closed by arrows,
* because it means caret moving with selection clearing)
*/
allowArrows?: boolean;
/**
* Optional callback for button click
*/
activateCallback?: (item: HTMLElement) => void;
/**
* List of keys allowed for handling.
* Can include codes of the following keys:
* - Tab
* - Enter
* - Arrow up
* - Arrow down
* - Arrow right
* - Arrow left
* If not specified all keys are enabled
*/
allowedKeys?: number[];
}
/**
@ -53,11 +55,9 @@ export default class Flipper {
private activated = false;
/**
* Flag that allows arrows usage to flip items
*
* @type {boolean}
* List codes of the keys allowed for handling
*/
private readonly allowArrows: boolean = true;
private readonly allowedKeys: number[];
/**
* Call back for button click/enter
@ -68,9 +68,9 @@ export default class Flipper {
* @param {FlipperOptions} options - different constructing settings
*/
constructor(options: FlipperOptions) {
this.allowArrows = _.isBoolean(options.allowArrows) ? options.allowArrows : true;
this.iterator = new DomIterator(options.items, options.focusedItemClass);
this.activateCallback = options.activateCallback;
this.allowedKeys = options.allowedKeys || Flipper.usedKeys;
}
/**
@ -120,15 +120,6 @@ export default class Flipper {
document.removeEventListener('keydown', this.onKeyDown);
}
/**
* Return current focused button
*
* @returns {HTMLElement|null}
*/
public get currentItem(): HTMLElement|null {
return this.iterator.currentItem;
}
/**
* Focus first item
*/
@ -142,6 +133,7 @@ export default class Flipper {
*/
public flipLeft(): void {
this.iterator.previous();
this.flipCallback();
}
/**
@ -149,6 +141,14 @@ export default class Flipper {
*/
public flipRight(): void {
this.iterator.next();
this.flipCallback();
}
/**
* Return true if some button is focused
*/
public hasFocus(): boolean {
return !!this.iterator.currentItem;
}
/**
@ -206,23 +206,7 @@ export default class Flipper {
* @returns {boolean}
*/
private isEventReadyForHandling(event: KeyboardEvent): boolean {
const handlingKeyCodeList = [
_.keyCodes.TAB,
_.keyCodes.ENTER,
];
const isCurrentItemIsFocusedInput = this.iterator.currentItem == document.activeElement;
if (this.allowArrows && !isCurrentItemIsFocusedInput) {
handlingKeyCodeList.push(
_.keyCodes.LEFT,
_.keyCodes.RIGHT,
_.keyCodes.UP,
_.keyCodes.DOWN
);
}
return this.activated && handlingKeyCodeList.indexOf(event.keyCode) !== -1;
return this.activated && this.allowedKeys.includes(event.keyCode);
}
/**
@ -266,4 +250,13 @@ export default class Flipper {
event.preventDefault();
event.stopPropagation();
}
/**
* Fired after flipping in any direction
*/
private flipCallback(): void {
if (this.iterator.currentItem) {
this.iterator.currentItem.scrollIntoViewIfNeeded();
}
}
}

View file

@ -13,7 +13,9 @@
},
"toolbar": {
"toolbox": {
"Add": ""
"Add": "",
"Filter": "",
"Nothing found": ""
}
}
},

View file

@ -3,6 +3,7 @@ import { BlockToolData, OutputData, ToolConfig } from '../../../../types';
import * as _ from './../../utils';
import BlockAPI from '../../block/api';
import Module from '../../__module';
import Block from '../../block';
/**
* @class BlocksAPI
@ -31,6 +32,7 @@ export default class BlocksAPI extends Module {
insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert,
update: this.update,
composeBlockData: this.composeBlockData,
};
}
@ -247,6 +249,24 @@ export default class BlocksAPI extends Module {
return new BlockAPI(insertedBlock);
}
/**
* Creates data of an empty block with a passed type.
*
* @param toolName - block tool name
*/
public composeBlockData = async (toolName: string): Promise<BlockToolData> => {
const tool = this.Editor.Tools.blockTools.get(toolName);
const block = new Block({
tool,
api: this.Editor.API,
readOnly: true,
data: {},
tunesData: {},
});
return block.data;
}
/**
* Insert new Block
* After set caret to this Block

View file

@ -10,9 +10,14 @@ export default class ReadOnlyAPI extends Module {
* Available methods
*/
public get methods(): ReadOnly {
const getIsEnabled = (): boolean => this.isEnabled;
// eslint-disable-next-line @typescript-eslint/no-this-alias
return {
toggle: (state): Promise<boolean> => this.toggle(state),
isEnabled: this.isEnabled,
get isEnabled(): boolean {
return getIsEnabled();
},
};
}

View file

@ -662,7 +662,7 @@ export default class Paste extends Module {
/** If there is no pattern substitute - insert string as it is */
if (BlockManager.currentBlock && BlockManager.currentBlock.currentInput) {
const currentToolSanitizeConfig = BlockManager.currentBlock.tool.sanitizeConfig;
const currentToolSanitizeConfig = BlockManager.currentBlock.tool.baseSanitizeConfig;
document.execCommand(
'insertHTML',

View file

@ -100,8 +100,9 @@ export default class Renderer extends Module {
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
const toolboxTitle = toolboxSettings[0]?.title;
stubData.title = toolboxSettings?.title || stubData.title;
stubData.title = toolboxTitle || stubData.title;
}
const stub = BlockManager.insert({

View file

@ -28,16 +28,11 @@ export default class Saver extends Module {
* @returns {OutputData}
*/
public async save(): Promise<OutputData> {
const { BlockManager, Tools, ModificationsObserver } = this.Editor;
const { BlockManager, Tools } = this.Editor;
const blocks = BlockManager.blocks,
chainData = [];
try {
/**
* Disable onChange callback on save to not to spam those events
*/
ModificationsObserver.disable();
blocks.forEach((block: Block) => {
chainData.push(this.getSavedData(block));
});
@ -50,8 +45,6 @@ export default class Saver extends Module {
return this.makeOutput(sanitizedData);
} catch (e) {
_.logLabeled(`Saving failed due to the Error %o`, 'error', e);
} finally {
ModificationsObserver.enable();
}
}

View file

@ -6,6 +6,7 @@ import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import { clean } from '../../utils/sanitizer';
import { ToolboxConfigEntry, BlockToolData } from '../../../../types';
/**
* HTML Elements used for ConversionToolbar
@ -47,9 +48,9 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
public opened = false;
/**
* Available tools
* Available tools data
*/
private tools: { [key: string]: HTMLElement } = {};
private tools: {name: string; toolboxItem: ToolboxConfigEntry; button: HTMLElement}[] = []
/**
* Instance of class that responses for leafing buttons by arrows/tab
@ -135,19 +136,18 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
this.nodes.wrapper.classList.add(ConversionToolbar.CSS.conversionToolbarShowed);
/**
* We use timeout to prevent bubbling Enter keydown on first dropdown item
* We use RAF to prevent bubbling Enter keydown on first dropdown item
* Conversion flipper will be activated after dropdown will open
*/
setTimeout(() => {
this.flipper.activate(Object.values(this.tools).filter((button) => {
window.requestAnimationFrame(() => {
this.flipper.activate(this.tools.map(tool => tool.button).filter((button) => {
return !button.classList.contains(ConversionToolbar.CSS.conversionToolHidden);
}));
this.flipper.focusFirst();
if (_.isFunction(this.togglingCallback)) {
this.togglingCallback(true);
}
}, 50);
});
}
/**
@ -167,9 +167,11 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
* Returns true if it has more than one tool available for convert in
*/
public hasTools(): boolean {
const tools = Object.keys(this.tools); // available tools in array representation
if (this.tools.length === 1) {
return this.tools[0].name !== this.config.defaultBlock;
}
return !(tools.length === 1 && tools.shift() === this.config.defaultBlock);
return true;
}
/**
@ -177,26 +179,18 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
* For that Tools must provide import/export methods
*
* @param {string} replacingToolName - name of Tool which replaces current
* @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified
*/
public async replaceWithBlock(replacingToolName: string): Promise<void> {
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
/**
* At first, we get current Block data
*
* @type {BlockToolConstructable}
*/
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
const currentBlockName = this.Editor.BlockManager.currentBlock.name;
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
const blockData = savedBlock.data;
/**
* When current Block name is equals to the replacing tool Name,
* than convert this Block back to the default Block
*/
if (currentBlockName === replacingToolName) {
replacingToolName = this.config.defaultBlock;
}
/**
* Getting a class of replacing Tool
*
@ -252,6 +246,14 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
return;
}
/**
* If this conversion fired by the one of multiple Toolbox items,
* extend converted data with this item's "data" overrides
*/
if (blockDataOverrides) {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
this.Editor.BlockManager.replace({
tool: replacingToolName,
data: newBlockData,
@ -276,64 +278,93 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
Array
.from(tools.entries())
.forEach(([name, tool]) => {
const toolboxSettings = tool.toolbox;
const conversionConfig = tool.conversionConfig;
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) {
return;
}
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
return;
}
this.addTool(name, toolboxSettings.icon, toolboxSettings.title);
tool.toolbox.forEach((toolboxItem) =>
this.addToolIfValid(name, toolboxItem)
);
});
}
/**
* Inserts a tool to the ConversionToolbar if the tool's toolbox config is valid
*
* @param name - tool's name
* @param toolboxSettings - tool's single toolbox setting
*/
private addToolIfValid(name: string, toolboxSettings: ToolboxConfigEntry): void {
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) {
return;
}
this.addTool(name, toolboxSettings);
}
/**
* Add tool to the Conversion Toolbar
*
* @param {string} toolName - name of Tool to add
* @param {string} toolIcon - Tool icon
* @param {string} title - button title
* @param toolName - name of Tool to add
* @param toolboxItem - tool's toolbox item data
*/
private addTool(toolName: string, toolIcon: string, title: string): void {
private addTool(toolName: string, toolboxItem: ToolboxConfigEntry): void {
const tool = $.make('div', [ ConversionToolbar.CSS.conversionTool ]);
const icon = $.make('div', [ ConversionToolbar.CSS.conversionToolIcon ]);
tool.dataset.tool = toolName;
icon.innerHTML = toolIcon;
icon.innerHTML = toolboxItem.icon;
$.append(tool, icon);
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, title || _.capitalize(toolName))));
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
$.append(this.nodes.tools, tool);
this.tools[toolName] = tool;
this.tools.push({
name: toolName,
button: tool,
toolboxItem: toolboxItem,
});
this.listeners.on(tool, 'click', async () => {
await this.replaceWithBlock(toolName);
await this.replaceWithBlock(toolName, toolboxItem.data);
});
}
/**
* Hide current Tool and show others
*/
private filterTools(): void {
private async filterTools(): Promise<void> {
const { currentBlock } = this.Editor.BlockManager;
const currentBlockActiveToolboxEntry = await currentBlock.getActiveToolboxEntry();
/**
* Show previously hided
* Compares two Toolbox entries
*
* @param entry1 - entry to compare
* @param entry2 - entry to compare with
*/
Object.entries(this.tools).forEach(([name, button]) => {
button.hidden = false;
button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, name === currentBlock.name);
function isTheSameToolboxEntry(entry1, entry2): boolean {
return entry1.icon === entry2.icon && entry1.title === entry2.title;
}
this.tools.forEach(tool => {
let hidden = false;
if (currentBlockActiveToolboxEntry) {
const isToolboxItemActive = isTheSameToolboxEntry(currentBlockActiveToolboxEntry, tool.toolboxItem);
hidden = (tool.button.dataset.tool === currentBlock.name && isToolboxItemActive);
}
tool.button.hidden = hidden;
tool.button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, hidden);
});
}

View file

@ -13,6 +13,10 @@ import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
* - make Block Settings a standalone module
*
* @todo - Keyboard-only mode bug:
* press Tab, flip to the Checkbox. press Enter (block will be added), Press Tab
* (Block Tunes will be opened with Move up focused), press Enter, press Tab both Block Tunes and Toolbox will be opened
*
* @todo TESTCASE - show toggler after opening and closing the Inline Toolbar
* @todo TESTCASE - Click outside Editor holder should close Toolbar and Clear Focused blocks
* @todo TESTCASE - Click inside Editor holder should close Toolbar and Clear Focused blocks
@ -33,11 +37,7 @@ interface ToolbarNodes {
content: HTMLElement;
actions: HTMLElement;
// Content Zone
plusButton: HTMLElement;
// Actions Zone
blockActionsButtons: HTMLElement;
settingsToggler: HTMLElement;
}
/**
@ -137,14 +137,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
toolbarOpened: 'ce-toolbar--opened',
openedToolboxHolderModifier: 'codex-editor--toolbox-opened',
// Content Zone
plusButton: 'ce-toolbar__plus',
plusButtonShortcut: 'ce-toolbar__plus-shortcut',
plusButtonHidden: 'ce-toolbar__plus--hidden',
// Actions Zone
blockActionsButtons: 'ce-toolbar__actions-buttons',
settingsToggler: 'ce-toolbar__settings-btn',
settingsTogglerHidden: 'ce-toolbar__settings-btn--hidden',
};
}
@ -157,23 +153,6 @@ export default class Toolbar extends Module<ToolbarNodes> {
return this.nodes.wrapper.classList.contains(this.CSS.toolbarOpened);
}
/**
* Plus Button public methods
*
* @returns {{hide: function(): void, show: function(): void}}
*/
public get plusButton(): { hide: () => void; show: () => void } {
return {
hide: (): void => this.nodes.plusButton.classList.add(this.CSS.plusButtonHidden),
show: (): void => {
if (this.toolboxInstance.isEmpty) {
return;
}
this.nodes.plusButton.classList.remove(this.CSS.plusButtonHidden);
},
};
}
/**
* Public interface for accessing the Toolbox
*/
@ -182,11 +161,13 @@ export default class Toolbar extends Module<ToolbarNodes> {
close: () => void;
open: () => void;
toggle: () => void;
flipperHasFocus: boolean;
hasFocus: () => boolean;
} {
return {
opened: this.toolboxInstance.opened,
close: (): void => this.toolboxInstance.close(),
close: (): void => {
this.toolboxInstance.close();
},
open: (): void => {
/**
* Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block.
@ -196,14 +177,12 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance.open();
},
toggle: (): void => this.toolboxInstance.toggle(),
flipperHasFocus: this.toolboxInstance.flipperHasFocus,
hasFocus: (): boolean => this.toolboxInstance.hasFocus(),
};
}
/**
* Block actions appearance manipulations
*
* @returns {{hide: function(): void, show: function(): void}}
*/
private get blockActions(): { hide: () => void; show: () => void } {
return {
@ -216,6 +195,16 @@ export default class Toolbar extends Module<ToolbarNodes> {
};
}
/**
* Methods for working with Block Tunes toggler
*/
private get blockTunesToggler(): { hide: () => void; show: () => void } {
return {
hide: (): void => this.nodes.settingsToggler.classList.add(this.CSS.settingsTogglerHidden),
show: (): void => this.nodes.settingsToggler.classList.remove(this.CSS.settingsTogglerHidden),
};
}
/**
* Toggles read-only mode
*
@ -276,15 +265,15 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Move Toolbar to the Top coordinate of Block
*/
this.nodes.wrapper.style.transform = `translate3D(0, ${Math.floor(toolbarY)}px, 0)`;
this.nodes.wrapper.style.top = `${Math.floor(toolbarY)}px`;
/**
* Plus Button should be shown only for __empty__ __default__ block
* Do not show Block Tunes Toggler near single and empty block
*/
if (block.tool.isDefault && block.isEmpty) {
this.plusButton.show();
if (this.Editor.BlockManager.blocks.length === 1 && block.isEmpty) {
this.blockTunesToggler.hide();
} else {
this.plusButton.hide();
this.blockTunesToggler.show();
}
this.open();
@ -381,13 +370,11 @@ export default class Toolbar extends Module<ToolbarNodes> {
* - Remove Block Button
* - Settings Panel
*/
this.nodes.blockActionsButtons = $.make('div', this.CSS.blockActionsButtons);
this.nodes.settingsToggler = $.make('span', this.CSS.settingsToggler);
const settingsIcon = $.svg('dots', 16, 16);
$.append(this.nodes.settingsToggler, settingsIcon);
$.append(this.nodes.blockActionsButtons, this.nodes.settingsToggler);
$.append(this.nodes.actions, this.nodes.blockActionsButtons);
$.append(this.nodes.actions, this.nodes.settingsToggler);
this.tooltip.onHover(
this.nodes.settingsToggler,
@ -400,7 +387,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Appending Toolbar components to itself
*/
$.append(this.nodes.content, this.makeToolbox());
$.append(this.nodes.actions, this.makeToolbox());
$.append(this.nodes.actions, this.Editor.BlockSettings.nodes.wrapper);
/**
@ -419,6 +406,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance = new Toolbox({
api: this.Editor.API.methods,
tools: this.Editor.Tools.blockTools,
i18nLabels: {
filter: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Filter'),
nothingFound: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Nothing found'),
},
});
this.toolboxInstance.on(ToolboxEvent.Opened, () => {
@ -487,18 +478,25 @@ export default class Toolbar extends Module<ToolbarNodes> {
}, true);
/**
* Subscribe to the 'block-hovered' event
* Subscribe to the 'block-hovered' event if currenct view is not mobile
*
* @see https://github.com/codex-team/editor.js/issues/1972
*/
this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => {
if (!_.isMobileScreen()) {
/**
* Do not move toolbar if Block Settings or Toolbox opened
* Subscribe to the 'block-hovered' event
*/
if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) {
return;
}
this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => {
/**
* Do not move toolbar if Block Settings or Toolbox opened
*/
if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) {
return;
}
this.moveAndOpen(data.block);
});
this.moveAndOpen(data.block);
});
}
}
/**

View file

@ -463,7 +463,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Changes Conversion Dropdown content for current block's Tool
*/
private setConversionTogglerContent(): void {
private async setConversionTogglerContent(): Promise<void> {
const { BlockManager } = this.Editor;
const { currentBlock } = BlockManager;
const toolName = currentBlock.name;
@ -480,7 +480,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Get icon or title for dropdown
*/
const toolboxSettings = currentBlock.tool.toolbox || {};
const toolboxSettings = await currentBlock.getActiveToolboxEntry() || {};
this.nodes.conversionTogglerContent.innerHTML =
toolboxSettings.icon ||
@ -698,7 +698,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
private enableFlipper(): void {
this.flipper = new Flipper({
focusedItemClass: this.CSS.focusedButton,
allowArrows: false,
allowedKeys: [
_.keyCodes.ENTER,
_.keyCodes.TAB,
],
});
}
}

View file

@ -231,7 +231,7 @@ export default class UI extends Module<UINodes> {
* Toolbar has internal module (Toolbox) that has own Flipper,
* so we check it manually
*/
if (this.Editor.Toolbar.toolbox.flipperHasFocus) {
if (this.Editor.Toolbar.toolbox.hasFocus()) {
return true;
}
@ -239,7 +239,7 @@ export default class UI extends Module<UINodes> {
return moduleClass.flipper instanceof Flipper;
})
.some(([moduleName, moduleClass]) => {
return moduleClass.flipper.currentItem;
return moduleClass.flipper.hasFocus();
});
}
@ -385,7 +385,7 @@ export default class UI extends Module<UINodes> {
*/
private watchBlockHoveredEvents(): void {
/**
* Used to not to emit the same block multiple times to the 'block-hovered' event on every mousemove
* Used to not emit the same block multiple times to the 'block-hovered' event on every mousemove
*/
let blockHoveredEmitted;
@ -544,6 +544,7 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.ConversionToolbar.opened) {

View file

@ -96,3 +96,46 @@ if (!Element.prototype.prepend) {
this.insertBefore(docFrag, this.firstChild);
};
}
interface Element {
/**
* Scrolls the current element into the visible area of the browser window
*
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
*/
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
}
/**
* ScrollIntoViewIfNeeded polyfill by KilianSSL (forked from hsablonniere)
*
* @see {@link https://gist.github.com/KilianSSL/774297b76378566588f02538631c3137}
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
*/
if (!Element.prototype.scrollIntoViewIfNeeded) {
Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded): void {
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
const parent = this.parentNode,
parentComputedStyle = window.getComputedStyle(parent, null),
parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')),
parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')),
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight),
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth),
alignWithTop = overTop && !overBottom;
if ((overTop || overBottom) && centerIfNeeded) {
parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2;
}
if ((overLeft || overRight) && centerIfNeeded) {
parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2;
}
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
this.scrollIntoView(alignWithTop);
}
};
}

View file

@ -5,8 +5,8 @@ import {
BlockToolConstructable,
BlockToolData,
ConversionConfig,
PasteConfig, SanitizerConfig,
ToolboxConfig
PasteConfig, SanitizerConfig, ToolboxConfig,
ToolboxConfigEntry
} from '../../../types';
import * as _ from '../utils';
import InlineTool from './inline';
@ -70,21 +70,67 @@ export default class BlockTool extends BaseTool<IBlockTool> {
}
/**
* Returns Tool toolbox configuration (internal or user-specified)
* Returns Tool toolbox configuration (internal or user-specified).
*
* Merges internal and user-defined toolbox configs based on the following rules:
*
* - If both internal and user-defined toolbox configs are arrays their items are merged.
* Length of the second one is kept.
*
* - If both are objects their properties are merged.
*
* - If one is an object and another is an array than internal config is replaced with user-defined
* config. This is made to allow user to override default tool's toolbox representation (single/multiple entries)
*/
public get toolbox(): ToolboxConfig {
public get toolbox(): ToolboxConfigEntry[] | undefined {
const toolToolboxSettings = this.constructable[InternalBlockToolSettings.Toolbox] as ToolboxConfig;
const userToolboxSettings = this.config[UserSettings.Toolbox];
if (_.isEmpty(toolToolboxSettings)) {
return;
}
if ((userToolboxSettings ?? toolToolboxSettings) === false) {
if (userToolboxSettings === false) {
return;
}
/**
* Return tool's toolbox settings if user settings are not defined
*/
if (!userToolboxSettings) {
return Array.isArray(toolToolboxSettings) ? toolToolboxSettings : [ toolToolboxSettings ];
}
return Object.assign({}, toolToolboxSettings, userToolboxSettings);
/**
* Otherwise merge user settings with tool's settings
*/
if (Array.isArray(toolToolboxSettings)) {
if (Array.isArray(userToolboxSettings)) {
return userToolboxSettings.map((item, i) => {
const toolToolboxEntry = toolToolboxSettings[i];
if (toolToolboxEntry) {
return {
...toolToolboxEntry,
...item,
};
}
return item;
});
}
return [ userToolboxSettings ];
} else {
if (Array.isArray(userToolboxSettings)) {
return userToolboxSettings;
}
return [
{
...toolToolboxSettings,
...userToolboxSettings,
},
];
}
}
/**

View file

@ -1,15 +1,17 @@
import $ from '../dom';
import * as _ from '../utils';
import Flipper from '../flipper';
import { BlockToolAPI } from '../block';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
import Shortcuts from '../utils/shortcuts';
import Tooltip from '../utils/tooltip';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API } from '../../../types';
import { API, BlockToolData, ToolboxConfigEntry } from '../../../types';
import EventsDispatcher from '../utils/events';
import Popover, { PopoverEvent, PopoverItem } from '../utils/popover';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
/**
* @todo the first Tab on the Block focus Plus Button, the second focus Block Tunes Toggler, the third focus next Block
*/
/**
* Event that can be triggered by the Toolbox
@ -31,6 +33,11 @@ export enum ToolboxEvent {
BlockAdded = 'toolbox-block-added',
}
/**
* Available i18n dict keys that should be passed to the constructor
*/
type toolboxTextLabelsKeys = 'filter' | 'nothingFound';
/**
* Toolbox
* This UI element contains list of Block Tools available to be inserted
@ -45,7 +52,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* @returns {boolean}
*/
public get isEmpty(): boolean {
return this.displayedToolsCount === 0;
return this.toolsToBeDisplayed.length === 0;
}
/**
@ -60,21 +67,29 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
*/
private api: API;
/**
* Popover instance. There is a util for vertical lists.
*/
private popover: Popover;
/**
* List of Tools available. Some of them will be shown in the Toolbox
*/
private tools: ToolsCollection<BlockTool>;
/**
* Text labels used in the Toolbox. Should be passed from the i18n module
*/
private i18nLabels: Record<toolboxTextLabelsKeys, string>;
/**
* Current module HTML Elements
*/
private nodes: {
toolbox: HTMLElement;
buttons: HTMLElement[];
} = {
toolbox: null,
buttons: [],
}
};
/**
* CSS styles
@ -84,34 +99,10 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
private static get CSS(): { [name: string]: string } {
return {
toolbox: 'ce-toolbox',
toolboxButton: 'ce-toolbox__button',
toolboxButtonActive: 'ce-toolbox__button--active',
toolboxOpened: 'ce-toolbox--opened',
buttonTooltip: 'ce-toolbox-button-tooltip',
buttonShortcut: 'ce-toolbox-button-tooltip__shortcut',
toolboxOpenedTop: 'ce-toolbox--opened-top',
};
}
/**
* How many tools displayed in Toolbox
*
* @type {number}
*/
private displayedToolsCount = 0;
/**
* Instance of class that responses for leafing buttons by arrows/tab
*
* @type {Flipper|null}
*/
private flipper: Flipper = null;
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* Id of listener added used to remove it on destroy()
*/
@ -124,68 +115,70 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* @param options.api - Editor API methods
* @param options.tools - Tools available to check whether some of them should be displayed at the Toolbox or not
*/
constructor({ api, tools }) {
constructor({ api, tools, i18nLabels }: {api: API; tools: ToolsCollection<BlockTool>; i18nLabels: Record<toolboxTextLabelsKeys, string>}) {
super();
this.api = api;
this.tools = tools;
this.tooltip = new Tooltip();
}
/**
* Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
*/
public get flipperHasFocus(): boolean {
return this.flipper && this.flipper.currentItem !== null;
this.i18nLabels = i18nLabels;
}
/**
* Makes the Toolbox
*/
public make(): Element {
this.nodes.toolbox = $.make('div', Toolbox.CSS.toolbox);
this.popover = new Popover({
className: Toolbox.CSS.toolbox,
searchable: true,
filterLabel: this.i18nLabels.filter,
nothingFoundLabel: this.i18nLabels.nothingFound,
items: this.toolboxItemsToBeDisplayed,
});
this.addTools();
this.enableFlipper();
this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked);
/**
* Enable tools shortcuts
*/
this.enableShortcuts();
this.nodes.toolbox = this.popover.getElement();
return this.nodes.toolbox;
}
/**
* Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
*/
public hasFocus(): boolean {
return this.popover.hasFocus();
}
/**
* Destroy Module
*/
public destroy(): void {
super.destroy();
/**
* Sometimes (in read-only mode) there is no Flipper
*/
if (this.flipper) {
this.flipper.deactivate();
this.flipper = null;
}
if (this.nodes && this.nodes.toolbox) {
this.nodes.toolbox.remove();
this.nodes.toolbox = null;
this.nodes.buttons = [];
}
this.api.listeners.offById(this.clickListenerId);
this.removeAllShortcuts();
this.tooltip.destroy();
this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked);
}
/**
* Toolbox Tool's button click handler
*
* @param {MouseEvent|KeyboardEvent} event - event that activates toolbox button
* @param {string} toolName - button to activate
* @param toolName - tool type to be activated
* @param blockDataOverrides - Block data predefined by the activated Toolbox item
*/
public toolButtonActivate(event: MouseEvent|KeyboardEvent, toolName: string): void {
this.insertNewBlock(toolName);
public toolButtonActivated(toolName: string, blockDataOverrides: BlockToolData): void {
this.insertNewBlock(toolName, blockDataOverrides);
}
/**
@ -196,24 +189,28 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
return;
}
this.emit(ToolboxEvent.Opened);
this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpened);
/**
* Open the popover above the button
* if there is not enough available space below it
*/
if (!this.shouldOpenPopoverBottom) {
this.nodes.toolbox.style.setProperty('--popover-height', this.popover.calculateHeight() + 'px');
this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpenedTop);
}
this.popover.show();
this.opened = true;
this.flipper.activate();
this.emit(ToolboxEvent.Opened);
}
/**
* Close Toolbox
*/
public close(): void {
this.emit(ToolboxEvent.Closed);
this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpened);
this.popover.hide();
this.opened = false;
this.flipper.deactivate();
this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop);
this.emit(ToolboxEvent.Closed);
}
/**
@ -228,106 +225,120 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
}
/**
* Iterates available tools and appends them to the Toolbox
* Checks if there popover should be opened downwards.
* It happens in case there is enough space below or not enough space above
*/
private addTools(): void {
Array
.from(this.tools.values())
.forEach((tool) => this.addTool(tool));
private get shouldOpenPopoverBottom(): boolean {
const toolboxRect = this.nodes.toolbox.getBoundingClientRect();
const editorElementRect = this.api.ui.nodes.redactor.getBoundingClientRect();
const popoverHeight = this.popover.calculateHeight();
const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight;
const popoverPotentialTopEdge = toolboxRect.top - popoverHeight;
const bottomEdgeForComparison = Math.min(window.innerHeight, editorElementRect.bottom);
return popoverPotentialTopEdge < editorElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
}
/**
* Append Tool to the Toolbox
*
* @param {BlockToolConstructable} tool - BlockTool object
* Handles overlay click
*/
private addTool(tool: BlockTool): void {
const toolToolboxSettings = tool.toolbox;
private onOverlayClicked = (): void => {
this.close();
}
/**
* Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter)
*/
@_.cacheable
private get toolsToBeDisplayed(): BlockTool[] {
return Array
.from(this.tools.values())
.reduce((result, tool) => {
const toolToolboxSettings = tool.toolbox;
if (toolToolboxSettings) {
const validToolboxSettings = toolToolboxSettings.filter(item => {
return this.areToolboxSettingsValid(item, tool.name);
});
result.push({
...tool,
toolbox: validToolboxSettings,
});
}
return result;
}, []);
}
/**
* Returns list of items that will be displayed in toolbox
*/
@_.cacheable
private get toolboxItemsToBeDisplayed(): PopoverItem[] {
/**
* Maps tool data to popover item structure
*/
const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItem => {
return {
icon: toolboxItem.icon,
label: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
name: tool.name,
onClick: (e): void => {
this.toolButtonActivated(tool.name, toolboxItem.data);
},
secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
};
};
return this.toolsToBeDisplayed
.reduce((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach(item => {
result.push(toPopoverItem(item, tool));
});
} else {
result.push(toPopoverItem(tool.toolbox, tool));
}
return result;
}, []);
}
/**
* Validates tool's toolbox settings
*
* @param toolToolboxSettings - item to validate
* @param toolName - name of the tool used in console warning if item is not valid
*/
private areToolboxSettingsValid(toolToolboxSettings: ToolboxConfigEntry, toolName: string): boolean {
/**
* Skip tools that don't pass 'toolbox' property
*/
if (!toolToolboxSettings) {
return;
return false;
}
if (toolToolboxSettings && !toolToolboxSettings.icon) {
_.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name);
_.log('Toolbar icon is missed. Tool %o skipped', 'warn', toolName);
return;
return false;
}
/**
* @todo Add checkup for the render method
*/
// if (typeof tool.render !== 'function') {
// _.log('render method missed. Tool %o skipped', 'warn', tool);
// return;
// }
const button = $.make('li', [ Toolbox.CSS.toolboxButton ]);
button.dataset.tool = tool.name;
button.innerHTML = toolToolboxSettings.icon;
$.append(this.nodes.toolbox, button);
this.nodes.toolbox.appendChild(button);
this.nodes.buttons.push(button);
/**
* Add click listener
*/
this.clickListenerId = this.api.listeners.on(button, 'click', (event: KeyboardEvent|MouseEvent) => {
this.toolButtonActivate(event, tool.name);
});
/**
* Add listeners to show/hide toolbox tooltip
*/
const tooltipContent = this.drawTooltip(tool);
this.tooltip.onHover(button, tooltipContent, {
placement: 'bottom',
hidingDelay: 200,
});
const shortcut = tool.shortcut;
if (shortcut) {
this.enableShortcut(tool.name, shortcut);
}
/** Increment Tools count */
this.displayedToolsCount++;
return true;
}
/**
* Draw tooltip for toolbox tools
*
* @param tool - BlockTool object
* @returns {HTMLElement}
* Iterate all tools and enable theirs shortcuts if specified
*/
private drawTooltip(tool: BlockTool): HTMLElement {
const toolboxSettings = tool.toolbox || {};
const name = I18n.t(I18nInternalNS.toolNames, toolboxSettings.title || tool.name);
private enableShortcuts(): void {
this.toolsToBeDisplayed.forEach((tool: BlockTool) => {
const shortcut = tool.shortcut;
let shortcut = tool.shortcut;
const tooltip = $.make('div', Toolbox.CSS.buttonTooltip);
const hint = document.createTextNode(_.capitalize(name));
tooltip.appendChild(hint);
if (shortcut) {
shortcut = _.beautifyShortcut(shortcut);
tooltip.appendChild($.make('div', Toolbox.CSS.buttonShortcut, {
textContent: shortcut,
}));
}
return tooltip;
if (shortcut) {
this.enableShortcutForTool(tool.name, shortcut);
}
});
}
/**
@ -336,7 +347,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* @param {string} toolName - Tool name
* @param {string} shortcut - shortcut according to the ShortcutData Module format
*/
private enableShortcut(toolName: string, shortcut: string): void {
private enableShortcutForTool(toolName: string, shortcut: string): void {
Shortcuts.add({
name: shortcut,
on: this.api.ui.nodes.redactor,
@ -352,26 +363,12 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* Fired when the Read-Only mode is activated
*/
private removeAllShortcuts(): void {
Array
.from(this.tools.values())
.forEach((tool) => {
const shortcut = tool.shortcut;
this.toolsToBeDisplayed.forEach((tool: BlockTool) => {
const shortcut = tool.shortcut;
if (shortcut) {
Shortcuts.remove(this.api.ui.nodes.redactor, shortcut);
}
});
}
/**
* Creates Flipper instance to be able to leaf tools
*/
private enableFlipper(): void {
const tools = Array.from(this.nodes.toolbox.childNodes) as HTMLElement[];
this.flipper = new Flipper({
items: tools,
focusedItemClass: Toolbox.CSS.toolboxButtonActive,
if (shortcut) {
Shortcuts.remove(this.api.ui.nodes.redactor, shortcut);
}
});
}
@ -380,8 +377,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
* Can be called when button clicked on Toolbox or by ShortcutData
*
* @param {string} toolName - Tool name
* @param blockDataOverrides - predefined Block data
*/
private insertNewBlock(toolName: string): void {
private async insertNewBlock(toolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
@ -395,9 +393,20 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
*/
const index = currentBlock.isEmpty ? currentBlockIndex : currentBlockIndex + 1;
let blockData;
if (blockDataOverrides) {
/**
* Merge real tool's data with data overrides
*/
const defaultBlockData = await this.api.blocks.composeBlockData(toolName);
blockData = Object.assign(defaultBlockData, blockDataOverrides);
}
const newBlock = this.api.blocks.insert(
toolName,
undefined,
blockData,
undefined,
index,
undefined,

View file

@ -761,7 +761,7 @@ export function cacheable<Target, Value, Arguments extends unknown[] = unknown[]
}
return descriptor;
}
};
/**
* Deep copy function.
@ -784,4 +784,39 @@ export function deepCopy<T extends Record<keyof T, unknown>>(target: T): T {
}
return target;
};
/**
* True if screen has mobile size
*/
export function isMobileScreen(): boolean {
return window.matchMedia('(max-width: 650px)').matches;
}
/**
* True if current device runs iOS
*/
export const isIosDevice =
typeof window !== 'undefined' &&
window.navigator &&
window.navigator.platform &&
(/iP(ad|hone|od)/.test(window.navigator.platform) ||
(window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1));
/**
* Compares two values with unknown type
*
* @param var1 - value to compare
* @param var2 - value to compare with
* @returns {boolean} true if they are equal
*/
export function equals(var1: unknown, var2: unknown): boolean {
const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1);
const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2);
if (isVar1NonPrimitive || isVar2NonPrimitive) {
return JSON.stringify(var1) === JSON.stringify(var2);
}
return var1 === var2;
}

View file

@ -0,0 +1,417 @@
import Dom from '../dom';
import Listeners from './listeners';
import Flipper from '../flipper';
import SearchInput from './search-input';
import EventsDispatcher from './events';
import { isMobileScreen, keyCodes, cacheable } from '../utils';
import ScrollLocker from './scroll-locker';
/**
* Describe parameters for rendering the single item of Popover
*/
export interface PopoverItem {
/**
* Item icon to be appeared near a title
*/
icon: string;
/**
* Displayed text
*/
label: string;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* Itm click handler
*
* @param item - clicked item
*/
onClick: (item: PopoverItem) => void;
}
/**
* Event that can be triggered by the Popover
*/
export enum PopoverEvent {
/**
* When popover overlay is clicked
*/
OverlayClicked = 'overlay-clicked',
}
/**
* Popover is the UI element for displaying vertical lists
*/
export default class Popover extends EventsDispatcher<PopoverEvent> {
/**
* Items list to be displayed
*/
private readonly items: PopoverItem[];
/**
* Stores the visibility state.
*/
private isShown = false;
/**
* Created nodes
*/
private nodes: {
wrapper: HTMLElement;
popover: HTMLElement;
items: HTMLElement;
nothingFound: HTMLElement;
overlay: HTMLElement;
} = {
wrapper: null,
popover: null,
items: null,
nothingFound: null,
overlay: null,
}
/**
* Additional wrapper's class name
*/
private readonly className: string;
/**
* Listeners util instance
*/
private listeners: Listeners;
/**
* Flipper - module for keyboard iteration between elements
*/
private flipper: Flipper;
/**
* Pass true to enable local search field
*/
private readonly searchable: boolean;
/**
* Instance of the Search Input
*/
private search: SearchInput;
/**
* Label for the 'Filter' placeholder
*/
private readonly filterLabel: string;
/**
* Label for the 'Nothing found' message
*/
private readonly nothingFoundLabel: string;
/**
* Style classes
*/
private static get CSS(): {
popover: string;
popoverOpened: string;
itemsWrapper: string;
item: string;
itemHidden: string;
itemFocused: string;
itemLabel: string;
itemIcon: string;
itemSecondaryLabel: string;
noFoundMessage: string;
noFoundMessageShown: string;
popoverOverlay: string;
popoverOverlayHidden: string;
} {
return {
popover: 'ce-popover',
popoverOpened: 'ce-popover--opened',
itemsWrapper: 'ce-popover__items',
item: 'ce-popover__item',
itemHidden: 'ce-popover__item--hidden',
itemFocused: 'ce-popover__item--focused',
itemLabel: 'ce-popover__item-label',
itemIcon: 'ce-popover__item-icon',
itemSecondaryLabel: 'ce-popover__item-secondary-label',
noFoundMessage: 'ce-popover__no-found',
noFoundMessageShown: 'ce-popover__no-found--shown',
popoverOverlay: 'ce-popover__overlay',
popoverOverlayHidden: 'ce-popover__overlay--hidden',
};
}
/**
* ScrollLocker instance
*/
private scrollLocker = new ScrollLocker()
/**
* Creates the Popover
*
* @param options - config
* @param options.items - config for items to be displayed
* @param options.className - additional class name to be added to the popover wrapper
* @param options.filterLabel - label for the search Field
* @param options.nothingFoundLabel - label of the 'nothing found' message
*/
constructor({ items, className, searchable, filterLabel, nothingFoundLabel }: {
items: PopoverItem[];
className?: string;
searchable?: boolean;
filterLabel: string;
nothingFoundLabel: string;
}) {
super();
this.items = items;
this.className = className || '';
this.searchable = searchable;
this.listeners = new Listeners();
this.filterLabel = filterLabel;
this.nothingFoundLabel = nothingFoundLabel;
this.render();
this.enableFlipper();
}
/**
* Returns rendered wrapper
*/
public getElement(): HTMLElement {
return this.nodes.wrapper;
}
/**
* Shows the Popover
*/
public show(): void {
/**
* Clear search and items scrolling
*/
this.search.clear();
this.nodes.items.scrollTop = 0;
this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
this.flipper.activate();
if (this.searchable) {
window.requestAnimationFrame(() => {
this.search.focus();
});
}
if (isMobileScreen()) {
this.scrollLocker.lock();
}
this.isShown = true;
}
/**
* Hides the Popover
*/
public hide(): void {
/**
* If it's already hidden, do nothing
* to prevent extra DOM operations
*/
if (!this.isShown) {
return;
}
this.nodes.popover.classList.remove(Popover.CSS.popoverOpened);
this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden);
this.flipper.deactivate();
if (isMobileScreen()) {
this.scrollLocker.unlock();
}
this.isShown = false;
}
/**
* Clears memory
*/
public destroy(): void {
this.listeners.removeAll();
}
/**
* Returns true if some item is focused
*/
public hasFocus(): boolean {
return this.flipper.hasFocus();
}
/**
* Helps to calculate height of popover while it is not displayed on screen.
* Renders invisible clone of popover to get actual height.
*/
@cacheable
public calculateHeight(): number {
let height = 0;
const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement;
popoverClone.style.visibility = 'hidden';
popoverClone.style.position = 'absolute';
popoverClone.style.top = '-1000px';
popoverClone.classList.add(Popover.CSS.popoverOpened);
document.body.appendChild(popoverClone);
height = popoverClone.offsetHeight;
popoverClone.remove();
return height;
}
/**
* Makes the UI
*/
private render(): void {
this.nodes.wrapper = Dom.make('div', this.className);
this.nodes.popover = Dom.make('div', Popover.CSS.popover);
this.nodes.wrapper.appendChild(this.nodes.popover);
this.nodes.overlay = Dom.make('div', [Popover.CSS.popoverOverlay, Popover.CSS.popoverOverlayHidden]);
this.nodes.wrapper.appendChild(this.nodes.overlay);
if (this.searchable) {
this.addSearch(this.nodes.popover);
}
this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper);
this.items.forEach(item => {
this.nodes.items.appendChild(this.createItem(item));
});
this.nodes.popover.appendChild(this.nodes.items);
this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], {
textContent: this.nothingFoundLabel,
});
this.nodes.popover.appendChild(this.nodes.nothingFound);
this.listeners.on(this.nodes.popover, 'click', (event: KeyboardEvent|MouseEvent) => {
const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
if (clickedItem) {
this.itemClicked(clickedItem);
}
});
this.listeners.on(this.nodes.overlay, 'click', () => {
this.emit(PopoverEvent.OverlayClicked);
});
}
/**
* Adds the s4arch field to passed element
*
* @param holder - where to append search input
*/
private addSearch(holder: HTMLElement): void {
this.search = new SearchInput({
items: this.items,
placeholder: this.filterLabel,
onSearch: (filteredItems): void => {
const itemsVisible = [];
this.items.forEach((item, index) => {
const itemElement = this.nodes.items.children[index];
if (filteredItems.includes(item)) {
itemsVisible.push(itemElement);
itemElement.classList.remove(Popover.CSS.itemHidden);
} else {
itemElement.classList.add(Popover.CSS.itemHidden);
}
});
this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, itemsVisible.length === 0);
/**
* Update flipper items with only visible
*/
this.flipper.deactivate();
this.flipper.activate(itemsVisible);
this.flipper.focusFirst();
},
});
const searchField = this.search.getElement();
holder.appendChild(searchField);
}
/**
* Renders the single item
*
* @param item - item data to be rendered
*/
private createItem(item: PopoverItem): HTMLElement {
const el = Dom.make('div', Popover.CSS.item);
el.dataset.itemName = item.name;
const label = Dom.make('div', Popover.CSS.itemLabel, {
innerHTML: item.label,
});
if (item.icon) {
el.appendChild(Dom.make('div', Popover.CSS.itemIcon, {
innerHTML: item.icon,
}));
}
el.appendChild(label);
if (item.secondaryLabel) {
el.appendChild(Dom.make('div', Popover.CSS.itemSecondaryLabel, {
textContent: item.secondaryLabel,
}));
}
return el;
}
/**
* Item click handler
*
* @param itemEl - clicked item
*/
private itemClicked(itemEl: HTMLElement): void {
const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`);
const itemIndex = Array.from(allItems).indexOf(itemEl);
const clickedItem = this.items[itemIndex];
clickedItem.onClick(clickedItem);
}
/**
* Creates Flipper instance to be able to leaf tools
*/
private enableFlipper(): void {
const tools = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
this.flipper = new Flipper({
items: tools,
focusedItemClass: Popover.CSS.itemFocused,
allowedKeys: [
keyCodes.TAB,
keyCodes.UP,
keyCodes.DOWN,
keyCodes.ENTER,
],
});
}
}

View file

@ -0,0 +1,64 @@
import { isIosDevice } from '../utils';
/**
* Utility allowing to lock body scroll on demand
*/
export default class ScrollLocker {
/**
* Style classes
*/
private static CSS = {
scrollLocked: 'ce-scroll-locked',
scrollLockedHard: 'ce-scroll-locked--hard',
}
/**
* Stores scroll position, used for hard scroll lock
*/
private scrollPosition: null|number
/**
* Locks body element scroll
*/
public lock(): void {
if (isIosDevice) {
this.lockHard();
} else {
document.body.classList.add(ScrollLocker.CSS.scrollLocked);
}
}
/**
* Unlocks body element scroll
*/
public unlock(): void {
if (isIosDevice) {
this.unlockHard();
} else {
document.body.classList.remove(ScrollLocker.CSS.scrollLocked);
}
}
/**
* Locks scroll in a hard way (via setting fixed position to body element)
*/
private lockHard(): void {
this.scrollPosition = window.pageYOffset;
document.documentElement.style.setProperty(
'--window-scroll-offset',
`${this.scrollPosition}px`
);
document.body.classList.add(ScrollLocker.CSS.scrollLockedHard);
}
/**
* Unlocks hard scroll lock
*/
private unlockHard(): void {
document.body.classList.remove(ScrollLocker.CSS.scrollLockedHard);
if (this.scrollPosition !== null) {
window.scrollTo(0, this.scrollPosition);
}
this.scrollPosition = null;
}
}

View file

@ -0,0 +1,152 @@
import Dom from '../dom';
import Listeners from './listeners';
/**
* Item that could be searched
*/
interface SearchableItem {
label: string;
}
/**
* Provides search input element and search logic
*/
export default class SearchInput {
/**
* Input wrapper element
*/
private wrapper: HTMLElement;
/**
* Editable input itself
*/
private input: HTMLInputElement;
/**
* The instance of the Listeners util
*/
private listeners: Listeners;
/**
* Items for local search
*/
private items: SearchableItem[];
/**
* Current search query
*/
private searchQuery: string;
/**
* Externally passed callback for the search
*/
private readonly onSearch: (items: SearchableItem[]) => void;
/**
* Styles
*/
private static get CSS(): {
input: string;
icon: string;
wrapper: string;
} {
return {
wrapper: 'cdx-search-field',
icon: 'cdx-search-field__icon',
input: 'cdx-search-field__input',
};
}
/**
* @param options - available config
* @param options.items - searchable items list
* @param options.onSearch - search callback
* @param options.placeholder - input placeholder
*/
constructor({ items, onSearch, placeholder }: {
items: SearchableItem[];
onSearch: (items: SearchableItem[]) => void;
placeholder: string;
}) {
this.listeners = new Listeners();
this.items = items;
this.onSearch = onSearch;
this.render(placeholder);
}
/**
* Returns search field element
*/
public getElement(): HTMLElement {
return this.wrapper;
}
/**
* Sets focus to the input
*/
public focus(): void {
this.input.focus();
}
/**
* Clears search query and results
*/
public clear(): void {
this.input.value = '';
this.searchQuery = '';
this.onSearch(this.foundItems);
}
/**
* Clears memory
*/
public destroy(): void {
this.listeners.removeAll();
}
/**
* Creates the search field
*
* @param placeholder - input placeholder
*/
private render(placeholder: string): void {
this.wrapper = Dom.make('div', SearchInput.CSS.wrapper);
const iconWrapper = Dom.make('div', SearchInput.CSS.icon);
const icon = Dom.svg('search', 16, 16);
this.input = Dom.make('input', SearchInput.CSS.input, {
placeholder,
}) as HTMLInputElement;
iconWrapper.appendChild(icon);
this.wrapper.appendChild(iconWrapper);
this.wrapper.appendChild(this.input);
this.listeners.on(this.input, 'input', () => {
this.searchQuery = this.input.value;
this.onSearch(this.foundItems);
});
}
/**
* Returns list of found items for the current search query
*/
private get foundItems(): SearchableItem[] {
return this.items.filter(item => this.checkItem(item));
}
/**
* Contains logic for checking whether passed item conforms the search query
*
* @param item - item to be checked
*/
private checkItem(item: SearchableItem): boolean {
const text = item.label.toLowerCase();
const query = this.searchQuery.toLowerCase();
return text.includes(query);
}
}

View file

@ -117,3 +117,20 @@
transform: translateY(0);
}
}
@keyframes panelShowingMobile {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
70% {
opacity: 1;
transform: translateY(-4px);
}
to {
transform: translateY(0);
}
}

View file

@ -1,4 +1,17 @@
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ce-block {
animation: fade-in 300ms ease;
animation-fill-mode: initial;
&:first-of-type {
margin-top: 0;
}

46
src/styles/input.css Normal file
View file

@ -0,0 +1,46 @@
.cdx-search-field {
--icon-margin-right: 10px;
background: rgba(232,232,235,0.49);
border: 1px solid rgba(226,226,229,0.20);
border-radius: 6px;
padding: 2px;
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto;
&__icon {
width: var(--toolbox-buttons-size);
height: var(--toolbox-buttons-size);
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--icon-margin-right);
.icon {
width: 14px;
height: 14px;
color: var(--grayText);
flex-shrink: 0;
}
}
&__input {
font-size: 14px;
outline: none;
font-weight: 500;
font-family: inherit;
border: 0;
background: transparent;
margin: 0;
padding: 0;
line-height: 22px;
min-width: calc(100% - var(--toolbox-buttons-size) - var(--icon-margin-right));
&::placeholder {
color: var(--grayText);
font-weight: 500;
}
}
}

View file

@ -10,3 +10,5 @@
@import './export.css';
@import './stub.css';
@import './rtl.css';
@import './popover.css';
@import './input.css';

144
src/styles/popover.css Normal file
View file

@ -0,0 +1,144 @@
.ce-popover {
position: absolute;
opacity: 0;
will-change: opacity, transform;
display: flex;
flex-direction: column;
padding: 6px;
min-width: 200px;
overflow: hidden;
box-sizing: border-box;
flex-shrink: 0;
max-height: 0;
pointer-events: none;
@apply --overlay-pane;
z-index: 4;
flex-wrap: nowrap;
&--opened {
opacity: 1;
max-height: 270px;
pointer-events: auto;
animation: panelShowing 100ms ease;
@media (--mobile) {
animation: panelShowingMobile 250ms ease;
}
}
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-thumb {
box-sizing: border-box;
box-shadow: inset 0 0 2px 2px var(--bg-light);
border: 3px solid transparent;
border-left-width: 0px;
border-top-width: 4px;
border-bottom-width: 4px;
}
@media (--mobile) {
position: fixed;
max-width: none;
min-width: auto;
left: 5px;
right: 5px;
bottom: calc(5px + env(safe-area-inset-bottom));
top: auto;
border-radius: 10px;
}
&__items {
overflow-y: auto;
overscroll-behavior: contain;
@media (--not-mobile) {
margin-top: 5px;
}
}
&__item {
@apply --popover-button;
&--focused {
@apply --button-focused;
}
&--hidden {
display: none;
}
&-icon {
@apply --tool-icon;
}
&-label {
&::after {
content: '';
width: 25px;
display: inline-block;
}
}
&-secondary-label {
color: var(--grayText);
font-size: 12px;
margin-left: auto;
white-space: nowrap;
letter-spacing: -0.1em;
padding-right: 5px;
margin-bottom: -2px;
opacity: 0.6;
@media (--mobile){
display: none;
}
}
}
&__no-found {
@apply --popover-button;
color: var(--grayText);
display: none;
cursor: default;
&--shown {
display: block;
}
&:hover {
background-color: transparent;
}
}
@media (--mobile) {
&__overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--color-dark);
opacity: 0.5;
z-index: 3;
transition: opacity 0.12s ease-in;
will-change: opacity;
visibility: visible;
}
.cdx-search-field {
display: none;
}
}
&__overlay--hidden {
z-index: 0;
opacity: 0;
visibility: hidden;
}
}

View file

@ -1,7 +1,7 @@
.ce-settings {
@apply --overlay-pane;
right: -1px;
top: 30px;
top: var(--toolbar-buttons-size);
left: 0;
min-width: 114px;
box-sizing: content-box;

View file

@ -4,34 +4,18 @@
right: 0;
top: 0;
transition: opacity 100ms ease;
will-change: opacity, transform;
display: none;
will-change: opacity, top;
@media (--mobile) {
@apply --overlay-pane;
padding: 3px;
margin-top: 5px;
}
display: none;
&--opened {
display: block;
@media (--mobile){
display: flex;
}
}
&__content {
max-width: var(--content-width);
margin: 0 auto;
position: relative;
@media (--mobile){
display: flex;
align-content: center;
margin: 0;
max-width: 100%;
}
}
&__plus {
@ -44,14 +28,9 @@
margin-top: 5px;
}
&--hidden {
display: none;
}
@media (--mobile){
display: inline-flex !important;
@apply --overlay-pane;
position: static;
transform: none !important;
}
}
@ -64,37 +43,37 @@
right: 100%;
opacity: 0;
display: flex;
@media (--mobile){
position: absolute;
right: auto;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
}
padding-right: 5px;
&--opened {
opacity: 1;
}
&-buttons {
text-align: right;
@media (--mobile){
right: auto;
}
}
&__settings-btn {
@apply --toolbox-button;
width: 18px;
margin: 0 5px;
margin-left: 5px;
cursor: pointer;
user-select: none;
}
}
.codex-editor--toolbox-opened .ce-toolbar__actions {
display: none;
@media (--not-mobile){
width: 18px;
}
&--hidden {
display: none;
}
@media (--mobile){
@apply --overlay-pane;
position: static;
}
}
}
/**

View file

@ -1,44 +1,24 @@
.ce-toolbox {
position: absolute;
visibility: hidden;
transition: opacity 100ms ease;
will-change: opacity;
display: flex;
flex-direction: row;
--gap: 8px;
@media (--mobile){
position: static;
transform: none !important;
align-items: center;
overflow-x: auto;
}
@media (--not-mobile){
position: absolute;
top: calc(var(--toolbox-buttons-size) + var(--gap));
left: 0;
&--opened {
opacity: 1;
visibility: visible;
}
&__button {
@apply --toolbox-button;
flex-shrink: 0;
margin-left: 5px;
&--opened-top {
top: calc(-1 * (var(--gap) + var(--popover-height)));
}
}
}
.ce-toolbox-button-tooltip {
&__shortcut {
opacity: 0.6;
word-spacing: -3px;
margin-top: 3px;
}
}
/**
* Styles for Narrow mode
*/
.codex-editor--narrow .ce-toolbox {
@media (--not-mobile) {
background: #fff;
z-index: 2;
@media (--not-mobile){
left: auto;
right: 0;
.ce-popover {
right: 0;
}
}
}

View file

@ -127,3 +127,14 @@
transform: rotate(360deg);
}
}
.ce-scroll-locked {
overflow: hidden;
}
.ce-scroll-locked--hard {
overflow: hidden;
top: calc(-1 * var(--window-scroll-offset));
position: fixed;
width: 100%;
}

View file

@ -1,5 +1,9 @@
/**
* Updating values in media queries should also include changes in utils.ts@isMobile
*/
@custom-media --mobile (width <= 650px);
@custom-media --not-mobile (width >= 651px);
@custom-media --can-hover (hover: hover);
:root {
/**
@ -21,7 +25,7 @@
/**
* Gray icons hover
*/
--color-dark: #1D202B;
--color-dark: #1D202B;
/**
* Blue icons
@ -95,6 +99,11 @@
}
};
--button-focused: {
box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08);
background: rgba(34, 186, 255, 0.08) !important;
};
/**
* Styles for Toolbox Buttons and Plus Button
*/
@ -103,22 +112,25 @@
cursor: pointer;
width: var(--toolbox-buttons-size);
height: var(--toolbox-buttons-size);
border-radius: 3px;
border-radius: 7px;
display: inline-flex;
justify-content: center;
align-items: center;
user-select: none;
@media (--mobile){
width: var(--toolbox-buttons-size--mobile);
height: var(--toolbox-buttons-size--mobile);
}
&:hover,
&--active {
background-color: var(--bg-light);
@media (--can-hover) {
&:hover {
background-color: var(--bg-light);
}
}
&--active{
&--active {
background-color: var(--bg-light);
animation: bounceIn 0.75s 1;
animation-fill-mode: forwards;
}
@ -132,9 +144,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
line-height: 34px;
width: var(--toolbar-buttons-size);
height: var(--toolbar-buttons-size);
line-height: var(--toolbar-buttons-size);
padding: 0 !important;
text-align: center;
border-radius: 3px;
@ -155,8 +167,7 @@
}
&--focused {
box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08);
background: rgba(34, 186, 255, 0.08) !important;
@apply --button-focused;
&-animated {
animation-name: buttonClicked;
@ -164,5 +175,66 @@
}
}
};
/**
* Element of the Toolbox. Has icon and label
*/
--popover-button: {
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto;
justify-content: start;
white-space: nowrap;
padding: 3px;
font-size: 14px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
align-items: center;
border-radius: 6px;
&:not(:last-of-type){
margin-bottom: 1px;
}
@media (--can-hover) {
&:hover {
background-color: var(--bg-light);
}
}
@media (--mobile) {
font-size: 16px;
padding: 4px;
}
};
/**
* Tool icon with border
*/
--tool-icon: {
display: inline-flex;
width: var(--toolbox-buttons-size);
height: var(--toolbox-buttons-size);
border: 1px solid var(--color-gray-border);
border-radius: 5px;
align-items: center;
justify-content: center;
background: #fff;
box-sizing: border-box;
flex-shrink: 0;
margin-right: 10px;
@media (--mobile) {
width: var(--toolbox-buttons-size--mobile);
height: var(--toolbox-buttons-size--mobile);
border-radius: 8px;
}
svg {
width: 12px;
height: 12px;
}
}
}

View file

@ -0,0 +1,269 @@
import { ToolboxConfig, BlockToolData, ToolboxConfigEntry } from '../../../../types';
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
describe('Editor Tools Api', () => {
context('Toolbox', () => {
it('should render a toolbox entry for tool if configured', () => {
/**
* Tool with single toolbox entry configured
*/
class TestTool {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Entry 1',
icon: ICON,
};
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.should('have.length', 1);
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool] .ce-popover__item-icon')
.should('contain.html', TestTool.toolbox.icon);
});
it('should render several toolbox entries for one tool if configured', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
},
{
title: 'Entry 2',
icon: ICON,
},
];
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.should('have.length', 2);
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.first()
.should('contain.text', TestTool.toolbox[0].title);
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.last()
.should('contain.text', TestTool.toolbox[1].title);
});
it('should insert block with overriden data on entry click in case toolbox entry provides data overrides', () => {
const text = 'Text';
const dataOverrides = {
testProp: 'new value',
};
/**
* Tool with default data to be overriden
*/
class TestTool {
private _data = {
testProp: 'default value',
}
/**
* Tool contructor
*
* @param data - previously saved data
*/
constructor({ data }) {
this._data = data;
}
/**
* Returns toolbox config as list of entries with overriden data
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
data: dataOverrides,
},
];
}
/**
* Return Tool's view
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.setAttribute('contenteditable', 'true');
return wrapper;
}
/**
* Extracts Tool's data from the view
*
* @param el - tool view
*/
public save(el: HTMLElement): BlockToolData {
return {
...this._data,
text: el.innerHTML,
};
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.last()
.click()
.type(text);
cy.get('@editorInstance')
.then(async (editor: any) => {
const editorData = await editor.save();
expect(editorData.blocks[0].data).to.be.deep.eq({
...dataOverrides,
text,
});
});
});
it('should not display tool in toolbox if the tool has single toolbox entry configured and it has icon missing', () => {
/**
* Tool with one of the toolbox entries with icon missing
*/
class TestTool {
/**
* Returns toolbox config as list of entries one of which has missing icon
*/
public static get toolbox(): ToolboxConfig {
return {
title: 'Entry 2',
};
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.should('not.exist');
});
it('should skip toolbox entries that have no icon', () => {
const skippedEntryTitle = 'Entry 2';
/**
* Tool with one of the toolbox entries with icon missing
*/
class TestTool {
/**
* Returns toolbox config as list of entries one of which has missing icon
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
},
{
title: skippedEntryTitle,
},
];
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.should('have.length', 1)
.should('not.contain', skippedEntryTitle);
});
});
});

View file

@ -31,7 +31,7 @@ describe.only('Block ids', () => {
.click();
cy.get('[data-cy=editorjs]')
.get('li.ce-toolbox__button[data-tool=header]')
.get('div.ce-popover__item[data-item-name=header]')
.click();
cy.get('[data-cy=editorjs]')

View file

@ -0,0 +1,144 @@
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../types';
describe('Editor i18n', () => {
context('Toolbox', () => {
it('should translate tool title in a toolbox', () => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
}
const toolNamesDictionary = {
Heading: 'Заголовок',
};
cy.createEditor({
tools: {
header: Header,
},
i18n: {
messages: {
toolNames: toolNamesDictionary,
},
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=header]')
.should('contain.text', toolNamesDictionary.Heading);
});
it('should translate titles of toolbox entries', () => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
}
const toolNamesDictionary = {
Title1: 'Название 1',
Title2: 'Название 2',
};
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Title1',
icon: 'Icon 1',
},
{
title: 'Title2',
icon: 'Icon 2',
},
];
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
i18n: {
messages: {
toolNames: toolNamesDictionary,
},
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.first()
.should('contain.text', toolNamesDictionary.Title1);
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.last()
.should('contain.text', toolNamesDictionary.Title2);
});
it('should use capitalized tool name as translation key if toolbox title is missing', () => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
}
/**
* Tool class allowing to test case when capitalized tool name is used as translation key if toolbox title is missing
*/
class TestTool {
/**
* Returns toolbox config without title
*/
public static get toolbox(): ToolboxConfig {
return {
title: '',
icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>',
};
}
}
const toolNamesDictionary = {
TestTool: 'ТестТул',
};
cy.createEditor({
tools: {
testTool: TestTool,
},
i18n: {
messages: {
toolNames: toolNamesDictionary,
},
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=testTool]')
.should('contain.text', toolNamesDictionary.TestTool);
});
});
});

View file

@ -1,5 +1,6 @@
import Header from '@editorjs/header';
import Code from '@editorjs/code';
import Delimiter from '@editorjs/delimiter';
import { BlockMutationType } from '../../../types/events/block/mutation-type';
/**
@ -32,6 +33,32 @@ describe('onChange callback', () => {
cy.createEditor(config).as('editorInstance');
}
/**
* Creates Editor instance with save inside the onChange event.
*
* @param blocks - list of blocks to prefill the editor
*/
function createEditorWithSave(blocks = null): void {
const config = {
tools: {
header: Header,
code: Code,
delimiter: Delimiter,
},
onChange: (api, event): void => {
console.log('something changed', api, event);
api.saver.save();
},
data: blocks ? {
blocks,
} : null,
};
cy.spy(config, 'onChange').as('onChange');
cy.createEditor(config).as('editorInstance');
}
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
@ -92,6 +119,53 @@ describe('onChange callback', () => {
}));
});
it('should fire onChange callback on block insertion with save inside onChange', () => {
createEditorWithSave();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-popover__item[data-item-name=delimiter]')
.click();
cy.get('@onChange').should('be.calledThrice');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Removed,
detail: {
index: 0,
target: {
name: 'paragraph',
},
},
}));
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
detail: {
index: 0,
target: {
name: 'delimiter',
},
},
}));
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
detail: {
index: 1,
target: {
name: 'paragraph',
},
},
}));
});
it('should fire onChange callback on block replacement for both of blocks', () => {
createEditor();
@ -104,7 +178,7 @@ describe('onChange callback', () => {
.click();
cy.get('[data-cy=editorjs]')
.get('li.ce-toolbox__button[data-tool=header]')
.get('div.ce-popover__item[data-item-name=header]')
.click();
cy.get('@onChange').should('be.calledTwice');
@ -171,6 +245,14 @@ describe('onChange callback', () => {
it('should fire onChange callback when block is removed', () => {
createEditor();
/**
* The only block does not have Tune menu, so need to create at least 2 blocks to test deleting
*/
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('some text');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();

View file

@ -0,0 +1,50 @@
import EditorJS, { EditorConfig } from '../../../types';
describe('ReadOnly API spec', () => {
function createEditor(config?: EditorConfig): void {
const editorConfig = Object.assign({}, config || {});
cy.createEditor(editorConfig).as('editorInstance');
}
it('should return correct value for readOnly.isEnabled when editor initialized in normal mode', () => {
createEditor();
cy
.get<EditorJS>('@editorInstance')
.then(editor => {
expect(editor.readOnly.isEnabled).to.be.false;
});
});
it('should return correct value for readOnly.isEnabled when editor initialized in read-only mode', () => {
createEditor({
readOnly: true,
});
cy
.get<EditorJS>('@editorInstance')
.then(editor => {
expect(editor.readOnly.isEnabled).to.be.true;
});
});
it('should return correct value for readOnly.isEnabled when read-only mode toggled', () => {
createEditor();
cy
.get<EditorJS>('@editorInstance')
.then(async editor => {
expect(editor.readOnly.isEnabled).to.be.false;
editor.readOnly.toggle()
.then(() => {
expect(editor.readOnly.isEnabled).to.be.true;
})
.then(() => editor.readOnly.toggle())
.then(() => {
expect(editor.readOnly.isEnabled).to.be.false;
});
});
});
});

View file

@ -351,13 +351,13 @@ describe('BlockTool', () => {
});
context('.toolbox', () => {
it('should return user provided toolbox config', () => {
it('should return user provided toolbox config wrapped in array', () => {
const tool = new BlockTool(options as any);
expect(tool.toolbox).to.be.deep.eq(options.config.toolbox);
expect(tool.toolbox).to.be.deep.eq([ options.config.toolbox ]);
});
it('should return Tool provided toolbox config if user one is not specified', () => {
it('should return Tool provided toolbox config wrapped in array if user one is not specified', () => {
const tool = new BlockTool({
...options,
config: {
@ -366,10 +366,10 @@ describe('BlockTool', () => {
},
} as any);
expect(tool.toolbox).to.be.deep.eq(options.constructable.toolbox);
expect(tool.toolbox).to.be.deep.eq([ options.constructable.toolbox ]);
});
it('should merge Tool provided toolbox config and user one', () => {
it('should merge Tool provided toolbox config and user one and wrap result in array in case both are objects', () => {
const tool1 = new BlockTool({
...options,
config: {
@ -389,8 +389,101 @@ describe('BlockTool', () => {
},
} as any);
expect(tool1.toolbox).to.be.deep.eq(Object.assign({}, options.constructable.toolbox, { title: options.config.toolbox.title }));
expect(tool2.toolbox).to.be.deep.eq(Object.assign({}, options.constructable.toolbox, { icon: options.config.toolbox.icon }));
expect(tool1.toolbox).to.be.deep.eq([ Object.assign({}, options.constructable.toolbox, { title: options.config.toolbox.title }) ]);
expect(tool2.toolbox).to.be.deep.eq([ Object.assign({}, options.constructable.toolbox, { icon: options.config.toolbox.icon }) ]);
});
it('should replace Tool provided toolbox config with user defined config in case the first is an array and the second is an object', () => {
const toolboxEntries = [
{
title: 'Toolbox entry 1',
},
{
title: 'Toolbox entry 2',
},
];
const userDefinedToolboxConfig = {
icon: options.config.toolbox.icon,
title: options.config.toolbox.title,
};
const tool = new BlockTool({
...options,
constructable: {
...options.constructable,
toolbox: toolboxEntries,
},
config: {
...options.config,
toolbox: userDefinedToolboxConfig,
},
} as any);
expect(tool.toolbox).to.be.deep.eq([ userDefinedToolboxConfig ]);
});
it('should replace Tool provided toolbox config with user defined config in case the first is an object and the second is an array', () => {
const userDefinedToolboxConfig = [
{
title: 'Toolbox entry 1',
},
{
title: 'Toolbox entry 2',
},
];
const tool = new BlockTool({
...options,
config: {
...options.config,
toolbox: userDefinedToolboxConfig,
},
} as any);
expect(tool.toolbox).to.be.deep.eq(userDefinedToolboxConfig);
});
it('should merge Tool provided toolbox config with user defined config in case both are arrays', () => {
const toolboxEntries = [
{
title: 'Toolbox entry 1',
},
];
const userDefinedToolboxConfig = [
{
icon: 'Icon 1',
},
{
icon: 'Icon 2',
title: 'Toolbox entry 2',
},
];
const tool = new BlockTool({
...options,
constructable: {
...options.constructable,
toolbox: toolboxEntries,
},
config: {
...options.config,
toolbox: userDefinedToolboxConfig,
},
} as any);
const expected = userDefinedToolboxConfig.map((item, i) => {
const toolToolboxEntry = toolboxEntries[i];
if (toolToolboxEntry) {
return {
...toolToolboxEntry,
...item,
};
}
return item;
});
expect(tool.toolbox).to.be.deep.eq(expected);
});
it('should return undefined if user specifies false as a value', () => {

View file

@ -113,6 +113,13 @@ export interface Blocks {
): BlockAPI;
/**
* Creates data of an empty block with a passed type.
*
* @param toolName - block tool name
*/
composeBlockData(toolName: string): Promise<BlockToolData>
/**
* Updates block data by id
*

1
types/index.d.ts vendored
View file

@ -48,6 +48,7 @@ export {
Tool,
ToolConstructable,
ToolboxConfig,
ToolboxConfigEntry,
ToolSettings,
ToolConfig,
PasteEvent,

View file

@ -1,8 +1,8 @@
import { ConversionConfig, PasteConfig, SanitizerConfig } from '../configs';
import { BlockToolData } from './block-tool-data';
import {BaseTool, BaseToolConstructable} from './tool';
import { BaseTool, BaseToolConstructable } from './tool';
import { ToolConfig } from './tool-config';
import {API, BlockAPI} from '../index';
import { API, BlockAPI, ToolboxConfig } from '../index';
import { PasteEvent } from './paste-events';
import { MoveEvent } from './hook-events';
@ -95,17 +95,7 @@ export interface BlockToolConstructable extends BaseToolConstructable {
/**
* Tool's Toolbox settings
*/
toolbox?: {
/**
* HTML string with an icon for Toolbox
*/
icon: string;
/**
* Tool title for Toolbox
*/
title?: string;
};
toolbox?: ToolboxConfig;
/**
* Paste substitutions configuration

View file

@ -1,10 +1,16 @@
import {ToolConfig} from './tool-config';
import {ToolConstructable} from './index';
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
/**
* Tool may specify its toolbox configuration
* It may include several entries as well
*/
export type ToolboxConfig = ToolboxConfigEntry | ToolboxConfigEntry[];
/**
* Tool's Toolbox settings
*/
export interface ToolboxConfig {
export interface ToolboxConfigEntry {
/**
* Tool title for Toolbox
*/
@ -14,6 +20,11 @@ export interface ToolboxConfig {
* HTML string with an icon for Toolbox
*/
icon?: string;
/**
* May contain overrides for tool default config
*/
data?: BlockToolData
}
/**

116
yarn.lock
View file

@ -1428,6 +1428,11 @@
resolved "https://registry.yarnpkg.com/@editorjs/code/-/code-2.7.0.tgz#0a21de9ac15e4533605ffcc80969513ab2142ac5"
integrity sha512-gXtTce915fHp3H9i4IqhTxEDbbkT2heFfYiW/bhFHsCmZDpyGzfZxi94kmrEqDmbxXjV49ZZ6GZbR26If13KJw==
"@editorjs/delimiter@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@editorjs/delimiter/-/delimiter-1.2.0.tgz#5075f1a3e68765cfb6aec8694b316d81e2b41607"
integrity sha512-GKsCFPk85vH5FuCuVQ48NTLc9hk0T3DsBH9zABaicTYIJayFcUa8N4/Y+L3i4tduzDqqyvoxkv+5n43GmC5gEA==
"@editorjs/header@^2.6.1":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.6.1.tgz#454a46e4dbb32ae3aa1db4d22b0ddf2cc36c3134"
@ -2390,32 +2395,25 @@ browserify@^16.1.0:
vm-browserify "^1.0.0"
xtend "^4.0.0"
browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.6.4, browserslist@^4.8.5, browserslist@^4.9.1:
version "4.12.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.0.tgz#06c6d5715a1ede6c51fc39ff67fd647f740b656d"
browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4.6.0, browserslist@^4.6.4, browserslist@^4.8.5, browserslist@^4.9.1:
version "4.20.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.3.tgz#eb7572f49ec430e054f56d52ff0ebe9be915f8bf"
integrity sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==
dependencies:
caniuse-lite "^1.0.30001043"
electron-to-chromium "^1.3.413"
node-releases "^1.1.53"
pkg-up "^2.0.0"
browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4.6.0:
version "4.16.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
dependencies:
caniuse-lite "^1.0.30001181"
colorette "^1.2.1"
electron-to-chromium "^1.3.649"
caniuse-lite "^1.0.30001332"
electron-to-chromium "^1.4.118"
escalade "^3.1.1"
node-releases "^1.1.70"
node-releases "^2.0.3"
picocolors "^1.0.0"
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer-xor@^1.0.3:
version "1.0.3"
@ -2509,8 +2507,9 @@ cache-base@^1.0.1:
unset-value "^1.0.0"
cached-path-relative@^1.0.0, cached-path-relative@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db"
version "1.1.0"
resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.1.0.tgz#865576dfef39c0d6a7defde794d078f5308e3ef3"
integrity sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==
cachedir@^2.3.0:
version "2.3.0"
@ -2570,13 +2569,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043:
version "1.0.30001048"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001048.tgz#4bb4f1bc2eb304e5e1154da80b93dee3f1cf447e"
caniuse-lite@^1.0.30001181:
version "1.0.30001202"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001202.tgz#4cb3bd5e8a808e8cd89e4e66c549989bc8137201"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001332:
version "1.0.30001332"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz#39476d3aa8d83ea76359c70302eafdd4a1d727dd"
integrity sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw==
caseless@~0.12.0:
version "0.12.0"
@ -2861,8 +2857,9 @@ color-name@^1.0.0, color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
color-string@^1.5.2:
version "1.5.3"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
@ -2874,10 +2871,6 @@ color@^3.0.0:
color-convert "^1.9.1"
color-string "^1.5.2"
colorette@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
colors@^1.1.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
@ -3564,13 +3557,10 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
electron-to-chromium@^1.3.413:
version "1.3.418"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.418.tgz#840021191f466b803a873e154113620c9f53cec6"
electron-to-chromium@^1.3.649:
version "1.3.690"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.690.tgz#54df63ec42fba6b8e9e05fe4be52caeeedb6e634"
electron-to-chromium@^1.4.118:
version "1.4.122"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.122.tgz#56e518e8c4433876b01d4460eac0f653841ed510"
integrity sha512-VuLNxTIt8sBWIT2sd186xPd18Y8KcK8myLd9nMdSJOYZwFUxxbLVmX/T1VX+qqaytRlrYYQv39myxJdXtu7Ysw==
elegant-spinner@^1.0.1:
version "1.0.1"
@ -3685,6 +3675,7 @@ es6-promise@^2.3.0:
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-string-regexp@4.0.0:
version "4.0.0"
@ -4890,6 +4881,7 @@ is-arrayish@^0.2.1:
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-binary-path@^1.0.0:
version "1.0.1"
@ -5811,8 +5803,9 @@ minimist-options@^4.0.1:
is-plain-obj "^1.1.0"
minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minipass-collect@^1.0.2:
version "1.0.2"
@ -5925,8 +5918,9 @@ module-deps@^6.0.0, module-deps@^6.2.3:
xtend "^4.0.0"
moment@^2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
version "2.29.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
move-concurrently@^1.0.1:
version "1.0.1"
@ -6034,13 +6028,10 @@ node-preload@^0.2.1:
dependencies:
process-on-spawn "^1.0.0"
node-releases@^1.1.53:
version "1.1.53"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4"
node-releases@^1.1.70:
version "1.1.71"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
node-releases@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.3.tgz#225ee7488e4a5e636da8da52854844f9d716ca96"
integrity sha512-maHFz6OLqYxz+VQyCAtA3PTX4UP/53pa05fyDNc9CwjvJ0yEh6+xBwKsgCxMNhS8taUKBFYxfuiaD9U/55iFaw==
normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
version "2.5.0"
@ -6476,6 +6467,11 @@ performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
@ -6512,12 +6508,6 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
pkg-up@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
dependencies:
find-up "^2.1.0"
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -7825,6 +7815,7 @@ simple-concat@^1.0.0:
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
dependencies:
is-arrayish "^0.3.1"
@ -7886,8 +7877,9 @@ source-map-resolve@^0.5.0:
urix "^0.1.0"
source-map-support@^0.5.16, source-map-support@~0.5.12:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
@ -7903,6 +7895,7 @@ source-map@^0.5.0, source-map@^0.5.6, source-map@~0.5.3:
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
spawn-wrap@^2.0.0:
version "2.0.0"
@ -8379,8 +8372,9 @@ terser-webpack-plugin@^2.3.6:
webpack-sources "^1.4.3"
terser@^4.1.2, terser@^4.6.12:
version "4.6.12"
resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.12.tgz#44b98aef8703fdb09a3491bf79b43faffc5b4fee"
version "4.8.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f"
integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==
dependencies:
commander "^2.20.0"
source-map "~0.6.1"