update branch

This commit is contained in:
Peter Savchenko 2021-08-07 12:34:07 +03:00
commit 829a327506
No known key found for this signature in database
GPG key ID: E68306B1AB0F727C
100 changed files with 7200 additions and 1661 deletions

View file

@ -10,5 +10,10 @@
"babel-plugin-add-module-exports",
"babel-plugin-class-display-name",
"@babel/plugin-transform-runtime"
]
],
"env": {
"test": {
"plugins": [ "istanbul" ]
}
}
}

View file

@ -7,7 +7,15 @@
* Temporary suppress some errors. We need to fix them partially in next patches
*/
"import/no-duplicates": ["warn"],
"@typescript-eslint/triple-slash-reference": ["off"]
"@typescript-eslint/triple-slash-reference": ["off"],
"jsdoc/no-undefined-types": ["warn", {"definedTypes": [
"ConstructorOptions",
"API",
"BlockToolConstructable",
"EditorConfig",
"Tool",
"ToolSettings"
]}]
},
"settings": {
"jsdoc": {

33
.github/workflows/cypress.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Tests
on: [pull_request]
jobs:
firefox:
runs-on: ubuntu-latest
container:
image: cypress/browsers:node14.16.0-chrome89-ff86
options: --user 1001
steps:
- uses: actions/checkout@v2
- run: yarn pull_tools
- uses: cypress-io/github-action@v2
with:
browser: firefox
build: yarn build
chrome:
runs-on: ubuntu-16.04
steps:
- uses: actions/checkout@v2
- run: yarn pull_tools
- uses: cypress-io/github-action@v2
with:
browser: chrome
build: yarn build
edge:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- run: yarn pull_tools
- uses: cypress-io/github-action@v2
with:
browser: edge
build: yarn build

View file

@ -15,6 +15,10 @@ jobs:
# Pull submodules
submodules: 'recursive'
- name: Get package info
id: package
uses: codex-team/action-nodejs-package-info@v1
# Setup node environment
- uses: actions/setup-node@v1
with:
@ -28,8 +32,14 @@ jobs:
- name: Build output files
run: yarn build
- name: Publish the package
run: yarn publish --access=public ${{ github.event.release.prerelease && '--tag=next' || '--tag=latest' }}
- name: Publish the package with a NEXT tag
run: yarn publish --access=public --tag=next
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Add LATEST tag for the published package if this is not a prerelease version
if: github.event.release.prerelease != true
run: npm dist-tag add ${{ steps.package.outputs.name }}@${{ steps.package.outputs.version }} latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ test/cypress/screenshots
test/cypress/videos
dist/
coverage/
.nyc_output/

7
.gitmodules vendored
View file

@ -16,8 +16,8 @@
[submodule "example/tools/simple-image"]
path = example/tools/simple-image
url = https://github.com/editor-js/simple-image
[submodule "src/components/tools/paragraph"]
path = src/components/tools/paragraph
[submodule "src/tools/paragraph"]
path = src/tools/paragraph
url = https://github.com/editor-js/paragraph
[submodule "example/tools/marker"]
path = example/tools/marker
@ -49,3 +49,6 @@
[submodule "example/tools/underline"]
path = example/tools/underline
url = https://github.com/editor-js/underline
[submodule "example/tools/nested-list"]
path = example/tools/nested-list
url = https://github.com/editor-js/nested-list

View file

@ -20,9 +20,13 @@ If you like Editor.js you can support project improvements and development of ne
### Sponsors
Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/editorjs#sponsor)]
Support us by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/editorjs/contribute/sir-8679/checkout)]
<a href="https://humm.earth/" target="_blank"><img src="https://images.opencollective.com/hummearth/2a8406a/logo/256.png" width="64"></a>
<a href="https://tesen.com/" target="_blank"><img src="https://images.opencollective.com/tesen-media-inc/b90cf6a/logo/256.png" width="64"></a>
<a href="https://slid.cc/" target="_blank"><img src="https://images.opencollective.com/slid_team/ff564d7/logo/256.png" width="64"></a>
<a href="https://opencollective.com/editorjs/sponsor/0/website" target="_blank"><img src="https://opencollective.com/editorjs/sponsor/0/avatar.svg"></a>
### Backers

View file

@ -1,9 +1,11 @@
{
"env": {
"NODE_ENV": "test"
},
"fixturesFolder": "test/cypress/fixtures",
"integrationFolder": "test/cypress/tests",
"screenshotsFolder": "test/cypress/screenshots",
"videosFolder": "test/cypress/videos",
"supportFile": "test/cypress/support/index.ts"
"supportFile": "test/cypress/support/index.ts",
"pluginsFile": "test/cypress/plugins/index.ts"
}

View file

@ -1,16 +1,58 @@
# Changelog
### 2.19.4
### 2.23.0
- `Improvements` - Vertical caret moving with UP or DOWN key [#857](https://github.com/codex-team/editor.js/issues/857).
### 2.22.2
- `Improvement` — Inline Toolbar might be used for any contenteditable element inside Editor.js zone
- `Improvement` *Tunes API* - Tunes now can provide sanitize configuration
- `Fix` *Tunes API* - Tune config now passed to constructor under `config` property
- `Fix` *Types* - Add common type for internal and external Tools configuration
- `Fix` — Block's destroy method is called on block deletion
- `Fix` - Fix jump to the button of editor zone on CBS
### 2.22.1
- `Fix` — I18n for internal Block Tunes [#1661](https://github.com/codex-team/editor.js/issues/1661)
### 2.22.0
- `New` - `onChange` callback now receive Block API object of affected block
- `New` - API method `blocks.update(id, data)` added.
### 2.21.0
- `New` - Blocks now have unique ids [#873](https://github.com/codex-team/editor.js/issues/873)
### 2.20.2
- `Fix` — Append default Tunes if user tunes are provided for Block Tool [#1640](https://github.com/codex-team/editor.js/issues/1640)
- `Fix` - Prevent the leak of codex-tooltip when Editor.js is destroyed [#1475](https://github.com/codex-team/editor.js/issues/1475).
- `Refactoring` - Notifier module now is a util.
### 2.20.1
- `Fix` - Create a new block when clicked at the bottom [#1588](https://github.com/codex-team/editor.js/issues/1588).
- `Fix` — Fix sanitisation problem with Inline Tools [#1631](https://github.com/codex-team/editor.js/issues/1631)
- `Fix` — Fix copy in FireFox [1625](https://github.com/codex-team/editor.js/issues/1625)
- `Refactoring` - The Sanitizer module is util now.
- `Refactoring` - Tooltip module is util now.
- `Refactoring` — Refactoring based on LGTM [#1577](https://github.com/codex-team/editor.js/issues/1577).
- `Refactoring` — Refactoring based on ESLint [#1636](https://github.com/codex-team/editor.js/issues/1636).
### 2.20.0
- `New` — [Block Tunes API](block-tunes.md) added
### 2.19.3
- `Fix` — Ignore error raised by Shortcut module
### 2.19.2
- `New` - `toolbar.toggleBlockSettings()` API method added [#1442](https://github.com/codex-team/editor.js/issues/1421).
- `New` - `toolbar.toggleBlockSettings()` API method added [#1442](https://github.com/codex-team/editor.js/issues/1421).
- `Improvements` - A generic type for Tool config added [#1516](https://github.com/codex-team/editor.js/issues/1516)
- `Improvements` - Remove unused `force` option in `Caret.navigateNext()` and `Caret.navigatePrevious()` [#857](https://github.com/codex-team/editor.js/issues/857#issuecomment-770363438).
- `Improvements` - Remove bundles from the repo [#1541](https://github.com/codex-team/editor.js/pull/1541).

View file

@ -77,6 +77,8 @@ use 'move' instead)
`insert(type?: string, data?: BlockToolData, config?: ToolConfig, index?: number, needToFocus?: boolean)` - insert new Block with passed parameters
`update(id: string, data: BlockToolData)` - updates data for the block with passed id
#### SanitizerAPI
`clean(taintString, config)` - method uses HTMLJanitor to clean taint string.

185
docs/block-tunes.md Normal file
View file

@ -0,0 +1,185 @@
# Block Tunes
Similar with [Tools](tools.md) represented Blocks, you can create Block Tunes and connect it to particular Tool or for all Tools.
Block Tunes allows you to set any additional options to Blocks. For example, with corresponded Block Tunes you can mark Block as «spoiler», give it an anchor, set a background, and so on.
## Base structure
Tune's class should have the `isTune` property (static getter) set to `true`.
Block Tune must implement the `render()` method which returns an HTML Element that will be appended to the Block Settings panel.
- `render()` — create a button
Also, you can provide optional methods
- `wrap()` — wraps Block content with own HTML elements
- `save()` — save Tunes state on Editor's save
At the constructor of Tune's class exemplar you will receive an object with following parameters:
| Parameter | Description |
| --------- | ----------- |
| api | Editor's [API](api.md) obejct |
| config | Configuration of Block Tool Tune is connected to (might be useful in some cases) |
| block | [Block API](api.md#block-api) methods for block Tune is connected to |
| data | Saved Tune data |
---
### render(): HTMLElement
Method that returns button to append to the block settings area
#### Parameters
Method does not accept any parameters
#### Return value
type | description |
-- | -- |
`HTMLElement` | element that will be added to the block settings area |
---
### wrap(blockContent: HTMLElement): HTMLElement
Method that accepts Block's content and wrap it with your own layout.
Might be useful if you want to modify Block appearance.
```javascript
class Tune {
wrap(blockContent) {
const myWrapper = document.createElement('div');
myWrapper.append(blockContent);
return myWrapper;
}
}
```
#### Parameters
name | type | description |
-- |-- | -- |
blockContent | HTMLElement | Block's content (might be wrapped by other Tunes) |
#### Return value
| type | description |
| -- | -- |
| HTMLElement | Your element that wraps block content |
---
### save()
Method should return Tune's state you want to save to Editor's output
#### Parameters
No parameters
#### Return value
type | description |
-- | -- |
`any` | any data you want to save |
---
### static prepare()
If you need to prepare some data for Tune (eg. load external script, create HTML nodes in the document, etc) you can use the static `prepare()` method.
It accepts tunes config passed on Editor's initialization as an argument:
```javascript
class Tune {
static prepare(config) {
loadScript();
insertNodes();
...
}
}
```
#### Parameters
type | description |
-- | -- |
`object` | your Tune configuration |
#### Return value
No return value
---
### static reset()
On Editor destroy you can use an opposite method `reset` to clean up all prepared data:
```javascript
class Tune {
static reset() {
cleanUpScripts();
deleteNodes();
...
}
}
```
#### Parameters
No parameters
#### Return value
No return value
---
### static get sanitize()
If your Tune inserts any HTML markup into Block's content you need to provide sanitize configuration, so your HTML is not trimmed on save.
Please see more information at [sanitizer page](sanitizer.md).
```javascript
class Tune {
static get sanitize() {
return {
sup: true
}
}
}
```
## Format
Tunes data is saved to `tunes` property of output object:
```
{
blocks: [
{
type: 'paragraph',
data: {
text: 'This is paragraph with Tune'
},
tunes: {
'my-tune-name': {},
favorite: true,
anchor: 'might be string'
}
}
]
}
```

View file

@ -193,7 +193,7 @@ var editor = new EditorJS({
/**
* onChange callback
*/
onChange: () => {console.log('Now I know that Editor\'s content changed!')}
onChange: (editorAPI, affectedBlockAPI) => {console.log('Now I know that Editor\'s content changed!')}
});
```

View file

@ -59,6 +59,7 @@ Options that Tool can specify. All settings should be passed as static propertie
| `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 |
| `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) |
| `sanitize` | _Object_ | `undefined` | Config for automatic sanitizing of saved data. See [Sanitize](#sanitize) section. |
| `conversionConfig` | _Object_ | `undefined` | Config allows Tool to specify how it can be converted into/from another Tool. See [Conversion config](#conversion-config) section. |

View file

@ -68,7 +68,8 @@
<script src="./tools/header/dist/bundle.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/bundle.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/bundle.js"></script><!-- Delimiter -->
<script src="./tools/list/dist/bundle.js"></script><!-- List -->
<!-- <script src="./tools/list/dist/bundle.js"></script> List -->
<script src="./tools/nested-list/dist/nested-list.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/bundle.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/bundle.js"></script><!-- Quote -->
<script src="./tools/code/dist/bundle.js"></script><!-- Code -->
@ -131,7 +132,7 @@
image: SimpleImage,
list: {
class: List,
class: NestedList,
inlineToolbar: true,
shortcut: 'CMD+SHIFT+L'
},
@ -195,6 +196,7 @@
data: {
blocks: [
{
id: "zcKCF1S7X8",
type: "header",
data: {
text: "Editor.js",
@ -202,13 +204,15 @@
}
},
{
type : 'paragraph',
data : {
text : 'Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration.'
"id": "b6ji-DvaKb",
"type": "paragraph",
"data": {
"text": "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration."
}
},
{
type: "header",
id: "7ItVl5biRo",
data: {
text: "Key features",
level: 3
@ -216,17 +220,28 @@
},
{
type : 'list',
id: "SSBSguGvP7",
data : {
items : [
'It is a block-styled editor',
'It returns clean data output in JSON',
'Designed to be extendable and pluggable with a simple API',
{
content: 'It is a block-styled editor',
items: []
},
{
content: 'It returns clean data output in JSON',
items: []
},
{
content: 'Designed to be extendable and pluggable with a simple API',
items: []
}
],
style: 'unordered'
}
},
{
type: "header",
id: "QZFox1m_ul",
data: {
text: "What does it mean «block-styled editor»",
level: 3
@ -234,18 +249,21 @@
},
{
type : 'paragraph',
id: "bwnFX5LoX7",
data : {
text : 'Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js <mark class=\"cdx-marker\">workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc</mark>. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor\'s Core.'
}
},
{
type : 'paragraph',
id: "mTrPOHAQTe",
data : {
text : `There are dozens of <a href="https://github.com/editor-js">ready-to-use Blocks</a> and the <a href="https://editorjs.io/creating-a-block-tool">simple API</a> for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games.`
}
},
{
type: "header",
id: "1sYMhUrznu",
data: {
text: "What does it mean clean data output",
level: 3
@ -253,34 +271,40 @@
},
{
type : 'paragraph',
id: "jpd7WEXrJG",
data : {
text : 'Classic WYSIWYG-editors produce raw HTML-markup with both content data and content appearance. On the contrary, Editor.js outputs JSON object with data of each Block. You can see an example below'
}
},
{
type : 'paragraph',
id: "0lOGNUKxqt",
data : {
text : `Given data can be used as you want: render with HTML for <code class="inline-code">Web clients</code>, render natively for <code class="inline-code">mobile apps</code>, create markup for <code class="inline-code">Facebook Instant Articles</code> or <code class="inline-code">Google AMP</code>, generate an <code class="inline-code">audio version</code> and so on.`
}
},
{
type : 'paragraph',
id: "WvX7kBjp0I",
data : {
text : 'Clean data is useful to sanitize, validate and process on the backend.'
}
},
{
type : 'delimiter',
id: "H9LWKQ3NYd",
data : {}
},
{
type : 'paragraph',
id: "h298akk2Ad",
data : {
text : 'We have been working on this project more than three years. Several large media projects help us to test and debug the Editor, to make its core more stable. At the same time we significantly improved the API. Now, it can be used to create any plugin for any task. Hope you enjoy. 😏'
}
},
{
type: 'image',
id: "9802bjaAA2",
data: {
url: 'assets/codex2x.png',
caption: '',
@ -294,8 +318,8 @@
onReady: function(){
saveButton.click();
},
onChange: function() {
console.log('something changed');
onChange: function(api, block) {
console.log('something changed', block);
},
});

View file

@ -281,8 +281,8 @@
onReady: function(){
saveButton.click();
},
onChange: function() {
console.log('something changed');
onChange: function(api, block) {
console.log('something changed', block);
}
});

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

@ -1 +1 @@
Subproject commit 5c1a73a8022c18ac1c15ee8d0134caae029bfbe9
Subproject commit 0bcc8d4da2bfb15e7bc3c50eb15a1ec16eb77f96

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.19.3",
"version": "2.22.2",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editor.js",
"types": "./types/index.d.ts",
@ -24,7 +24,7 @@
"svg": "svg-sprite-generate -d src/assets/ -o dist/sprite.svg",
"pull_tools": "git submodule update --init --recursive",
"checkout_tools": "git submodule foreach git pull origin master",
"test:e2e": "cypress run"
"test:e2e": "yarn build && cypress run"
},
"author": "CodeX",
"license": "Apache-2.0",
@ -37,21 +37,28 @@
"@babel/plugin-transform-runtime": "^7.9.0",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.9.5",
"@babel/preset-typescript": "^7.13.0",
"@babel/register": "^7.9.0",
"@babel/runtime": "^7.9.2",
"@codexteam/shortcuts": "^1.1.1",
"@cypress/code-coverage": "^3.9.2",
"@cypress/webpack-preprocessor": "^5.6.0",
"@types/node": "^14.14.35",
"@types/webpack": "^4.41.12",
"@types/webpack-env": "^1.15.2",
"babel-loader": "^8.1.0",
"babel-plugin-add-module-exports": "^1.0.0",
"babel-plugin-class-display-name": "^2.1.0",
"babel-plugin-istanbul": "^6.0.0",
"core-js": "3.6.5",
"css-loader": "^3.5.3",
"cssnano": "^4.1.10",
"cypress": "^5.5.0",
"cypress": "^6.8.0",
"cypress-intellij-reporter": "^0.0.6",
"eslint": "^6.8.0",
"eslint-config-codex": "^1.3.3",
"eslint-loader": "^4.0.2",
"eslint-plugin-chai-friendly": "^0.6.0",
"eslint-plugin-cypress": "^2.11.2",
"extract-text-webpack-plugin": "^3.0.2",
"html-janitor": "^2.0.4",
@ -80,6 +87,7 @@
},
"dependencies": {
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.1"
"codex-tooltip": "^1.0.2",
"nanoid": "^3.1.22"
}
}

View file

@ -93,8 +93,7 @@ export default class Module<T extends ModuleNodes = {}> {
/**
* @class
*
* @param {EditorConfig} config - Editor's config
* @param {EventsDispatcher} eventsDispatcher - Editor's event dispatcher
* @param {ModuleConfig} - Module config
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
if (new.target === Module) {

View file

@ -11,6 +11,11 @@ import $ from '../dom';
*
*/
export default class DeleteTune implements BlockTune {
/**
* Set Tool is Tune
*/
public static readonly isTune = true;
/**
* Property that contains Editor.js API methods
*
@ -70,7 +75,9 @@ export default class DeleteTune implements BlockTune {
/**
* Enable tooltip module
*/
this.api.tooltip.onHover(this.nodes.button, this.api.i18n.t('Delete'));
this.api.tooltip.onHover(this.nodes.button, this.api.i18n.t('Delete'), {
hidingDelay: 300,
});
return this.nodes.button;
}

View file

@ -12,6 +12,11 @@ import { API, BlockTune } from '../../../types';
*
*/
export default class MoveDownTune implements BlockTune {
/**
* Set Tool is Tune
*/
public static readonly isTune = true;
/**
* Property that contains Editor.js API methods
*
@ -58,7 +63,9 @@ export default class MoveDownTune implements BlockTune {
/**
* Enable tooltip module on button
*/
this.api.tooltip.onHover(moveDownButton, this.api.i18n.t('Move down'));
this.api.tooltip.onHover(moveDownButton, this.api.i18n.t('Move down'), {
hidingDelay: 300,
});
return moveDownButton;
}

View file

@ -11,6 +11,11 @@ import { API, BlockTune } from '../../../types';
*
*/
export default class MoveUpTune implements BlockTune {
/**
* Set Tool is Tune
*/
public static readonly isTune = true;
/**
* Property that contains Editor.js API methods
*
@ -57,7 +62,9 @@ export default class MoveUpTune implements BlockTune {
/**
* Enable tooltip module on button
*/
this.api.tooltip.onHover(moveUpButton, this.api.i18n.t('Move up'));
this.api.tooltip.onHover(moveUpButton, this.api.i18n.t('Move up'), {
hidingDelay: 300,
});
return moveUpButton;
}

View file

@ -14,6 +14,14 @@ function BlockAPI(
block: Block
): void {
const blockAPI: BlockAPIInterface = {
/**
* Block id
*
* @returns {string}
*/
get id(): string {
return block.id;
},
/**
* Tool name
*

View file

@ -1,13 +1,10 @@
import {
BlockAPI as BlockAPIInterface,
BlockTool,
BlockToolConstructable,
BlockTool as IBlockTool,
BlockToolData,
BlockTune,
BlockTuneConstructable,
BlockTune as IBlockTune,
SanitizerConfig,
ToolConfig,
ToolSettings
ToolConfig
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -15,22 +12,22 @@ import $ from '../dom';
import * as _ from '../utils';
import ApiModules from '../modules/api';
import BlockAPI from './api';
import { ToolType } from '../modules/tools';
/** Import default tunes */
import MoveUpTune from '../block-tunes/block-tune-move-up';
import DeleteTune from '../block-tunes/block-tune-delete';
import MoveDownTune from '../block-tunes/block-tune-move-down';
import SelectionUtils from '../selection';
import BlockTool from '../tools/block';
import BlockTune from '../tools/tune';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import ToolsCollection from '../tools/collection';
import EventsDispatcher from '../utils/events';
/**
* Interface describes Block class constructor argument
*/
interface BlockConstructorOptions {
/**
* Tool's name
* Block's id. Should be passed for existed block, and omitted for a new one.
*/
name: string;
id?: string;
/**
* Initial Block data
@ -38,14 +35,9 @@ interface BlockConstructorOptions {
data: BlockToolData;
/**
* Tool's class or constructor function
* Tool object
*/
Tool: BlockToolConstructable;
/**
* Tool settings from initial config
*/
settings: ToolSettings;
tool: BlockTool;
/**
* Editor's API methods
@ -56,6 +48,11 @@ interface BlockConstructorOptions {
* This flag indicates that the Block should be constructed in the read-only mode.
*/
readOnly: boolean;
/**
* Tunes data for current Block
*/
tunesData: {[name: string]: BlockTuneData};
}
/**
@ -83,6 +80,11 @@ export enum BlockToolAPI {
ON_PASTE = 'onPaste',
}
/**
* Names of events supported by Block class
*/
type BlockEvents = 'didMutated';
/**
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
*
@ -90,7 +92,7 @@ export enum BlockToolAPI {
* @property {HTMLElement} holder - Div element that wraps block content with Tool's content. Has `ce-block` CSS class
* @property {HTMLElement} pluginsContent - HTML content that returns by Tool's render function
*/
export default class Block {
export default class Block extends EventsDispatcher<BlockEvents> {
/**
* CSS classes for the Block
*
@ -107,35 +109,35 @@ export default class Block {
};
}
/**
* Block unique identifier
*/
public id: string;
/**
* Block Tool`s name
*/
public name: string;
public readonly name: string;
/**
* Instance of the Tool Block represents
*/
public tool: BlockTool;
/**
* Class blueprint of the ool Block represents
*/
public class: BlockToolConstructable;
public readonly tool: BlockTool;
/**
* User Tool configuration
*/
public settings: ToolConfig;
public readonly settings: ToolConfig;
/**
* Wrapper for Block`s content
*/
public holder: HTMLDivElement;
public readonly holder: HTMLDivElement;
/**
* Tunes used by Tool
*/
public tunes: BlockTune[];
public readonly tunes: ToolsCollection<BlockTune>;
/**
* Tool's user configuration
@ -149,6 +151,27 @@ export default class Block {
*/
private cachedInputs: HTMLElement[] = [];
/**
* Tool class instance
*/
private readonly toolInstance: IBlockTool;
/**
* User provided Block Tunes instances
*/
private readonly tunesInstances: Map<string, IBlockTune> = new Map();
/**
* Editor provided Block Tunes instances
*/
private readonly defaultTunesInstances: Map<string, IBlockTune> = new Map();
/**
* If there is saved data for Tune which is not available at the moment,
* we will store it here and provide back on save so data is not lost
*/
private unavailableTunesData: {[name: string]: BlockTuneData} = {};
/**
* Editor`s API module
*/
@ -178,7 +201,19 @@ export default class Block {
/**
* Is fired when DOM mutation has been happened
*/
private didMutated = _.debounce((): void => {
private didMutated = _.debounce((mutations: MutationRecord[]): void => {
const shouldFireUpdate = !mutations.some(({ addedNodes = [], removedNodes }) => {
return [...Array.from(addedNodes), ...Array.from(removedNodes)]
.some(node => $.isElement(node) && (node as HTMLElement).dataset.mutationFree === 'true');
});
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event
*/
if (!shouldFireUpdate) {
return;
}
/**
* Drop cache
*/
@ -190,6 +225,8 @@ export default class Block {
this.updateCurrentInput();
this.call(BlockToolAPI.UPDATED);
this.emit('didMutated', this);
}, this.modificationDebounceTimer);
/**
@ -199,43 +236,42 @@ export default class Block {
/**
* @param {object} options - block constructor options
* @param {string} options.name - Tool name that passed on initialization
* @param {string} [options.id] - block's id. Will be generated if omitted.
* @param {BlockToolData} options.data - Tool's initial data
* @param {BlockToolConstructable} options.Tool Tool's class
* @param {ToolSettings} options.settings - default tool's config
* @param {BlockToolConstructable} options.tool block's tool
* @param options.api - Editor API module for pass it to the Block Tunes
* @param {boolean} options.readOnly - Read-Only flag
*/
constructor({
name,
id = _.generateBlockId(),
data,
Tool,
settings,
tool,
api,
readOnly,
tunesData,
}: BlockConstructorOptions) {
this.name = name;
this.class = Tool;
this.settings = settings;
this.config = settings.config || {};
super();
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
this.config = tool.settings.config || {};
this.api = api;
this.blockAPI = new BlockAPI(this);
this.mutationObserver = new MutationObserver(this.didMutated);
this.tool = new Tool({
data,
config: this.config,
api: this.api.getMethodsForTool(name, ToolType.Block),
block: this.blockAPI,
readOnly,
});
this.tool = tool;
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
this.holder = this.compose();
/**
* @type {BlockTune[]}
*/
this.tunes = this.makeTunes();
this.tunes = tool.tunes;
this.composeTunes(tunesData);
this.holder = this.compose();
}
/**
@ -349,7 +385,7 @@ export default class Block {
* @returns {object}
*/
public get sanitize(): SanitizerConfig {
return this.tool.sanitize;
return this.tool.sanitizeConfig;
}
/**
@ -359,7 +395,7 @@ export default class Block {
* @returns {boolean}
*/
public get mergeable(): boolean {
return _.isFunction(this.tool.merge);
return _.isFunction(this.toolInstance.merge);
}
/**
@ -424,8 +460,12 @@ export default class Block {
public set selected(state: boolean) {
if (state) {
this.holder.classList.add(Block.CSS.selected);
SelectionUtils.addFakeCursor(this.holder);
} else {
this.holder.classList.remove(Block.CSS.selected);
SelectionUtils.removeFakeCursor(this.holder);
}
}
@ -502,7 +542,7 @@ export default class Block {
/**
* call Tool's method with the instance context
*/
if (this.tool[methodName] && this.tool[methodName] instanceof Function) {
if (_.isFunction(this.toolInstance[methodName])) {
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
_.log(
'`appendCallback` hook is deprecated and will be removed in the next major release. ' +
@ -513,7 +553,7 @@ export default class Block {
try {
// eslint-disable-next-line no-useless-call
this.tool[methodName].call(this.tool, params);
this.toolInstance[methodName].call(this.toolInstance, params);
} catch (e) {
_.log(`Error during '${methodName}' call: ${e.message}`, 'error');
}
@ -526,7 +566,7 @@ export default class Block {
* @param {BlockToolData} data - data to merge
*/
public async mergeWith(data: BlockToolData): Promise<void> {
await this.tool.merge(data);
await this.toolInstance.merge(data);
}
/**
@ -536,7 +576,22 @@ export default class Block {
* @returns {object}
*/
public async save(): Promise<void|SavedData> {
const extractedBlock = await this.tool.save(this.pluginsContent as HTMLElement);
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
const tunesData: {[name: string]: BlockTuneData} = this.unavailableTunesData;
[
...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries(),
]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
try {
tunesData[name] = tune.save();
} catch (e) {
_.log(`Tune ${tune.constructor.name} save method throws an Error %o`, 'warn', e);
}
}
});
/**
* Measuring execution time
@ -550,8 +605,10 @@ export default class Block {
measuringEnd = window.performance.now();
return {
id: this.id,
tool: this.name,
data: finishedExtraction,
tunes: tunesData,
time: measuringEnd - measuringStart,
};
})
@ -572,57 +629,30 @@ export default class Block {
public async validate(data: BlockToolData): Promise<boolean> {
let isValid = true;
if (this.tool.validate instanceof Function) {
isValid = await this.tool.validate(data);
if (this.toolInstance.validate instanceof Function) {
isValid = await this.toolInstance.validate(data);
}
return isValid;
}
/**
* Make an array with default settings
* Each block has default tune instance that have states
*
* @returns {BlockTune[]}
*/
public makeTunes(): BlockTune[] {
const tunesList = [
{
name: 'moveUp',
Tune: MoveUpTune,
},
{
name: 'delete',
Tune: DeleteTune,
},
{
name: 'moveDown',
Tune: MoveDownTune,
},
];
// Pluck tunes list and return tune instances with passed Editor API and settings
return tunesList.map(({ name, Tune }: {name: string; Tune: BlockTuneConstructable}) => {
return new Tune({
api: this.api.getMethodsForTool(name, ToolType.Tune),
settings: this.config,
});
});
}
/**
* Enumerates initialized tunes and returns fragment that can be appended to the toolbars area
*
* @returns {DocumentFragment}
* @returns {DocumentFragment[]}
*/
public renderTunes(): DocumentFragment {
public renderTunes(): [DocumentFragment, DocumentFragment] {
const tunesElement = document.createDocumentFragment();
const defaultTunesElement = document.createDocumentFragment();
this.tunes.forEach((tune) => {
this.tunesInstances.forEach((tune) => {
$.append(tunesElement, tune.render());
});
this.defaultTunesInstances.forEach((tune) => {
$.append(defaultTunesElement, tune.render());
});
return tunesElement;
return [tunesElement, defaultTunesElement];
}
/**
@ -672,6 +702,26 @@ export default class Block {
this.removeInputEvents();
}
/**
* Call Tool instance destroy method
*/
public destroy(): void {
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
this.toolInstance.destroy();
}
}
/**
* Call Tool instance renderSettings method
*/
public renderSettings(): HTMLElement | undefined {
if (_.isFunction(this.toolInstance.renderSettings)) {
return this.toolInstance.renderSettings();
}
}
/**
* Make default Block wrappers and put Tool`s content there
*
@ -680,14 +730,60 @@ export default class Block {
private compose(): HTMLDivElement {
const wrapper = $.make('div', Block.CSS.wrapper) as HTMLDivElement,
contentNode = $.make('div', Block.CSS.content),
pluginsContent = this.tool.render();
pluginsContent = this.toolInstance.render();
contentNode.appendChild(pluginsContent);
wrapper.appendChild(contentNode);
/**
* Block Tunes might wrap Block's content node to provide any UI changes
*
* <tune2wrapper>
* <tune1wrapper>
* <blockContent />
* </tune1wrapper>
* </tune2wrapper>
*/
let wrappedContentNode: HTMLElement = contentNode;
[...this.tunesInstances.values(), ...this.defaultTunesInstances.values()]
.forEach((tune) => {
if (_.isFunction(tune.wrap)) {
try {
wrappedContentNode = tune.wrap(wrappedContentNode);
} catch (e) {
_.log(`Tune ${tune.constructor.name} wrap method throws an Error %o`, 'warn', e);
}
}
});
wrapper.appendChild(wrappedContentNode);
return wrapper;
}
/**
* Instantiate Block Tunes
*
* @param tunesData - current Block tunes data
* @private
*/
private composeTunes(tunesData: {[name: string]: BlockTuneData}): void {
Array.from(this.tunes.values()).forEach((tune) => {
const collection = tune.isInternal ? this.defaultTunesInstances : this.tunesInstances;
collection.set(tune.name, tune.create(tunesData[tune.name], this.blockAPI));
});
/**
* Check if there is some data for not available tunes
*/
Object.entries(tunesData).forEach(([name, data]) => {
if (!this.tunesInstances.has(name)) {
this.unavailableTunesData[name] = data;
}
});
}
/**
* Is fired when text input or contentEditable is focused
*/
@ -709,6 +805,13 @@ export default class Block {
private addInputEvents(): void {
this.inputs.forEach(input => {
input.addEventListener('focus', this.handleFocus);
/**
* If input is native input add oninput listener to observe changes
*/
if ($.isNativeInput(input)) {
input.addEventListener('input', this.didMutated);
}
});
}
@ -718,6 +821,10 @@ export default class Block {
private removeInputEvents(): void {
this.inputs.forEach(input => {
input.removeEventListener('focus', this.handleFocus);
if ($.isNativeInput(input)) {
input.removeEventListener('input', this.didMutated);
}
});
}
}

View file

@ -202,7 +202,7 @@ export default class Dom {
public static get allInputsSelector(): string {
const allowedInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url'];
return '[contenteditable], textarea, input:not([type]), ' +
return '[contenteditable=true], textarea, input:not([type]), ' +
allowedInputTypes.map((type) => `input[type="${type}"]`).join(', ');
}

View file

@ -2,7 +2,7 @@ import SelectionUtils from '../selection';
import $ from '../dom';
import * as _ from '../utils';
import { API, InlineTool, SanitizerConfig } from '../../../types';
import { InlineTool, SanitizerConfig } from '../../../types';
import { Notifier, Toolbar, I18n } from '../../../types/api';
/**

View file

@ -23,11 +23,13 @@ export default class BlocksAPI extends Module {
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex),
getBlockByIndex: (index: number): BlockAPIInterface | void => this.getBlockByIndex(index),
getById: (id: string): BlockAPIInterface | null => this.getById(id),
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
getBlocksCount: (): number => this.getBlocksCount(),
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert,
update: this.update,
};
}
@ -66,6 +68,23 @@ export default class BlocksAPI extends Module {
return new BlockAPI(block);
}
/**
* Returns BlockAPI object by Block id
*
* @param id - id of block to get
*/
public getById(id: string): BlockAPIInterface | null {
const block = this.Editor.BlockManager.getBlockById(id);
if (block === undefined) {
_.logLabeled('There is no block with id `' + id + '`', 'warn');
return null;
}
return new BlockAPI(block);
}
/**
* Call Block Manager method that swap Blocks
*
@ -229,4 +248,32 @@ export default class BlocksAPI extends Module {
'Use blocks.insert() instead.', 'warn');
this.insert();
}
/**
* Updates block data by id
*
* @param id - id of the block to update
* @param data - the new data
*/
public update = (id: string, data: BlockToolData): void => {
const { BlockManager } = this.Editor;
const block = BlockManager.getBlockById(id);
if (!block) {
_.log('blocks.update(): Block with passed id was not found', 'warn');
return;
}
const blockIndex = BlockManager.getBlockIndex(block);
BlockManager.insert({
id: block.id,
tool: block.name,
data,
index: blockIndex,
replace: true,
tunes: block.tunes,
});
}
}

View file

@ -1,8 +1,8 @@
import { I18n } from '../../../../types/api';
import I18nInternal from '../../i18n';
import { ToolType } from '../tools';
import { logLabeled } from '../../utils';
import Module from '../../__module';
import { ToolClass } from '../../tools/collection';
/**
* Provides methods for working with i18n
@ -11,17 +11,14 @@ export default class I18nAPI extends Module {
/**
* Return namespace section for tool or block tune
*
* @param toolName - name of tool. Used to provide dictionary only for this tool
* @param toolType - 'block' for Block Tool, 'inline' for Inline Tool, 'tune' for Block Tunes
* @param tool - tool object
*/
private static getNamespace(toolName: string, toolType: ToolType): string {
switch (toolType) {
case ToolType.Block:
case ToolType.Inline:
return `tools.${toolName}`;
case ToolType.Tune:
return `blockTunes.${toolName}`;
private static getNamespace(tool: ToolClass): string {
if (tool.isTune()) {
return `blockTunes.${tool.name}`;
}
return `tools.${tool.name}`;
}
/**
@ -40,15 +37,14 @@ export default class I18nAPI extends Module {
/**
* Return I18n API methods with tool namespaced dictionary
*
* @param toolName - name of tool. Used to provide dictionary only for this tool
* @param toolType - 'block' for Block Tool, 'inline' for Inline Tool, 'tune' for Block Tunes
* @param tool - Tool object
*/
public getMethodsForTool(toolName: string, toolType: ToolType): I18n {
public getMethodsForTool(tool: ToolClass): I18n {
return Object.assign(
this.methods,
{
t: (dictKey: string): string => {
return I18nInternal.t(I18nAPI.getNamespace(toolName, toolType), dictKey);
return I18nInternal.t(I18nAPI.getNamespace(tool), dictKey);
},
});
}

View file

@ -7,7 +7,7 @@
*/
import Module from '../../__module';
import { API as APIInterfaces } from '../../../../types';
import { ToolType } from '../tools';
import { ToolClass } from '../../tools/collection';
/**
* @class API
@ -38,16 +38,13 @@ export default class API extends Module {
/**
* Returns Editor.js Core API methods for passed tool
*
* @param toolName - how user name tool. It can be used in some API logic,
* for example in i18n to provide namespaced dictionary
*
* @param toolType - 'block' for Block Tool, 'inline' for Inline Tool, 'tune' for Block Tunes
* @param tool - tool object
*/
public getMethodsForTool(toolName: string, toolType = ToolType.Block): APIInterfaces {
public getMethodsForTool(tool: ToolClass): APIInterfaces {
return Object.assign(
this.methods,
{
i18n: this.Editor.I18nAPI.getMethodsForTool(toolName, toolType),
i18n: this.Editor.I18nAPI.getMethodsForTool(tool),
}
) as APIInterfaces;
}

View file

@ -1,15 +1,38 @@
import { Notifier } from '../../../../types/api';
import EventsDispatcher from '../../utils/events';
import { Notifier as INotifier } from '../../../../types/api';
import Notifier from '../../utils/notifier';
import { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier';
import Module from '../../__module';
import { ModuleConfig } from '../../../types-internal/module-config';
/**
*
*/
export default class NotifierAPI extends Module {
/**
* Notifier utility Instance
*/
private notifier: Notifier;
/**
* @class
* @param {object} moduleConfiguration - Module Configuration
* @param {EditorConfig} moduleConfiguration.config - Editor's config
* @param {EventsDispatcher} moduleConfiguration.eventsDispatcher - Editor's event dispatcher
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.notifier = new Notifier();
}
/**
* Available methods
*/
public get methods(): Notifier {
public get methods(): INotifier {
return {
show: (options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions): void => this.show(options),
};
@ -21,6 +44,6 @@ export default class NotifierAPI extends Module {
* @param {NotifierOptions} options - message option
*/
public show(options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions): void {
return this.Editor.Notifier.show(options);
return this.notifier.show(options);
}
}

View file

@ -12,6 +12,7 @@ export default class ReadOnlyAPI extends Module {
public get methods(): ReadOnly {
return {
toggle: (state): Promise<boolean> => this.toggle(state),
isEnabled: this.isEnabled,
};
}
@ -25,4 +26,11 @@ export default class ReadOnlyAPI extends Module {
public toggle(state?: boolean): Promise<boolean> {
return this.Editor.ReadOnly.toggle(state);
}
/**
* Returns current read-only state
*/
public get isEnabled(): boolean {
return this.Editor.ReadOnly.isEnabled;
}
}

View file

@ -1,6 +1,7 @@
import { Sanitizer } from '../../../../types/api';
import { Sanitizer as ISanitizer } from '../../../../types/api';
import { SanitizerConfig } from '../../../../types/configs';
import Module from '../../__module';
import { clean } from '../../utils/sanitizer';
/**
* @class SanitizerAPI
@ -10,9 +11,9 @@ export default class SanitizerAPI extends Module {
/**
* Available methods
*
* @returns {Sanitizer}
* @returns {SanitizerConfig}
*/
public get methods(): Sanitizer {
public get methods(): ISanitizer {
return {
clean: (taintString, config): string => this.clean(taintString, config),
};
@ -27,6 +28,6 @@ export default class SanitizerAPI extends Module {
* @returns {string}
*/
public clean(taintString: string, config: SanitizerConfig): string {
return this.Editor.Sanitizer.clean(taintString, config);
return clean(taintString, config);
}
}

View file

@ -1,16 +1,45 @@
import { Tooltip } from '../../../../types/api';
import { Tooltip as ITooltip } from '../../../../types/api';
import { TooltipContent, TooltipOptions } from 'codex-tooltip';
import Module from '../../__module';
import { ModuleConfig } from '../../../types-internal/module-config';
import Tooltip from '../../utils/tooltip';
import EventsDispatcher from '../../utils/events';
import { EditorConfig } from '../../../../types';
/**
* @class TooltipAPI
* @classdesc Tooltip API
*/
export default class TooltipAPI extends Module {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param {object} moduleConfiguration - Module Configuration
* @param {EditorConfig} moduleConfiguration.config - Editor's config
* @param {EventsDispatcher} moduleConfiguration.eventsDispatcher - Editor's event dispatcher
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
* Destroy Module
*/
public destroy(): void {
this.tooltip.destroy();
}
/**
* Available methods
*/
public get methods(): Tooltip {
public get methods(): ITooltip {
return {
show: (element: HTMLElement,
content: TooltipContent,
@ -32,14 +61,14 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.Editor.Tooltip.show(element, content, options);
this.tooltip.show(element, content, options);
}
/**
* Method hides tooltip on HTML page
*/
public hide(): void {
this.Editor.Tooltip.hide();
this.tooltip.hide();
}
/**
@ -50,6 +79,6 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.Editor.Tooltip.onHover(element, content, options);
this.tooltip.onHover(element, content, options);
}
}

View file

@ -118,14 +118,14 @@ export default class BlockEvents extends Module {
*/
this.Editor.BlockSelection.clearSelection(event);
const { BlockManager, Tools, InlineToolbar, ConversionToolbar } = this.Editor;
const { BlockManager, InlineToolbar, ConversionToolbar } = this.Editor;
const currentBlock = BlockManager.currentBlock;
if (!currentBlock) {
return;
}
const canOpenToolbox = Tools.isDefault(currentBlock.tool) && currentBlock.isEmpty;
const canOpenToolbox = currentBlock.tool.isDefault && currentBlock.isEmpty;
const conversionToolbarOpened = !currentBlock.isEmpty && ConversionToolbar.opened;
const inlineToolbarOpened = !currentBlock.isEmpty && !SelectionUtils.isCollapsed && InlineToolbar.opened;
@ -167,7 +167,7 @@ export default class BlockEvents extends Module {
*
* @param {ClipboardEvent} event - clipboard event
*/
public handleCommandC(event: ClipboardEvent): Promise<void> {
public handleCommandC(event: ClipboardEvent): void {
const { BlockSelection } = this.Editor;
if (!BlockSelection.anyBlockSelected) {
@ -175,7 +175,7 @@ export default class BlockEvents extends Module {
}
// Copy Selected Blocks
return BlockSelection.copySelectedBlocks(event);
BlockSelection.copySelectedBlocks(event);
}
/**
@ -183,21 +183,26 @@ export default class BlockEvents extends Module {
*
* @param {ClipboardEvent} event - clipboard event
*/
public async handleCommandX(event: ClipboardEvent): Promise<void> {
public handleCommandX(event: ClipboardEvent): void {
const { BlockSelection, BlockManager, Caret } = this.Editor;
if (!BlockSelection.anyBlockSelected) {
return;
}
await BlockSelection.copySelectedBlocks(event);
BlockSelection.copySelectedBlocks(event).then(() => {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
/**
* Insert default block in place of removed ones
*/
const insertedBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
Caret.setToBlock(insertedBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
/** Clear selection */
BlockSelection.clearSelection(event);
});
}
/**
@ -206,15 +211,14 @@ export default class BlockEvents extends Module {
* @param {KeyboardEvent} event - keydown
*/
private enter(event: KeyboardEvent): void {
const { BlockManager, Tools, UI } = this.Editor;
const { BlockManager, UI } = this.Editor;
const currentBlock = BlockManager.currentBlock;
const tool = Tools.available[currentBlock.name];
/**
* Don't handle Enter keydowns when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*/
if (tool && tool[Tools.INTERNAL_SETTINGS.IS_ENABLED_LINE_BREAKS]) {
if (currentBlock.tool.isLineBreaksEnabled) {
return;
}
@ -253,7 +257,7 @@ export default class BlockEvents extends Module {
/**
* If new Block is empty
*/
if (this.Editor.Tools.isDefault(newCurrent.tool) && newCurrent.isEmpty) {
if (newCurrent.tool.isDefault && newCurrent.isEmpty) {
/**
* Show Toolbar
*/
@ -276,7 +280,7 @@ export default class BlockEvents extends Module {
private backspace(event: KeyboardEvent): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const currentBlock = BlockManager.currentBlock;
const tool = this.Editor.Tools.available[currentBlock.name];
const tool = currentBlock.tool;
/**
* Check if Block should be removed by current Backspace keydown
@ -314,7 +318,7 @@ export default class BlockEvents extends Module {
*
* But if caret is at start of the block, we allow to remove it by backspaces
*/
if (tool && tool[this.Editor.Tools.INTERNAL_SETTINGS.IS_ENABLED_LINE_BREAKS] && !Caret.isAtStart) {
if (tool.isLineBreaksEnabled && !Caret.isAtStart) {
return;
}

View file

@ -11,7 +11,9 @@ import Module from '../__module';
import $ from '../dom';
import * as _ from '../utils';
import Blocks from '../blocks';
import { BlockToolConstructable, BlockToolData, PasteEvent } from '../../../types';
import { BlockToolData, PasteEvent } from '../../../types';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import BlockAPI from '../block/api';
/**
* @typedef {BlockManager} BlockManager
@ -215,21 +217,26 @@ export default class BlockManager extends Module {
*
* @param {object} options - block creation options
* @param {string} options.tool - tools passed in editor config {@link EditorConfig#tools}
* @param {string} [options.id] - unique id for this block
* @param {BlockToolData} [options.data] - constructor params
*
* @returns {Block}
*/
public composeBlock({ tool, data = {} }: {tool: string; data?: BlockToolData}): Block {
public composeBlock({
tool: name,
data = {},
id = undefined,
tunes: tunesData = {},
}: {tool: string; id?: string; data?: BlockToolData; tunes?: {[name: string]: BlockTuneData}}): Block {
const readOnly = this.Editor.ReadOnly.isEnabled;
const settings = this.Editor.Tools.getToolSettings(tool);
const Tool = this.Editor.Tools.available[tool] as BlockToolConstructable;
const tool = this.Editor.Tools.blockTools.get(name);
const block = new Block({
name: tool,
id,
data,
Tool,
settings,
tool,
api: this.Editor.API,
readOnly,
tunesData,
});
if (!readOnly) {
@ -243,26 +250,31 @@ export default class BlockManager extends Module {
* Insert new block into _blocks
*
* @param {object} options - insert options
* @param {string} options.tool - plugin name, by default method inserts the default block type
* @param {object} options.data - plugin data
* @param {number} options.index - index where to insert new Block
* @param {boolean} options.needToFocus - flag shows if needed to update current Block index
* @param {boolean} options.replace - flag shows if block by passed index should be replaced with inserted one
* @param {string} [options.id] - block's unique id
* @param {string} [options.tool] - plugin name, by default method inserts the default block type
* @param {object} [options.data] - plugin data
* @param {number} [options.index] - index where to insert new Block
* @param {boolean} [options.needToFocus] - flag shows if needed to update current Block index
* @param {boolean} [options.replace] - flag shows if block by passed index should be replaced with inserted one
*
* @returns {Block}
*/
public insert({
id = undefined,
tool = this.config.defaultBlock,
data = {},
index,
needToFocus = true,
replace = false,
tunes = {},
}: {
id?: string;
tool?: string;
data?: BlockToolData;
index?: number;
needToFocus?: boolean;
replace?: boolean;
tunes?: {[name: string]: BlockTuneData};
} = {}): Block {
let newIndex = index;
@ -271,12 +283,19 @@ export default class BlockManager extends Module {
}
const block = this.composeBlock({
id,
tool,
data,
tunes,
});
this._blocks.insert(newIndex, block, replace);
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(block);
if (needToFocus) {
this.currentBlockIndex = newIndex;
} else if (newIndex <= this.currentBlockIndex) {
@ -348,6 +367,11 @@ export default class BlockManager extends Module {
this._blocks[index] = block;
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(block);
if (needToFocus) {
this.currentBlockIndex = index;
} else if (index <= this.currentBlockIndex) {
@ -413,8 +437,16 @@ export default class BlockManager extends Module {
throw new Error('Can\'t find a Block to remove');
}
const blockToRemove = this._blocks[index];
blockToRemove.destroy();
this._blocks.remove(index);
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(blockToRemove);
if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
@ -508,6 +540,26 @@ export default class BlockManager extends Module {
return this._blocks[index];
}
/**
* Returns an index for passed Block
*
* @param block - block to find index
*/
public getBlockIndex(block: Block): number {
return this._blocks.indexOf(block);
}
/**
* Returns the Block by passed id
*
* @param id - id of block to get
*
* @returns {Block}
*/
public getBlockById(id): Block | undefined {
return this._blocks.array.find(block => block.id === id);
}
/**
* Get Block instance by html element
*
@ -665,6 +717,11 @@ export default class BlockManager extends Module {
/** Now actual block moved so that current block index changed */
this.currentBlockIndex = toIndex;
/**
* Force call of didMutated event on Block movement
*/
this.blockDidMutated(this.currentBlock);
}
/**
@ -703,9 +760,7 @@ export default class BlockManager extends Module {
*/
public async destroy(): Promise<void> {
await Promise.all(this.blocks.map((block) => {
if (_.isFunction(block.tool.destroy)) {
return block.tool.destroy();
}
return block.destroy();
}));
}
@ -732,6 +787,8 @@ export default class BlockManager extends Module {
this.readOnlyMutableListeners.on(block.holder, 'dragleave', (event: DragEvent) => {
BlockEvents.dragLeave(event);
});
block.on('didMutated', (affectedBlock: Block) => this.blockDidMutated(affectedBlock));
}
/**
@ -767,4 +824,15 @@ export default class BlockManager extends Module {
private validateIndex(index: number): boolean {
return !(index < 0 || index >= this._blocks.length);
}
/**
* Block mutation callback
*
* @param block - mutated block
*/
private blockDidMutated(block: Block): Block {
this.Editor.ModificationsObserver.onChange(new BlockAPI(block));
return block;
}
}

View file

@ -13,6 +13,7 @@ import Shortcuts from '../utils/shortcuts';
import SelectionUtils from '../selection';
import { SanitizerConfig } from '../../../types/configs';
import { clean } from '../utils/sanitizer';
/**
*
@ -285,7 +286,7 @@ export default class BlockSelection extends Module {
*
* @returns {Promise<void>}
*/
public async copySelectedBlocks(e: ClipboardEvent): Promise<void> {
public copySelectedBlocks(e: ClipboardEvent): Promise<void> {
/**
* Prevent default copy
*/
@ -297,22 +298,29 @@ export default class BlockSelection extends Module {
/**
* Make <p> tag that holds clean HTML
*/
const cleanHTML = this.Editor.Sanitizer.clean(block.holder.innerHTML, this.sanitizerConfig);
const cleanHTML = clean(block.holder.innerHTML, this.sanitizerConfig);
const fragment = $.make('p');
fragment.innerHTML = cleanHTML;
fakeClipboard.appendChild(fragment);
});
const savedData = await Promise.all(this.selectedBlocks.map((block) => block.save()));
const textPlain = Array.from(fakeClipboard.childNodes).map((node) => node.textContent)
.join('\n\n');
const textHTML = fakeClipboard.innerHTML;
e.clipboardData.setData('text/plain', textPlain);
e.clipboardData.setData('text/html', textHTML);
e.clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData));
return Promise
.all(this.selectedBlocks.map((block) => block.save()))
.then(savedData => {
try {
e.clipboardData.setData(this.Editor.Paste.MIME_TYPE, JSON.stringify(savedData));
} catch (err) {
// In Firefox we can't set data in async function
}
});
}
/**

View file

@ -370,7 +370,7 @@ export default class Caret extends Module {
* If last block is empty and it is an defaultBlock, set to that.
* Otherwise, append new empty block and set to that
*/
if (this.Editor.Tools.isDefault(lastBlock.tool) && lastBlock.isEmpty) {
if (lastBlock.tool.isDefault && lastBlock.isEmpty) {
this.setToBlock(lastBlock);
} else {
const newBlock = this.Editor.BlockManager.insertAtEnd();
@ -562,7 +562,7 @@ export default class Caret extends Module {
* Detect an next line position from the caret position
*/
private detectNextLinePosition(): Position | false {
const { BlockManager, Tools } = this.Editor;
const { BlockManager } = this.Editor;
const nextInput = BlockManager.currentBlock.nextInput;
let nextBlock = BlockManager.nextContentfulBlock;
@ -573,7 +573,7 @@ export default class Caret extends Module {
* This code allows to exit from the last non-initial tool:
* https://github.com/codex-team/editor.js/issues/1103
*/
if (Tools.isDefault(BlockManager.currentBlock.tool)) {
if (BlockManager.currentBlock.tool.isDefault) {
return false;
}

View file

@ -1,195 +1,40 @@
/**
* @module ModificationsObserver
*
* Handles any mutations
* and gives opportunity to handle outside
*/
import Module from '../__module';
import { BlockAPI } from '../../../types';
import * as _ from '../utils';
import Block from '../block';
/**
*
* Single entry point for Block mutation events
*/
export default class ModificationsObserver extends Module {
/**
* Debounce Timer
*
* @type {number}
*/
public static readonly DebounceTimer = 450;
/**
* MutationObserver instance
*/
private observer: MutationObserver;
/**
* Allows to temporary disable mutations handling
* Flag shows onChange event is disabled
*/
private disabled = false;
/**
* Used to prevent several mutation callback execution
*
* @type {Function}
*/
private mutationDebouncer = _.debounce(() => {
this.updateNativeInputs();
if (_.isFunction(this.config.onChange)) {
this.config.onChange(this.Editor.API.methods);
}
}, ModificationsObserver.DebounceTimer);
/**
* Array of native inputs in Blocks.
* Changes in native inputs are not handled by modification observer, so we need to set change event listeners on them
*/
private nativeInputs: HTMLElement[] = [];
/**
* Clear timeout and set null to mutationDebouncer property
*/
public destroy(): void {
this.mutationDebouncer = null;
if (this.observer) {
this.observer.disconnect();
}
this.observer = null;
this.nativeInputs.forEach((input) => this.listeners.off(input, 'input', this.mutationDebouncer));
this.mutationDebouncer = null;
}
/**
* Set read-only state
*
* @param {boolean} readOnlyEnabled - read only flag value
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (readOnlyEnabled) {
this.disableModule();
} else {
this.enableModule();
}
}
/**
* Allows to disable observer,
* for example when Editor wants to stealthy mutate DOM
*/
public disable(): void {
this.disabled = true;
}
/**
* Enables mutation handling
* Should be called after .disable()
* Enables onChange event
*/
public enable(): void {
this.disabled = false;
}
/**
* setObserver
*
* sets 'DOMSubtreeModified' listener on Editor's UI.nodes.redactor
* so that User can handle outside from API
* Disables onChange event
*/
private setObserver(): void {
const { UI } = this.Editor;
const observerOptions = {
childList: true,
attributes: true,
subtree: true,
characterData: true,
characterDataOldValue: true,
};
this.observer = new MutationObserver((mutationList, observer) => {
this.mutationHandler(mutationList, observer);
});
this.observer.observe(UI.nodes.redactor, observerOptions);
public disable(): void {
this.disabled = true;
}
/**
* MutationObserver events handler
* Call onChange event passed to Editor.js configuration
*
* @param {MutationRecord[]} mutationList - list of mutations
* @param {MutationObserver} observer - observer instance
* @param block - changed Block
*/
private mutationHandler(mutationList: MutationRecord[], observer: MutationObserver): void {
/**
* Skip mutations in stealth mode
*/
if (this.disabled) {
public onChange(block: BlockAPI): void {
if (this.disabled || !_.isFunction(this.config.onChange)) {
return;
}
/**
* We divide two Mutation types:
* 1) mutations that concerns client changes: settings changes, symbol added, deletion, insertions and so on
* 2) functional changes. On each client actions we set functional identifiers to interact with user
*/
let contentMutated = false;
mutationList.forEach((mutation) => {
switch (mutation.type) {
case 'childList':
case 'characterData':
contentMutated = true;
break;
case 'attributes':
/**
* Changes on Element.ce-block usually is functional
*/
if (!(mutation.target as Element).classList.contains(Block.CSS.wrapper)) {
contentMutated = true;
}
break;
}
});
/** call once */
if (contentMutated) {
this.mutationDebouncer();
}
}
/**
* Gets native inputs and set oninput event handler
*/
private updateNativeInputs(): void {
if (this.nativeInputs) {
this.nativeInputs.forEach((input) => {
this.listeners.off(input, 'input');
});
}
this.nativeInputs = Array.from(this.Editor.UI.nodes.redactor.querySelectorAll('textarea, input, select'));
this.nativeInputs.forEach((input) => this.listeners.on(input, 'input', this.mutationDebouncer));
}
/**
* Sets observer and enables it
*/
private enableModule(): void {
/**
* wait till Browser render Editor's Blocks
*/
window.setTimeout(() => {
this.setObserver();
this.updateNativeInputs();
this.enable();
}, 1000);
}
/**
* Disables observer
*/
private disableModule(): void {
this.disable();
this.config.onChange(this.Editor.API.methods, block);
}
}

View file

@ -2,14 +2,14 @@ import Module from '../__module';
import $ from '../dom';
import * as _ from '../utils';
import {
BlockTool,
BlockToolConstructable,
PasteConfig,
BlockAPI,
PasteEvent,
PasteEventDetail
} from '../../../types';
import Block from '../block';
import { SavedData } from '../../../types/data-formats';
import { clean, sanitizeBlocks } from '../utils/sanitizer';
import BlockTool from '../tools/block';
/**
* Tag substitute object.
@ -18,9 +18,8 @@ interface TagSubstitute {
/**
* Name of related Tool
*
* @type {string}
*/
tool: string;
tool: BlockTool;
}
/**
@ -29,24 +28,18 @@ interface TagSubstitute {
interface PatternSubstitute {
/**
* Pattern`s key
*
* @type {string}
*/
key: string;
/**
* Pattern regexp
*
* @type {RegExp}
*/
pattern: RegExp;
/**
* Name of related Tool
*
* @type {string}
*/
tool: string;
tool: BlockTool;
}
/**
@ -166,8 +159,7 @@ export default class Paste extends Module {
* @param {boolean} isDragNDrop - true if data transfer comes from drag'n'drop events
*/
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
const { Sanitizer } = this.Editor;
const { Tools } = this.Editor;
const types = dataTransfer.types;
/**
@ -211,9 +203,8 @@ export default class Paste extends Module {
return result;
}, {});
const customConfig = Object.assign({}, toolsTags, Sanitizer.getAllInlineToolsConfig(), { br: {} });
const cleanData = Sanitizer.clean(htmlData, customConfig);
const customConfig = Object.assign({}, toolsTags, Tools.getAllInlineToolsSanitizeConfig(), { br: {} });
const cleanData = clean(htmlData, customConfig);
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
@ -230,7 +221,7 @@ export default class Paste extends Module {
* @param {boolean} isHTML - if passed string is HTML, this parameter should be true
*/
public async processText(data: string, isHTML = false): Promise<void> {
const { Caret, BlockManager, Tools } = this.Editor;
const { Caret, BlockManager } = this.Editor;
const dataToInsert = isHTML ? this.processHTML(data) : this.processPlain(data);
if (!dataToInsert.length) {
@ -247,7 +238,7 @@ export default class Paste extends Module {
return;
}
const isCurrentBlockDefault = BlockManager.currentBlock && Tools.isDefault(BlockManager.currentBlock.tool);
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
dataToInsert.map(
@ -279,23 +270,22 @@ export default class Paste extends Module {
private processTools(): void {
const tools = this.Editor.Tools.blockTools;
Object.entries(tools).forEach(this.processTool);
Array
.from(tools.values())
.forEach(this.processTool);
}
/**
* Process paste config for each tool
*
* @param tool - BlockTool object
*/
private processTool = ([name, tool]: [string, BlockToolConstructable]): void => {
private processTool = (tool: BlockTool): void => {
try {
const toolInstance = new this.Editor.Tools.blockTools[name]({
api: this.Editor.API.getMethodsForTool(name),
config: {},
data: {},
readOnly: false,
}) as BlockTool;
const toolInstance = tool.create({}, {} as BlockAPI, false);
if (tool.pasteConfig === false) {
this.exceptionList.push(name);
this.exceptionList.push(tool.name);
return;
}
@ -304,14 +294,12 @@ export default class Paste extends Module {
return;
}
const toolPasteConfig = tool.pasteConfig || {};
this.getTagsConfig(name, toolPasteConfig);
this.getFilesConfig(name, toolPasteConfig);
this.getPatternsConfig(name, toolPasteConfig);
this.getTagsConfig(tool);
this.getFilesConfig(tool);
this.getPatternsConfig(tool);
} catch (e) {
_.log(
`Paste handling for «${name}» Tool hasn't been set up because of the error`,
`Paste handling for «${tool.name}» Tool hasn't been set up because of the error`,
'warn',
e
);
@ -321,17 +309,16 @@ export default class Paste extends Module {
/**
* Get tags to substitute by Tool
*
* @param {string} name - Tool name
* @param {PasteConfig} toolPasteConfig - Tool onPaste configuration
* @param tool - BlockTool object
*/
private getTagsConfig(name: string, toolPasteConfig: PasteConfig): void {
const tags = toolPasteConfig.tags || [];
private getTagsConfig(tool: BlockTool): void {
const tags = tool.pasteConfig.tags || [];
tags.forEach((tag) => {
if (Object.prototype.hasOwnProperty.call(this.toolsTags, tag)) {
_.log(
`Paste handler for «${name}» Tool on «${tag}» tag is skipped ` +
`because it is already used by «${this.toolsTags[tag].tool}» Tool.`,
`Paste handler for «${tool.name}» Tool on «${tag}» tag is skipped ` +
`because it is already used by «${this.toolsTags[tag].tool.name}» Tool.`,
'warn'
);
@ -339,21 +326,20 @@ export default class Paste extends Module {
}
this.toolsTags[tag.toUpperCase()] = {
tool: name,
tool,
};
});
this.tagsByTool[name] = tags.map((t) => t.toUpperCase());
this.tagsByTool[tool.name] = tags.map((t) => t.toUpperCase());
}
/**
* Get files` types and extensions to substitute by Tool
*
* @param {string} name - Tool name
* @param {PasteConfig} toolPasteConfig - Tool onPaste configuration
* @param tool - BlockTool object
*/
private getFilesConfig(name: string, toolPasteConfig: PasteConfig): void {
const { files = {} } = toolPasteConfig;
private getFilesConfig(tool: BlockTool): void {
const { files = {} } = tool.pasteConfig;
let { extensions, mimeTypes } = files;
if (!extensions && !mimeTypes) {
@ -361,19 +347,19 @@ export default class Paste extends Module {
}
if (extensions && !Array.isArray(extensions)) {
_.log(`«extensions» property of the onDrop config for «${name}» Tool should be an array`);
_.log(`«extensions» property of the onDrop config for «${tool.name}» Tool should be an array`);
extensions = [];
}
if (mimeTypes && !Array.isArray(mimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${name}» Tool should be an array`);
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
mimeTypes = [];
}
if (mimeTypes) {
mimeTypes = mimeTypes.filter((type) => {
if (!_.isValidMimeType(type)) {
_.log(`MIME type value «${type}» for the «${name}» Tool is not a valid MIME type`, 'warn');
_.log(`MIME type value «${type}» for the «${tool.name}» Tool is not a valid MIME type`, 'warn');
return false;
}
@ -382,7 +368,7 @@ export default class Paste extends Module {
});
}
this.toolsFiles[name] = {
this.toolsFiles[tool.name] = {
extensions: extensions || [],
mimeTypes: mimeTypes || [],
};
@ -391,19 +377,18 @@ export default class Paste extends Module {
/**
* Get RegExp patterns to substitute by Tool
*
* @param {string} name - Tool name
* @param {PasteConfig} toolPasteConfig - Tool onPaste configuration
* @param tool - BlockTool object
*/
private getPatternsConfig(name: string, toolPasteConfig: PasteConfig): void {
if (!toolPasteConfig.patterns || _.isEmpty(toolPasteConfig.patterns)) {
private getPatternsConfig(tool: BlockTool): void {
if (!tool.pasteConfig.patterns || _.isEmpty(tool.pasteConfig.patterns)) {
return;
}
Object.entries(toolPasteConfig.patterns).forEach(([key, pattern]: [string, RegExp]) => {
Object.entries(tool.pasteConfig.patterns).forEach(([key, pattern]: [string, RegExp]) => {
/** Still need to validate pattern as it provided by user */
if (!(pattern instanceof RegExp)) {
_.log(
`Pattern ${pattern} for «${name}» Tool is skipped because it should be a Regexp instance.`,
`Pattern ${pattern} for «${tool.name}» Tool is skipped because it should be a Regexp instance.`,
'warn'
);
}
@ -411,7 +396,7 @@ export default class Paste extends Module {
this.toolsPatterns.push({
key,
pattern,
tool: name,
tool,
});
});
}
@ -462,9 +447,9 @@ export default class Paste extends Module {
* @param {FileList} items - pasted or dropped items
*/
private async processFiles(items: FileList): Promise<void> {
const { BlockManager, Tools } = this.Editor;
const { BlockManager } = this.Editor;
let dataToInsert: Array<{type: string; event: PasteEvent}>;
let dataToInsert: {type: string; event: PasteEvent}[];
dataToInsert = await Promise.all(
Array
@ -473,7 +458,7 @@ export default class Paste extends Module {
);
dataToInsert = dataToInsert.filter((data) => !!data);
const isCurrentBlockDefault = Tools.isDefault(BlockManager.currentBlock.tool);
const isCurrentBlockDefault = BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
dataToInsert.forEach(
@ -529,8 +514,7 @@ export default class Paste extends Module {
* @returns {PasteData[]}
*/
private processHTML(innerHTML: string): PasteData[] {
const { Tools, Sanitizer } = this.Editor;
const initialTool = this.config.defaultBlock;
const { Tools } = this.Editor;
const wrapper = $.make('DIV');
wrapper.innerHTML = innerHTML;
@ -539,7 +523,7 @@ export default class Paste extends Module {
return nodes
.map((node) => {
let content, tool = initialTool, isBlock = false;
let content, tool = Tools.defaultTool, isBlock = false;
switch (node.nodeType) {
/** If node is a document fragment, use temp wrapper to get innerHTML */
@ -559,16 +543,16 @@ export default class Paste extends Module {
break;
}
const { tags } = Tools.blockTools[tool].pasteConfig as PasteConfig;
const { tags } = tool.pasteConfig;
const toolTags = tags.reduce((result, tag) => {
result[tag.toLowerCase()] = {};
return result;
}, {});
const customConfig = Object.assign({}, toolTags, Sanitizer.getInlineToolsConfig(tool));
const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig);
content.innerHTML = Sanitizer.clean(content.innerHTML, customConfig);
content.innerHTML = clean(content.innerHTML, customConfig);
const event = this.composePasteEvent('tag', {
data: content,
@ -577,7 +561,7 @@ export default class Paste extends Module {
return {
content,
isBlock,
tool,
tool: tool.name,
event,
};
})
@ -627,7 +611,7 @@ export default class Paste extends Module {
* @param {PasteData} dataToInsert - data of Block to inseret
*/
private async processSingleBlock(dataToInsert: PasteData): Promise<void> {
const { Caret, BlockManager, Tools } = this.Editor;
const { Caret, BlockManager } = this.Editor;
const { currentBlock } = BlockManager;
/**
@ -638,7 +622,7 @@ export default class Paste extends Module {
dataToInsert.tool !== currentBlock.name ||
!$.containsOnlyInlineElements(dataToInsert.content.innerHTML)
) {
this.insertBlock(dataToInsert, currentBlock && Tools.isDefault(currentBlock.tool) && currentBlock.isEmpty);
this.insertBlock(dataToInsert, currentBlock?.tool.isDefault && currentBlock.isEmpty);
return;
}
@ -655,17 +639,17 @@ export default class Paste extends Module {
* @param {PasteData} dataToInsert - data of Block to insert
*/
private async processInlinePaste(dataToInsert: PasteData): Promise<void> {
const { BlockManager, Caret, Sanitizer, Tools } = this.Editor;
const { BlockManager, Caret } = this.Editor;
const { content } = dataToInsert;
const currentBlockIsDefault = BlockManager.currentBlock && Tools.isDefault(BlockManager.currentBlock.tool);
const currentBlockIsDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
if (currentBlockIsDefault && content.textContent.length < Paste.PATTERN_PROCESSING_MAX_LENGTH) {
const blockData = await this.processPattern(content.textContent);
if (blockData) {
const needToReplaceCurrentBlock = BlockManager.currentBlock &&
Tools.isDefault(BlockManager.currentBlock.tool) &&
BlockManager.currentBlock.tool.isDefault &&
BlockManager.currentBlock.isEmpty;
const insertedBlock = BlockManager.paste(blockData.tool, blockData.event, needToReplaceCurrentBlock);
@ -678,12 +662,12 @@ 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 = Sanitizer.getInlineToolsConfig(BlockManager.currentBlock.name);
const currentToolSanitizeConfig = BlockManager.currentBlock.tool.sanitizeConfig;
document.execCommand(
'insertHTML',
false,
Sanitizer.clean(content.innerHTML, currentToolSanitizeConfig)
clean(content.innerHTML, currentToolSanitizeConfig)
);
} else {
this.insertBlock(dataToInsert);
@ -719,7 +703,7 @@ export default class Paste extends Module {
return {
event,
tool: pattern.tool,
tool: pattern.tool.name,
};
}
@ -755,15 +739,17 @@ export default class Paste extends Module {
*
* @returns {void}
*/
private insertEditorJSData(blocks: Array<Pick<SavedData, 'data' | 'tool'>>): void {
const { BlockManager, Caret, Sanitizer, Tools } = this.Editor;
const sanitizedBlocks = Sanitizer.sanitizeBlocks(blocks);
private insertEditorJSData(blocks: Pick<SavedData, 'id' | 'data' | 'tool'>[]): void {
const { BlockManager, Caret, Tools } = this.Editor;
const sanitizedBlocks = sanitizeBlocks(blocks, (name) =>
Tools.blockTools.get(name).sanitizeConfig
);
sanitizedBlocks.forEach(({ tool, data }, i) => {
let needToReplaceCurrentBlock = false;
if (i === 0) {
const isCurrentBlockDefault = BlockManager.currentBlock && Tools.isDefault(BlockManager.currentBlock.tool);
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
}
@ -792,8 +778,8 @@ export default class Paste extends Module {
const element = node as HTMLElement;
const { tool = '' } = this.toolsTags[element.tagName] || {};
const toolTags = this.tagsByTool[tool] || [];
const { tool } = this.toolsTags[element.tagName] || {};
const toolTags = this.tagsByTool[tool?.name] || [];
const isSubstitutable = tags.includes(element.tagName);
const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase());

View file

@ -40,11 +40,13 @@ export default class ReadOnly extends Module {
const { blockTools } = Tools;
const toolsDontSupportReadOnly: string[] = [];
Object.entries(blockTools).forEach(([name, tool]) => {
if (!Tools.isReadOnlySupported(tool)) {
toolsDontSupportReadOnly.push(name);
}
});
Array
.from(blockTools.entries())
.forEach(([name, tool]) => {
if (!tool.isReadOnlySupported) {
toolsDontSupportReadOnly.push(name);
}
});
this.toolsDontSupportReadOnly = toolsDontSupportReadOnly;

View file

@ -211,7 +211,16 @@ export default class RectangleSelection extends Module {
if (mouseEvent.button !== this.MAIN_MOUSE_BUTTON) {
return;
}
this.startSelection(mouseEvent.pageX, mouseEvent.pageY);
/**
* Do not enable the Rectangle Selection when mouse dragging started some editable input
* Used to prevent Rectangle Selection on Block Tune wrappers' inputs that also can be inside the Block
*/
const startedFromContentEditable = (mouseEvent.target as Element).closest($.allInputsSelector) !== null;
if (!startedFromContentEditable) {
this.startSelection(mouseEvent.pageX, mouseEvent.pageY);
}
}
/**

View file

@ -1,6 +1,7 @@
import Module from '../__module';
import * as _ from '../utils';
import { BlockToolConstructable, OutputBlockData } from '../../../types';
import { OutputBlockData } from '../../../types';
import BlockTool from '../tools/block';
/**
* Editor.js Renderer Module
@ -22,12 +23,14 @@ export default class Renderer extends Module {
*
* blocks: [
* {
* id : 'oDe-EVrGWA',
* type : 'paragraph',
* data : {
* text : 'Hello from Codex!'
* }
* },
* {
* id : 'Ld5BJjJCHs',
* type : 'paragraph',
* data : {
* text : 'Leave feedback if you like it!'
@ -45,8 +48,15 @@ export default class Renderer extends Module {
public async render(blocks: OutputBlockData[]): Promise<void> {
const chainData = blocks.map((block) => ({ function: (): Promise<void> => this.insertBlock(block) }));
/**
* Disable onChange callback on render to not to spam those events
*/
this.Editor.ModificationsObserver.disable();
const sequence = await _.sequence(chainData as _.ChainData[]);
this.Editor.ModificationsObserver.enable();
this.Editor.UI.checkEmptiness();
return sequence;
@ -63,14 +73,15 @@ export default class Renderer extends Module {
*/
public async insertBlock(item: OutputBlockData): Promise<void> {
const { Tools, BlockManager } = this.Editor;
const tool = item.type;
const data = item.data;
const { type: tool, data, tunes, id } = item;
if (tool in Tools.available) {
if (Tools.available.has(tool)) {
try {
BlockManager.insert({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'warn', data);
@ -80,20 +91,21 @@ export default class Renderer extends Module {
/** If Tool is unavailable, create stub Block for it */
const stubData = {
savedData: {
id,
type: tool,
data,
},
title: tool,
};
if (tool in Tools.unavailable) {
const toolToolboxSettings = (Tools.unavailable[tool] as BlockToolConstructable).toolbox;
const userToolboxSettings = Tools.getToolSettings(tool).toolbox;
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
stubData.title = toolToolboxSettings.title || (userToolboxSettings && userToolboxSettings.title) || stubData.title;
stubData.title = toolboxSettings?.title || stubData.title;
}
const stub = BlockManager.insert({
id,
tool: Tools.stubTool,
data: stubData,
});

View file

@ -1,333 +0,0 @@
/**
* CodeX Sanitizer
*
* @module Sanitizer
* Clears HTML from taint tags
*
* @version 2.0.0
*
* @example
* Module can be used within two ways:
* 1) When you have an instance
* - this.Editor.Sanitizer.clean(yourTaintString);
* 2) As static method
* - EditorJS.Sanitizer.clean(yourTaintString, yourCustomConfiguration);
*
* {@link SanitizerConfig}
*/
import Module from '../__module';
import * as _ from '../utils';
/**
* @typedef {object} SanitizerConfig
* @property {object} tags - define tags restrictions
*
* @example
*
* tags : {
* p: true,
* a: {
* href: true,
* rel: "nofollow",
* target: "_blank"
* }
* }
*/
import HTMLJanitor from 'html-janitor';
import { BlockToolData, InlineToolConstructable, SanitizerConfig } from '../../../types';
import { SavedData } from '../../../types/data-formats';
/**
*
*/
export default class Sanitizer extends Module {
/**
* Memoize tools config
*/
private configCache: {[toolName: string]: SanitizerConfig} = {};
/**
* Cached inline tools config
*/
private inlineToolsConfigCache: SanitizerConfig | null = null;
/**
* Sanitize Blocks
*
* Enumerate blocks and clean data
*
* @param {Array<{tool, data: BlockToolData}>} blocksData - blocks' data to sanitize
*/
public sanitizeBlocks(
blocksData: Array<Pick<SavedData, 'data' | 'tool'>>
): Array<Pick<SavedData, 'data' | 'tool'>> {
return blocksData.map((block) => {
const toolConfig = this.composeToolConfig(block.tool);
if (_.isEmpty(toolConfig)) {
return block;
}
block.data = this.deepSanitize(block.data, toolConfig) as BlockToolData;
return block;
});
}
/**
* Method recursively reduces Block's data and cleans with passed rules
*
* @param {BlockToolData|object|*} dataToSanitize - taint string or object/array that contains taint string
* @param {SanitizerConfig} rules - object with sanitizer rules
*/
public deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): object | string {
/**
* BlockData It may contain 3 types:
* - Array
* - Object
* - Primitive
*/
if (Array.isArray(dataToSanitize)) {
/**
* Array: call sanitize for each item
*/
return this.cleanArray(dataToSanitize, rules);
} else if (_.isObject(dataToSanitize)) {
/**
* Objects: just clean object deeper.
*/
return this.cleanObject(dataToSanitize, rules);
} else {
/**
* Primitives (number|string|boolean): clean this item
*
* Clean only strings
*/
if (_.isString(dataToSanitize)) {
return this.cleanOneItem(dataToSanitize, rules);
}
return dataToSanitize;
}
}
/**
* Cleans string from unwanted tags
* Method allows to use default config
*
* @param {string} taintString - taint string
* @param {SanitizerConfig} customConfig - allowed tags
*
* @returns {string} clean HTML
*/
public clean(taintString: string, customConfig: SanitizerConfig = {} as SanitizerConfig): string {
const sanitizerConfig = {
tags: customConfig,
};
/**
* API client can use custom config to manage sanitize process
*/
const sanitizerInstance = this.createHTMLJanitorInstance(sanitizerConfig);
return sanitizerInstance.clean(taintString);
}
/**
* Merge with inline tool config
*
* @param {string} toolName - tool name
*
* @returns {SanitizerConfig}
*/
public composeToolConfig(toolName: string): SanitizerConfig {
/**
* If cache is empty, then compose tool config and put it to the cache object
*/
if (this.configCache[toolName]) {
return this.configCache[toolName];
}
const sanitizeGetter = this.Editor.Tools.INTERNAL_SETTINGS.SANITIZE_CONFIG;
const toolClass = this.Editor.Tools.available[toolName];
const baseConfig = this.getInlineToolsConfig(toolName);
/**
* If Tools doesn't provide sanitizer config or it is empty
*/
if (!toolClass.sanitize || (toolClass[sanitizeGetter] && _.isEmpty(toolClass[sanitizeGetter]))) {
return baseConfig;
}
const toolRules = toolClass.sanitize;
const toolConfig = {} as SanitizerConfig;
for (const fieldName in toolRules) {
if (Object.prototype.hasOwnProperty.call(toolRules, fieldName)) {
const rule = toolRules[fieldName];
if (_.isObject(rule)) {
toolConfig[fieldName] = Object.assign({}, baseConfig, rule);
} else {
toolConfig[fieldName] = rule;
}
}
}
this.configCache[toolName] = toolConfig;
return toolConfig;
}
/**
* Returns Sanitizer config
* When Tool's "inlineToolbar" value is True, get all sanitizer rules from all tools,
* otherwise get only enabled
*
* @param {string} name - Inline Tool name
*/
public getInlineToolsConfig(name: string): SanitizerConfig {
const { Tools } = this.Editor;
const toolsConfig = Tools.getToolSettings(name);
const enableInlineTools = toolsConfig.inlineToolbar || [];
let config = {} as SanitizerConfig;
if (_.isBoolean(enableInlineTools) && enableInlineTools) {
/**
* getting all tools sanitizer rule
*/
config = this.getAllInlineToolsConfig();
} else {
/**
* getting only enabled
*/
(enableInlineTools as string[]).map((inlineToolName) => {
config = Object.assign(
config,
Tools.inline[inlineToolName][Tools.INTERNAL_SETTINGS.SANITIZE_CONFIG]
) as SanitizerConfig;
});
}
/**
* Allow linebreaks
*/
config['br'] = true;
config['wbr'] = true;
return config;
}
/**
* Return general config for all inline tools
*/
public getAllInlineToolsConfig(): SanitizerConfig {
const { Tools } = this.Editor;
if (this.inlineToolsConfigCache) {
return this.inlineToolsConfigCache;
}
const config: SanitizerConfig = {} as SanitizerConfig;
Object.entries(Tools.inline)
.forEach(([, inlineTool]: [string, InlineToolConstructable]) => {
Object.assign(config, inlineTool[Tools.INTERNAL_SETTINGS.SANITIZE_CONFIG]);
});
this.inlineToolsConfigCache = config;
return this.inlineToolsConfigCache;
}
/**
* Clean array
*
* @param {Array} array - [1, 2, {}, []]
* @param {SanitizerConfig} ruleForItem - sanitizer config for array
*/
private cleanArray(array: Array<object | string>, ruleForItem: SanitizerConfig): Array<object | string> {
return array.map((arrayItem) => this.deepSanitize(arrayItem, ruleForItem));
}
/**
* Clean object
*
* @param {object} object - {level: 0, text: 'adada', items: [1,2,3]}}
* @param {object} rules - { b: true } or true|false
* @returns {object}
*/
private cleanObject(object: object, rules: SanitizerConfig|{[field: string]: SanitizerConfig}): object {
const cleanData = {};
for (const fieldName in object) {
if (!Object.prototype.hasOwnProperty.call(object, fieldName)) {
continue;
}
const currentIterationItem = object[fieldName];
/**
* Get object from config by field name
* - if it is a HTML Janitor rule, call with this rule
* - otherwise, call with parent's config
*/
const ruleForItem = this.isRule(rules[fieldName] as SanitizerConfig) ? rules[fieldName] : rules;
cleanData[fieldName] = this.deepSanitize(currentIterationItem, ruleForItem as SanitizerConfig);
}
return cleanData;
}
/**
* Clean primitive value
*
* @param {string} taintString - string to clean
* @param {SanitizerConfig|boolean} rule - sanitizer rule
*
* @returns {string}
*/
private cleanOneItem(taintString: string, rule: SanitizerConfig|boolean): string {
if (_.isObject(rule)) {
return this.clean(taintString, rule);
} else if (rule === false) {
return this.clean(taintString, {} as SanitizerConfig);
} else {
return taintString;
}
}
/**
* Check if passed item is a HTML Janitor rule:
* { a : true }, {}, false, true, function(){} correct rules
* undefined, null, 0, 1, 2 not a rules
*
* @param {SanitizerConfig} config - config to check
*/
private isRule(config: SanitizerConfig): boolean {
return _.isObject(config) || _.isBoolean(config) || _.isFunction(config);
}
/**
* If developer uses editor's API, then he can customize sanitize restrictions.
* Or, sanitizing config can be defined globally in editors initialization. That config will be used everywhere
* At least, if there is no config overrides, that API uses Default configuration
*
* @see {@link https://www.npmjs.com/package/html-janitor}
* @license Apache-2.0
* @see {@link https://github.com/guardian/html-janitor/blob/master/LICENSE}
*
* @param {SanitizerConfig} config - sanitizer extension
*/
private createHTMLJanitorInstance(config: {tags: SanitizerConfig}): HTMLJanitor|null {
if (config) {
return new HTMLJanitor(config);
}
return null;
}
}

View file

@ -7,9 +7,10 @@
*/
import Module from '../__module';
import { OutputData } from '../../../types';
import { ValidatedData } from '../../../types/data-formats';
import { SavedData, ValidatedData } from '../../../types/data-formats';
import Block from '../block';
import * as _ from '../utils';
import { sanitizeBlocks } from '../utils/sanitizer';
declare const VERSION: string;
@ -27,24 +28,28 @@ export default class Saver extends Module {
* @returns {OutputData}
*/
public async save(): Promise<OutputData> {
const { BlockManager, Sanitizer, ModificationsObserver } = this.Editor;
const { BlockManager, Tools, ModificationsObserver } = this.Editor;
const blocks = BlockManager.blocks,
chainData = [];
/**
* Disable modifications observe while saving
*/
ModificationsObserver.disable();
try {
/**
* Disable onChange callback on save to not to spam those events
*/
ModificationsObserver.disable();
blocks.forEach((block: Block) => {
chainData.push(this.getSavedData(block));
});
const extractedData = await Promise.all(chainData);
const sanitizedData = await Sanitizer.sanitizeBlocks(extractedData);
const extractedData = await Promise.all(chainData) as Array<Pick<SavedData, 'data' | 'tool'>>;
const sanitizedData = await sanitizeBlocks(extractedData, (name) => {
return Tools.blockTools.get(name).sanitizeConfig;
});
return this.makeOutput(sanitizedData);
} catch (e) {
_.logLabeled(`Saving failed due to the Error %o`, 'error', e);
} finally {
ModificationsObserver.enable();
}
@ -78,7 +83,7 @@ export default class Saver extends Module {
_.log('[Editor.js saving]:', 'groupCollapsed');
allExtractedData.forEach(({ tool, data, time, isValid }) => {
allExtractedData.forEach(({ id, tool, data, tunes, time, isValid }) => {
totalTime += time;
/**
@ -104,10 +109,16 @@ export default class Saver extends Module {
return;
}
blocks.push({
const output = {
id,
type: tool,
data,
});
...!_.isEmpty(tunes) && {
tunes,
},
};
blocks.push(output);
});
_.log('Total', 'log', totalTime);

View file

@ -144,7 +144,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Add default settings that presents for all Blocks
*/
this.addDefaultSettings();
this.addTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
@ -229,16 +229,21 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* Add Tool's settings
*/
private addToolSettings(): void {
if (_.isFunction(this.Editor.BlockManager.currentBlock.tool.renderSettings)) {
$.append(this.nodes.toolSettings, this.Editor.BlockManager.currentBlock.tool.renderSettings());
const settingsElement = this.Editor.BlockManager.currentBlock.renderSettings();
if (settingsElement) {
$.append(this.nodes.toolSettings, settingsElement);
}
}
/**
* Add default settings
* Add tunes: provided by user and default ones
*/
private addDefaultSettings(): void {
$.append(this.nodes.defaultSettings, this.Editor.BlockManager.currentBlock.renderTunes());
private addTunes(): void {
const [toolTunes, defaultTunes] = this.Editor.BlockManager.currentBlock.renderTunes();
$.append(this.nodes.toolSettings, toolTunes);
$.append(this.nodes.defaultSettings, defaultTunes);
}
/**

View file

@ -1,11 +1,11 @@
import Module from '../../__module';
import $ from '../../dom';
import { BlockToolConstructable } from '../../../../types';
import * as _ from '../../utils';
import { SavedData } from '../../../../types/data-formats';
import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import { clean } from '../../utils/sanitizer';
/**
* HTML Elements used for ConversionToolbar
@ -182,10 +182,9 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
*
* @type {BlockToolConstructable}
*/
const currentBlockClass = this.Editor.BlockManager.currentBlock.class;
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 { INTERNAL_SETTINGS } = this.Editor.Tools;
const blockData = savedBlock.data;
/**
@ -201,7 +200,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
*
* @type {BlockToolConstructable}
*/
const replacingTool = this.Editor.Tools.toolsClasses[replacingToolName] as BlockToolConstructable;
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
/**
* Export property can be:
@ -211,7 +210,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
* In both cases returning value must be a string
*/
let exportData = '';
const exportProp = currentBlockClass[INTERNAL_SETTINGS.CONVERSION_CONFIG].export;
const exportProp = currentBlockTool.conversionConfig.export;
if (_.isFunction(exportProp)) {
exportData = exportProp(blockData);
@ -227,9 +226,9 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
/**
* Clean exported data with replacing sanitizer config
*/
const cleaned: string = this.Editor.Sanitizer.clean(
const cleaned: string = clean(
exportData,
replacingTool.sanitize
replacingTool.sanitizeConfig
);
/**
@ -238,7 +237,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
* string the name of data field to import
*/
let newBlockData = {};
const importProp = replacingTool[INTERNAL_SETTINGS.CONVERSION_CONFIG].import;
const importProp = replacingTool.conversionConfig.import;
if (_.isFunction(importProp)) {
newBlockData = importProp(cleaned);
@ -272,37 +271,28 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
private addTools(): void {
const tools = this.Editor.Tools.blockTools;
for (const toolName in tools) {
if (!Object.prototype.hasOwnProperty.call(tools, toolName)) {
continue;
}
Array
.from(tools.entries())
.forEach(([name, tool]) => {
const toolboxSettings = tool.toolbox;
const conversionConfig = tool.conversionConfig;
const internalSettings = this.Editor.Tools.INTERNAL_SETTINGS;
const toolClass = tools[toolName] as BlockToolConstructable;
const toolToolboxSettings = toolClass[internalSettings.TOOLBOX];
const conversionConfig = toolClass[internalSettings.CONVERSION_CONFIG];
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) {
return;
}
const userSettings = this.Editor.Tools.USER_SETTINGS;
const userToolboxSettings = this.Editor.Tools.getToolSettings(toolName)[userSettings.TOOLBOX];
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
return;
}
const toolboxSettings = userToolboxSettings ?? toolToolboxSettings;
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) {
continue;
}
/**
* Skip tools without «import» rule specified
*/
if (!conversionConfig || !conversionConfig.import) {
continue;
}
this.addTool(toolName, toolboxSettings.icon, toolboxSettings.title);
}
this.addTool(name, toolboxSettings.icon, toolboxSettings.title);
});
}
/**

View file

@ -3,6 +3,10 @@ import $ from '../../dom';
import * as _ from '../../utils';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import { EditorConfig } from '../../../../types';
import SelectionUtils from '../../selection';
/**
* HTML Elements used for Toolbar UI
@ -72,6 +76,24 @@ interface ToolbarNodes {
* @property {Element} nodes.defaultSettings - Default Settings section of Settings Panel
*/
export default class Toolbar extends Module<ToolbarNodes> {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param {object} moduleConfiguration - Module Configuration
* @param {EditorConfig} moduleConfiguration.config - Editor's config
* @param {EventsDispatcher} moduleConfiguration.eventsDispatcher - Editor's event dispatcher
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
* CSS styles
*
@ -277,7 +299,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
textContent: '⇥ Tab',
}));
this.Editor.Tooltip.onHover(this.nodes.plusButton, tooltipContent);
this.tooltip.onHover(this.nodes.plusButton, tooltipContent);
/**
* Fill Actions Zone:
@ -293,7 +315,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.blockActionsButtons, this.nodes.settingsToggler);
$.append(this.nodes.actions, this.nodes.blockActionsButtons);
this.Editor.Tooltip.onHover(
this.tooltip.onHover(
this.nodes.settingsToggler,
I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
{
@ -326,10 +348,19 @@ export default class Toolbar extends Module<ToolbarNodes> {
private enableModuleBindings(): void {
/**
* Settings toggler
*
* mousedown is used because on click selection is lost in Safari and FF
*/
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'click', () => {
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'mousedown', (e) => {
/**
* Stop propagation to prevent block selection clearance
*
* @see UI.documentClicked
*/
e.stopPropagation();
this.settingsTogglerClicked();
});
}, true);
}
/**
@ -384,5 +415,6 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
private destroy(): void {
this.removeAllNodes();
this.tooltip.destroy();
}
}

View file

@ -2,12 +2,15 @@ import Module from '../../__module';
import $ from '../../dom';
import SelectionUtils from '../../selection';
import * as _ from '../../utils';
import { InlineTool, InlineToolConstructable, ToolConstructable, ToolSettings } from '../../../../types';
import { InlineTool as IInlineTool, EditorConfig } from '../../../../types';
import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Shortcuts from '../../utils/shortcuts';
import { EditorModules } from '../../../types-internal/editor-modules';
import Tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import InlineTool from '../../tools/inline';
import { CommonInternalSettings } from '../../tools/base';
/**
* Inline Toolbar elements
@ -66,9 +69,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
private readonly toolbarVerticalMargin: number = 5;
/**
* TODO: Get rid of this
*
* Currently visible tools instances
*/
private toolsInstances: Map<string, InlineTool>;
private toolsInstances: Map<string, IInlineTool>;
/**
* Buttons List
@ -90,35 +95,21 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
private flipper: Flipper = null;
/**
* Internal inline tools: Link, Bold, Italic
* Tooltip utility Instance
*/
private internalTools: {[name: string]: InlineToolConstructable} = {};
private tooltip: Tooltip;
/**
* Editor modules setter
*
* @param {EditorModules} Editor - Editor's Modules
* @class
* @param {object} moduleConfiguration - Module Configuration
* @param {EditorConfig} moduleConfiguration.config - Editor's config
* @param {EventsDispatcher} moduleConfiguration.eventsDispatcher - Editor's event dispatcher
*/
public set state(Editor: EditorModules) {
this.Editor = Editor;
const { Tools } = Editor;
/**
* Set internal inline tools
*/
Object
.entries(Tools.internalTools)
.filter(([, toolClass]: [string, ToolConstructable | ToolSettings]) => {
if (_.isFunction(toolClass)) {
return toolClass[Tools.INTERNAL_SETTINGS.IS_INLINE];
}
return (toolClass as ToolSettings).class[Tools.INTERNAL_SETTINGS.IS_INLINE];
})
.map(([name, toolClass]: [string, InlineToolConstructable | ToolSettings]) => {
this.internalTools[name] = _.isFunction(toolClass) ? toolClass : (toolClass as ToolSettings).class;
});
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
@ -143,10 +134,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Shows Inline Toolbar if something is selected
*
* @param {boolean} [needToClose] - pass true to close toolbar if it is not allowed.
* @param [needToClose] - pass true to close toolbar if it is not allowed.
* Avoid to use it just for closing IT, better call .close() clearly.
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public tryToShow(needToClose = false): void {
public tryToShow(needToClose = false, needToShowConversionToolbar = true): void {
if (!this.allowedToShow()) {
if (needToClose) {
this.close();
@ -156,7 +148,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.move();
this.open();
this.open(needToShowConversionToolbar);
this.Editor.Toolbar.close();
}
@ -242,8 +234,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Shows Inline Toolbar
*
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public open(): void {
public open(needToShowConversionToolbar = true): void {
if (this.opened) {
return;
}
@ -260,7 +254,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
if (this.Editor.ConversionToolbar.hasTools()) {
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/
@ -305,80 +299,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.removeAllNodes();
}
/**
* Returns inline toolbar settings for a particular tool
*
* @param {string} toolName - user specified name of tool
* @returns {string[] | boolean} array of ordered tool names or false
*/
private getInlineToolbarSettings(toolName): string[] | boolean {
const toolSettings = this.Editor.Tools.getToolSettings(toolName);
/**
* InlineToolbar property of a particular tool
*/
const settingsForTool = toolSettings[this.Editor.Tools.USER_SETTINGS.ENABLED_INLINE_TOOLS];
/**
* Whether to enable IT for a particular tool is the decision of the editor user.
* He can enable it by the inlineToolbar settings for this tool. To enable, he should pass true or strings[]
*/
const enabledForTool = settingsForTool === true || Array.isArray(settingsForTool);
/**
* Disabled by user
*/
if (!enabledForTool) {
return false;
}
/**
* 1st priority.
*
* If user pass the list of inline tools for the particular tool, return it.
*/
if (Array.isArray(settingsForTool)) {
return settingsForTool;
}
/**
* 2nd priority.
*
* If user pass just 'true' for tool, get common inlineToolbar settings
* - if common settings is an array, use it
* - if common settings is 'true' or not specified, get default order
*/
/**
* Common inlineToolbar settings got from the root of EditorConfig
*/
const commonInlineToolbarSettings = this.config.inlineToolbar;
/**
* If common settings is an array, use it
*/
if (Array.isArray(commonInlineToolbarSettings)) {
return commonInlineToolbarSettings;
}
/**
* If common settings is 'true' or not specified (will be set as true at core.ts), get the default order
*/
if (commonInlineToolbarSettings === true) {
const defaultToolsOrder: string[] = Object.entries(this.Editor.Tools.available)
.filter(([name, tool]) => {
return tool[this.Editor.Tools.INTERNAL_SETTINGS.IS_INLINE];
})
.map(([name, tool]) => {
return name;
});
return defaultToolsOrder;
}
return false;
this.tooltip.destroy();
}
/**
@ -489,12 +410,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
return false;
}
/**
* getInlineToolbarSettings could return an string[] (order of tools) or false (Inline Toolbar disabled).
*/
const inlineToolbarSettings = this.getInlineToolbarSettings(currentBlock.name);
return inlineToolbarSettings !== false;
return currentBlock.tool.inlineTools.size !== 0;
}
/**
@ -538,7 +454,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
});
});
this.Editor.Tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
this.tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
placement: 'top',
hidingDelay: 100,
});
@ -548,13 +464,14 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Changes Conversion Dropdown content for current block's Tool
*/
private setConversionTogglerContent(): void {
const { BlockManager, Tools } = this.Editor;
const toolName = BlockManager.currentBlock.name;
const { BlockManager } = this.Editor;
const { currentBlock } = BlockManager;
const toolName = currentBlock.name;
/**
* If tool does not provide 'export' rule, hide conversion dropdown
*/
const conversionConfig = Tools.available[toolName][Tools.INTERNAL_SETTINGS.CONVERSION_CONFIG] || {};
const conversionConfig = currentBlock.tool.conversionConfig;
const exportRuleDefined = conversionConfig && conversionConfig.export;
this.nodes.conversionToggler.hidden = !exportRuleDefined;
@ -563,14 +480,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Get icon or title for dropdown
*/
const toolSettings = Tools.getToolSettings(toolName);
const toolboxSettings = Tools.available[toolName][Tools.INTERNAL_SETTINGS.TOOLBOX] || {};
const userToolboxSettings = toolSettings.toolbox || {};
const toolboxSettings = currentBlock.tool.toolbox || {};
this.nodes.conversionTogglerContent.innerHTML =
userToolboxSettings.icon ||
toolboxSettings.icon ||
userToolboxSettings.title ||
toolboxSettings.title ||
_.capitalize(toolName);
}
@ -603,21 +516,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.nodes.actions.innerHTML = '';
this.toolsInstances = new Map();
/**
* Filter buttons if Block Tool pass config like inlineToolbar=['link']
* Else filter them according to the default inlineToolbar property.
*
* For this moment, inlineToolbarOrder could not be 'false'
* because this method will be called only if the Inline Toolbar is enabled
*/
const inlineToolbarOrder = this.getInlineToolbarSettings(currentBlock.name) as string[];
inlineToolbarOrder.forEach((toolName) => {
const toolSettings = this.Editor.Tools.getToolSettings(toolName);
const tool = this.Editor.Tools.constructInline(this.Editor.Tools.inline[toolName], toolName, toolSettings);
this.addTool(toolName, tool);
tool.checkState(SelectionUtils.get());
Array.from(currentBlock.tool.inlineTools.values()).forEach(tool => {
this.addTool(tool);
});
/**
@ -629,43 +529,38 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Add tool button and activate clicks
*
* @param {string} toolName - name of Tool to add
* @param {InlineTool} tool - Tool class instance
* @param {InlineTool} tool - InlineTool object
*/
private addTool(toolName: string, tool: InlineTool): void {
const {
Tools,
Tooltip,
} = this.Editor;
const button = tool.render();
private addTool(tool: InlineTool): void {
const instance = tool.create();
const button = instance.render();
if (!button) {
_.log('Render method must return an instance of Node', 'warn', toolName);
_.log('Render method must return an instance of Node', 'warn', tool.name);
return;
}
button.dataset.tool = toolName;
button.dataset.tool = tool.name;
this.nodes.buttons.appendChild(button);
this.toolsInstances.set(toolName, tool);
this.toolsInstances.set(tool.name, instance);
if (_.isFunction(tool.renderActions)) {
const actions = tool.renderActions();
if (_.isFunction(instance.renderActions)) {
const actions = instance.renderActions();
this.nodes.actions.appendChild(actions);
}
this.listeners.on(button, 'click', (event) => {
this.toolClicked(tool);
this.toolClicked(instance);
event.preventDefault();
});
const shortcut = this.getToolShortcut(toolName);
const shortcut = this.getToolShortcut(tool.name);
if (shortcut) {
try {
this.enableShortcuts(tool, shortcut);
this.enableShortcuts(instance, shortcut);
} catch (e) {}
}
@ -675,7 +570,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
const tooltipContent = $.make('div');
const toolTitle = I18n.t(
I18nInternalNS.toolNames,
Tools.toolsClasses[toolName][Tools.INTERNAL_SETTINGS.TITLE] || _.capitalize(toolName)
tool.title || _.capitalize(tool.name)
);
tooltipContent.appendChild($.text(toolTitle));
@ -686,10 +581,12 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}));
}
Tooltip.onHover(button, tooltipContent, {
this.tooltip.onHover(button, tooltipContent, {
placement: 'top',
hidingDelay: 100,
});
instance.checkState(SelectionUtils.get());
}
/**
@ -704,21 +601,20 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Enable shortcuts
* Ignore tool that doesn't have shortcut or empty string
*/
const toolSettings = Tools.getToolSettings(toolName);
const tool = this.toolsInstances.get(toolName);
const tool = Tools.inlineTools.get(toolName);
/**
* 1) For internal tools, check public getter 'shortcut'
* 2) For external tools, check tool's settings
* 3) If shortcut is not set in settings, check Tool's public property
*/
if (Object.keys(this.internalTools).includes(toolName)) {
return this.inlineTools[toolName][Tools.INTERNAL_SETTINGS.SHORTCUT];
} else if (toolSettings && toolSettings[Tools.USER_SETTINGS.SHORTCUT]) {
return toolSettings[Tools.USER_SETTINGS.SHORTCUT];
} else if (tool.shortcut) {
return tool.shortcut;
const internalTools = Tools.internal.inlineTools;
if (Array.from(internalTools.keys()).includes(toolName)) {
return this.inlineTools[toolName][CommonInternalSettings.Shortcut];
}
return tool.shortcut;
}
/**
@ -727,7 +623,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* @param {InlineTool} tool - Tool instance
* @param {string} shortcut - shortcut according to the ShortcutData Module format
*/
private enableShortcuts(tool: InlineTool, shortcut: string): void {
private enableShortcuts(tool: IInlineTool, shortcut: string): void {
Shortcuts.add({
name: shortcut,
handler: (event) => {
@ -747,9 +643,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
// if (SelectionUtils.isCollapsed) return;
const toolSettings = this.Editor.Tools.getToolSettings(currentBlock.name);
if (!toolSettings || !toolSettings[this.Editor.Tools.USER_SETTINGS.ENABLED_INLINE_TOOLS]) {
if (!currentBlock.tool.enabledInlineTools) {
return;
}
@ -765,7 +659,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*
* @param {InlineTool} tool - Tool's instance
*/
private toolClicked(tool: InlineTool): void {
private toolClicked(tool: IInlineTool): void {
const range = SelectionUtils.range;
tool.surround(range);
@ -785,16 +679,14 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Get inline tools tools
* Tools that has isInline is true
*/
private get inlineTools(): { [name: string]: InlineTool } {
private get inlineTools(): { [name: string]: IInlineTool } {
const result = {};
for (const tool in this.Editor.Tools.inline) {
if (Object.prototype.hasOwnProperty.call(this.Editor.Tools.inline, tool)) {
const toolSettings = this.Editor.Tools.getToolSettings(tool);
result[tool] = this.Editor.Tools.constructInline(this.Editor.Tools.inline[tool], tool, toolSettings);
}
}
Array
.from(this.Editor.Tools.inlineTools.entries())
.forEach(([name, tool]) => {
result[name] = tool.create();
});
return result;
}

View file

@ -1,12 +1,15 @@
import Module from '../../__module';
import $ from '../../dom';
import * as _ from '../../utils';
import { BlockToolConstructable, ToolConstructable } from '../../../../types';
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 { ModuleConfig } from '../../../types-internal/module-config';
import EventsDispatcher from '../../utils/events';
import BlockTool from '../../tools/block';
/**
* HTMLElements used for Toolbox UI
@ -40,7 +43,7 @@ export default class Toolbox extends Module<ToolboxNodes> {
*
* @returns {object.<string, string>}
*/
public get CSS(): {[name: string]: string} {
public get CSS(): { [name: string]: string } {
return {
toolbox: 'ce-toolbox',
toolboxButton: 'ce-toolbox__button',
@ -83,6 +86,24 @@ export default class Toolbox extends Module<ToolboxNodes> {
*/
private flipper: Flipper = null;
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param {object} moduleConfiguration - Module Configuration
* @param {EditorConfig} moduleConfiguration.config - Editor's config
* @param {EventsDispatcher} moduleConfiguration.eventsDispatcher - Editor's event dispatcher
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
* Makes the Toolbox
*/
@ -107,6 +128,7 @@ export default class Toolbox extends Module<ToolboxNodes> {
this.removeAllNodes();
this.removeAllShortcuts();
this.tooltip.destroy();
}
/**
@ -116,9 +138,7 @@ export default class Toolbox extends Module<ToolboxNodes> {
* @param {string} toolName - button to activate
*/
public toolButtonActivate(event: MouseEvent|KeyboardEvent, toolName: string): void {
const tool = this.Editor.Tools.toolsClasses[toolName] as BlockToolConstructable;
this.insertNewBlock(tool, toolName);
this.insertNewBlock(toolName);
}
/**
@ -162,36 +182,30 @@ export default class Toolbox extends Module<ToolboxNodes> {
* Iterates available tools and appends them to the Toolbox
*/
private addTools(): void {
const tools = this.Editor.Tools.available;
const tools = this.Editor.Tools.blockTools;
for (const toolName in tools) {
if (Object.prototype.hasOwnProperty.call(tools, toolName)) {
this.addTool(toolName, tools[toolName] as BlockToolConstructable);
}
}
Array
.from(tools.values())
.forEach((tool) => this.addTool(tool));
}
/**
* Append Tool to the Toolbox
*
* @param {string} toolName - tool name
* @param {BlockToolConstructable} tool - tool class
* @param {BlockToolConstructable} tool - BlockTool object
*/
private addTool(toolName: string, tool: BlockToolConstructable): void {
const internalSettings = this.Editor.Tools.INTERNAL_SETTINGS;
const userSettings = this.Editor.Tools.USER_SETTINGS;
const toolToolboxSettings = tool[internalSettings.TOOLBOX];
private addTool(tool: BlockTool): void {
const toolToolboxSettings = tool.toolbox;
/**
* Skip tools that don't pass 'toolbox' property
*/
if (_.isEmpty(toolToolboxSettings)) {
if (!toolToolboxSettings) {
return;
}
if (toolToolboxSettings && !toolToolboxSettings.icon) {
_.log('Toolbar icon is missed. Tool %o skipped', 'warn', toolName);
_.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name);
return;
}
@ -204,19 +218,10 @@ export default class Toolbox extends Module<ToolboxNodes> {
// return;
// }
const userToolboxSettings = this.Editor.Tools.getToolSettings(toolName)[userSettings.TOOLBOX];
/**
* Hide Toolbox button if Toolbox settings is false
*/
if ((userToolboxSettings ?? toolToolboxSettings) === false) {
return;
}
const button = $.make('li', [ this.CSS.toolboxButton ]);
button.dataset.tool = toolName;
button.innerHTML = (userToolboxSettings && userToolboxSettings.icon) || toolToolboxSettings.icon;
button.dataset.tool = tool.name;
button.innerHTML = toolToolboxSettings.icon;
$.append(this.nodes.toolbox, button);
@ -227,61 +232,40 @@ export default class Toolbox extends Module<ToolboxNodes> {
* Add click listener
*/
this.listeners.on(button, 'click', (event: KeyboardEvent|MouseEvent) => {
this.toolButtonActivate(event, toolName);
this.toolButtonActivate(event, tool.name);
});
/**
* Add listeners to show/hide toolbox tooltip
*/
const tooltipContent = this.drawTooltip(toolName);
const tooltipContent = this.drawTooltip(tool);
this.Editor.Tooltip.onHover(button, tooltipContent, {
this.tooltip.onHover(button, tooltipContent, {
placement: 'bottom',
hidingDelay: 200,
});
const shortcut = this.getToolShortcut(toolName, tool);
const shortcut = tool.shortcut;
if (shortcut) {
this.enableShortcut(tool, toolName, shortcut);
this.enableShortcut(tool.name, shortcut);
}
/** Increment Tools count */
this.displayedToolsCount++;
}
/**
* Returns tool's shortcut
* It can be specified via internal 'shortcut' static getter or by user settings for tool
*
* @param {string} toolName - tool's name
* @param {ToolConstructable} tool - tool's class (not instance)
*/
private getToolShortcut(toolName: string, tool: ToolConstructable): string|null {
/**
* Enable shortcut
*/
const toolSettings = this.Editor.Tools.getToolSettings(toolName);
const internalToolShortcut = tool[this.Editor.Tools.INTERNAL_SETTINGS.SHORTCUT];
const userSpecifiedShortcut = toolSettings ? toolSettings[this.Editor.Tools.USER_SETTINGS.SHORTCUT] : null;
return userSpecifiedShortcut || internalToolShortcut;
}
/**
* Draw tooltip for toolbox tools
*
* @param {string} toolName - toolbox tool name
* @param tool - BlockTool object
* @returns {HTMLElement}
*/
private drawTooltip(toolName: string): HTMLElement {
const tool = this.Editor.Tools.available[toolName];
const toolSettings = this.Editor.Tools.getToolSettings(toolName);
const toolboxSettings = this.Editor.Tools.available[toolName][this.Editor.Tools.INTERNAL_SETTINGS.TOOLBOX] || {};
const userToolboxSettings = toolSettings.toolbox || {};
const name = I18n.t(I18nInternalNS.toolNames, userToolboxSettings.title || toolboxSettings.title || toolName);
private drawTooltip(tool: BlockTool): HTMLElement {
const toolboxSettings = tool.toolbox || {};
const name = I18n.t(I18nInternalNS.toolNames, toolboxSettings.title || tool.name);
let shortcut = this.getToolShortcut(toolName, tool);
let shortcut = tool.shortcut;
const tooltip = $.make('div', this.CSS.buttonTooltip);
const hint = document.createTextNode(_.capitalize(name));
@ -302,16 +286,15 @@ export default class Toolbox extends Module<ToolboxNodes> {
/**
* Enable shortcut Block Tool implemented shortcut
*
* @param {BlockToolConstructable} tool - Tool class
* @param {string} toolName - Tool name
* @param {string} shortcut - shortcut according to the ShortcutData Module format
*/
private enableShortcut(tool: BlockToolConstructable, toolName: string, shortcut: string): void {
private enableShortcut(toolName: string, shortcut: string): void {
Shortcuts.add({
name: shortcut,
handler: (event: KeyboardEvent) => {
event.preventDefault();
this.insertNewBlock(tool, toolName);
this.insertNewBlock(toolName);
},
on: this.Editor.UI.nodes.redactor,
});
@ -322,17 +305,17 @@ export default class Toolbox extends Module<ToolboxNodes> {
* Fired when the Read-Only mode is activated
*/
private removeAllShortcuts(): void {
const tools = this.Editor.Tools.available;
const tools = this.Editor.Tools.blockTools;
for (const toolName in tools) {
if (Object.prototype.hasOwnProperty.call(tools, toolName)) {
const shortcut = this.getToolShortcut(toolName, tools[toolName]);
Array
.from(tools.values())
.forEach((tool) => {
const shortcut = tool.shortcut;
if (shortcut) {
Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut);
}
}
}
});
}
/**
@ -351,10 +334,9 @@ export default class Toolbox extends Module<ToolboxNodes> {
* Inserts new block
* Can be called when button clicked on Toolbox or by ShortcutData
*
* @param {BlockToolConstructable} tool - Tool Class
* @param {string} toolName - Tool name
*/
private insertNewBlock(tool: BlockToolConstructable, toolName: string): void {
private insertNewBlock(toolName: string): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock } = BlockManager;

View file

@ -1,21 +1,19 @@
import Paragraph from '../tools/paragraph/dist/bundle';
import Paragraph from '../../tools/paragraph/dist/bundle';
import Module from '../__module';
import * as _ from '../utils';
import {
BlockToolConstructable,
EditorConfig,
InlineTool,
InlineToolConstructable, Tool,
ToolConfig,
ToolConstructable,
ToolSettings
} from '../../../types';
import { SanitizerConfig, ToolConstructable, ToolSettings } from '../../../types';
import BoldInlineTool from '../inline-tools/inline-tool-bold';
import ItalicInlineTool from '../inline-tools/inline-tool-italic';
import LinkInlineTool from '../inline-tools/inline-tool-link';
import Stub from '../tools/stub';
import { ModuleConfig } from '../../types-internal/module-config';
import EventsDispatcher from '../utils/events';
import Stub from '../../tools/stub';
import ToolsFactory from '../tools/factory';
import InlineTool from '../tools/inline';
import BlockTool from '../tools/block';
import BlockTune from '../tools/tune';
import MoveDownTune from '../block-tunes/block-tune-move-down';
import DeleteTune from '../block-tunes/block-tune-delete';
import MoveUpTune from '../block-tunes/block-tune-move-up';
import ToolsCollection from '../tools/collection';
/**
* @module Editor.js Tools Submodule
@ -23,6 +21,8 @@ import EventsDispatcher from '../utils/events';
* Creates Instances from Plugins and binds external config to the instances
*/
type ToolClass = BlockTool | InlineTool | BlockTune;
/**
* Class properties:
*
@ -47,7 +47,7 @@ export default class Tools extends Module {
*
* @returns {object<Tool>}
*/
public get available(): { [name: string]: ToolConstructable } {
public get available(): ToolsCollection {
return this.toolsAvailable;
}
@ -56,7 +56,7 @@ export default class Tools extends Module {
*
* @returns {Tool[]}
*/
public get unavailable(): { [name: string]: ToolConstructable } {
public get unavailable(): ToolsCollection {
return this.toolsUnavailable;
}
@ -65,170 +65,53 @@ export default class Tools extends Module {
*
* @returns {object} - object of Inline Tool's classes
*/
public get inline(): { [name: string]: InlineToolConstructable } {
if (this._inlineTools) {
return this._inlineTools;
}
const tools = Object.entries(this.available).filter(([name, tool]) => {
if (!tool[this.INTERNAL_SETTINGS.IS_INLINE]) {
return false;
}
/**
* Some Tools validation
*/
const inlineToolRequiredMethods = ['render', 'surround', 'checkState'];
const notImplementedMethods = inlineToolRequiredMethods.filter((method) => !this.constructInline(tool, name)[method]);
if (notImplementedMethods.length) {
_.log(
`Incorrect Inline Tool: ${tool.name}. Some of required methods is not implemented %o`,
'warn',
notImplementedMethods
);
return false;
}
return true;
});
/**
* collected inline tools with key of tool name
*/
const result = {};
tools.forEach(([name, tool]) => {
result[name] = tool;
});
/**
* Cache prepared Tools
*/
this._inlineTools = result;
return this._inlineTools;
public get inlineTools(): ToolsCollection<InlineTool> {
return this.available.inlineTools;
}
/**
* Return editor block tools
*/
public get blockTools(): { [name: string]: BlockToolConstructable } {
const tools = Object.entries(this.available).filter(([, tool]) => {
return !tool[this.INTERNAL_SETTINGS.IS_INLINE];
});
/**
* collected block tools with key of tool name
*/
const result = {};
tools.forEach(([name, tool]) => {
result[name] = tool;
});
return result;
public get blockTools(): ToolsCollection<BlockTool> {
return this.available.blockTools;
}
/**
* Constant for available Tools internal settings provided by Tool developer
* Return available Block Tunes
*
* @returns {object}
* @returns {object} - object of Inline Tool's classes
*/
public get INTERNAL_SETTINGS(): { [name: string]: string } {
return {
IS_ENABLED_LINE_BREAKS: 'enableLineBreaks',
IS_INLINE: 'isInline',
TITLE: 'title', // for Inline Tools. Block Tools can pass title along with icon through the 'toolbox' static prop.
SHORTCUT: 'shortcut',
TOOLBOX: 'toolbox',
SANITIZE_CONFIG: 'sanitize',
CONVERSION_CONFIG: 'conversionConfig',
IS_READ_ONLY_SUPPORTED: 'isReadOnlySupported',
};
public get blockTunes(): ToolsCollection<BlockTune> {
return this.available.blockTunes;
}
/**
* Constant for available Tools settings provided by user
*
* return {object}
* Returns default Tool object
*/
public get USER_SETTINGS(): { [name: string]: string } {
return {
SHORTCUT: 'shortcut',
TOOLBOX: 'toolbox',
ENABLED_INLINE_TOOLS: 'inlineToolbar',
CONFIG: 'config',
};
public get defaultTool(): BlockTool {
return this.blockTools.get(this.config.defaultBlock);
}
/**
* Map {name: Class, ...} where:
* name block type name in JSON. Got from EditorConfig.tools keys
*
* @type {object}
* Tools objects factory
*/
public readonly toolsClasses: { [name: string]: ToolConstructable } = {};
private factory: ToolsFactory;
/**
* Tools` classes available to use
*/
private readonly toolsAvailable: { [name: string]: ToolConstructable } = {};
private readonly toolsAvailable: ToolsCollection = new ToolsCollection();
/**
* Tools` classes not available to use because of preparation failure
*/
private readonly toolsUnavailable: { [name: string]: ToolConstructable } = {};
private readonly toolsUnavailable: ToolsCollection = new ToolsCollection();
/**
* Tools settings in a map {name: settings, ...}
*
* @type {object}
* Returns internal tools
*/
private readonly toolsSettings: { [name: string]: ToolSettings } = {};
/**
* Cache for the prepared inline tools
*
* @type {null|object}
* @private
*/
private _inlineTools: { [name: string]: ToolConstructable } = {};
/**
* @class
*
* @param {EditorConfig} config - Editor's configuration
* @param {EventsDispatcher} eventsDispatcher - Editor's event dispatcher
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.toolsClasses = {};
this.toolsSettings = {};
/**
* Available tools list
* {name: Class, ...}
*
* @type {object}
*/
this.toolsAvailable = {};
/**
* Tools that rejected a prepare method
* {name: Class, ... }
*
* @type {object}
*/
this.toolsUnavailable = {};
this._inlineTools = null;
public get internal(): ToolsCollection {
return this.available.internalTools;
}
/**
@ -236,7 +119,7 @@ export default class Tools extends Module {
*
* @returns {Promise<void>}
*/
public prepare(): Promise<void> {
public async prepare(): Promise<void> {
this.validateTools();
/**
@ -248,54 +131,14 @@ export default class Tools extends Module {
throw Error('Can\'t start without tools');
}
/**
* Save Tools settings to a map
*/
for (const toolName in this.config.tools) {
/**
* If Tool is an object not a Tool's class then
* save class and settings separately
*/
if (_.isObject(this.config.tools[toolName])) {
/**
* Save Tool's class from 'class' field
*
* @type {Tool}
*/
this.toolsClasses[toolName] = (this.config.tools[toolName] as ToolSettings).class;
const config = this.prepareConfig();
/**
* Save Tool's settings
*
* @type {ToolSettings}
*/
this.toolsSettings[toolName] = this.config.tools[toolName] as ToolSettings;
/**
* Remove Tool's class from settings
*/
delete this.toolsSettings[toolName].class;
} else {
/**
* Save Tool's class
*
* @type {Tool}
*/
this.toolsClasses[toolName] = this.config.tools[toolName] as ToolConstructable;
/**
* Set empty settings for Block by default
*
* @type {{}}
*/
this.toolsSettings[toolName] = { class: this.config.tools[toolName] as ToolConstructable };
}
}
this.factory = new ToolsFactory(config, this.config, this.Editor.API);
/**
* getting classes that has prepare method
*/
const sequenceData = this.getListOfPrepareFunctions();
const sequenceData = this.getListOfPrepareFunctions(config);
/**
* if sequence data contains nothing then resolve current chain and run other module prepare
@ -307,109 +150,28 @@ export default class Tools extends Module {
/**
* to see how it works {@link '../utils.ts#sequence'}
*/
return _.sequence(sequenceData, (data: { toolName: string }) => {
this.success(data);
await _.sequence(sequenceData, (data: { toolName: string }) => {
this.toolPrepareMethodSuccess(data);
}, (data: { toolName: string }) => {
this.fallback(data);
this.toolPrepareMethodFallback(data);
});
this.prepareBlockTools();
}
/**
* Success callback
*
* @param {object} data - append tool to available list
* Return general Sanitizer config for all inline tools
*/
public success(data: { toolName: string }): void {
this.toolsAvailable[data.toolName] = this.toolsClasses[data.toolName];
}
@_.cacheable
public getAllInlineToolsSanitizeConfig(): SanitizerConfig {
const config: SanitizerConfig = {} as SanitizerConfig;
/**
* Fail callback
*
* @param {object} data - append tool to unavailable list
*/
public fallback(data: { toolName: string }): void {
this.toolsUnavailable[data.toolName] = this.toolsClasses[data.toolName];
}
Array.from(this.inlineTools.values())
.forEach(inlineTool => {
Object.assign(config, inlineTool.sanitizeConfig);
});
/**
* Return Inline Tool's instance
*
* @param {InlineTool} tool - Inline Tool instance
* @param {string} name - tool name
* @param {ToolSettings} toolSettings - tool settings
*
* @returns {InlineTool} instance
*/
public constructInline(
tool: InlineToolConstructable,
name: string,
toolSettings: ToolSettings = {} as ToolSettings
): InlineTool {
const constructorOptions = {
api: this.Editor.API.getMethodsForTool(name),
config: (toolSettings[this.USER_SETTINGS.CONFIG] || {}) as ToolSettings,
};
// eslint-disable-next-line new-cap
return new tool(constructorOptions) as InlineTool;
}
/**
* Check if passed Tool is an instance of Default Block Tool
*
* @param {Tool} tool - Tool to check
*
* @returns {boolean}
*/
public isDefault(tool): boolean {
return tool instanceof this.available[this.config.defaultBlock];
}
/**
* Return Tool's config by name
*
* @param {string} toolName - name of tool
*
* @returns {ToolSettings}
*/
public getToolSettings(toolName): ToolSettings {
const settings = this.toolsSettings[toolName];
const config = settings[this.USER_SETTINGS.CONFIG] || {};
// Pass placeholder to default Block config
if (toolName === this.config.defaultBlock && !config.placeholder) {
config.placeholder = this.config.placeholder;
settings[this.USER_SETTINGS.CONFIG] = config;
}
return settings;
}
/**
* Returns internal tools
* Includes Bold, Italic, Link and Paragraph
*/
public get internalTools(): { [toolName: string]: ToolConstructable | ToolSettings } {
return {
bold: { class: BoldInlineTool },
italic: { class: ItalicInlineTool },
link: { class: LinkInlineTool },
paragraph: {
class: Paragraph,
inlineToolbar: true,
},
stub: { class: Stub },
};
}
/**
* Returns true if tool supports read-only mode
*
* @param tool - tool to check
*/
public isReadOnlySupported(tool: BlockToolConstructable): boolean {
return tool[this.INTERNAL_SETTINGS.IS_READ_ONLY_SUPPORTED] === true;
return config;
}
/**
@ -424,44 +186,201 @@ export default class Tools extends Module {
}
/**
* Binds prepare function of plugins with user or default config
*
* @returns {Array} list of functions that needs to be fired sequentially
* Returns internal tools
* Includes Bold, Italic, Link and Paragraph
*/
private getListOfPrepareFunctions(): Array<{
function: (data: { toolName: string; config: ToolConfig }) => void;
data: { toolName: string; config: ToolConfig };
}> {
const toolPreparationList: Array<{
function: (data: { toolName: string; config: ToolConfig }) => void;
data: { toolName: string; config: ToolConfig };
}
> = [];
private get internalTools(): { [toolName: string]: ToolConstructable | ToolSettings & { isInternal?: boolean } } {
return {
bold: {
class: BoldInlineTool,
isInternal: true,
},
italic: {
class: ItalicInlineTool,
isInternal: true,
},
link: {
class: LinkInlineTool,
isInternal: true,
},
paragraph: {
class: Paragraph,
inlineToolbar: true,
isInternal: true,
},
stub: {
class: Stub,
isInternal: true,
},
moveUp: {
class: MoveUpTune,
isInternal: true,
},
delete: {
class: DeleteTune,
isInternal: true,
},
moveDown: {
class: MoveDownTune,
isInternal: true,
},
};
}
for (const toolName in this.toolsClasses) {
if (Object.prototype.hasOwnProperty.call(this.toolsClasses, toolName)) {
const toolClass = this.toolsClasses[toolName];
const toolConfig = this.toolsSettings[toolName][this.USER_SETTINGS.CONFIG];
/**
* Tool prepare method success callback
*
* @param {object} data - append tool to available list
*/
private toolPrepareMethodSuccess(data: { toolName: string }): void {
const tool = this.factory.get(data.toolName);
/**
* If Tool hasn't a prepare method,
* still push it to tool preparation list to save tools order in Toolbox.
* As Tool's prepare method might be async, _.sequence util helps to save the order.
*/
toolPreparationList.push({
// eslint-disable-next-line @typescript-eslint/no-empty-function
function: _.isFunction(toolClass.prepare) ? toolClass.prepare : (): void => { },
data: {
toolName,
config: toolConfig,
},
});
if (tool.isInline()) {
/**
* Some Tools validation
*/
const inlineToolRequiredMethods = ['render', 'surround', 'checkState'];
const notImplementedMethods = inlineToolRequiredMethods.filter((method) => !tool.create()[method]);
if (notImplementedMethods.length) {
_.log(
`Incorrect Inline Tool: ${tool.name}. Some of required methods is not implemented %o`,
'warn',
notImplementedMethods
);
this.toolsUnavailable.set(tool.name, tool);
return;
}
}
this.toolsAvailable.set(tool.name, tool);
}
/**
* Tool prepare method fail callback
*
* @param {object} data - append tool to unavailable list
*/
private toolPrepareMethodFallback(data: { toolName: string }): void {
this.toolsUnavailable.set(data.toolName, this.factory.get(data.toolName));
}
/**
* Binds prepare function of plugins with user or default config
*
* @returns {Array} list of functions that needs to be fired sequentially
* @param config - tools config
*/
private getListOfPrepareFunctions(config: {[name: string]: ToolSettings}): {
function: (data: { toolName: string }) => void | Promise<void>;
data: { toolName: string };
}[] {
const toolPreparationList: {
function: (data: { toolName: string }) => void | Promise<void>;
data: { toolName: string };
}[] = [];
Object
.entries(config)
.forEach(([toolName, settings]) => {
toolPreparationList.push({
// eslint-disable-next-line @typescript-eslint/no-empty-function
function: _.isFunction(settings.class.prepare) ? settings.class.prepare : (): void => {},
data: {
toolName,
},
});
});
return toolPreparationList;
}
/**
* Assign enabled Inline Tools and Block Tunes for Block Tool
*/
private prepareBlockTools(): void {
Array.from(this.blockTools.values()).forEach(tool => {
this.assignInlineToolsToBlockTool(tool);
this.assignBlockTunesToBlockTool(tool);
});
}
/**
* Assign enabled Inline Tools for Block Tool
*
* @param tool - Block Tool
*/
private assignInlineToolsToBlockTool(tool: BlockTool): void {
/**
* If common inlineToolbar property is false no Inline Tools should be assigned
*/
if (this.config.inlineToolbar === false) {
return;
}
/**
* If user pass just 'true' for tool, get common inlineToolbar settings
* - if common settings is an array, use it
* - if common settings is 'true' or not specified, get default order
*/
if (tool.enabledInlineTools === true) {
tool.inlineTools = new ToolsCollection<InlineTool>(
Array.isArray(this.config.inlineToolbar)
? this.config.inlineToolbar.map(name => [name, this.inlineTools.get(name)])
/**
* If common settings is 'true' or not specified (will be set as true at core.ts), get the default order
*/
: Array.from(this.inlineTools.entries())
);
return;
}
/**
* If user pass the list of inline tools for the particular tool, return it.
*/
if (Array.isArray(tool.enabledInlineTools)) {
tool.inlineTools = new ToolsCollection<InlineTool>(
tool.enabledInlineTools.map(name => [name, this.inlineTools.get(name)])
);
}
}
/**
* Assign enabled Block Tunes for Block Tool
*
* @param tool Block Tool
*/
private assignBlockTunesToBlockTool(tool: BlockTool): void {
if (tool.enabledBlockTunes === false) {
return;
}
if (Array.isArray(tool.enabledBlockTunes)) {
const userTunes = new ToolsCollection<BlockTune>(
tool.enabledBlockTunes.map(name => [name, this.blockTunes.get(name)])
);
tool.tunes = new ToolsCollection<BlockTune>([...userTunes, ...this.blockTunes.internalTools]);
return;
}
if (Array.isArray(this.config.tunes)) {
const userTunes = new ToolsCollection<BlockTune>(
this.config.tunes.map(name => [name, this.blockTunes.get(name)])
);
tool.tunes = new ToolsCollection<BlockTune>([...userTunes, ...this.blockTunes.internalTools]);
return;
}
tool.tunes = this.blockTunes.internalTools;
}
/**
* Validate Tools configuration objects and throw Error for user if it is invalid
*/
@ -485,23 +404,28 @@ export default class Tools extends Module {
}
}
}
}
/**
* What kind of plugins developers can create
*/
export enum ToolType {
/**
* Block tool
*/
Block,
/**
* Inline tool
*/
Inline,
/**
* Block tune
* Unify tools config
*/
Tune,
private prepareConfig(): {[name: string]: ToolSettings} {
const config: {[name: string]: ToolSettings} = {};
/**
* Save Tools settings to a map
*/
for (const toolName in this.config.tools) {
/**
* If Tool is an object not a Tool's class then
* save class and settings separately
*/
if (_.isObject(this.config.tools[toolName])) {
config[toolName] = this.config.tools[toolName] as ToolSettings;
} else {
config[toolName] = { class: this.config.tools[toolName] as ToolConstructable };
}
}
return config;
}
}

View file

@ -340,7 +340,7 @@ export default class UI extends Module<UINodes> {
this.documentKeydown(event);
}, true);
this.readOnlyMutableListeners.on(document, 'click', (event: MouseEvent) => {
this.readOnlyMutableListeners.on(document, 'mousedown', (event: MouseEvent) => {
this.documentClicked(event);
}, true);
@ -591,9 +591,7 @@ export default class UI extends Module<UINodes> {
/**
* Clear Selection if user clicked somewhere
*/
if (!this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted) {
this.Editor.BlockSelection.clearSelection(event);
}
this.Editor.BlockSelection.clearSelection(event);
}
/**
@ -664,6 +662,8 @@ export default class UI extends Module<UINodes> {
* - otherwise, add a new empty Block and set a Caret to that
*/
private redactorClicked(event: MouseEvent): void {
const { BlockSelection } = this.Editor;
if (!Selection.isCollapsed) {
return;
}
@ -691,10 +691,33 @@ export default class UI extends Module<UINodes> {
return;
}
if (!this.Editor.BlockManager.currentBlock) {
const isClickedBottom = event.target instanceof Element &&
event.target.isEqualNode(this.nodes.redactor) &&
/**
* If there is cross block selection started, target will be equal to redactor so we need additional check
*/
!BlockSelection.anyBlockSelected;
if (isClickedBottom) {
stopPropagation();
this.Editor.BlockManager.insert();
const { BlockManager, Caret, Toolbar } = this.Editor;
/**
* Insert a default-block at the bottom if:
* - last-block is not a default-block (Text)
* to prevent unnecessary tree-walking on Tools with many nodes (for ex. Table)
* - Or, default-block is not empty
*/
if (!BlockManager.lastBlock.tool.isDefault || !BlockManager.lastBlock.isEmpty) {
BlockManager.insertAtEnd();
}
/**
* Set the caret and toolbar to empty Block
*/
Caret.setToTheLastBlock();
Toolbar.move();
}
/**
@ -702,7 +725,7 @@ export default class UI extends Module<UINodes> {
* - Block is an default-block (Text)
* - Block is empty
*/
const isDefaultBlock = this.Editor.Tools.isDefault(this.Editor.BlockManager.currentBlock.tool);
const isDefaultBlock = this.Editor.BlockManager.currentBlock.tool.isDefault;
if (isDefaultBlock) {
stopPropagation();
@ -736,10 +759,28 @@ export default class UI extends Module<UINodes> {
}
/**
* Event can be fired on clicks at the Editor elements, for example, at the Inline Toolbar
* We need to skip such firings
* Usual clicks on some controls, for example, Block Tunes Toggler
*/
if (!focusedElement || !focusedElement.closest(`.${Block.CSS.content}`)) {
if (!focusedElement) {
/**
* If there is no selected range, close inline toolbar
*
* @todo Make this method more straightforward
*/
if (!Selection.range) {
this.Editor.InlineToolbar.close();
}
return;
}
/**
* Event can be fired on clicks at non-block-content elements,
* for example, at the Inline Toolbar or some Block Tune element
*/
const clickedOutsideBlockContent = focusedElement.closest(`.${Block.CSS.content}`) === null;
if (clickedOutsideBlockContent) {
/**
* If new selection is not on Inline Toolbar, we need to close it
*/
@ -747,7 +788,16 @@ export default class UI extends Module<UINodes> {
this.Editor.InlineToolbar.close();
}
return;
/**
* Case when we click on external tool elements,
* for example some Block Tune element.
* If this external content editable element has data-inline-toolbar="true"
*/
const inlineToolbarEnabledForExternalTool = (focusedElement as HTMLElement).dataset.inlineToolbar === 'true';
if (!inlineToolbarEnabledForExternalTool) {
return;
}
}
/**
@ -757,10 +807,12 @@ export default class UI extends Module<UINodes> {
this.Editor.BlockManager.setCurrentBlockByChildNode(focusedElement);
}
const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true;
/**
* @todo add debounce
*/
this.Editor.InlineToolbar.tryToShow(true);
this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar);
}
/**

View file

@ -34,6 +34,34 @@ interface Document {
* @typedef {SelectionUtils} SelectionUtils
*/
export default class SelectionUtils {
/**
* Selection instances
*
* @todo Check if this is still relevant
*/
public instance: Selection = null;
public selection: Selection = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range = null;
/**
* Fake background is active
*
* @returns {boolean}
*/
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
/**
* Editor styles
*
@ -112,7 +140,18 @@ export default class SelectionUtils {
* @returns {boolean}
*/
public static get isAtEditor(): boolean {
const selection = SelectionUtils.get();
return this.isSelectionAtEditor(SelectionUtils.get());
}
/**
* Check if passed selection is at Editor's zone
*
* @param selection - Selectoin object to check
*/
public static isSelectionAtEditor(selection: Selection): boolean {
if (!selection) {
return false;
}
/**
* Something selected on document
@ -125,14 +164,42 @@ export default class SelectionUtils {
let editorZone = null;
if (selectedNode) {
if (selectedNode && selectedNode instanceof Element) {
editorZone = selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`);
}
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone && editorZone.nodeType === Node.ELEMENT_NODE;
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
* Check if passed range at Editor zone
*
* @param range - range to check
*/
public static isRangeAtEditor(range: Range): boolean {
if (!range) {
return;
}
let selectedNode = range.startContainer as HTMLElement;
if (selectedNode && selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode as HTMLElement;
}
let editorZone = null;
if (selectedNode && selectedNode instanceof Element) {
editorZone = selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`);
}
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
@ -150,8 +217,15 @@ export default class SelectionUtils {
* @returns {Range|null}
*/
public static get range(): Range | null {
const selection = window.getSelection();
return this.getRangeFromSelection(this.get());
}
/**
* Returns range from passed Selection object
*
* @param selection - Selection object to get Range from
*/
public static getRangeFromSelection(selection: Selection): Range {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
}
@ -237,34 +311,6 @@ export default class SelectionUtils {
return window.getSelection ? window.getSelection().toString() : '';
}
/**
* Selection instances
*
* @todo Check if this is still relevant
*/
public instance: Selection = null;
public selection: Selection = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range = null;
/**
* Fake background is active
*
* @returns {boolean}
*/
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
/**
* Returns window SelectionUtils
* {@link https://developer.mozilla.org/ru/docs/Web/API/Window/getSelection}
@ -308,6 +354,36 @@ export default class SelectionUtils {
return range.getBoundingClientRect();
}
/**
* Adds fake cursor to the current range
*
* @param [container] - if passed cursor will be added only if container contains current range
*/
public static addFakeCursor(container?: HTMLElement): void {
const range = SelectionUtils.range;
const fakeCursor = $.make('span', 'codex-editor__fake-cursor');
fakeCursor.dataset.mutationFree = 'true';
if (!range || (container && !container.contains(range.startContainer))) {
return;
}
range.collapse();
range.insertNode(fakeCursor);
}
/**
* Removes fake cursor from a container
*
* @param container - container to look for
*/
public static removeFakeCursor(container: HTMLElement = document.body): void {
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
fakeCursor && fakeCursor.remove();
}
/**
* Removes fake background
*/

View file

@ -0,0 +1,277 @@
import { Tool, ToolConstructable, ToolSettings } from '../../../types/tools';
import { SanitizerConfig } from '../../../types';
import * as _ from '../utils';
import type InlineTool from './inline';
import type BlockTool from './block';
import type BlockTune from './tune';
import API from '../modules/api';
/**
* What kind of plugins developers can create
*/
export enum ToolType {
/**
* Block tool
*/
Block,
/**
* Inline tool
*/
Inline,
/**
* Block tune
*/
Tune,
}
/**
* Enum of Tool options provided by user
*/
export enum UserSettings {
/**
* Shortcut for Tool
*/
Shortcut = 'shortcut',
/**
* Toolbox config for Tool
*/
Toolbox = 'toolbox',
/**
* Enabled Inline Tools for Block Tool
*/
EnabledInlineTools = 'inlineToolbar',
/**
* Enabled Block Tunes for Block Tool
*/
EnabledBlockTunes = 'tunes',
/**
* Tool configuration
*/
Config = 'config',
}
/**
* Enum of Tool options provided by Tool
*/
export enum CommonInternalSettings {
/**
* Shortcut for Tool
*/
Shortcut = 'shortcut',
/**
* Sanitize configuration for Tool
*/
SanitizeConfig = 'sanitize',
}
/**
* Enum of Tool optoins provided by Block Tool
*/
export enum InternalBlockToolSettings {
/**
* Is linebreaks enabled for Tool
*/
IsEnabledLineBreaks = 'enableLineBreaks',
/**
* Tool Toolbox config
*/
Toolbox = 'toolbox',
/**
* Tool conversion config
*/
ConversionConfig = 'conversionConfig',
/**
* Is readonly mode supported for Tool
*/
IsReadOnlySupported = 'isReadOnlySupported',
/**
* Tool paste config
*/
PasteConfig = 'pasteConfig'
}
/**
* Enum of Tool options provided by Inline Tool
*/
export enum InternalInlineToolSettings {
/**
* Flag specifies Tool is inline
*/
IsInline = 'isInline',
/**
* Inline Tool title for toolbar
*/
Title = 'title', // for Inline Tools. Block Tools can pass title along with icon through the 'toolbox' static prop.
}
/**
* Enum of Tool options provided by Block Tune
*/
export enum InternalTuneSettings {
/**
* Flag specifies Tool is Block Tune
*/
IsTune = 'isTune',
}
export type ToolOptions = Omit<ToolSettings, 'class'>
interface ConstructorOptions {
name: string;
constructable: ToolConstructable;
config: ToolOptions;
api: API;
isDefault: boolean;
isInternal: boolean;
defaultPlaceholder?: string | false;
}
/**
* Base abstract class for Tools
*/
export default abstract class BaseTool<Type extends Tool = Tool> {
/**
* Tool type: Block, Inline or Tune
*/
public type: ToolType;
/**
* Tool name specified in EditorJS config
*/
public name: string;
/**
* Flag show is current Tool internal (bundled with EditorJS core) or not
*/
public readonly isInternal: boolean;
/**
* Flag show is current Tool default or not
*/
public readonly isDefault: boolean;
/**
* EditorJS API for current Tool
*/
protected api: API;
/**
* Current tool user configuration
*/
protected config: ToolOptions;
/**
* Tool's constructable blueprint
*/
protected constructable: ToolConstructable;
/**
* Default placeholder specified in EditorJS user configuration
*/
protected defaultPlaceholder?: string | false;
/**
* @class
*
* @param {ConstructorOptions} - Constructor options
*/
constructor({
name,
constructable,
config,
api,
isDefault,
isInternal = false,
defaultPlaceholder,
}: ConstructorOptions) {
this.api = api;
this.name = name;
this.constructable = constructable;
this.config = config;
this.isDefault = isDefault;
this.isInternal = isInternal;
this.defaultPlaceholder = defaultPlaceholder;
}
/**
* Returns Tool user configuration
*/
public get settings(): ToolOptions {
const config = this.config[UserSettings.Config] || {};
if (this.isDefault && !('placeholder' in config) && this.defaultPlaceholder) {
config.placeholder = this.defaultPlaceholder;
}
return config;
}
/**
* Calls Tool's reset method
*/
public reset(): void | Promise<void> {
if (_.isFunction(this.constructable.reset)) {
return this.constructable.reset();
}
}
/**
* Calls Tool's prepare method
*/
public prepare(): void | Promise<void> {
if (_.isFunction(this.constructable.prepare)) {
return this.constructable.prepare({
toolName: this.name,
config: this.settings,
});
}
}
/**
* Returns shortcut for Tool (internal or specified by user)
*/
public get shortcut(): string | undefined {
const toolShortcut = this.constructable[CommonInternalSettings.Shortcut];
const userShortcut = this.config[UserSettings.Shortcut];
return userShortcut || toolShortcut;
}
/**
* Returns Tool's sanitizer configuration
*/
public get sanitizeConfig(): SanitizerConfig {
return this.constructable[CommonInternalSettings.SanitizeConfig] || {};
}
/**
* Returns true if Tools is inline
*/
public isInline(): this is InlineTool {
return this.type === ToolType.Inline;
}
/**
* Returns true if Tools is block
*/
public isBlock(): this is BlockTool {
return this.type === ToolType.Block;
}
/**
* Returns true if Tools is tune
*/
public isTune(): this is BlockTune {
return this.type === ToolType.Tune;
}
/**
* Constructs new Tool instance from constructable blueprint
*
* @param args
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public abstract create(...args: any[]): Type;
}

View file

@ -0,0 +1,169 @@
import BaseTool, { InternalBlockToolSettings, ToolType, UserSettings } from './base';
import {
BlockAPI,
BlockTool as IBlockTool,
BlockToolConstructable,
BlockToolData,
ConversionConfig,
PasteConfig, SanitizerConfig,
ToolboxConfig
} from '../../../types';
import * as _ from '../utils';
import InlineTool from './inline';
import BlockTune from './tune';
import ToolsCollection from './collection';
/**
* Class to work with Block tools constructables
*/
export default class BlockTool extends BaseTool<IBlockTool> {
/**
* Tool type Block
*/
public type = ToolType.Block;
/**
* InlineTool collection for current Block Tool
*/
public inlineTools: ToolsCollection<InlineTool> = new ToolsCollection<InlineTool>();
/**
* BlockTune collection for current Block Tool
*/
public tunes: ToolsCollection<BlockTune> = new ToolsCollection<BlockTune>();
/**
* Tool's constructable blueprint
*/
protected constructable: BlockToolConstructable;
/**
* Creates new Tool instance
*
* @param data - Tool data
* @param block - BlockAPI for current Block
* @param readOnly - True if Editor is in read-only mode
*/
public create(data: BlockToolData, block: BlockAPI, readOnly: boolean): IBlockTool {
// eslint-disable-next-line new-cap
return new this.constructable({
data,
block,
readOnly,
api: this.api.getMethodsForTool(this),
config: this.settings,
}) as IBlockTool;
}
/**
* Returns true if read-only mode is supported by Tool
*/
public get isReadOnlySupported(): boolean {
return this.constructable[InternalBlockToolSettings.IsReadOnlySupported] === true;
}
/**
* Returns true if Tool supports linebreaks
*/
public get isLineBreaksEnabled(): boolean {
return this.constructable[InternalBlockToolSettings.IsEnabledLineBreaks];
}
/**
* Returns Tool toolbox configuration (internal or user-specified)
*/
public get toolbox(): ToolboxConfig {
const toolToolboxSettings = this.constructable[InternalBlockToolSettings.Toolbox] as ToolboxConfig;
const userToolboxSettings = this.config[UserSettings.Toolbox];
if (_.isEmpty(toolToolboxSettings)) {
return;
}
if ((userToolboxSettings ?? toolToolboxSettings) === false) {
return;
}
return Object.assign({}, toolToolboxSettings, userToolboxSettings);
}
/**
* Returns Tool conversion configuration
*/
public get conversionConfig(): ConversionConfig {
return this.constructable[InternalBlockToolSettings.ConversionConfig];
}
/**
* Returns enabled inline tools for Tool
*/
public get enabledInlineTools(): boolean | string[] {
return this.config[UserSettings.EnabledInlineTools] || false;
}
/**
* Returns enabled tunes for Tool
*/
public get enabledBlockTunes(): boolean | string[] {
return this.config[UserSettings.EnabledBlockTunes];
}
/**
* Returns Tool paste configuration
*/
public get pasteConfig(): PasteConfig {
return this.constructable[InternalBlockToolSettings.PasteConfig] || {};
}
/**
* Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes
*/
@_.cacheable
public get sanitizeConfig(): SanitizerConfig {
const toolRules = super.sanitizeConfig;
const baseConfig = this.baseSanitizeConfig;
if (_.isEmpty(toolRules)) {
return baseConfig;
}
const toolConfig = {} as SanitizerConfig;
for (const fieldName in toolRules) {
if (Object.prototype.hasOwnProperty.call(toolRules, fieldName)) {
const rule = toolRules[fieldName];
/**
* If rule is object, merge it with Inline Tools configuration
*
* Otherwise pass as it is
*/
if (_.isObject(rule)) {
toolConfig[fieldName] = Object.assign({}, baseConfig, rule);
} else {
toolConfig[fieldName] = rule;
}
}
}
return toolConfig;
}
/**
* Returns sanitizer configuration composed from sanitize config of Inline Tools enabled for Tool
*/
@_.cacheable
public get baseSanitizeConfig(): SanitizerConfig {
const baseConfig = {};
Array
.from(this.inlineTools.values())
.forEach(tool => Object.assign(baseConfig, tool.sanitizeConfig));
Array
.from(this.tunes.values())
.forEach(tune => Object.assign(baseConfig, tune.sanitizeConfig));
return baseConfig;
}
}

View file

@ -0,0 +1,65 @@
import BlockTool from './block';
import InlineTool from './inline';
import BlockTune from './tune';
export type ToolClass = BlockTool | InlineTool | BlockTune;
/**
* Class to store Editor Tools
*/
export default class ToolsCollection<V extends ToolClass = ToolClass> extends Map<string, V> {
/**
* Returns Block Tools collection
*/
public get blockTools(): ToolsCollection<BlockTool> {
const tools = Array
.from(this.entries())
.filter(([, tool]) => tool.isBlock()) as [string, BlockTool][];
return new ToolsCollection<BlockTool>(tools);
}
/**
* Returns Inline Tools collection
*/
public get inlineTools(): ToolsCollection<InlineTool> {
const tools = Array
.from(this.entries())
.filter(([, tool]) => tool.isInline()) as [string, InlineTool][];
return new ToolsCollection<InlineTool>(tools);
}
/**
* Returns Block Tunes collection
*/
public get blockTunes(): ToolsCollection<BlockTune> {
const tools = Array
.from(this.entries())
.filter(([, tool]) => tool.isTune()) as [string, BlockTune][];
return new ToolsCollection<BlockTune>(tools);
}
/**
* Returns internal Tools collection
*/
public get internalTools(): ToolsCollection<V> {
const tools = Array
.from(this.entries())
.filter(([, tool]) => tool.isInternal);
return new ToolsCollection<V>(tools);
}
/**
* Returns Tools collection provided by user
*/
public get externalTools(): ToolsCollection<V> {
const tools = Array
.from(this.entries())
.filter(([, tool]) => !tool.isInternal);
return new ToolsCollection<V>(tools);
}
}

View file

@ -0,0 +1,83 @@
import { ToolConstructable, ToolSettings } from '../../../types/tools';
import { InternalInlineToolSettings, InternalTuneSettings } from './base';
import InlineTool from './inline';
import BlockTune from './tune';
import BlockTool from './block';
import API from '../modules/api';
import { EditorConfig } from '../../../types/configs';
type ToolConstructor = typeof InlineTool | typeof BlockTool | typeof BlockTune;
/**
* Factory to construct classes to work with tools
*/
export default class ToolsFactory {
/**
* Tools configuration specified by user
*/
private config: {[name: string]: ToolSettings & { isInternal?: boolean }};
/**
* EditorJS API Module
*/
private api: API;
/**
* EditorJS configuration
*/
private editorConfig: EditorConfig;
/**
* @class
*
* @param config - tools config
* @param editorConfig - EditorJS config
* @param api - EditorJS API module
*/
constructor(
config: {[name: string]: ToolSettings & { isInternal?: boolean }},
editorConfig: EditorConfig,
api: API
) {
this.api = api;
this.config = config;
this.editorConfig = editorConfig;
}
/**
* Returns Tool object based on it's type
*
* @param name - tool name
*/
public get(name: string): InlineTool | BlockTool | BlockTune {
const { class: constructable, isInternal = false, ...config } = this.config[name];
const Constructor = this.getConstructor(constructable);
return new Constructor({
name,
constructable,
config,
api: this.api,
isDefault: name === this.editorConfig.defaultBlock,
defaultPlaceholder: this.editorConfig.placeholder,
isInternal,
});
}
/**
* Find appropriate Tool object constructor for Tool constructable
*
* @param constructable - Tools constructable
*/
private getConstructor(constructable: ToolConstructable): ToolConstructor {
switch (true) {
case constructable[InternalInlineToolSettings.IsInline]:
return InlineTool;
case constructable[InternalTuneSettings.IsTune]:
return BlockTune;
default:
return BlockTool;
}
}
}

View file

@ -0,0 +1,35 @@
import BaseTool, { InternalInlineToolSettings, ToolType } from './base';
import { InlineTool as IInlineTool, InlineToolConstructable } from '../../../types';
/**
* InlineTool object to work with Inline Tools constructables
*/
export default class InlineTool extends BaseTool<IInlineTool> {
/**
* Tool type Inline
*/
public type = ToolType.Inline;
/**
* Tool's constructable blueprint
*/
protected constructable: InlineToolConstructable;
/**
* Returns title for Inline Tool if specified by user
*/
public get title(): string {
return this.constructable[InternalInlineToolSettings.Title];
}
/**
* Constructs new InlineTool instance from constructable
*/
public create(): IInlineTool {
// eslint-disable-next-line new-cap
return new this.constructable({
api: this.api.getMethodsForTool(this),
config: this.settings,
}) as IInlineTool;
}
}

View file

@ -0,0 +1,36 @@
import BaseTool, { ToolType } from './base';
import { BlockAPI, BlockTune as IBlockTune, BlockTuneConstructable } from '../../../types';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
/**
* Stub class for BlockTunes
*
* @todo Implement
*/
export default class BlockTune extends BaseTool<IBlockTune> {
/**
* Tool type Tune
*/
public type = ToolType.Tune;
/**
* Tool's constructable blueprint
*/
protected readonly constructable: BlockTuneConstructable;
/**
* Constructs new BlockTune instance from constructable
*
* @param data - Tune data
* @param block - Block API object
*/
public create(data: BlockTuneData, block: BlockAPI): IBlockTune {
// eslint-disable-next-line new-cap
return new this.constructable({
api: this.api.getMethodsForTool(this),
config: this.settings,
block,
data,
});
}
}

View file

@ -2,6 +2,7 @@
* Class Util
*/
import { nanoid } from 'nanoid';
import Dom from './dom';
/**
@ -428,14 +429,12 @@ export function isValidMimeType(type: string): boolean {
* @param {boolean} immediate - call now
* @returns {Function}
*/
export function debounce(func: () => void, wait?: number, immediate?: boolean): () => void {
export function debounce(func: (...args: unknown[]) => void, wait?: number, immediate?: boolean): () => void {
let timeout;
return (): void => {
return (...args: unknown[]): void => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const context = this,
// eslint-disable-next-line prefer-rest-params
args = arguments;
const context = this;
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const later = () => {
@ -607,6 +606,15 @@ export function getValidUrl(url: string): string {
}
}
/**
* Create a block id
*
* @returns {string}
*/
export function generateBlockId(): string {
return nanoid(10);
}
/**
* Opens new Tab with passed URL
*
@ -642,3 +650,53 @@ export function deprecationAssert(condition: boolean, oldProperty: string, newPr
logLabeled(message, 'warn');
}
}
/**
* Decorator which provides ability to cache method or accessor result
*
* @param target - target instance or constructor function
* @param propertyKey - method or accessor name
* @param descriptor - property descriptor
*/
export function cacheable<Target, Value, Arguments extends unknown[] = unknown[]>(
target: Target,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const propertyToOverride = descriptor.value ? 'value' : 'get';
const originalMethod = descriptor[propertyToOverride];
const cacheKey = `#${propertyKey}Cache`;
/**
* Override get or value descriptor property to cache return value
*
* @param args - method args
*/
descriptor[propertyToOverride] = function (...args: Arguments): Value {
/**
* If there is no cache, create it
*/
if (this[cacheKey] === undefined) {
this[cacheKey] = originalMethod.apply(this, ...args);
}
return this[cacheKey];
};
/**
* If get accessor has been overridden, we need to override set accessor to clear cache
*
* @param value - value to set
*/
if (propertyToOverride === 'get' && descriptor.set) {
const originalSet = descriptor.set;
descriptor.set = function (value: unknown): void {
delete target[cacheKey];
originalSet.apply(this, value);
};
}
return descriptor;
};

View file

@ -1,3 +1,5 @@
import { isEmpty } from '../utils';
/**
* @class EventDispatcher
*
@ -11,7 +13,7 @@
* @typedef {Events} Events
* @property {object} subscribers - all subscribers grouped by event name
*/
export default class EventsDispatcher {
export default class EventsDispatcher<Events extends string = string> {
/**
* Object with events` names as key and array of callback functions as value
*
@ -25,7 +27,7 @@ export default class EventsDispatcher {
* @param {string} eventName - event name
* @param {Function} callback - subscriber
*/
public on(eventName: string, callback: (data: object) => object): void {
public on(eventName: Events, callback: (data: object) => object): void {
if (!(eventName in this.subscribers)) {
this.subscribers[eventName] = [];
}
@ -40,7 +42,7 @@ export default class EventsDispatcher {
* @param {string} eventName - event name
* @param {Function} callback - subscriber
*/
public once(eventName: string, callback: (data: object) => object): void {
public once(eventName: Events, callback: (data: object) => object): void {
if (!(eventName in this.subscribers)) {
this.subscribers[eventName] = [];
}
@ -67,8 +69,8 @@ export default class EventsDispatcher {
* @param {string} eventName - event name
* @param {object} data - subscribers get this data when they were fired
*/
public emit(eventName: string, data?: object): void {
if (!this.subscribers[eventName]) {
public emit(eventName: Events, data?: object): void {
if (isEmpty(this.subscribers) || !this.subscribers[eventName]) {
return;
}
@ -85,7 +87,7 @@ export default class EventsDispatcher {
* @param {string} eventName - event name
* @param {Function} callback - event handler
*/
public off(eventName: string, callback: (data: object) => object): void {
public off(eventName: Events, callback: (data: object) => object): void {
for (let i = 0; i < this.subscribers[eventName].length; i++) {
if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i];

View file

@ -1,5 +1,3 @@
import Module from '../__module';
/**
* Use external package module for notifications
*
@ -8,9 +6,9 @@ import Module from '../__module';
import notifier, { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier';
/**
* Notifier module
* Util for showing notifications
*/
export default class Notifier extends Module {
export default class Notifier {
/**
* Show web notification
*

View file

@ -0,0 +1,188 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
/**
* CodeX Sanitizer
*
* Clears HTML from taint tags
*
* @version 2.0.0
*
* @example
*
* clean(yourTaintString, yourConfig);
*
* {@link SanitizerConfig}
*/
import * as _ from '../utils';
/**
* @typedef {object} SanitizerConfig
* @property {object} tags - define tags restrictions
*
* @example
*
* tags : {
* p: true,
* a: {
* href: true,
* rel: "nofollow",
* target: "_blank"
* }
* }
*/
import HTMLJanitor from 'html-janitor';
import { BlockToolData, SanitizerConfig } from '../../../types';
import { SavedData } from '../../../types/data-formats';
/**
* Sanitize Blocks
*
* Enumerate blocks and clean data
*
* @param blocksData - blocks' data to sanitize
* @param sanitizeConfig sanitize config to use or function to get config for Tool
*/
export function sanitizeBlocks(
blocksData: Array<Pick<SavedData, 'data' | 'tool'>>,
sanitizeConfig: SanitizerConfig | ((toolName: string) => SanitizerConfig)
): Array<Pick<SavedData, 'data' | 'tool'>> {
return blocksData.map((block) => {
const toolConfig = _.isFunction(sanitizeConfig) ? sanitizeConfig(block.tool) : sanitizeConfig;
if (_.isEmpty(toolConfig)) {
return block;
}
block.data = deepSanitize(block.data, toolConfig) as BlockToolData;
return block;
});
}
/**
* Cleans string from unwanted tags
* Method allows to use default config
*
* @param {string} taintString - taint string
* @param {SanitizerConfig} customConfig - allowed tags
*
* @returns {string} clean HTML
*/
export function clean(taintString: string, customConfig: SanitizerConfig = {} as SanitizerConfig): string {
const sanitizerConfig = {
tags: customConfig,
};
/**
* API client can use custom config to manage sanitize process
*/
const sanitizerInstance = new HTMLJanitor(sanitizerConfig);
return sanitizerInstance.clean(taintString);
}
/**
* Method recursively reduces Block's data and cleans with passed rules
*
* @param {BlockToolData|object|*} dataToSanitize - taint string or object/array that contains taint string
* @param {SanitizerConfig} rules - object with sanitizer rules
*/
function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): object | string {
/**
* BlockData It may contain 3 types:
* - Array
* - Object
* - Primitive
*/
if (Array.isArray(dataToSanitize)) {
/**
* Array: call sanitize for each item
*/
return cleanArray(dataToSanitize, rules);
} else if (_.isObject(dataToSanitize)) {
/**
* Objects: just clean object deeper.
*/
return cleanObject(dataToSanitize, rules);
} else {
/**
* Primitives (number|string|boolean): clean this item
*
* Clean only strings
*/
if (_.isString(dataToSanitize)) {
return cleanOneItem(dataToSanitize, rules);
}
return dataToSanitize;
}
}
/**
* Clean array
*
* @param {Array} array - [1, 2, {}, []]
* @param {SanitizerConfig} ruleForItem - sanitizer config for array
*/
function cleanArray(array: Array<object | string>, ruleForItem: SanitizerConfig): Array<object | string> {
return array.map((arrayItem) => deepSanitize(arrayItem, ruleForItem));
}
/**
* Clean object
*
* @param {object} object - {level: 0, text: 'adada', items: [1,2,3]}}
* @param {object} rules - { b: true } or true|false
* @returns {object}
*/
function cleanObject(object: object, rules: SanitizerConfig|{[field: string]: SanitizerConfig}): object {
const cleanData = {};
for (const fieldName in object) {
if (!Object.prototype.hasOwnProperty.call(object, fieldName)) {
continue;
}
const currentIterationItem = object[fieldName];
/**
* Get object from config by field name
* - if it is a HTML Janitor rule, call with this rule
* - otherwise, call with parent's config
*/
const ruleForItem = isRule(rules[fieldName] as SanitizerConfig) ? rules[fieldName] : rules;
cleanData[fieldName] = deepSanitize(currentIterationItem, ruleForItem as SanitizerConfig);
}
return cleanData;
}
/**
* Clean primitive value
*
* @param {string} taintString - string to clean
* @param {SanitizerConfig|boolean} rule - sanitizer rule
*
* @returns {string}
*/
function cleanOneItem(taintString: string, rule: SanitizerConfig|boolean): string {
if (_.isObject(rule)) {
return clean(taintString, rule);
} else if (rule === false) {
return clean(taintString, {} as SanitizerConfig);
} else {
return taintString;
}
}
/**
* Check if passed item is a HTML Janitor rule:
* { a : true }, {}, false, true, function(){} correct rules
* undefined, null, 0, 1, 2 not a rules
*
* @param {SanitizerConfig} config - config to check
*/
function isRule(config: SanitizerConfig): boolean {
return _.isObject(config) || _.isBoolean(config) || _.isFunction(config);
}

View file

@ -1,6 +1,4 @@
/* eslint-disable jsdoc/no-undefined-types */
import Module from '../__module';
/**
* Use external module CodeX Tooltip
*/
@ -11,7 +9,7 @@ import CodeXTooltips, { TooltipContent, TooltipOptions } from 'codex-tooltip';
*
* Decorates any tooltip module like adapter
*/
export default class Tooltip extends Module {
export default class Tooltip {
/**
* Tooltips lib: CodeX Tooltips
*
@ -19,6 +17,13 @@ export default class Tooltip extends Module {
*/
private lib: CodeXTooltips = new CodeXTooltips();
/**
* Release the library
*/
public destroy(): void {
this.lib.destroy();
}
/**
* Shows tooltip on element with passed HTML content
*

View file

@ -7,6 +7,7 @@
will-change: transform, opacity;
top: 0;
left: 0;
z-index: 3;
&--showed {
opacity: 1;

View file

@ -1,5 +1,5 @@
import $ from '../../dom';
import { API, BlockTool, BlockToolData, BlockToolConstructorOptions } from '../../../../types';
import $ from '../../components/dom';
import { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types';
export interface StubData extends BlockToolData {
title: string;

View file

@ -5,12 +5,8 @@ import InlineToolbar from '../components/modules/toolbar/inline';
import Toolbox from '../components/modules/toolbar/toolbox';
import BlockSettings from '../components/modules/toolbar/blockSettings';
import Paste from '../components/modules/paste';
import Notifier from '../components/modules/notifier';
import Tooltip from '../components/modules/tooltip';
import DragNDrop from '../components/modules/dragNDrop';
import ModificationsObserver from '../components/modules/modificationsObserver';
import Renderer from '../components/modules/renderer';
import Sanitizer from '../components/modules/sanitizer';
import Tools from '../components/modules/tools';
import API from '../components/modules/api/index';
import Caret from '../components/modules/caret';
@ -35,6 +31,7 @@ import TooltipAPI from '../components/modules/api/tooltip';
import ReadOnly from '../components/modules/readonly';
import ReadOnlyAPI from '../components/modules/api/readonly';
import I18nAPI from '../components/modules/api/i18n';
import ModificationsObserver from '../components/modules/modificationsObserver';
export interface EditorModules {
UI: UI;
@ -48,15 +45,11 @@ export interface EditorModules {
ConversionToolbar: ConversionToolbar;
Paste: Paste;
DragNDrop: DragNDrop;
ModificationsObserver: ModificationsObserver;
Renderer: Renderer;
Sanitizer: Sanitizer;
Tools: Tools;
API: API;
Caret: Caret;
Saver: Saver;
Notifier: Notifier;
Tooltip: Tooltip;
BlockManager: BlockManager;
BlocksAPI: BlocksAPI;
CaretAPI: CaretAPI;
@ -74,4 +67,5 @@ export interface EditorModules {
ReadOnly: ReadOnly;
ReadOnlyAPI: ReadOnlyAPI;
I18nAPI: I18nAPI;
ModificationsObserver: ModificationsObserver;
}

View file

@ -1,12 +1,14 @@
{
"plugins": [
"cypress"
"cypress",
"chai-friendly"
],
"env": {
"cypress/globals": true
},
"extends": [
"plugin:cypress/recommended"
"plugin:cypress/recommended",
"plugin:chai-friendly/recommended"
],
"rules": {
"cypress/require-data-selectors": 2
@ -14,4 +16,4 @@
"globals": {
"EditorJS": true
}
}
}

View file

@ -1,5 +1,39 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* tslint:disable:no-var-requires */
/**
* This file contains connection of Cypres plugins
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
// export default function(on, config): void {}
const webpackConfig = require('../../../webpack.config.js');
const preprocessor = require('@cypress/webpack-preprocessor');
const codeCoverageTask = require('@cypress/code-coverage/task');
module.exports = (on, config): unknown => {
/**
* Add Cypress task to get code coverage
*/
codeCoverageTask(on, config);
/**
* Prepare webpack preprocessor options
*/
const options = preprocessor.defaultOptions;
/**
* Provide path to typescript package
*/
options.typescript = require.resolve('typescript');
/**
* Provide our webpack config
*/
options.webpackOptions = webpackConfig({}, { mode: 'test' });
/**
* Register webpack preprocessor
*/
on('file:preprocessor', preprocessor(options));
// It's IMPORTANT to return the config object
// with any changed environment variables
return config;
};

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* This file contains custom commands for Cypress.
* Also it can override the existing commands.
@ -5,7 +6,7 @@
* --------------------------------------------------
*/
import type { EditorConfig } from './../../../types/index';
import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index';
import Chainable = Cypress.Chainable;
@ -36,3 +37,92 @@ Cypress.Commands.add('createEditor', (editorConfig: EditorConfig = {}): Chainabl
});
});
});
/**
* Paste command to dispatch paste event
*
* Usage
* cy.get('div').paste({'text/plain': 'Text', 'text/html': '<b>Text</b>'})
*
* @param data - map with MIME type as a key and data as value
*/
Cypress.Commands.add('paste', {
prevSubject: true,
}, (subject, data: {[type: string]: string}) => {
const pasteEvent = Object.assign(new Event('paste', {
bubbles: true,
cancelable: true,
}), {
clipboardData: {
getData: (type): string => data[type],
types: Object.keys(data),
},
});
subject[0].dispatchEvent(pasteEvent);
return subject;
});
/**
* Copy command to dispatch copy event on subject
*
* Usage:
* cy.get('div').copy().then(data => {})
*/
Cypress.Commands.add('copy', { prevSubject: true }, async (subject) => {
const clipboardData: {[type: string]: any} = {};
const copyEvent = Object.assign(new Event('copy', {
bubbles: true,
cancelable: true,
}), {
clipboardData: {
setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data;
},
},
});
subject[0].dispatchEvent(copyEvent);
return clipboardData;
});
/**
* Cut command to dispatch cut event on subject
*
* Usage:
* cy.get('div').cut().then(data => {})
*/
Cypress.Commands.add('cut', { prevSubject: true }, async (subject) => {
const clipboardData: {[type: string]: any} = {};
const copyEvent = Object.assign(new Event('cut', {
bubbles: true,
cancelable: true,
}), {
clipboardData: {
setData: (type: string, data: any): void => {
console.log(type, data);
clipboardData[type] = data;
},
},
});
subject[0].dispatchEvent(copyEvent);
return clipboardData;
});
/**
* Calls EditorJS API render method
*
* @param data data to render
*/
Cypress.Commands.add('render', { prevSubject: true }, async (subject: EditorJS, data: OutputData): Promise<EditorJS> => {
await subject.render(data);
return subject;
});

View file

@ -2,7 +2,7 @@
// load type definitions that come with Cypress module
/// <reference types="cypress" />
import type { EditorConfig } from './../../../types/index';
import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index'
declare global {
@ -14,6 +14,39 @@ declare global {
* @example cy.createEditor({})
*/
createEditor(editorConfig: EditorConfig): Chainable<EditorJS>
/**
* Paste command to dispatch paste event
*
* @usage
* cy.get('div').paste({'text/plain': 'Text', 'text/html': '<b>Text</b>'})
*
* @param data - map with MIME type as a key and data as value
*/
paste(data: {[type: string]: string}): Chainable<Subject>
/**
* Copy command to dispatch copy event on subject
*
* @usage
* cy.get('div').copy().then(data => {})
*/
copy(): Chainable<{ [type: string]: any }>;
/**
* Cut command to dispatch cut event on subject
*
* @usage
* cy.get('div').cut().then(data => {})
*/
cut(): Chainable<{ [type: string]: any }>;
/**
* Calls EditorJS API render method
*
* @param data data to render
*/
render(data: OutputData): Chainable<EditorJS>;
}
interface ApplicationWindow {

View file

@ -6,6 +6,8 @@
* behavior that modifies Cypress.
*/
import '@cypress/code-coverage/support';
/**
* File with the helpful commands
*/

View file

@ -0,0 +1,120 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* There will be described test cases of 'blocks.*' API
*/
describe('api.blocks', () => {
const firstBlock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
const editorDataMock = {
blocks: [
firstBlock,
],
};
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
}
});
/**
* api.blocks.getById(id)
*/
describe('.getById()', () => {
/**
* Check that api.blocks.getByUd(id) returns the Block for existed id
*/
it('should return Block API for existed id', () => {
cy.get('@editorInstance').then(async (editor: any) => {
const block = editor.blocks.getById(firstBlock.id);
expect(block).not.to.be.undefined;
expect(block.id).to.be.eq(firstBlock.id);
});
});
/**
* Check that api.blocks.getByUd(id) returns null for the not-existed id
*/
it('should return null for not-existed id', () => {
cy.get('@editorInstance').then(async (editor: any) => {
expect(editor.blocks.getById('not-existed-id')).to.be.null;
});
});
});
/**
* api.blocks.update(id, newData)
*/
describe('.update()', () => {
/**
* Check if block is updated in DOM
*/
it('should update block in DOM', () => {
cy.get('@editorInstance').then(async (editor: any) => {
const idToUpdate = firstBlock.id;
const newBlockData = {
text: 'Updated text',
};
editor.blocks.update(idToUpdate, newBlockData);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(newBlockData.text);
});
});
});
/**
* Check if block's data is updated after saving
*/
it('should update block in saved data', () => {
cy.get('@editorInstance').then(async (editor: any) => {
const idToUpdate = firstBlock.id;
const newBlockData = {
text: 'Updated text',
};
editor.blocks.update(idToUpdate, newBlockData);
const output = await (editor as any).save();
const text = output.blocks[0].data.text;
expect(text).to.be.eq(newBlockData.text);
});
});
/**
* When incorrect id passed, editor should not update any block
*/
it('shouldn\'t update any block if not-existed id passed', () => {
cy.get('@editorInstance').then(async (editor: any) => {
const idToUpdate = 'wrong-id-123';
const newBlockData = {
text: 'Updated text',
};
editor.blocks.update(idToUpdate, newBlockData);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(firstBlock.data.text);
});
});
});
});
});

View file

@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Header from '../../../example/tools/header';
import { nanoid } from 'nanoid';
describe.only('Block ids', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
tools: {
header: Header,
},
}).as('editorInstance');
}
});
it('Should generate unique block ids for new blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('First block ')
.type('{enter}')
.get('div.ce-block')
.last()
.type('Second block ')
.type('{enter}');
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('li.ce-toolbox__button[data-tool=header]')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.last()
.click()
.type('Header');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
data.blocks.forEach(block => {
expect(typeof block.id).to.eq('string');
});
});
});
it('should preserve passed ids', () => {
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get('@editorInstance')
.render({
blocks,
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{movetoend} Some more text');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
data.blocks.forEach((block, index) => {
expect(block.id).to.eq(blocks[index].id);
});
});
});
it('should preserve passed ids if blocks were added', () => {
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get('@editorInstance')
.render({
blocks,
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{enter}')
.next()
.type('Middle block');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id);
});
});
});

View file

@ -0,0 +1,277 @@
import Header from '../../../example/tools/header';
import Image from '../../../example/tools/simple-image';
import * as _ from '../../../src/components/utils';
describe('Copy pasting from Editor', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
tools: {
header: Header,
image: Image,
},
}).as('editorInstance');
}
});
context('pasting', () => {
it('should paste plain text', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/plain': 'Some plain text',
})
.wait(0)
.should('contain', 'Some plain text');
});
it('should paste inline html data', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/html': '<p><b>Some text</b></p>',
})
.should('contain.html', '<b>Some text</b>');
});
it('should paste several blocks if plain text contains new lines', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/plain': 'First block\n\nSecond block',
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.then(blocks => {
expect(blocks[0].textContent).to.eq('First block');
expect(blocks[1].textContent).to.eq('Second block');
});
});
it('should paste several blocks if html contains several paragraphs', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/html': '<p>First block</p><p>Second block</p>',
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.then(blocks => {
expect(blocks[0].textContent).to.eq('First block');
expect(blocks[1].textContent).to.eq('Second block');
});
});
it('should paste using custom data type', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'application/x-editor-js': JSON.stringify([
{
tool: 'paragraph',
data: {
text: 'First block',
},
},
{
tool: 'paragraph',
data: {
text: 'Second block',
},
},
]),
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.then(blocks => {
expect(blocks[0].textContent).to.eq('First block');
expect(blocks[1].textContent).to.eq('Second block');
});
});
it('should parse block tags', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/html': '<h2>First block</h2><p>Second block</p>',
});
cy.get('[data-cy=editorjs]')
.get('h2.ce-header')
.should('contain', 'First block');
cy.get('[data-cy=editorjs]')
.get('div.ce-paragraph')
.should('contain', 'Second block');
});
it('should parse pattern', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.paste({
'text/plain': 'https://codex.so/public/app/img/external/codex2x.png',
});
cy.get('[data-cy=editorjs]')
// In Edge test are performed slower, so we need to increase timeout to wait until image is loaded on the page
.get('img', { timeout: 10000 })
.should('have.attr', 'src', 'https://codex.so/public/app/img/external/codex2x.png');
});
});
context('copying', () => {
it('should copy inline fragment', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('Some text{selectall}')
.copy()
.then(clipboardData => {
/**
* As no blocks selected, clipboard data will be empty as will be handled by browser
*/
expect(clipboardData).to.be.empty;
});
});
it('should copy several blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('First block{enter}');
cy.get('[data-cy=editorjs')
.get('div.ce-block')
.next()
.type('Second block')
.type('{movetostart}')
.trigger('keydown', {
shiftKey: true,
keyCode: _.keyCodes.UP,
})
.copy()
.then(clipboardData => {
expect(clipboardData['text/html']).to.match(/<p>First block(<br>)?<\/p><p>Second block(<br>)?<\/p>/);
expect(clipboardData['text/plain']).to.eq(`First block\n\nSecond block`);
/**
* Need to wait for custom data as it is set asynchronously
*/
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(() => {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
const data = JSON.parse(clipboardData['application/x-editor-js']);
expect(data[0].tool).to.eq('paragraph');
expect(data[0].data.text).to.match(/First block(<br>)?/);
expect(data[1].tool).to.eq('paragraph');
expect(data[1].data.text).to.match(/Second block(<br>)?/);
});
});
});
});
context('cutting', () => {
it('should cut inline fragment', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('Some text{selectall}')
.cut()
.then(clipboardData => {
/**
* As no blocks selected, clipboard data will be empty as will be handled by browser
*/
expect(clipboardData).to.be.empty;
});
});
it('should cut several blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('First block{enter}');
cy.get('[data-cy=editorjs')
.get('div.ce-block')
.next()
.type('Second block')
.type('{movetostart}')
.trigger('keydown', {
shiftKey: true,
keyCode: _.keyCodes.UP,
})
.cut()
.then(clipboardData => {
expect(clipboardData['text/html']).to.match(/<p>First block(<br>)?<\/p><p>Second block(<br>)?<\/p>/);
expect(clipboardData['text/plain']).to.eq(`First block\n\nSecond block`);
/**
* Need to wait for custom data as it is set asynchronously
*/
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(() => {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
const data = JSON.parse(clipboardData['application/x-editor-js']);
expect(data[0].tool).to.eq('paragraph');
expect(data[0].data.text).to.match(/First block(<br>)?/);
expect(data[1].tool).to.eq('paragraph');
expect(data[1].data.text).to.match(/Second block(<br>)?/);
});
});
cy.get('[data-cy=editorjs]')
.should('not.contain', 'First block')
.should('not.contain', 'Second block');
});
it('should cut lots of blocks', () => {
const numberOfBlocks = 50;
for (let i = 0; i < numberOfBlocks; i++) {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.last()
.click()
.type(`Block ${i}{enter}`);
}
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{ctrl+A}')
.type('{ctrl+A}')
.cut()
.then((clipboardData) => {
/**
* Need to wait for custom data as it is set asynchronously
*/
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(0).then(() => {
expect(clipboardData['application/x-editor-js']).not.to.be.undefined;
const data = JSON.parse(clipboardData['application/x-editor-js']);
expect(data.length).to.eq(numberOfBlocks + 1);
});
});
});
});
});

View file

@ -9,7 +9,7 @@ describe('Editor basic initialization', () => {
const editorConfig = {};
beforeEach(() => {
if (this.editorInstance) {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor(editorConfig).as('editorInstance');

View file

@ -0,0 +1,296 @@
/* tslint:disable:max-classes-per-file */
/* eslint-disable @typescript-eslint/ban-ts-ignore,@typescript-eslint/no-explicit-any,jsdoc/require-jsdoc */
import Tools from '../../../../src/components/modules/tools';
import { EditorConfig } from '../../../../types';
import BlockTool from '../../../../src/components/tools/block';
describe('Tools module', () => {
const defaultConfig = {
tools: {},
};
/**
* Construct Tools module for testing purposes
*
* @param config - Editor config
*/
function constructModule(config: EditorConfig = defaultConfig): Tools {
const module = new Tools({
config,
eventsDispatcher: {},
} as any);
const APIMethods = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
method(): void {},
};
/**
* Module state should be Editor modules, so we mock required ones only
*/
module.state = {
API: {
getMethodsForTool(): typeof APIMethods {
return APIMethods;
},
},
} as any;
return module;
}
context('.prepare()', () => {
it('should return Promise resolved to void', async () => {
const module = constructModule();
let err;
try {
await module.prepare();
} catch (e) {
err = e;
}
expect(err).to.be.undefined;
});
it('should throw an error if tools config is corrupted', async () => {
const module = constructModule({
tools: {
// @ts-ignore
corruptedTool: 'value',
},
});
let err;
try {
await module.prepare();
} catch (e) {
err = e;
}
expect(err).to.be.instanceOf(Error);
});
});
context('collection accessors', () => {
let module: Tools;
beforeEach(async () => {
module = constructModule({
defaultBlock: 'withoutPrepare',
tools: {
withSuccessfulPrepare: {
class: class {
// eslint-disable-next-line @typescript-eslint/no-empty-function
public static prepare(): void {}
} as any,
inlineToolbar: [ 'inlineTool2' ],
tunes: [ 'blockTune2' ],
},
withFailedPrepare: class {
public static prepare(): void {
throw new Error();
}
} as any,
withoutPrepare: {
class: class {} as any,
inlineToolbar: false,
tunes: false,
},
blockTool: {
class: class {} as any,
inlineToolbar: true,
},
blockToolWithoutSettings: class {} as any,
inlineTool: class {
public static isInline = true
// eslint-disable-next-line @typescript-eslint/no-empty-function
public render(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public surround(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public checkState(): void {}
} as any,
inlineTool2: class {
public static isInline = true
// eslint-disable-next-line @typescript-eslint/no-empty-function
public render(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public surround(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public checkState(): void {}
} as any,
/**
* This tool will be unavailable as it doesn't have required methods
*/
unavailableInlineTool: class {
public static isInline = true;
} as any,
blockTune: class {
public static isTune = true;
} as any,
blockTune2: class {
public static isTune = true;
} as any,
unavailableBlockTune: class {
public static isTune = true;
public static prepare(): void {
throw new Error();
}
} as any,
},
inlineToolbar: ['inlineTool2', 'inlineTool'],
tunes: ['blockTune2', 'blockTune'],
});
await module.prepare();
});
context('.available', () => {
it('should return Map instance', () => {
expect(module.available).to.be.instanceOf(Map);
});
it('should contain only ready to use Tools', () => {
expect(module.available.has('withSuccessfulPrepare')).to.be.true;
expect(module.available.has('withoutPrepare')).to.be.true;
expect(module.available.has('withFailedPrepare')).to.be.false;
expect(module.available.has('unavailableInlineTool')).to.be.false;
});
});
context('.unavailable', () => {
it('should return Map instance', () => {
expect(module.unavailable).to.be.instanceOf(Map);
});
it('should contain only ready to use Tools', () => {
expect(module.unavailable.has('withSuccessfulPrepare')).to.be.false;
expect(module.unavailable.has('withoutPrepare')).to.be.false;
expect(module.unavailable.has('withFailedPrepare')).to.be.true;
expect(module.unavailable.has('unavailableInlineTool')).to.be.true;
});
});
context('.inlineTools', () => {
it('should return Map instance', () => {
expect(module.inlineTools).to.be.instanceOf(Map);
});
it('should contain only available Inline Tools', () => {
expect(module.inlineTools.has('inlineTool')).to.be.true;
expect(module.inlineTools.has('unavailableInlineTool')).to.be.false;
expect(Array.from(module.inlineTools.values()).every(tool => tool.isInline())).to.be.true;
});
});
context('.blockTools', () => {
it('should return Map instance', () => {
expect(module.blockTools).to.be.instanceOf(Map);
});
it('should contain only available Block Tools', () => {
expect(module.blockTools.has('withSuccessfulPrepare')).to.be.true;
expect(module.blockTools.has('withoutPrepare')).to.be.true;
expect(module.blockTools.has('withFailedPrepare')).to.be.false;
expect(Array.from(module.blockTools.values()).every(tool => tool.isBlock())).to.be.true;
});
it('Block Tools should contain default tunes if no settings is specified', () => {
const tool = module.blockTools.get('blockToolWithoutSettings');
expect(tool.tunes.has('delete')).to.be.true;
expect(tool.tunes.has('moveUp')).to.be.true;
expect(tool.tunes.has('moveDown')).to.be.true;
});
it('Block Tools should contain default tunes', () => {
const tool = module.blockTools.get('blockTool');
expect(tool.tunes.has('delete')).to.be.true;
expect(tool.tunes.has('moveUp')).to.be.true;
expect(tool.tunes.has('moveDown')).to.be.true;
});
it('Block Tools should contain tunes in correct order', () => {
let tool = module.blockTools.get('blockTool');
expect(tool.tunes.has('blockTune')).to.be.true;
expect(tool.tunes.has('blockTune2')).to.be.true;
expect(Array.from(tool.tunes.keys())).to.be.deep.eq(['blockTune2', 'blockTune', 'moveUp', 'delete', 'moveDown']);
tool = module.blockTools.get('withSuccessfulPrepare');
expect(tool.tunes.has('blockTune')).to.be.false;
expect(tool.tunes.has('blockTune2')).to.be.true;
tool = module.blockTools.get('withoutPrepare');
expect(tool.tunes.has('blockTune')).to.be.false;
expect(tool.tunes.has('blockTune2')).to.be.false;
});
it('Block Tools should contain inline tools in correct order', () => {
let tool = module.blockTools.get('blockTool');
expect(tool.inlineTools.has('inlineTool')).to.be.true;
expect(tool.inlineTools.has('inlineTool2')).to.be.true;
expect(Array.from(tool.inlineTools.keys())).to.be.deep.eq(['inlineTool2', 'inlineTool']);
tool = module.blockTools.get('withSuccessfulPrepare');
expect(tool.inlineTools.has('inlineTool')).to.be.false;
expect(tool.inlineTools.has('inlineTool2')).to.be.true;
tool = module.blockTools.get('withoutPrepare');
expect(tool.inlineTools.has('inlineTool')).to.be.false;
expect(tool.inlineTools.has('inlineTool2')).to.be.false;
});
});
context('.blockTunes', () => {
it('should return Map instance', () => {
expect(module.blockTunes).to.be.instanceOf(Map);
});
it('should contain only available Block Tunes', () => {
expect(module.blockTunes.has('blockTune')).to.be.true;
expect(module.blockTunes.has('unavailableBlockTune')).to.be.false;
expect(Array.from(module.blockTunes.values()).every(tool => tool.isTune())).to.be.true;
});
});
context('.internal', () => {
it('should return Map instance', () => {
expect(module.internal).to.be.instanceOf(Map);
});
it('should contain only internal tunes', () => {
expect(Array.from(module.internal.values()).every(tool => tool.isInternal)).to.be.true;
});
});
context('.defaultTools', () => {
/**
* @todo add check if user provided default tool is not Block Tool
*/
it('should return BlockTool instance', () => {
expect(module.defaultTool).to.be.instanceOf(BlockTool);
});
it('should return default Tool', () => {
expect(module.defaultTool.isDefault).to.be.true;
});
});
});
});

View file

@ -0,0 +1,127 @@
import Header from '../../../example/tools/header';
/**
* @todo Add checks that correct block API object is passed to onChange
* @todo Add cases for native inputs changes
*/
describe('onChange callback', () => {
const config = {
tools: {
header: Header,
},
onChange: (): void => {
console.log('something changed');
},
};
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.spy(config, 'onChange').as('onChange');
cy.createEditor(config).as('editorInstance');
}
});
it('should fire onChange callback on block insertion', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('{enter}');
cy.get('@onChange').should('be.called');
});
it('should fire onChange callback on typing into block', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('some text');
cy.get('@onChange').should('be.called');
});
it('should fire onChange callback on block replacement', () => {
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('li.ce-toolbox__button[data-tool=header]')
.click();
cy.get('@onChange').should('be.calledWithMatch', Cypress.sinon.match.any, Cypress.sinon.match({ name: 'header' }));
});
it('should fire onChange callback on tune modifier', () => {
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('li.ce-toolbox__button[data-tool=header]')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('span.ce-toolbar__settings-btn')
.click();
cy.get('[data-cy=editorjs]')
.get('span.cdx-settings-button[data-level=1]')
.click();
cy.get('@onChange').should('be.calledWithMatch', Cypress.sinon.match.any, Cypress.sinon.match({ name: 'header' }));
});
it('should fire onChange callback when block is removed', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('span.ce-toolbar__settings-btn')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-settings__button--delete')
.click()
.click();
cy.get('@onChange').should('be.called');
});
it('should fire onChange callback when block is moved', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('{enter}');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.last()
.click();
cy.get('[data-cy=editorjs]')
.get('span.ce-toolbar__settings-btn')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-tune-move-up')
.click();
cy.get('@onChange').should('be.called');
});
});

View file

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('Output sanitisation', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({}).as('editorInstance');
}
});
context('Output should save inline formatting', () => {
it('should save initial formatting for paragraph', () => {
cy.createEditor({
data: {
blocks: [ {
type: 'paragraph',
data: { text: '<b>Bold text</b>' },
} ],
},
}).then(async editor => {
const output = await (editor as any).save();
const boldText = output.blocks[0].data.text;
expect(boldText).to.eq('<b>Bold text</b>');
});
});
it('should save formatting for paragraph', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('This text should be bold.{selectall}');
cy.get('[data-cy=editorjs]')
.get('button.ce-inline-tool--bold')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('@editorInstance').then(async editorInstance => {
const output = await (editorInstance as any).save();
const text = output.blocks[0].data.text;
expect(text).to.match(/<b>This text should be bold\.(<br>)?<\/b>/);
});
});
it('should save formatting for paragraph on paste', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.paste({ 'text/html': '<p>Text</p><p><b>Bold text</b></p>' });
cy.get('@editorInstance').then(async editorInstance => {
const output = await (editorInstance as any).save();
const boldText = output.blocks[1].data.text;
expect(boldText).to.eq('<b>Bold text</b>');
});
});
});
});

View file

@ -0,0 +1,33 @@
import * as _ from '../../../src/components/utils';
describe('Blocks selection', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({}).as('editorInstance');
}
});
it('should remove block selection on click', () => {
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.click()
.type('First block{enter}');
cy.get('[data-cy=editorjs')
.find('div.ce-block')
.next()
.type('Second block')
.type('{movetostart}')
.trigger('keydown', {
shiftKey: true,
keyCode: _.keyCodes.UP,
});
cy.get('[data-cy=editorjs')
.click()
.find('div.ce-block')
.should('not.have.class', '.ce-block--selected');
});
});

View file

@ -0,0 +1,475 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* tslint:disable:max-classes-per-file */
import { BlockToolData, ToolSettings } from '../../../../types';
import { ToolType } from '../../../../src/components/tools/base';
import BlockTool from '../../../../src/components/tools/block';
import InlineTool from '../../../../src/components/tools/inline';
import ToolsCollection from '../../../../src/components/tools/collection';
describe('BlockTool', () => {
/**
* Mock for BlockTool constructor options
*/
const options = {
name: 'blockTool',
constructable: class {
public static sanitize = {
rule1: {
div: true,
},
}
public static toolbox = {
icon: 'Tool icon',
title: 'Tool title',
};
public static enableLineBreaks = true;
public static pasteConfig = {
tags: [ 'div' ],
};
public static conversionConfig = {
import: 'import',
export: 'export',
};
public static isReadOnlySupported = true;
public static reset;
public static prepare;
public static shortcut = 'CTRL+N';
public data: BlockToolData;
public block: object;
public readonly: boolean;
public api: object;
public config: ToolSettings;
/**
*
*/
constructor({ data, block, readOnly, api, config }) {
this.data = data;
this.block = block;
this.readonly = readOnly;
this.api = api;
this.config = config;
}
},
config: {
config: {
option1: 'option1',
option2: 'option2',
},
inlineToolbar: ['link', 'bold'],
tunes: ['anchor', 'favorites'],
shortcut: 'CMD+SHIFT+B',
toolbox: {
title: 'User Block Tool',
icon: 'User icon',
},
},
api: {
getMethodsForTool(): object {
return {
prop1: 'prop1',
prop2: 'prop2',
};
},
},
isDefault: false,
isInternal: false,
defaultPlaceholder: 'Default placeholder',
};
it('.type should return ToolType.Block', () => {
const tool = new BlockTool(options as any);
expect(tool.type).to.be.eq(ToolType.Block);
});
it('.name should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.name).to.be.eq(options.name);
});
it('.isDefault should return correct value', () => {
const tool1 = new BlockTool(options as any);
const tool2 = new BlockTool({
...options,
isDefault: true,
} as any);
expect(tool1.isDefault).to.be.false;
expect(tool2.isDefault).to.be.true;
});
it('.isInternal should return correct value', () => {
const tool1 = new BlockTool(options as any);
const tool2 = new BlockTool({
...options,
isInternal: true,
} as any);
expect(tool1.isInternal).to.be.false;
expect(tool2.isInternal).to.be.true;
});
context('.settings', () => {
it('should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.settings).to.be.deep.eq(options.config.config);
});
it('should add default placeholder if Tool is default', () => {
const tool = new BlockTool({
...options,
isDefault: true,
} as any);
expect(tool.settings).to.have.property('placeholder').that.eq(options.defaultPlaceholder);
});
});
context('.sanitizeConfig', () => {
it('should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.sanitizeConfig).to.be.deep.eq(options.constructable.sanitize);
});
it('should return composed config if there are enabled inline tools', () => {
const tool = new BlockTool(options as any);
const inlineTool = new InlineTool({
name: 'inlineTool',
constructable: class {
public static sanitize = {
b: true,
}
},
api: {},
config: {},
} as any);
tool.inlineTools = new ToolsCollection([ ['inlineTool', inlineTool] ]);
const expected = options.constructable.sanitize;
// tslint:disable-next-line:forin
for (const key in expected) {
expected[key] = {
...expected[key],
b: true,
};
}
expect(tool.sanitizeConfig).to.be.deep.eq(expected);
});
it('should return inline tools config if block one is not set', () => {
const tool = new BlockTool({
...options,
constructable: class {},
} as any);
const inlineTool1 = new InlineTool({
name: 'inlineTool',
constructable: class {
public static sanitize = {
b: true,
}
},
api: {},
config: {},
} as any);
const inlineTool2 = new InlineTool({
name: 'inlineTool',
constructable: class {
public static sanitize = {
a: true,
}
},
api: {},
config: {},
} as any);
tool.inlineTools = new ToolsCollection([ ['inlineTool', inlineTool1], ['inlineTool2', inlineTool2] ]);
expect(tool.sanitizeConfig).to.be.deep.eq(Object.assign(
{},
inlineTool1.sanitizeConfig,
inlineTool2.sanitizeConfig
));
});
it('should return empty object by default', () => {
const tool = new BlockTool({
...options,
constructable: class {},
} as any);
expect(tool.sanitizeConfig).to.be.deep.eq({});
});
});
it('.isBlock() should return true', () => {
const tool = new BlockTool(options as any);
expect(tool.isBlock()).to.be.true;
});
it('.isInline() should return false', () => {
const tool = new BlockTool(options as any);
expect(tool.isInline()).to.be.false;
});
it('.isTune() should return false', () => {
const tool = new BlockTool(options as any);
expect(tool.isTune()).to.be.false;
});
it('.isReadOnlySupported should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.isReadOnlySupported).to.be.eq(options.constructable.isReadOnlySupported);
});
it('.isLineBreaksEnabled should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.isLineBreaksEnabled).to.be.eq(options.constructable.enableLineBreaks);
});
it('.conversionConfig should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.conversionConfig).to.be.deep.eq(options.constructable.conversionConfig);
});
it('.pasteConfig should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.pasteConfig).to.be.deep.eq(options.constructable.pasteConfig);
});
context('.enabledInlineTools', () => {
it('should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.enabledInlineTools).to.be.deep.eq(options.config.inlineToolbar);
});
it('should return false by default', () => {
const tool = new BlockTool({
...options,
config: {
...options.config,
inlineToolbar: undefined,
},
} as any);
expect(tool.enabledInlineTools).to.be.false;
});
});
it('.enabledBlockTunes should return correct value', () => {
const tool = new BlockTool(options as any);
expect(tool.enabledBlockTunes).to.be.deep.eq(options.config.tunes);
});
context('.prepare()', () => {
it('should call Tool prepare method', () => {
options.constructable.prepare = cy.stub();
const tool = new BlockTool(options as any);
tool.prepare();
expect(options.constructable.prepare).to.have.been.calledWithMatch({
toolName: tool.name,
config: tool.settings,
});
});
it('should not fail if Tool prepare method is not exist', () => {
const tool = new BlockTool({
...options,
constructable: {},
} as any);
expect(tool.prepare).to.not.throw;
});
});
context('.reset()', () => {
it('should call Tool reset method', () => {
options.constructable.reset = cy.stub();
const tool = new BlockTool(options as any);
tool.reset();
expect(options.constructable.reset).to.be.calledOnce;
});
it('should not fail if Tool reset method is not exist', () => {
const tool = new BlockTool({
...options,
constructable: {},
} as any);
expect(tool.reset).to.not.throw;
});
});
context('.shortcut', () => {
it('should return user provided shortcut', () => {
const tool = new BlockTool(options as any);
expect(tool.shortcut).to.be.eq(options.config.shortcut);
});
it('should return Tool provided shortcut if user one is not specified', () => {
const tool = new BlockTool({
...options,
config: {
...options.config,
shortcut: undefined,
},
} as any);
expect(tool.shortcut).to.be.eq(options.constructable.shortcut);
});
});
context('.toolbox', () => {
it('should return user provided toolbox config', () => {
const tool = new BlockTool(options as any);
expect(tool.toolbox).to.be.deep.eq(options.config.toolbox);
});
it('should return Tool provided toolbox config if user one is not specified', () => {
const tool = new BlockTool({
...options,
config: {
...options.config,
toolbox: undefined,
},
} as any);
expect(tool.toolbox).to.be.deep.eq(options.constructable.toolbox);
});
it('should merge Tool provided toolbox config and user one', () => {
const tool1 = new BlockTool({
...options,
config: {
...options.config,
toolbox: {
title: options.config.toolbox.title,
},
},
} as any);
const tool2 = new BlockTool({
...options,
config: {
...options.config,
toolbox: {
icon: options.config.toolbox.icon,
},
},
} 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 }));
});
it('should return undefined if user specifies false as a value', () => {
const tool = new BlockTool({
...options,
config: {
...options.config,
toolbox: false,
},
} as any);
expect(tool.toolbox).to.be.undefined;
});
it('should return undefined if Tool specifies false as a value', () => {
const tool = new BlockTool({
...options,
constructable: class {
public static toolbox = false
},
} as any);
expect(tool.toolbox).to.be.undefined;
});
it('should return undefined if Tool provides empty config', () => {
const tool = new BlockTool({
...options,
constructable: class {
public static toolbox = {}
},
} as any);
expect(tool.toolbox).to.be.undefined;
});
});
context('.create()', () => {
const tool = new BlockTool(options as any);
const data = { text: 'text' };
const blockAPI = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
method(): void {},
};
it('should return Tool instance', () => {
expect(tool.create(data, blockAPI as any, false)).to.be.instanceOf(options.constructable);
});
it('should return Tool instance with passed data', () => {
const instance = tool.create(data, blockAPI as any, false) as any;
expect(instance.data).to.be.deep.eq(data);
});
it('should return Tool instance with passed BlockAPI object', () => {
const instance = tool.create(data, blockAPI as any, false) as any;
expect(instance.block).to.be.deep.eq(blockAPI);
});
it('should return Tool instance with passed readOnly flag', () => {
const instance1 = tool.create(data, blockAPI as any, false) as any;
const instance2 = tool.create(data, blockAPI as any, true) as any;
expect(instance1.readonly).to.be.eq(false);
expect(instance2.readonly).to.be.eq(true);
});
it('should return Tool instance with passed API object', () => {
const instance = tool.create(data, blockAPI as any, false) as any;
expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool());
});
it('should return Tool instance with passed config', () => {
const instance = tool.create(data, blockAPI as any, false) as any;
expect(instance.config).to.be.deep.eq(options.config.config);
});
});
});

View file

@ -0,0 +1,179 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* tslint:disable:max-classes-per-file */
import { ToolSettings } from '../../../../types';
import { ToolType } from '../../../../src/components/tools/base';
import BlockTune from '../../../../src/components/tools/tune';
import { BlockTuneData } from '../../../../types/block-tunes/block-tune-data';
describe('BlockTune', () => {
/**
* Mock for BlockTune constructor options
*/
const options = {
name: 'blockTune',
constructable: class {
public static reset;
public static prepare;
public api: object;
public config: ToolSettings;
public data: BlockTuneData;
public block: object;
/**
*
*/
constructor({ api, config, block, data }) {
this.api = api;
this.config = config;
this.block = block;
this.data = data;
}
},
config: {
config: {
option1: 'option1',
option2: 'option2',
},
shortcut: 'CMD+SHIFT+B',
},
api: {
getMethodsForTool(): object {
return {
prop1: 'prop1',
prop2: 'prop2',
};
},
},
isDefault: false,
isInternal: false,
defaultPlaceholder: 'Default placeholder',
};
it('.type should return ToolType.Tune', () => {
const tool = new BlockTune(options as any);
expect(tool.type).to.be.eq(ToolType.Tune);
});
it('.name should return correct value', () => {
const tool = new BlockTune(options as any);
expect(tool.name).to.be.eq(options.name);
});
it('.isInternal should return correct value', () => {
const tool1 = new BlockTune(options as any);
const tool2 = new BlockTune({
...options,
isInternal: true,
} as any);
expect(tool1.isInternal).to.be.false;
expect(tool2.isInternal).to.be.true;
});
it('.settings should return correct value', () => {
const tool = new BlockTune(options as any);
expect(tool.settings).to.be.deep.eq(options.config.config);
});
it('.isBlock() should return false', () => {
const tool = new BlockTune(options as any);
expect(tool.isBlock()).to.be.false;
});
it('.isInline() should return false', () => {
const tool = new BlockTune(options as any);
expect(tool.isInline()).to.be.false;
});
it('.isTune() should return true', () => {
const tool = new BlockTune(options as any);
expect(tool.isTune()).to.be.true;
});
context('.prepare()', () => {
it('should call Tool prepare method', () => {
options.constructable.prepare = cy.stub();
const tool = new BlockTune(options as any);
tool.prepare();
expect(options.constructable.prepare).to.have.been.calledWithMatch({
toolName: tool.name,
config: tool.settings,
});
});
it('should not fail if Tool prepare method is not exist', () => {
const tool = new BlockTune({
...options,
constructable: {},
} as any);
expect(tool.prepare).to.not.throw;
});
});
context('.reset()', () => {
it('should call Tool reset method', () => {
options.constructable.reset = cy.stub();
const tool = new BlockTune(options as any);
tool.reset();
expect(options.constructable.reset).to.be.calledOnce;
});
it('should not fail if Tool reset method is not exist', () => {
const tool = new BlockTune({
...options,
constructable: {},
} as any);
expect(tool.reset).to.not.throw;
});
});
context('.create()', () => {
const tool = new BlockTune(options as any);
const data = { text: 'text' };
const blockAPI = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
method(): void {},
};
it('should return Tool instance', () => {
expect(tool.create(data, blockAPI as any)).to.be.instanceOf(options.constructable);
});
it('should return Tool instance with passed data', () => {
const instance = tool.create(data, blockAPI as any) as any;
expect(instance.data).to.be.deep.eq(data);
});
it('should return Tool instance with passed BlockAPI object', () => {
const instance = tool.create(data, blockAPI as any) as any;
expect(instance.block).to.be.deep.eq(blockAPI);
});
it('should return Tool instance with passed API object', () => {
const instance = tool.create(data, blockAPI as any) as any;
expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool());
});
it('should return Tool instance with passed settings', () => {
const instance = tool.create(data, blockAPI as any) as any;
expect(instance.config).to.be.deep.eq(options.config.config);
});
});
});

View file

@ -0,0 +1,197 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* tslint:disable:max-classes-per-file */
import { ToolSettings } from '../../../../types';
import { ToolType } from '../../../../src/components/tools/base';
import InlineTool from '../../../../src/components/tools/inline';
describe('InlineTool', () => {
/**
* Mock for InlineTool constructor options
*/
const options = {
name: 'inlineTool',
constructable: class {
public static sanitize = {
rule1: 'rule1',
}
public static title = 'Title'
public static reset;
public static prepare;
public static shortcut = 'CTRL+N';
public api: object;
public config: ToolSettings;
/**
*
*/
constructor({ api, config }) {
this.api = api;
this.config = config;
}
},
config: {
config: {
option1: 'option1',
option2: 'option2',
},
shortcut: 'CMD+SHIFT+B',
},
api: {
getMethodsForTool(): object {
return {
prop1: 'prop1',
prop2: 'prop2',
};
},
},
isDefault: false,
isInternal: false,
defaultPlaceholder: 'Default placeholder',
};
it('.type should return ToolType.Inline', () => {
const tool = new InlineTool(options as any);
expect(tool.type).to.be.eq(ToolType.Inline);
});
it('.name should return correct value', () => {
const tool = new InlineTool(options as any);
expect(tool.name).to.be.eq(options.name);
});
it('.title should return correct title', () => {
const tool = new InlineTool(options as any);
expect(tool.title).to.be.eq(options.constructable.title);
});
it('.isInternal should return correct value', () => {
const tool1 = new InlineTool(options as any);
const tool2 = new InlineTool({
...options,
isInternal: true,
} as any);
expect(tool1.isInternal).to.be.false;
expect(tool2.isInternal).to.be.true;
});
it('.settings should return correct value', () => {
const tool = new InlineTool(options as any);
expect(tool.settings).to.be.deep.eq(options.config.config);
});
it('.sanitizeConfig should return correct value', () => {
const tool = new InlineTool(options as any);
expect(tool.sanitizeConfig).to.be.deep.eq(options.constructable.sanitize);
});
it('.isBlock() should return false', () => {
const tool = new InlineTool(options as any);
expect(tool.isBlock()).to.be.false;
});
it('.isInline() should return true', () => {
const tool = new InlineTool(options as any);
expect(tool.isInline()).to.be.true;
});
it('.isTune() should return false', () => {
const tool = new InlineTool(options as any);
expect(tool.isTune()).to.be.false;
});
context('.prepare()', () => {
it('should call Tool prepare method', () => {
options.constructable.prepare = cy.stub();
const tool = new InlineTool(options as any);
tool.prepare();
expect(options.constructable.prepare).to.have.been.calledWithMatch({
toolName: tool.name,
config: tool.settings,
});
});
it('should not fail if Tool prepare method is not exist', () => {
const tool = new InlineTool({
...options,
constructable: {},
} as any);
expect(tool.prepare).to.not.throw;
});
});
context('.reset()', () => {
it('should call Tool reset method', () => {
options.constructable.reset = cy.stub();
const tool = new InlineTool(options as any);
tool.reset();
expect(options.constructable.reset).to.be.calledOnce;
});
it('should not fail if Tool reset method is not exist', () => {
const tool = new InlineTool({
...options,
constructable: {},
} as any);
expect(tool.reset).to.not.throw;
});
});
context('.shortcut', () => {
it('should return user provided shortcut', () => {
const tool = new InlineTool(options as any);
expect(tool.shortcut).to.be.eq(options.config.shortcut);
});
it('should return Tool provided shortcut if user one is not specified', () => {
const tool = new InlineTool({
...options,
config: {
...options.config,
shortcut: undefined,
},
} as any);
expect(tool.shortcut).to.be.eq(options.constructable.shortcut);
});
});
context('.create()', () => {
const tool = new InlineTool(options as any);
it('should return Tool instance', () => {
expect(tool.create()).to.be.instanceOf(options.constructable);
});
it('should return Tool instance with passed API object', () => {
const instance = tool.create() as any;
expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool());
});
it('should return Tool instance with passed config', () => {
const instance = tool.create() as any;
expect(instance.config).to.be.deep.eq(options.config.config);
});
});
});

View file

@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ToolsCollection from '../../../../src/components/tools/collection';
import BlockTool from '../../../../src/components/tools/block';
import InlineTool from '../../../../src/components/tools/inline';
import BlockTune from '../../../../src/components/tools/tune';
import BaseTool from '../../../../src/components/tools/base';
const FakeTool = {
isBlock(): boolean {
return false;
},
isInline(): boolean {
return false;
},
isTune(): boolean {
return false;
},
isInternal: false,
};
const FakeBlockTool = {
...FakeTool,
isBlock(): boolean {
return true;
},
};
const FakeInlineTool = {
...FakeTool,
isInline(): boolean {
return true;
},
};
const FakeBlockTune = {
...FakeTool,
isTune(): boolean {
return true;
},
};
/**
* Unit tests for ToolsCollection class
*/
describe('ToolsCollection', (): void => {
let collection;
/**
* Mock for Tools in collection
*/
const fakeTools = [
['block1', FakeBlockTool],
['inline1', FakeInlineTool],
['block2', {
...FakeBlockTool,
isInternal: true,
} ],
['tune1', FakeBlockTune],
['block3', FakeBlockTool],
['inline2', {
...FakeInlineTool,
isInternal: true,
} ],
['tune2', FakeBlockTune],
['tune3', {
...FakeBlockTune,
isInternal: true,
} ],
['block3', FakeInlineTool],
['block4', FakeBlockTool],
];
beforeEach((): void => {
collection = new ToolsCollection(fakeTools as any);
});
it('should be instance of Map', (): void => {
expect(collection instanceof Map).to.be.true;
});
context('.blockTools', (): void => {
it('should return new instance of ToolsCollection', (): void => {
expect(collection.blockTools instanceof ToolsCollection).to.be.true;
});
it('result should contain only block tools', (): void => {
expect(
Array
.from(
collection.blockTools.values()
)
.every((tool: BlockTool) => tool.isBlock())
).to.be.true;
});
});
context('.inlineTools', (): void => {
it('should return new instance of ToolsCollection', (): void => {
expect(collection.inlineTools instanceof ToolsCollection).to.be.true;
});
it('result should contain only inline tools', (): void => {
expect(
Array
.from(
collection.inlineTools.values()
)
.every((tool: InlineTool) => tool.isInline())
).to.be.true;
});
});
context('.blockTunes', (): void => {
it('should return new instance of ToolsCollection', (): void => {
expect(collection.blockTunes instanceof ToolsCollection).to.be.true;
});
it('result should contain only block tools', (): void => {
expect(
Array
.from(
collection.blockTunes.values()
)
.every((tool: BlockTune) => tool.isTune())
).to.be.true;
});
});
context('.internalTools', (): void => {
it('should return new instance of ToolsCollection', (): void => {
expect(collection.internalTools instanceof ToolsCollection).to.be.true;
});
it('result should contain only internal tools', (): void => {
expect(
Array
.from(
collection.internalTools.values()
)
.every((tool: BaseTool) => tool.isInternal)
).to.be.true;
});
});
context('.externalTools', (): void => {
it('should return new instance of ToolsCollection', (): void => {
expect(collection.externalTools instanceof ToolsCollection).to.be.true;
});
it('result should contain only external tools', (): void => {
expect(
Array
.from(
collection.externalTools.values()
)
.every((tool: BaseTool) => !tool.isInternal)
).to.be.true;
});
});
context('mixed access', (): void => {
context('.blockTunes.internalTools', (): void => {
it('should return only internal tunes', (): void => {
expect(
Array
.from(
collection.blockTunes.internalTools.values()
)
.every((tool: BlockTune) => tool.isTune() && tool.isInternal)
).to.be.true;
});
});
context('.externalTools.blockTools', (): void => {
it('should return only external block tools', (): void => {
expect(
Array
.from(
collection.externalTools.blockTools.values()
)
.every((tool: BlockTool) => tool.isBlock() && !tool.isInternal)
).to.be.true;
});
});
});
});

View file

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import LinkInlineTool from '../../../../src/components/inline-tools/inline-tool-link';
import MoveUpTune from '../../../../src/components/block-tunes/block-tune-move-up';
import ToolsFactory from '../../../../src/components/tools/factory';
import InlineTool from '../../../../src/components/tools/inline';
import BlockTool from '../../../../src/components/tools/block';
import BlockTune from '../../../../src/components/tools/tune';
import Paragraph from '../../../../src/tools/paragraph/dist/bundle';
describe('ToolsFactory', (): void => {
let factory;
const config = {
paragraph: {
class: Paragraph,
},
link: {
class: LinkInlineTool,
},
moveUp: {
class: MoveUpTune,
},
};
beforeEach((): void => {
factory = new ToolsFactory(
config,
{
placeholder: 'Placeholder',
defaultBlock: 'paragraph',
} as any,
{} as any
);
});
context('.get', (): void => {
it('should return appropriate tool object', (): void => {
const tool = factory.get('link');
expect(tool.name).to.be.eq('link');
});
it('should return InlineTool object for inline tool', (): void => {
const tool = factory.get('link');
expect(tool instanceof InlineTool).to.be.true;
});
it('should return BlockTool object for block tool', (): void => {
const tool = factory.get('paragraph');
expect(tool instanceof BlockTool).to.be.true;
});
it('should return BlockTune object for tune', (): void => {
const tool = factory.get('moveUp');
expect(tool instanceof BlockTune).to.be.true;
});
});
});

View file

@ -1,10 +1,13 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["es2017", "dom"],
"types": ["cypress"]
"lib": ["dom", "es2017", "es2018"],
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true
},
"include": [
"**/*.ts"
"../../**/*.ts"
]
}
}

View file

@ -53,13 +53,11 @@ This document will describe various test cases of the editor.js functionality. F
- [ ] If omitted the Editor.js should be initialized with the default `sanitizer` configuration, which allows the tags like `paragraph`, `anchor`, and `bold` for cleaning HTML.
- [ ] `tools` property
- [ ] If omitted
- [ ] Editor.js should be initialized with the Paragraph tool only.
- [ ] If omitted,the Editor.js should be initialized with the Paragraph tool only.
- [ ] If `object` passed
- [ ] Editor.js should be initialized with all the passed tools.
- [ ] The keys of the object should be represented as `type` fields for corresponded blocks in output JSON
- [ ] If value is a JavaScript class
- [ ] This class should be used as a tool
- [ ] If value is a JavaScript class, the class should be used as a tool
- [ ] If value is an `object`
- [ ] Checking the `class` property
- [ ] If omitted, the tool should be skipped with a warning in a console.
@ -69,14 +67,42 @@ This document will describe various test cases of the editor.js functionality. F
- [ ] Checking the `shortcut` property
- [ ] If `string` passed Editor.js should append the `tool` when such keys combination executed.
- [ ] Checking the `inilineToolbar` property
- [ ] If `true`
- [ ] Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings.
- [ ] If `false`
- [ ] Editor.js should not show the Inline Toolbar for this tool.
- [ ] If `array`
- [ ] Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order.
- [ ] If omitted
- [ ] Editor.js should not show the Inline Toolbar for this tool.
- [ ] If `true` passed, the Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings.
- [ ] If `false` passed, the Editor.js should not show the Inline Toolbar for this tool.
- [ ] If `array` passed, the Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order.
- [ ] If omitted, the Editor.js should not show the Inline Toolbar for this tool.
- [ ] Checking the `toolbox` property
- [ ] If it contains `title`, this title should be used as a tool title
- [ ] If it contains `icon`, this HTML code (maybe SVG) should be used as a tool icon
- [ ] `onReady` property
- [ ] If `function` passed, the Editor.js should call the `function` when it's ready to work.
- [ ] If omitted, the Editor.js should be initialized with the `tools` only.
- [ ] `onChange` property
- [ ] If `function` passed,the Editor.js should call the `function` when something changed in Editor.js DOM.
- [ ] If omitted, the Editor.js should be initialized with the `tools` only.
- [ ] `data` property
- [ ] If omitted
- [ ] the Editor.js should be initialized with the `tools` only.
- [ ] the Editor.js should be empty.
- [ ] If `object` passed
- [ ] Checking the `blocks` property
- [ ] If `array` of `object` passed,
- [ ] for each `object`
- [ ] Checking the `type` and `data` property
- [ ] the Editor.js should be initialize with `block` of class `type`
- [ ] If `type` not present in `tools`, the Editor.js should throw an error.
- [ ] If omitted
- [ ] the Editor.js should be initialized with the `tools` only.
- [ ] the Editor.js should be empty.
- [ ] `readOnly` property
- [ ] If `true` passed,
- [ ] If any `tool` have not readOnly getter defined,The Editor.js should throw an error.
- [ ] otherwise, the Editor.js should be initialize with readOnly mode.
- [ ] If `false` passed,the Editor.js should be initialized with the `tools` only.
- [ ] If omitted,the Editor.js should be initialized with the `tools` only.
- [ ] `i18n` property

View file

@ -10,6 +10,7 @@
"resolveJsonModule": true,
// allows to omit export default in .json files
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true
}
}

View file

@ -5,6 +5,11 @@ import {SavedData} from '../data-formats';
* @interface BlockAPI Describes Block API methods and properties
*/
export interface BlockAPI {
/**
* Block unique identifier
*/
readonly id: string;
/**
* Tool name
*/

14
types/api/blocks.d.ts vendored
View file

@ -54,6 +54,12 @@ export interface Blocks {
*/
getBlockByIndex(index: number): BlockAPI | void;
/**
* Returns Block API object by passed Block id
* @param id - id of the block
*/
getById(id: string): BlockAPI | null;
/**
* Returns current Block index
* @returns {number}
@ -99,4 +105,12 @@ export interface Blocks {
needToFocus?: boolean,
): void;
/**
* Updates block data by id
*
* @param id - id of the block to update
* @param data - the new data
*/
update(id: string, data: BlockToolData): void;
}

View file

@ -9,4 +9,9 @@ export interface ReadOnly {
* @returns {Promise<boolean>} current value
*/
toggle: (state?: boolean) => Promise<boolean>;
/**
* Contains current read-only state
*/
isEnabled: boolean;
}

View file

@ -0,0 +1 @@
export type BlockTuneData = any;

View file

@ -1,4 +1,5 @@
import {API, ToolConfig} from '../index';
import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index';
import { BlockTuneData } from './block-tune-data';
/**
* Describes BLockTune blueprint
@ -10,11 +11,61 @@ export interface BlockTune {
* @return {HTMLElement}
*/
render(): HTMLElement;
/**
* Method called on Tool render. Pass Tool content as an argument.
*
* You can wrap Tool's content with any wrapper you want to provide Tune's UI
*
* @param {HTMLElement} pluginsContent Tool's content wrapper
*
* @return {HTMLElement}
*/
wrap?(pluginsContent: HTMLElement): HTMLElement;
/**
* Called on Tool's saving. Should return any data Tune needs to save
*
* @return {BlockTuneData}
*/
save?(): BlockTuneData;
}
/**
* Describes BlockTune class constructor function
*/
export interface BlockTuneConstructable {
new (config: {api: API, settings?: ToolConfig}): BlockTune;
/**
* Flag show Tool is Block Tune
*/
isTune: boolean;
/**
* Tune's sanitize configuration
*/
sanitize?: SanitizerConfig;
/**
* @constructor
*
* @param config - Block Tune config
*/
new(config: {
api: API,
config?: ToolConfig,
block: BlockAPI,
data: BlockTuneData,
}): BlockTune;
/**
* Tune`s prepare method. Can be async
* @param data
*/
prepare?(): Promise<void> | void;
/**
* Tune`s reset method to clean up anything set by prepare. Can be async
*/
reset?(): void | Promise<void>;
}

View file

@ -1,5 +1,5 @@
import {ToolConstructable, ToolSettings} from '../tools';
import {API, LogLevels, OutputData} from '../index';
import {API, BlockAPI, LogLevels, OutputData} from '../index';
import {SanitizerConfig} from './sanitizer-config';
import {I18nConfig} from './i18n-config';
@ -53,7 +53,9 @@ export interface EditorConfig {
/**
* Map of Tools to use
*/
tools?: {[toolName: string]: ToolConstructable|ToolSettings};
tools?: {
[toolName: string]: ToolConstructable|ToolSettings;
}
/**
* Data to render on Editor start
@ -88,11 +90,17 @@ export interface EditorConfig {
/**
* Fires when something changed in DOM
* @param {API} api - editor.js api
* @param block - changed block API
*/
onChange?(api: API): void;
onChange?(api: API, block: BlockAPI): void;
/**
* Defines default toolbar for all tools.
*/
inlineToolbar?: string[]|boolean;
/**
* Common Block Tunes list. Will be added to all the blocks which do not specify their own 'tunes' set
*/
tunes?: string[];
}

View file

@ -4,6 +4,7 @@ import {BlockToolData} from '../tools';
* Tool's saved data
*/
export interface SavedData {
id: string;
tool: string;
data: BlockToolData;
time: number;
@ -13,6 +14,7 @@ export interface SavedData {
* Tool's data after validation
*/
export interface ValidatedData {
id?: string;
tool?: string;
data?: BlockToolData;
time?: number;

View file

@ -1,4 +1,5 @@
import {BlockToolData} from '../tools';
import {BlockTuneData} from '../block-tunes/block-tune-data';
/**
* Output of one Tool
@ -7,6 +8,10 @@ import {BlockToolData} from '../tools';
* @template Data - the structure describing a data object supported by the tool
*/
export interface OutputBlockData<Type extends string = string, Data extends object = any> {
/**
* Unique Id of the block
*/
id?: string;
/**
* Tool type
*/
@ -15,6 +20,11 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
* Saved Block data
*/
data: BlockToolData<Data>;
/**
* Block Tunes data
*/
tunes?: {[name: string]: BlockTuneData};
}
export interface OutputData {

View file

@ -1,6 +1,6 @@
import {BlockTool, BlockToolConstructable} from './block-tool';
import {InlineTool, InlineToolConstructable} from './inline-tool';
import {BaseTool, BaseToolConstructable} from './tool';
import { BlockTool, BlockToolConstructable } from './block-tool';
import { InlineTool, InlineToolConstructable } from './inline-tool';
import { BlockTune, BlockTuneConstructable } from '../block-tunes';
export * from './block-tool';
export * from './block-tool-data';
@ -11,5 +11,5 @@ export * from './tool-settings';
export * from './paste-events';
export * from './hook-events';
export type Tool = BlockTool | InlineTool;
export type ToolConstructable = BlockToolConstructable | InlineToolConstructable;
export type Tool = BlockTool | InlineTool | BlockTune;
export type ToolConstructable = BlockToolConstructable | InlineToolConstructable | BlockTuneConstructable;

View file

@ -1,5 +1,5 @@
import {BaseTool, BaseToolConstructable} from './tool';
import {API, ToolConfig} from "../index";
import {API, ToolConfig} from '../index';
/**
* Base structure for the Inline Toolbar Tool
*/

View file

@ -21,7 +21,7 @@ export interface ToolboxConfig {
*
* @template Config - the structure describing a config object supported by the tool
*/
export interface ToolSettings <Config extends object = any> {
export interface ExternalToolSettings<Config extends object = any> {
/**
* Tool's class
@ -39,6 +39,12 @@ export interface ToolSettings <Config extends object = any> {
*/
inlineToolbar?: boolean | string[];
/**
* BlockTunes for Tool
* Can accept array of tune names or boolean.
*/
tunes?: boolean | string[];
/**
* Define shortcut that will render Tool
*/
@ -50,3 +56,13 @@ export interface ToolSettings <Config extends object = any> {
*/
toolbox?: ToolboxConfig | false;
}
/**
* For internal Tools 'class' property is optional
*/
export type InternalToolSettings<Config extends object = any> = Omit<ExternalToolSettings<Config>, 'class'> & Partial<Pick<ExternalToolSettings<Config>, 'class'>>;
/**
* Union of external and internal Tools settings
*/
export type ToolSettings<Config extends object = any> = InternalToolSettings<Config> | ExternalToolSettings<Config>;

2262
yarn.lock

File diff suppressed because it is too large Load diff