mirror of
https://github.com/codex-team/editor.js
synced 2026-03-18 00:19:53 +01:00
Merge branch 'next' into feat/dragndrop
This commit is contained in:
commit
2de7c7dee6
63 changed files with 3009 additions and 626 deletions
|
|
@ -49,8 +49,7 @@ jobs:
|
|||
# Setup node environment
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 15
|
||||
registry-url: https://registry.npmjs.org/
|
||||
node-version: 16
|
||||
|
||||
# Bump version to the next prerelease (patch) with rc suffix
|
||||
- name: Suggest the new version
|
||||
|
|
|
|||
23
.github/workflows/create-a-release-draft.yml
vendored
23
.github/workflows/create-a-release-draft.yml
vendored
|
|
@ -12,6 +12,9 @@ jobs:
|
|||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
# Checkout to target branch
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
|
|
@ -53,8 +56,7 @@ jobs:
|
|||
# Setup node environment
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.17.0
|
||||
registry-url: https://registry.npmjs.org/
|
||||
node-version: 16
|
||||
|
||||
# Prepare, build and publish project
|
||||
- name: Install dependencies
|
||||
|
|
@ -87,16 +89,27 @@ jobs:
|
|||
# If version name contains "-rc" suffix than mark a "pre-release" checkbox
|
||||
prerelease: ${{ contains(steps.package.outputs.version, '-rc') }}
|
||||
|
||||
# Build and upload target Editor.js build to release as artifact
|
||||
# Build and upload target Editor.js UMD build to release as artifact
|
||||
- name: Upload Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: dist/editor.js
|
||||
asset_name: editor.js
|
||||
asset_path: dist/editorjs.umd.js
|
||||
asset_name: editorjs.umd.js
|
||||
asset_content_type: application/javascript
|
||||
|
||||
# Build and upload target Editor.js MJS build to release as artifact
|
||||
- name: Upload Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: dist/editorjs.mjs
|
||||
asset_name: editorjs.mjs
|
||||
asset_content_type: application/javascript
|
||||
|
||||
# Send a notification message
|
||||
- name: Send a message
|
||||
|
|
|
|||
2
.github/workflows/publish-package-to-npm.yml
vendored
2
.github/workflows/publish-package-to-npm.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
# Setup node environment
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.17.0
|
||||
node-version: 16
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
# Prepare, build and publish project
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -8,6 +8,7 @@
|
|||
"colspan",
|
||||
"contenteditable",
|
||||
"contentless",
|
||||
"Convertable",
|
||||
"cssnano",
|
||||
"cssnext",
|
||||
"Debouncer",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"textareas",
|
||||
"twitterwidget",
|
||||
"typeof",
|
||||
"Unmergeable",
|
||||
"viewports"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,49 @@
|
|||
# Changelog
|
||||
|
||||
### 2.28.0
|
||||
|
||||
- `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id.
|
||||
- `New` - The `.convert(blockId, newType)` API method added
|
||||
- `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one.
|
||||
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
|
||||
- `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current.
|
||||
- `Improvement` - Tools shortcuts could be used to convert one Block to another.
|
||||
- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar
|
||||
|
||||
### 2.27.2
|
||||
|
||||
- `Fix` - `onChange` won't be called when element with data-mutation-free changes some attribute
|
||||
|
||||
### 2.27.1
|
||||
|
||||
- `Fix` - `onChange` will be called on removing the whole text in a block
|
||||
|
||||
### 2.27.0
|
||||
|
||||
- `New` — *Toolbar API* — Toolbox toggling method added.
|
||||
- `Refactoring` — Popover class refactored.
|
||||
- `New` — *Toolbar API* — Added a new method for toggling the toolbox.
|
||||
- `New` — Added types for block mutation events
|
||||
- `New` — Batching added to the `onChange` callback. Now the second argument can contain an array of CustomEvents as well as a single one. Multiple changes made in a short period of time will be batched under a single `onChange` call.
|
||||
- `Improvement` — *Toolbox* — Number of `close()` method calls optimized.
|
||||
- `Improvement` — The `onChange` callback won't be triggered only if all mutations contain nodes with the `data-mutation-free` attributes.
|
||||
- `Fix` — Resolve compiler error from importing the BlockToolData type.
|
||||
- `Fix` — Resolved a problem when document was being scrolled to the beginning after moving up a Block above the viewport.
|
||||
- `Improvement` — The `onChange` callback can be muted if all mutations contain nodes with the `data-mutation-free` attribute.
|
||||
- `Improvement` — Pressing "Enter" at the end of a Block won't lead to redundant `block-changed` event triggering. Only `block-added` event will be dispatched.
|
||||
- `Improvement` — The block mutation handler is now called on every block change (including background changes), instead of only when a block is focused
|
||||
- `Improvement` — Number of caret saving method calls optimized for Block Tunes opening/closing.
|
||||
- `Improvement` — Package size reduced by removing redundant files.
|
||||
- `Fix`- Several bugs caused by random browser extensions.
|
||||
- `Improvement` — *Dependencies* — Upgrade TypeScript to v5.
|
||||
- `Refactoring` — Switched from Webpack to Vite as the build system.
|
||||
- `Refactoring` — *Dependencies* — Upgraded Cypress to v12 and related libraries to the latest versions.
|
||||
- `Refactoring` — *Dependencies* — Upgraded TypeScript to v5.
|
||||
- `Refactoring` — `EventDispatcher` types improved. Now we can pass `EventsMap` via generic to specify a map of event names and their payloads that can be used in a particular EventDispatcher instance.
|
||||
- `Refactoring` — All events in common editor Event Bus now have own type declarations.
|
||||
- `Refactoring` — Removed the block mutation observer from blocks and attached a single observer to the editor's blocks wrapper element.
|
||||
- `Refactoring` — Removed the debounce from the block mutation handler and used batching instead.
|
||||
- `Refactoring` — Refactored the popover class for better performance and maintenance.
|
||||
- `Fix` — The `onChange` callback won't trigger when block tunes are opened or closed.
|
||||
- `Fix` — Resolved a compiler error caused by importing the `BlockToolData` type.
|
||||
- `Fix` — Resolved a problem where the document would scroll to the beginning after moving a block above the viewport.
|
||||
- `Fix`- Fixed several bugs caused by browser extensions — Removed the search for a block's container in the DOM on saving and kept it in memory instead, updating it when the tool changes a container element.
|
||||
- `Fix` — *ToolsAPI* — `pasteConfig` getter with `false` value could be used to disable paste handling by Editor.js core. Could be useful if your tool has its own paste handler.
|
||||
- `Improvement` — *Dependencies* — Upgrade Cypress to v12, upgrade related libraries to latest versions.
|
||||
- `CI` — Use Ubuntu container for Edge tests runner.
|
||||
- `Improvement` — Use Vite as build system instead of Webpack.
|
||||
- `CI` — Ubuntu container is now used for Edge tests runner.
|
||||
- `CI` — Node 16 is used for GitHib Actions.
|
||||
|
||||
### 2.26.5
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 3cc506758440ac3f1bc83008a6ef75813b6386c3
|
||||
Subproject commit 605a73d2b7bec6438c7c0d5ab09eae86b5e9212e
|
||||
|
|
@ -406,4 +406,4 @@
|
|||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@editorjs/editorjs",
|
||||
"version": "2.27.0-rc.4",
|
||||
"version": "2.28.0-rc.1",
|
||||
"description": "Editor.js — Native JS, based on API and Open Source",
|
||||
"main": "dist/editorjs.umd.js",
|
||||
"module": "dist/editorjs.mjs",
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
"@editorjs/paragraph": "^2.9.0",
|
||||
"@editorjs/simple-image": "^1.4.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"chai-subset": "^1.6.0",
|
||||
"codex-notifier": "^1.1.2",
|
||||
"codex-tooltip": "^1.0.5",
|
||||
"core-js": "3.30.0",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EditorConfig } from '../../types';
|
|||
import { ModuleConfig } from '../types-internal/module-config';
|
||||
import Listeners from './utils/listeners';
|
||||
import EventsDispatcher from './utils/events';
|
||||
import { EditorEventMap } from './events';
|
||||
|
||||
/**
|
||||
* The type <T> of the Module generic.
|
||||
|
|
@ -42,7 +43,7 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
|
|||
/**
|
||||
* Editor event dispatcher class
|
||||
*/
|
||||
protected eventsDispatcher: EventsDispatcher;
|
||||
protected eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||
|
||||
/**
|
||||
* Util for bind/unbind DOM event listeners
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
|||
import ToolsCollection from '../tools/collection';
|
||||
import EventsDispatcher from '../utils/events';
|
||||
import { TunesMenuConfigItem } from '../../../types/tools';
|
||||
import { isMutationBelongsToElement } from '../utils/mutations';
|
||||
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
|
||||
import { convertBlockDataToString } from '../utils/blocks';
|
||||
|
||||
/**
|
||||
* Interface describes Block class constructor argument
|
||||
|
|
@ -93,9 +97,11 @@ export enum BlockDropZonePosition {
|
|||
}
|
||||
|
||||
/**
|
||||
* Names of events supported by Block class
|
||||
* Names of events used in Block
|
||||
*/
|
||||
type BlockEvents = 'didMutated';
|
||||
interface BlockEvents {
|
||||
'didMutated': Block,
|
||||
}
|
||||
|
||||
/**
|
||||
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
|
||||
|
|
@ -203,89 +209,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
private inputIndex = 0;
|
||||
|
||||
/**
|
||||
* Mutation observer to handle DOM mutations
|
||||
*
|
||||
* @type {MutationObserver}
|
||||
* Common editor event bus
|
||||
*/
|
||||
private mutationObserver: MutationObserver;
|
||||
private readonly editorEventBus: EventsDispatcher<EditorEventMap> | null = null;
|
||||
|
||||
/**
|
||||
* Debounce Timer
|
||||
*
|
||||
* @type {number}
|
||||
* Link to editor dom change callback. Used to remove listener on remove
|
||||
*/
|
||||
private readonly modificationDebounceTimer = 450;
|
||||
|
||||
/**
|
||||
* Is fired when DOM mutation has been happened
|
||||
*
|
||||
* mutationsOrInputEvent — actual changes
|
||||
* - MutationRecord[] - any DOM change
|
||||
* - InputEvent — <input> change
|
||||
* - undefined — manual triggering of block.dispatchChange()
|
||||
*/
|
||||
private didMutated = _.debounce((mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
|
||||
/**
|
||||
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
|
||||
*/
|
||||
let shouldFireUpdate;
|
||||
|
||||
if (mutationsOrInputEvent === undefined) {
|
||||
shouldFireUpdate = true;
|
||||
} else if (mutationsOrInputEvent instanceof InputEvent) {
|
||||
shouldFireUpdate = true;
|
||||
} else {
|
||||
/**
|
||||
* Update from 2023, Feb 17:
|
||||
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
|
||||
* since there could be a real mutations same-time with mutation-free changes,
|
||||
* for example when Block Tune change: block is changing along with FakeCursor (mutation-free) removing
|
||||
* — we should fire 'didMutated' event in that case
|
||||
*/
|
||||
const everyRecordIsMutationFree = mutationsOrInputEvent.length > 0 && mutationsOrInputEvent.every((record) => {
|
||||
const { addedNodes, removedNodes } = record;
|
||||
const changedNodes = [
|
||||
...Array.from(addedNodes),
|
||||
...Array.from(removedNodes),
|
||||
];
|
||||
|
||||
return changedNodes.some((node) => {
|
||||
if ($.isElement(node) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (node as HTMLElement).dataset.mutationFree === 'true';
|
||||
});
|
||||
});
|
||||
|
||||
if (everyRecordIsMutationFree) {
|
||||
shouldFireUpdate = false;
|
||||
} else {
|
||||
shouldFireUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In case some mutation free elements are added or removed, do not trigger didMutated event
|
||||
*/
|
||||
if (!shouldFireUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cache
|
||||
*/
|
||||
this.cachedInputs = [];
|
||||
|
||||
/**
|
||||
* Update current input
|
||||
*/
|
||||
this.updateCurrentInput();
|
||||
|
||||
this.call(BlockToolAPI.UPDATED);
|
||||
|
||||
this.emit('didMutated', this);
|
||||
}, this.modificationDebounceTimer);
|
||||
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void;
|
||||
|
||||
/**
|
||||
* Current block API interface
|
||||
|
|
@ -293,12 +224,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
private readonly blockAPI: BlockAPIInterface;
|
||||
|
||||
/**
|
||||
* @param {object} options - block constructor options
|
||||
* @param {string} [options.id] - block's id. Will be generated if omitted.
|
||||
* @param {BlockToolData} options.data - Tool's initial data
|
||||
* @param {BlockTool} options.tool — block's tool
|
||||
* @param options - block constructor options
|
||||
* @param [options.id] - block's id. Will be generated if omitted.
|
||||
* @param options.data - Tool's initial data
|
||||
* @param 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
|
||||
* @param options.readOnly - Read-Only flag
|
||||
* @param [eventBus] - Editor common event bus. Allows to subscribe on some Editor events. Could be omitted when "virtual" Block is created. See BlocksAPI@composeBlockData.
|
||||
*/
|
||||
constructor({
|
||||
id = _.generateBlockId(),
|
||||
|
|
@ -307,7 +239,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
api,
|
||||
readOnly,
|
||||
tunesData,
|
||||
}: BlockConstructorOptions) {
|
||||
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
|
||||
super();
|
||||
|
||||
this.name = tool.name;
|
||||
|
|
@ -315,10 +247,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
this.settings = tool.settings;
|
||||
this.config = tool.settings.config || {};
|
||||
this.api = api;
|
||||
this.editorEventBus = eventBus || null;
|
||||
this.blockAPI = new BlockAPI(this);
|
||||
|
||||
this.mutationObserver = new MutationObserver(this.didMutated);
|
||||
|
||||
this.tool = tool;
|
||||
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
|
||||
|
||||
|
|
@ -330,6 +261,17 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
this.composeTunes(tunesData);
|
||||
|
||||
this.holder = this.compose();
|
||||
|
||||
/**
|
||||
* Start watching block mutations
|
||||
*/
|
||||
this.watchBlockMutations();
|
||||
|
||||
/**
|
||||
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
|
||||
* so we need to track focus events to update current input and clear cache.
|
||||
*/
|
||||
this.addInputEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -516,14 +458,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
* @param {boolean} state - 'true' to select, 'false' to remove selection
|
||||
*/
|
||||
public set selected(state: boolean) {
|
||||
if (state) {
|
||||
this.holder.classList.add(Block.CSS.selected);
|
||||
this.holder.classList.toggle(Block.CSS.selected, state);
|
||||
|
||||
SelectionUtils.addFakeCursor(this.holder);
|
||||
} else {
|
||||
this.holder.classList.remove(Block.CSS.selected);
|
||||
const fakeCursorWillBeAdded = state === true && SelectionUtils.isRangeInsideContainer(this.holder);
|
||||
const fakeCursorWillBeRemoved = state === false && SelectionUtils.isFakeCursorInsideContainer(this.holder);
|
||||
|
||||
SelectionUtils.removeFakeCursor(this.holder);
|
||||
if (fakeCursorWillBeAdded || fakeCursorWillBeRemoved) {
|
||||
this.editorEventBus?.emit(FakeCursorAboutToBeToggled, { state }); // mutex
|
||||
|
||||
if (fakeCursorWillBeAdded) {
|
||||
SelectionUtils.addFakeCursor();
|
||||
} else {
|
||||
SelectionUtils.removeFakeCursor(this.holder);
|
||||
}
|
||||
|
||||
this.editorEventBus?.emit(FakeCursorHaveBeenSet, { state });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -751,38 +700,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
: SelectionUtils.anchorNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is fired when Block will be selected as current
|
||||
*/
|
||||
public willSelect(): void {
|
||||
/**
|
||||
* Observe DOM mutations to update Block inputs
|
||||
*/
|
||||
this.mutationObserver.observe(
|
||||
this.holder.firstElementChild,
|
||||
{
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
|
||||
* so we need to track focus events to update current input and clear cache.
|
||||
*/
|
||||
this.addInputEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is fired when Block will be unselected
|
||||
*/
|
||||
public willUnselect(): void {
|
||||
this.mutationObserver.disconnect();
|
||||
this.removeInputEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
|
||||
* Can be useful for block changes invisible for editor core.
|
||||
|
|
@ -795,6 +712,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
* Call Tool instance destroy method
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.unwatchBlockMutations();
|
||||
this.removeInputEvents();
|
||||
|
||||
super.destroy();
|
||||
|
||||
if (_.isFunction(this.toolInstance.destroy)) {
|
||||
|
|
@ -844,6 +764,15 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports Block data as string using conversion config
|
||||
*/
|
||||
public async exportDataAsString(): Promise<string> {
|
||||
const blockData = await this.data;
|
||||
|
||||
return convertBlockDataToString(blockData, this.tool.conversionConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make default Block wrappers and put Tool`s content there
|
||||
*
|
||||
|
|
@ -854,6 +783,12 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
contentNode = $.make('div', Block.CSS.content),
|
||||
pluginsContent = this.toolInstance.render();
|
||||
|
||||
/**
|
||||
* Export id to the DOM three
|
||||
* Useful for standalone modules development. For example, allows to identify Block by some child node. Or scroll to a particular Block by id.
|
||||
*/
|
||||
wrapper.dataset.id = this.id;
|
||||
|
||||
/**
|
||||
* Saving a reference to plugin's content element for guaranteed accessing it later
|
||||
*/
|
||||
|
|
@ -916,9 +851,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
*/
|
||||
private handleFocus = (): void => {
|
||||
/**
|
||||
* Drop cache
|
||||
* Drop inputs cache to query the new ones
|
||||
*/
|
||||
this.cachedInputs = [];
|
||||
this.dropInputsCache();
|
||||
|
||||
/**
|
||||
* Update current input
|
||||
|
|
@ -937,7 +872,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
* If input is native input add oninput listener to observe changes
|
||||
*/
|
||||
if ($.isNativeInput(input)) {
|
||||
input.addEventListener('input', this.didMutated);
|
||||
input.addEventListener('input', this.didMutated as EventListener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -950,8 +885,148 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
|||
input.removeEventListener('focus', this.handleFocus);
|
||||
|
||||
if ($.isNativeInput(input)) {
|
||||
input.removeEventListener('input', this.didMutated);
|
||||
input.removeEventListener('input', this.didMutated as EventListener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is fired when DOM mutation has been happened
|
||||
*
|
||||
* @param mutationsOrInputEvent - actual changes
|
||||
* - MutationRecord[] - any DOM change
|
||||
* - InputEvent — <input> change
|
||||
* - undefined — manual triggering of block.dispatchChange()
|
||||
*/
|
||||
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
|
||||
/**
|
||||
* Block API have dispatchChange() method. In this case, mutations list will be undefined.
|
||||
*/
|
||||
const isManuallyDispatched = mutationsOrInputEvent === undefined;
|
||||
|
||||
/**
|
||||
* True if didMutated has been called as "input" event handler
|
||||
*/
|
||||
const isInputEventHandler = mutationsOrInputEvent instanceof InputEvent;
|
||||
|
||||
/**
|
||||
* If tool updates its own root element, we need to renew it in our memory
|
||||
*/
|
||||
if (!isManuallyDispatched && !isInputEventHandler) {
|
||||
this.detectToolRootChange(mutationsOrInputEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
|
||||
*/
|
||||
let shouldFireUpdate;
|
||||
|
||||
if (isManuallyDispatched) {
|
||||
shouldFireUpdate = true;
|
||||
} else if (isInputEventHandler) {
|
||||
shouldFireUpdate = true;
|
||||
} else {
|
||||
/**
|
||||
* Update from 2023, Feb 17:
|
||||
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
|
||||
* since there could be a real mutations same-time with mutation-free changes,
|
||||
* for example when Block Tune change: block is changing along with FakeCursor (mutation-free) removing
|
||||
* — we should fire 'didMutated' event in that case
|
||||
*/
|
||||
const everyRecordIsMutationFree = mutationsOrInputEvent.length > 0 && mutationsOrInputEvent.every((record) => {
|
||||
const { addedNodes, removedNodes, target } = record;
|
||||
const changedNodes = [
|
||||
...Array.from(addedNodes),
|
||||
...Array.from(removedNodes),
|
||||
target,
|
||||
];
|
||||
|
||||
return changedNodes.some((node) => {
|
||||
if (!$.isElement(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (node as HTMLElement).dataset.mutationFree === 'true';
|
||||
});
|
||||
});
|
||||
|
||||
shouldFireUpdate = !everyRecordIsMutationFree;
|
||||
}
|
||||
|
||||
/**
|
||||
* In case some mutation free elements are added or removed, do not trigger didMutated event
|
||||
*/
|
||||
if (!shouldFireUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dropInputsCache();
|
||||
|
||||
/**
|
||||
* Update current input
|
||||
*/
|
||||
this.updateCurrentInput();
|
||||
|
||||
this.call(BlockToolAPI.UPDATED);
|
||||
|
||||
/**
|
||||
* Emit a Block Event with current Block instance.
|
||||
* Block Manager subscribed to these events
|
||||
*/
|
||||
this.emit('didMutated', this);
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen common editor Dom Changed event and detect mutations related to the Block
|
||||
*/
|
||||
private watchBlockMutations(): void {
|
||||
/**
|
||||
* Save callback to a property to remove it on Block destroy
|
||||
*
|
||||
* @param payload - event payload
|
||||
*/
|
||||
this.redactorDomChangedCallback = (payload) => {
|
||||
const { mutations } = payload;
|
||||
|
||||
const mutationBelongsToBlock = mutations.some(record => isMutationBelongsToElement(record, this.toolRenderedElement));
|
||||
|
||||
if (mutationBelongsToBlock) {
|
||||
this.didMutated(mutations);
|
||||
}
|
||||
};
|
||||
|
||||
this.editorEventBus?.on(RedactorDomChanged, this.redactorDomChangedCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove redactor dom change event listener
|
||||
*/
|
||||
private unwatchBlockMutations(): void {
|
||||
this.editorEventBus?.off(RedactorDomChanged, this.redactorDomChangedCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sometimes Tool can replace own main element, for example H2 -> H4 or UL -> OL
|
||||
* We need to detect such changes and update a link to tools main element with the new one
|
||||
*
|
||||
* @param mutations - records of block content mutations
|
||||
*/
|
||||
private detectToolRootChange(mutations: MutationRecord[]): void {
|
||||
mutations.forEach(record => {
|
||||
const toolRootHasBeenUpdated = Array.from(record.removedNodes).includes(this.toolRenderedElement);
|
||||
|
||||
if (toolRootHasBeenUpdated) {
|
||||
const newToolElement = record.addedNodes[record.addedNodes.length - 1];
|
||||
|
||||
this.toolRenderedElement = newToolElement as HTMLElement;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears inputs cached value
|
||||
*/
|
||||
private dropInputsCache(): void {
|
||||
this.cachedInputs = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,10 @@ import I18n from './i18n';
|
|||
import { CriticalError } from './errors/critical';
|
||||
import EventsDispatcher from './utils/events';
|
||||
import Modules from './modules';
|
||||
import { EditorEventMap } from './events';
|
||||
|
||||
/**
|
||||
* @class Core
|
||||
* @classdesc Editor.js core class
|
||||
* @property {EditorConfig} config - all settings
|
||||
* @property {EditorModules} moduleInstances - constructed editor components
|
||||
* @type {Core}
|
||||
* Editor.js core class. Bootstraps modules.
|
||||
*/
|
||||
export default class Core {
|
||||
/**
|
||||
|
|
@ -31,9 +28,9 @@ export default class Core {
|
|||
public isReady: Promise<void>;
|
||||
|
||||
/**
|
||||
* Event Dispatcher util
|
||||
* Common Editor Event Bus
|
||||
*/
|
||||
private eventsDispatcher: EventsDispatcher = new EventsDispatcher();
|
||||
private eventsDispatcher: EventsDispatcher<EditorEventMap> = new EventsDispatcher();
|
||||
|
||||
/**
|
||||
* @param {EditorConfig} config - user configuration
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export default class Dom {
|
|||
* @param {string} selector - searching string
|
||||
* @returns {Element}
|
||||
*/
|
||||
public static find(el: Element | Document = document, selector: string): Element {
|
||||
public static find(el: Element | Document = document, selector: string): Element | null {
|
||||
return el.querySelector(selector);
|
||||
}
|
||||
|
||||
|
|
|
|||
16
src/components/events/BlockChanged.ts
Normal file
16
src/components/events/BlockChanged.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { BlockMutationEvent } from '../../../types/events/block';
|
||||
|
||||
/**
|
||||
* Fired when some block state has changed
|
||||
*/
|
||||
export const BlockChanged = 'block changed';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface BlockChangedPayload {
|
||||
/**
|
||||
* CustomEvent describing a block change
|
||||
*/
|
||||
event: BlockMutationEvent;
|
||||
}
|
||||
16
src/components/events/BlockHovered.ts
Normal file
16
src/components/events/BlockHovered.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type Block from '../block';
|
||||
|
||||
/**
|
||||
* Fired when some block is hovered by user
|
||||
*/
|
||||
export const BlockHovered = 'block hovered';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface BlockHoveredPayload {
|
||||
/**
|
||||
* Hovered block
|
||||
*/
|
||||
block: Block;
|
||||
}
|
||||
17
src/components/events/FakeCursorAboutToBeToggled.ts
Normal file
17
src/components/events/FakeCursorAboutToBeToggled.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Fired before we're adding/removing a fake cursor.
|
||||
*
|
||||
* Allows to disable mutation observer to skip this block change
|
||||
*/
|
||||
export const FakeCursorAboutToBeToggled = 'fake cursor is about to be toggled';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface FakeCursorAboutToBeToggledPayload {
|
||||
/**
|
||||
* true - when added a cursor
|
||||
* false - when removed
|
||||
*/
|
||||
state: boolean;
|
||||
}
|
||||
17
src/components/events/FakeCursorHaveBeenSet.ts
Normal file
17
src/components/events/FakeCursorHaveBeenSet.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Fired after we've added/removed a fake cursor.
|
||||
*
|
||||
* Allows to enable mutation observer which was disabled before setting
|
||||
*/
|
||||
export const FakeCursorHaveBeenSet = 'fake cursor have been set';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface FakeCursorHaveBeenSetPayload {
|
||||
/**
|
||||
* true - when added a cursor
|
||||
* false - when removed
|
||||
*/
|
||||
state: boolean;
|
||||
}
|
||||
14
src/components/events/RedactorDomChanged.ts
Normal file
14
src/components/events/RedactorDomChanged.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Fired when blocks wrapper (.codex-editor-redactor) dom changed
|
||||
*/
|
||||
export const RedactorDomChanged = 'redactor dom changed';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface RedactorDomChangedPayload {
|
||||
/**
|
||||
* Mutations happened with blocks wrapper
|
||||
*/
|
||||
mutations: MutationRecord[];
|
||||
}
|
||||
26
src/components/events/index.ts
Normal file
26
src/components/events/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { RedactorDomChanged, RedactorDomChangedPayload } from './RedactorDomChanged';
|
||||
import { BlockChanged, BlockChangedPayload } from './BlockChanged';
|
||||
import { BlockHovered, BlockHoveredPayload } from './BlockHovered';
|
||||
import { FakeCursorAboutToBeToggled, FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled';
|
||||
import { FakeCursorHaveBeenSet, FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet';
|
||||
|
||||
/**
|
||||
* Events fired by Editor Event Dispatcher
|
||||
*/
|
||||
export {
|
||||
RedactorDomChanged,
|
||||
BlockChanged,
|
||||
FakeCursorAboutToBeToggled,
|
||||
FakeCursorHaveBeenSet
|
||||
};
|
||||
|
||||
/**
|
||||
* Event name -> Event payload
|
||||
*/
|
||||
export interface EditorEventMap {
|
||||
[BlockHovered]: BlockHoveredPayload;
|
||||
[RedactorDomChanged]: RedactorDomChangedPayload;
|
||||
[BlockChanged]: BlockChangedPayload;
|
||||
[FakeCursorAboutToBeToggled]: FakeCursorAboutToBeToggledPayload;
|
||||
[FakeCursorHaveBeenSet]: FakeCursorHaveBeenSetPayload;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as _ from './../../utils';
|
|||
import BlockAPI from '../../block/api';
|
||||
import Module from '../../__module';
|
||||
import Block from '../../block';
|
||||
import { capitalize } from './../../utils';
|
||||
|
||||
/**
|
||||
* @class BlocksAPI
|
||||
|
|
@ -33,6 +34,7 @@ export default class BlocksAPI extends Module {
|
|||
insert: this.insert,
|
||||
update: this.update,
|
||||
composeBlockData: this.composeBlockData,
|
||||
convert: this.convert,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -137,9 +139,11 @@ export default class BlocksAPI extends Module {
|
|||
*
|
||||
* @param {number} blockIndex - index of Block to delete
|
||||
*/
|
||||
public delete(blockIndex?: number): void {
|
||||
public delete(blockIndex: number = this.Editor.BlockManager.currentBlockIndex): void {
|
||||
try {
|
||||
this.Editor.BlockManager.removeBlock(blockIndex);
|
||||
const block = this.Editor.BlockManager.getBlockByIndex(blockIndex);
|
||||
|
||||
this.Editor.BlockManager.removeBlock(block);
|
||||
} catch (e) {
|
||||
_.logLabeled(e, 'warn');
|
||||
|
||||
|
|
@ -309,4 +313,42 @@ export default class BlocksAPI extends Module {
|
|||
tunes: block.tunes,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts block to another type. Both blocks should provide the conversionConfig.
|
||||
*
|
||||
* @param id - id of the existing block to convert. Should provide 'conversionConfig.export' method
|
||||
* @param newType - new block type. Should provide 'conversionConfig.import' method
|
||||
* @param dataOverrides - optional data overrides for the new block
|
||||
* @throws Error if conversion is not possible
|
||||
*/
|
||||
private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => {
|
||||
const { BlockManager, Tools } = this.Editor;
|
||||
const blockToConvert = BlockManager.getBlockById(id);
|
||||
|
||||
if (!blockToConvert) {
|
||||
throw new Error(`Block with id "${id}" not found`);
|
||||
}
|
||||
|
||||
const originalBlockTool = Tools.blockTools.get(blockToConvert.name);
|
||||
const targetBlockTool = Tools.blockTools.get(newType);
|
||||
|
||||
if (!targetBlockTool) {
|
||||
throw new Error(`Block Tool with type "${newType}" not found`);
|
||||
}
|
||||
|
||||
const originalBlockConvertable = originalBlockTool?.conversionConfig?.export !== undefined;
|
||||
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
|
||||
|
||||
if (originalBlockConvertable && targetBlockConvertable) {
|
||||
BlockManager.convert(blockToConvert, newType, dataOverrides);
|
||||
} else {
|
||||
const unsupportedBlockTypes = [
|
||||
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,
|
||||
!targetBlockConvertable ? capitalize(newType) : false,
|
||||
].filter(Boolean).join(' and ');
|
||||
|
||||
throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import * as _ from '../utils';
|
|||
import SelectionUtils from '../selection';
|
||||
import Flipper from '../flipper';
|
||||
import { BlockDropZonePosition } from '../block';
|
||||
import type Block from '../block';
|
||||
import { areBlocksMergeable } from '../utils/blocks';
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
@ -30,6 +32,10 @@ export default class BlockEvents extends Module {
|
|||
this.backspace(event);
|
||||
break;
|
||||
|
||||
case _.keyCodes.DELETE:
|
||||
this.delete(event);
|
||||
break;
|
||||
|
||||
case _.keyCodes.ENTER:
|
||||
this.enter(event);
|
||||
break;
|
||||
|
|
@ -113,7 +119,7 @@ export default class BlockEvents extends Module {
|
|||
*
|
||||
* @param {KeyboardEvent} event - tab keydown event
|
||||
*/
|
||||
public tabPressed(event): void {
|
||||
public tabPressed(event: KeyboardEvent): void {
|
||||
/**
|
||||
* Clear blocks selection by tab
|
||||
*/
|
||||
|
|
@ -264,6 +270,13 @@ export default class BlockEvents extends Module {
|
|||
*/
|
||||
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
|
||||
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
|
||||
|
||||
/**
|
||||
* If caret is at very end of the block, just append the new block without splitting
|
||||
* to prevent unnecessary dom mutation observing
|
||||
*/
|
||||
} else if (this.Editor.Caret.isAtEnd) {
|
||||
newCurrent = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1);
|
||||
} else {
|
||||
/**
|
||||
* Split the Current Block into two blocks
|
||||
|
|
@ -288,104 +301,176 @@ export default class BlockEvents extends Module {
|
|||
* @param {KeyboardEvent} event - keydown
|
||||
*/
|
||||
private backspace(event: KeyboardEvent): void {
|
||||
const { BlockManager, BlockSelection, Caret } = this.Editor;
|
||||
const currentBlock = BlockManager.currentBlock;
|
||||
const tool = currentBlock.tool;
|
||||
const { BlockManager, Caret } = this.Editor;
|
||||
const { currentBlock, previousBlock } = BlockManager;
|
||||
|
||||
/**
|
||||
* Check if Block should be removed by current Backspace keydown
|
||||
* If some fragment is selected, leave native behaviour
|
||||
*/
|
||||
if (currentBlock.selected || (currentBlock.isEmpty && currentBlock.currentInput === currentBlock.firstInput)) {
|
||||
event.preventDefault();
|
||||
if (!SelectionUtils.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = BlockManager.currentBlockIndex;
|
||||
/**
|
||||
* If caret is not at the start, leave native behaviour
|
||||
*/
|
||||
if (!Caret.isAtStart) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* All the cases below have custom behaviour, so we don't need a native one
|
||||
*/
|
||||
event.preventDefault();
|
||||
this.Editor.Toolbar.close();
|
||||
|
||||
if (BlockManager.previousBlock && BlockManager.previousBlock.inputs.length === 0) {
|
||||
/** If previous block doesn't contain inputs, remove it */
|
||||
BlockManager.removeBlock(index - 1);
|
||||
} else {
|
||||
/** If block is empty, just remove it */
|
||||
BlockManager.removeBlock();
|
||||
}
|
||||
const isFirstInputFocused = currentBlock.currentInput === currentBlock.firstInput;
|
||||
|
||||
Caret.setToBlock(
|
||||
BlockManager.currentBlock,
|
||||
index ? Caret.positions.END : Caret.positions.START
|
||||
);
|
||||
|
||||
/** Close Toolbar */
|
||||
this.Editor.Toolbar.close();
|
||||
|
||||
/** Clear selection */
|
||||
BlockSelection.clearSelection(event);
|
||||
/**
|
||||
* For example, caret at the start of the Quote second input (caption) — just navigate previous input
|
||||
*/
|
||||
if (!isFirstInputFocused) {
|
||||
Caret.navigatePrevious();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't handle Backspaces when Tool sets enableLineBreaks to true.
|
||||
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
|
||||
*
|
||||
* But if caret is at start of the block, we allow to remove it by backspaces
|
||||
* Backspace at the start of the first Block should do nothing
|
||||
*/
|
||||
if (tool.isLineBreaksEnabled && !Caret.isAtStart) {
|
||||
if (previousBlock === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstBlock = BlockManager.currentBlockIndex === 0;
|
||||
const canMergeBlocks = Caret.isAtStart &&
|
||||
SelectionUtils.isCollapsed &&
|
||||
currentBlock.currentInput === currentBlock.firstInput &&
|
||||
!isFirstBlock;
|
||||
/**
|
||||
* If prev Block is empty, it should be removed just like a character
|
||||
*/
|
||||
if (previousBlock.isEmpty) {
|
||||
BlockManager.removeBlock(previousBlock);
|
||||
|
||||
if (canMergeBlocks) {
|
||||
/**
|
||||
* preventing browser default behaviour
|
||||
*/
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge Blocks
|
||||
*/
|
||||
this.mergeBlocks();
|
||||
/**
|
||||
* If current Block is empty, just remove it and set cursor to the previous Block (like we're removing line break char)
|
||||
*/
|
||||
if (currentBlock.isEmpty) {
|
||||
BlockManager.removeBlock(currentBlock);
|
||||
|
||||
const newCurrentBlock = BlockManager.currentBlock;
|
||||
|
||||
Caret.setToBlock(newCurrentBlock, Caret.positions.END);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock);
|
||||
|
||||
/**
|
||||
* If Blocks could be merged, do it
|
||||
* Otherwise, just navigate previous block
|
||||
*/
|
||||
if (bothBlocksMergeable) {
|
||||
this.mergeBlocks(previousBlock, currentBlock);
|
||||
} else {
|
||||
Caret.setToBlock(previousBlock, Caret.positions.END);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge current and previous Blocks if they have the same type
|
||||
* Handles delete keydown on Block
|
||||
* Removes char after the caret.
|
||||
* If caret is at the end of the block, merge next block with current
|
||||
*
|
||||
* @param {KeyboardEvent} event - keydown
|
||||
*/
|
||||
private mergeBlocks(): void {
|
||||
const { BlockManager, Caret, Toolbar } = this.Editor;
|
||||
const targetBlock = BlockManager.previousBlock;
|
||||
const blockToMerge = BlockManager.currentBlock;
|
||||
private delete(event: KeyboardEvent): void {
|
||||
const { BlockManager, Caret } = this.Editor;
|
||||
const { currentBlock, nextBlock } = BlockManager;
|
||||
|
||||
/**
|
||||
* Blocks that can be merged:
|
||||
* 1) with the same Name
|
||||
* 2) Tool has 'merge' method
|
||||
*
|
||||
* other case will handle as usual ARROW LEFT behaviour
|
||||
* If some fragment is selected, leave native behaviour
|
||||
*/
|
||||
if (blockToMerge.name !== targetBlock.name || !targetBlock.mergeable) {
|
||||
/** If target Block doesn't contain inputs or empty, remove it */
|
||||
if (targetBlock.inputs.length === 0 || targetBlock.isEmpty) {
|
||||
BlockManager.removeBlock(BlockManager.currentBlockIndex - 1);
|
||||
if (!SelectionUtils.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
Caret.setToBlock(BlockManager.currentBlock);
|
||||
Toolbar.close();
|
||||
/**
|
||||
* If caret is not at the end, leave native behaviour
|
||||
*/
|
||||
if (!Caret.isAtEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* All the cases below have custom behaviour, so we don't need a native one
|
||||
*/
|
||||
event.preventDefault();
|
||||
this.Editor.Toolbar.close();
|
||||
|
||||
if (Caret.navigatePrevious()) {
|
||||
Toolbar.close();
|
||||
}
|
||||
const isLastInputFocused = currentBlock.currentInput === currentBlock.lastInput;
|
||||
|
||||
/**
|
||||
* For example, caret at the end of the Quote first input (quote text) — just navigate next input (caption)
|
||||
*/
|
||||
if (!isLastInputFocused) {
|
||||
Caret.navigateNext();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete at the end of the last Block should do nothing
|
||||
*/
|
||||
if (nextBlock === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If next Block is empty, it should be removed just like a character
|
||||
*/
|
||||
if (nextBlock.isEmpty) {
|
||||
BlockManager.removeBlock(nextBlock);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If current Block is empty, just remove it and set cursor to the next Block (like we're removing line break char)
|
||||
*/
|
||||
if (currentBlock.isEmpty) {
|
||||
BlockManager.removeBlock(currentBlock);
|
||||
|
||||
Caret.setToBlock(nextBlock, Caret.positions.START);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const bothBlocksMergeable = areBlocksMergeable(currentBlock, nextBlock);
|
||||
|
||||
/**
|
||||
* If Blocks could be merged, do it
|
||||
* Otherwise, just navigate to the next block
|
||||
*/
|
||||
if (bothBlocksMergeable) {
|
||||
this.mergeBlocks(currentBlock, nextBlock);
|
||||
} else {
|
||||
Caret.setToBlock(nextBlock, Caret.positions.START);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge passed Blocks
|
||||
*
|
||||
* @param targetBlock - to which Block we want to merge
|
||||
* @param blockToMerge - what Block we want to merge
|
||||
*/
|
||||
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
|
||||
const { BlockManager, Caret, Toolbar } = this.Editor;
|
||||
|
||||
Caret.createShadow(targetBlock.pluginsContent);
|
||||
BlockManager.mergeBlocks(targetBlock, blockToMerge)
|
||||
|
||||
BlockManager
|
||||
.mergeBlocks(targetBlock, blockToMerge)
|
||||
.then(() => {
|
||||
/** Restore caret position after merge */
|
||||
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@ import Blocks from '../blocks';
|
|||
import { BlockToolData, PasteEvent } from '../../../types';
|
||||
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
||||
import BlockAPI from '../block/api';
|
||||
import { BlockMutationType } from '../../../types/events/block/mutation-type';
|
||||
import { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block';
|
||||
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
|
||||
import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
|
||||
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
|
||||
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
|
||||
import { BlockChanged } from '../events';
|
||||
import { clean } from '../utils/sanitizer';
|
||||
import { convertStringToBlockData } from '../utils/blocks';
|
||||
|
||||
/**
|
||||
* @typedef {BlockManager} BlockManager
|
||||
|
|
@ -35,14 +42,6 @@ export default class BlockManager extends Module {
|
|||
* @param {number} newIndex - index of Block to set as current
|
||||
*/
|
||||
public set currentBlockIndex(newIndex: number) {
|
||||
if (this._blocks[this._currentBlockIndex]) {
|
||||
this._blocks[this._currentBlockIndex].willUnselect();
|
||||
}
|
||||
|
||||
if (this._blocks[newIndex]) {
|
||||
this._blocks[newIndex].willSelect();
|
||||
}
|
||||
|
||||
this._currentBlockIndex = newIndex;
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +86,7 @@ export default class BlockManager extends Module {
|
|||
*
|
||||
* @returns {Block|null}
|
||||
*/
|
||||
public get nextBlock(): Block {
|
||||
public get nextBlock(): Block | null {
|
||||
const isLastBlock = this.currentBlockIndex === (this._blocks.length - 1);
|
||||
|
||||
if (isLastBlock) {
|
||||
|
|
@ -124,7 +123,7 @@ export default class BlockManager extends Module {
|
|||
*
|
||||
* @returns {Block|null}
|
||||
*/
|
||||
public get previousBlock(): Block {
|
||||
public get previousBlock(): Block | null {
|
||||
const isFirstBlock = this.currentBlockIndex === 0;
|
||||
|
||||
if (isFirstBlock) {
|
||||
|
|
@ -242,7 +241,7 @@ export default class BlockManager extends Module {
|
|||
api: this.Editor.API,
|
||||
readOnly,
|
||||
tunesData,
|
||||
});
|
||||
}, this.eventsDispatcher);
|
||||
|
||||
if (!readOnly) {
|
||||
this.bindBlockEvents(block);
|
||||
|
|
@ -298,7 +297,7 @@ export default class BlockManager extends Module {
|
|||
* we need to dispatch the 'block-removing' event for the replacing block
|
||||
*/
|
||||
if (replace) {
|
||||
this.blockDidMutated(BlockMutationType.Removed, this.getBlockByIndex(newIndex), {
|
||||
this.blockDidMutated(BlockRemovedMutationType, this.getBlockByIndex(newIndex), {
|
||||
index: newIndex,
|
||||
});
|
||||
}
|
||||
|
|
@ -308,7 +307,7 @@ export default class BlockManager extends Module {
|
|||
/**
|
||||
* Force call of didMutated event on Block insertion
|
||||
*/
|
||||
this.blockDidMutated(BlockMutationType.Added, block, {
|
||||
this.blockDidMutated(BlockAddedMutationType, block, {
|
||||
index: newIndex,
|
||||
});
|
||||
|
||||
|
|
@ -322,21 +321,19 @@ export default class BlockManager extends Module {
|
|||
}
|
||||
|
||||
/**
|
||||
* Replace current working block
|
||||
* Replace passed Block with the new one with specified Tool and data
|
||||
*
|
||||
* @param {object} options - replace options
|
||||
* @param {string} options.tool — plugin name
|
||||
* @param {BlockToolData} options.data — plugin data
|
||||
* @returns {Block}
|
||||
* @param block - block to replace
|
||||
* @param newTool - new Tool name
|
||||
* @param data - new Tool data
|
||||
*/
|
||||
public replace({
|
||||
tool = this.config.defaultBlock,
|
||||
data = {},
|
||||
}): Block {
|
||||
return this.insert({
|
||||
tool,
|
||||
public replace(block: Block, newTool: string, data: BlockToolData): void {
|
||||
const blockIndex = this.getBlockIndex(block);
|
||||
|
||||
this.insert({
|
||||
tool: newTool,
|
||||
data,
|
||||
index: this.currentBlockIndex,
|
||||
index: blockIndex,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -384,7 +381,7 @@ export default class BlockManager extends Module {
|
|||
/**
|
||||
* Force call of didMutated event on Block insertion
|
||||
*/
|
||||
this.blockDidMutated(BlockMutationType.Added, block, {
|
||||
this.blockDidMutated(BlockAddedMutationType, block, {
|
||||
index,
|
||||
});
|
||||
|
||||
|
|
@ -422,29 +419,24 @@ export default class BlockManager extends Module {
|
|||
* @returns {Promise} - the sequence that can be continued
|
||||
*/
|
||||
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
|
||||
const blockToMergeIndex = this._blocks.indexOf(blockToMerge);
|
||||
|
||||
if (blockToMerge.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockToMergeData = await blockToMerge.data;
|
||||
|
||||
if (!_.isEmpty(blockToMergeData)) {
|
||||
await targetBlock.mergeWith(blockToMergeData);
|
||||
}
|
||||
|
||||
this.removeBlock(blockToMergeIndex);
|
||||
this.removeBlock(blockToMerge);
|
||||
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove block with passed index or remove last
|
||||
* Remove passed Block
|
||||
*
|
||||
* @param {number|null} index - index of Block to remove
|
||||
* @throws {Error} if Block to remove is not found
|
||||
* @param block - Block to remove
|
||||
*/
|
||||
public removeBlock(index = this.currentBlockIndex): void {
|
||||
public removeBlock(block: Block): void {
|
||||
const index = this._blocks.indexOf(block);
|
||||
|
||||
/**
|
||||
* If index is not passed and there is no block selected, show a warning
|
||||
*/
|
||||
|
|
@ -452,15 +444,13 @@ export default class BlockManager extends Module {
|
|||
throw new Error('Can\'t find a Block to remove');
|
||||
}
|
||||
|
||||
const blockToRemove = this._blocks[index];
|
||||
|
||||
blockToRemove.destroy();
|
||||
block.destroy();
|
||||
this._blocks.remove(index);
|
||||
|
||||
/**
|
||||
* Force call of didMutated event on Block removal
|
||||
*/
|
||||
this.blockDidMutated(BlockMutationType.Removed, blockToRemove, {
|
||||
this.blockDidMutated(BlockRemovedMutationType, block, {
|
||||
index,
|
||||
});
|
||||
|
||||
|
|
@ -496,7 +486,7 @@ export default class BlockManager extends Module {
|
|||
continue;
|
||||
}
|
||||
|
||||
this.removeBlock(index);
|
||||
this.removeBlock(this.blocks[index]);
|
||||
firstSelectedBlockIndex = index;
|
||||
}
|
||||
|
||||
|
|
@ -745,12 +735,68 @@ export default class BlockManager extends Module {
|
|||
/**
|
||||
* Force call of didMutated event on Block movement
|
||||
*/
|
||||
this.blockDidMutated(BlockMutationType.Moved, this.currentBlock, {
|
||||
this.blockDidMutated(BlockMovedMutationType, this.currentBlock, {
|
||||
fromIndex,
|
||||
toIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts passed Block to the new Tool
|
||||
* Uses Conversion Config
|
||||
*
|
||||
* @param blockToConvert - Block that should be converted
|
||||
* @param targetToolName - name of the Tool to convert to
|
||||
* @param blockDataOverrides - optional new Block data overrides
|
||||
*/
|
||||
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
|
||||
/**
|
||||
* At first, we get current Block data
|
||||
*/
|
||||
const savedBlock = await blockToConvert.save();
|
||||
|
||||
if (!savedBlock) {
|
||||
throw new Error('Could not convert Block. Failed to extract original Block data.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting a class of the replacing Tool
|
||||
*/
|
||||
const replacingTool = this.Editor.Tools.blockTools.get(targetToolName);
|
||||
|
||||
if (!replacingTool) {
|
||||
throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using Conversion Config "export" we get a stringified version of the Block data
|
||||
*/
|
||||
const exportedData = await blockToConvert.exportDataAsString();
|
||||
|
||||
/**
|
||||
* Clean exported data with replacing sanitizer config
|
||||
*/
|
||||
const cleanData: string = clean(
|
||||
exportedData,
|
||||
replacingTool.sanitizeConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Now using Conversion Config "import" we compose a new Block data
|
||||
*/
|
||||
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig);
|
||||
|
||||
/**
|
||||
* Optional data overrides.
|
||||
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
|
||||
*/
|
||||
if (blockDataOverrides) {
|
||||
newBlockData = Object.assign(newBlockData, blockDataOverrides);
|
||||
}
|
||||
|
||||
this.replace(blockToConvert, replacingTool.name, newBlockData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets current Block Index -1 which means unknown
|
||||
* and clear highlights
|
||||
|
|
@ -816,7 +862,7 @@ export default class BlockManager extends Module {
|
|||
});
|
||||
|
||||
block.on('didMutated', (affectedBlock: Block) => {
|
||||
return this.blockDidMutated(BlockMutationType.Changed, affectedBlock, {
|
||||
return this.blockDidMutated(BlockChangedMutationType, affectedBlock, {
|
||||
index: this.getBlockIndex(affectedBlock),
|
||||
});
|
||||
});
|
||||
|
|
@ -860,18 +906,25 @@ export default class BlockManager extends Module {
|
|||
*
|
||||
* @param mutationType - what happened with block
|
||||
* @param block - mutated block
|
||||
* @param details - additional data to pass with change event
|
||||
* @param detailData - additional data to pass with change event
|
||||
*/
|
||||
private blockDidMutated(mutationType: BlockMutationType, block: Block, details: Record<string, unknown> = {}): Block {
|
||||
private blockDidMutated<Type extends BlockMutationType>(mutationType: Type, block: Block, detailData: BlockMutationEventDetailWithoutTarget<Type>): Block {
|
||||
const event = new CustomEvent(mutationType, {
|
||||
detail: {
|
||||
target: new BlockAPI(block),
|
||||
...details,
|
||||
...detailData as BlockMutationEventDetailWithoutTarget<Type>,
|
||||
},
|
||||
});
|
||||
|
||||
this.Editor.ModificationsObserver.onChange(event);
|
||||
this.eventsDispatcher.emit(BlockChanged, {
|
||||
event: event as BlockMutationEventMap[Type],
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for Block Mutation event without 'target' field, used in 'blockDidMutated' method
|
||||
*/
|
||||
type BlockMutationEventDetailWithoutTarget<Type extends BlockMutationType> = Omit<BlockMutationEventMap[Type]['detail'], 'target'>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { BlockId } from '../../../types';
|
||||
import { BlockMutationEvent, BlockMutationType } from '../../../types/events/block';
|
||||
import { ModuleConfig } from '../../types-internal/module-config';
|
||||
import Module from '../__module';
|
||||
import { BlockChanged, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||
import * as _ from '../utils';
|
||||
|
||||
/**
|
||||
* We use map of block mutations to filter only unique events
|
||||
*/
|
||||
type UniqueBlockMutationKey = `block:${BlockId}:event:${BlockMutationType}`;
|
||||
|
||||
/**
|
||||
* Single entry point for Block mutation events
|
||||
*/
|
||||
|
|
@ -10,10 +19,74 @@ export default class ModificationsObserver extends Module {
|
|||
*/
|
||||
private disabled = false;
|
||||
|
||||
/**
|
||||
* Blocks wrapper mutation observer instance
|
||||
*/
|
||||
private readonly mutationObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Timeout used to batched several events in a single onChange call
|
||||
*/
|
||||
private batchingTimeout: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
/**
|
||||
* Array of onChange events used to batch them
|
||||
*
|
||||
* Map is used to filter duplicated events related to the same block
|
||||
*/
|
||||
private batchingOnChangeQueue = new Map<UniqueBlockMutationKey, BlockMutationEvent>();
|
||||
|
||||
/**
|
||||
* Fired onChange events will be batched by this time
|
||||
*/
|
||||
private readonly batchTime = 400;
|
||||
|
||||
/**
|
||||
* Prepare the module
|
||||
*
|
||||
* @param options - options used by the modification observer module
|
||||
* @param options.config - Editor configuration object
|
||||
* @param options.eventsDispatcher - common Editor event bus
|
||||
*/
|
||||
constructor({ config, eventsDispatcher }: ModuleConfig) {
|
||||
super({
|
||||
config,
|
||||
eventsDispatcher,
|
||||
});
|
||||
|
||||
this.mutationObserver = new MutationObserver((mutations) => {
|
||||
this.redactorChanged(mutations);
|
||||
});
|
||||
|
||||
this.eventsDispatcher.on(BlockChanged, (payload) => {
|
||||
this.particularBlockChanged(payload.event);
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutex for fake cursor setting/removing operation
|
||||
*/
|
||||
this.eventsDispatcher.on(FakeCursorAboutToBeToggled, () => {
|
||||
this.disable();
|
||||
});
|
||||
|
||||
this.eventsDispatcher.on(FakeCursorHaveBeenSet, () => {
|
||||
this.enable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables onChange event
|
||||
*/
|
||||
public enable(): void {
|
||||
this.mutationObserver.observe(
|
||||
this.Editor.UI.nodes.redactor,
|
||||
{
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
}
|
||||
);
|
||||
this.disabled = false;
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +94,7 @@ export default class ModificationsObserver extends Module {
|
|||
* Disables onChange event
|
||||
*/
|
||||
public disable(): void {
|
||||
this.mutationObserver.disconnect();
|
||||
this.disabled = true;
|
||||
}
|
||||
|
||||
|
|
@ -29,11 +103,45 @@ export default class ModificationsObserver extends Module {
|
|||
*
|
||||
* @param event - some of our custom change events
|
||||
*/
|
||||
public onChange(event: CustomEvent): void {
|
||||
private particularBlockChanged(event: BlockMutationEvent): void {
|
||||
if (this.disabled || !_.isFunction(this.config.onChange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.onChange(this.Editor.API.methods, event);
|
||||
this.batchingOnChangeQueue.set(`block:${event.detail.target.id}:event:${event.type as BlockMutationType}`, event);
|
||||
|
||||
if (this.batchingTimeout) {
|
||||
clearTimeout(this.batchingTimeout);
|
||||
}
|
||||
|
||||
this.batchingTimeout = setTimeout(() => {
|
||||
let eventsToEmit;
|
||||
|
||||
/**
|
||||
* Ih we have only 1 event in a queue, unwrap it
|
||||
*/
|
||||
if (this.batchingOnChangeQueue.size === 1) {
|
||||
eventsToEmit = this.batchingOnChangeQueue.values().next().value;
|
||||
} else {
|
||||
eventsToEmit = Array.from(this.batchingOnChangeQueue.values());
|
||||
}
|
||||
|
||||
if (this.config.onChange) {
|
||||
this.config.onChange(this.Editor.API.methods, eventsToEmit);
|
||||
}
|
||||
|
||||
this.batchingOnChangeQueue.clear();
|
||||
}, this.batchTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired on every blocks wrapper dom change
|
||||
*
|
||||
* @param mutations - mutations happened
|
||||
*/
|
||||
private redactorChanged(mutations: MutationRecord[]): void {
|
||||
this.eventsDispatcher.emit(RedactorDomChanged, {
|
||||
mutations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import Module from '../../__module';
|
||||
import $ from '../../dom';
|
||||
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';
|
||||
import { ToolboxConfigEntry, BlockToolData } from '../../../../types';
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +32,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
conversionTool: 'ce-conversion-tool',
|
||||
conversionToolHidden: 'ce-conversion-tool--hidden',
|
||||
conversionToolIcon: 'ce-conversion-tool__icon',
|
||||
conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label',
|
||||
|
||||
conversionToolFocused: 'ce-conversion-tool--focused',
|
||||
conversionToolActive: 'ce-conversion-tool--active',
|
||||
|
|
@ -179,90 +178,21 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
* For that Tools must provide import/export methods
|
||||
*
|
||||
* @param {string} replacingToolName - name of Tool which replaces current
|
||||
* @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified
|
||||
* @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides
|
||||
*/
|
||||
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
|
||||
/**
|
||||
* At first, we get current Block data
|
||||
*/
|
||||
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
|
||||
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
|
||||
const blockData = savedBlock.data;
|
||||
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
|
||||
|
||||
/**
|
||||
* Getting a class of replacing Tool
|
||||
*/
|
||||
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
|
||||
BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
|
||||
|
||||
/**
|
||||
* Export property can be:
|
||||
* 1) Function — Tool defines which data to return
|
||||
* 2) String — the name of saved property
|
||||
*
|
||||
* In both cases returning value must be a string
|
||||
*/
|
||||
let exportData = '';
|
||||
const exportProp = currentBlockTool.conversionConfig.export;
|
||||
|
||||
if (_.isFunction(exportProp)) {
|
||||
exportData = exportProp(blockData);
|
||||
} else if (_.isString(exportProp)) {
|
||||
exportData = blockData[exportProp];
|
||||
} else {
|
||||
_.log('Conversion «export» property must be a string or function. ' +
|
||||
'String means key of saved data object to export. Function should export processed string to export.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean exported data with replacing sanitizer config
|
||||
*/
|
||||
const cleaned: string = clean(
|
||||
exportData,
|
||||
replacingTool.sanitizeConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* «import» property can be Function or String
|
||||
* function — accept imported string and compose tool data object
|
||||
* string — the name of data field to import
|
||||
*/
|
||||
let newBlockData = {};
|
||||
const importProp = replacingTool.conversionConfig.import;
|
||||
|
||||
if (_.isFunction(importProp)) {
|
||||
newBlockData = importProp(cleaned);
|
||||
} else if (_.isString(importProp)) {
|
||||
newBlockData[importProp] = cleaned;
|
||||
} else {
|
||||
_.log('Conversion «import» property must be a string or function. ' +
|
||||
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this conversion fired by the one of multiple Toolbox items,
|
||||
* extend converted data with this item's "data" overrides
|
||||
*/
|
||||
if (blockDataOverrides) {
|
||||
newBlockData = Object.assign(newBlockData, blockDataOverrides);
|
||||
}
|
||||
|
||||
this.Editor.BlockManager.replace({
|
||||
tool: replacingToolName,
|
||||
data: newBlockData,
|
||||
});
|
||||
this.Editor.BlockSelection.clearSelection();
|
||||
BlockSelection.clearSelection();
|
||||
|
||||
this.close();
|
||||
this.Editor.InlineToolbar.close();
|
||||
InlineToolbar.close();
|
||||
|
||||
_.delay(() => {
|
||||
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
}, 10)();
|
||||
window.requestAnimationFrame(() => {
|
||||
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -283,7 +213,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
if (!conversionConfig || !conversionConfig.import) {
|
||||
return;
|
||||
}
|
||||
tool.toolbox.forEach((toolboxItem) =>
|
||||
tool.toolbox?.forEach((toolboxItem) =>
|
||||
this.addToolIfValid(name, toolboxItem)
|
||||
);
|
||||
});
|
||||
|
|
@ -322,6 +252,16 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
|
|||
$.append(tool, icon);
|
||||
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
|
||||
|
||||
const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut;
|
||||
|
||||
if (shortcut) {
|
||||
const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, {
|
||||
innerText: _.beautifyShortcut(shortcut),
|
||||
});
|
||||
|
||||
$.append(tool, shortcutEl);
|
||||
}
|
||||
|
||||
$.append(this.nodes.tools, tool);
|
||||
this.tools.push({
|
||||
name: toolName,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import I18n from '../../i18n';
|
|||
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
||||
import Tooltip from '../../utils/tooltip';
|
||||
import { ModuleConfig } from '../../../types-internal/module-config';
|
||||
import { BlockAPI } from '../../../../types';
|
||||
import Block from '../../block';
|
||||
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
|
||||
import { IconMenu, IconPlus } from '@codexteam/icons';
|
||||
import { BlockHovered } from '../../events/BlockHovered';
|
||||
|
||||
/**
|
||||
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
|
||||
|
|
@ -322,6 +322,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
*/
|
||||
private make(): void {
|
||||
this.nodes.wrapper = $.make('div', this.CSS.toolbar);
|
||||
/**
|
||||
* @todo detect test environment and add data-cy="toolbar" to use it in tests instead of class name
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make Content Zone and Actions Zone
|
||||
|
|
@ -422,7 +425,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
this.Editor.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier);
|
||||
});
|
||||
|
||||
this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }: { block: BlockAPI }) => {
|
||||
this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }) => {
|
||||
const { BlockManager, Caret } = this.Editor;
|
||||
const newBlock = BlockManager.getBlockById(block.id);
|
||||
|
||||
|
|
@ -518,7 +521,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
|||
/**
|
||||
* Subscribe to the 'block-hovered' event
|
||||
*/
|
||||
this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: { block: Block }) => {
|
||||
this.eventsDispatcher.on(BlockHovered, (data) => {
|
||||
/**
|
||||
* Do not move toolbar if Block Settings or Toolbox opened or Drag started.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import Flipper from '../flipper';
|
|||
import { mobileScreenBreakpoint } from '../utils';
|
||||
|
||||
import styles from '../../styles/main.css?inline';
|
||||
import { BlockHovered } from '../events/BlockHovered';
|
||||
/**
|
||||
* HTML Elements used for UI
|
||||
*/
|
||||
|
|
@ -41,15 +42,6 @@ interface UINodes {
|
|||
* @property {Element} nodes.redactor - <ce-redactor>
|
||||
*/
|
||||
export default class UI extends Module<UINodes> {
|
||||
/**
|
||||
* Events could be emitted by this module.
|
||||
*/
|
||||
public get events(): { blockHovered: string } {
|
||||
return {
|
||||
blockHovered: 'block-hovered',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor.js UI CSS class names
|
||||
*
|
||||
|
|
@ -395,7 +387,7 @@ export default class UI extends Module<UINodes> {
|
|||
|
||||
blockHoveredEmitted = hoveredBlock;
|
||||
|
||||
this.eventsDispatcher.emit(this.events.blockHovered, {
|
||||
this.eventsDispatcher.emit(BlockHovered, {
|
||||
block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
|
|
@ -438,6 +430,7 @@ export default class UI extends Module<UINodes> {
|
|||
break;
|
||||
|
||||
case _.keyCodes.BACKSPACE:
|
||||
case _.keyCodes.DELETE:
|
||||
this.backspacePressed(event);
|
||||
break;
|
||||
|
||||
|
|
@ -685,9 +678,11 @@ export default class UI extends Module<UINodes> {
|
|||
*/
|
||||
try {
|
||||
/**
|
||||
* Renew Current Block
|
||||
* Renew Current Block. Use RAF to wait until Selection is set.
|
||||
*/
|
||||
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
|
||||
window.requestAnimationFrame(() => {
|
||||
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
|
||||
});
|
||||
|
||||
/**
|
||||
* Highlight Current Node
|
||||
|
|
|
|||
|
|
@ -353,24 +353,47 @@ export default class SelectionUtils {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds fake cursor to the current range
|
||||
* Check if current range exists and belongs to container
|
||||
*
|
||||
* @param [container] - if passed cursor will be added only if container contains current range
|
||||
* @param container - where range should be
|
||||
*/
|
||||
public static addFakeCursor(container?: HTMLElement): void {
|
||||
public static isRangeInsideContainer(container: HTMLElement): boolean {
|
||||
const range = SelectionUtils.range;
|
||||
|
||||
if (range === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return container.contains(range.startContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds fake cursor to the current range
|
||||
*/
|
||||
public static addFakeCursor(): void {
|
||||
const range = SelectionUtils.range;
|
||||
|
||||
if (range === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if passed element contains a fake cursor
|
||||
*
|
||||
* @param el - where to check
|
||||
*/
|
||||
public static isFakeCursorInsideContainer(el: HTMLElement): boolean {
|
||||
return $.find(el, `.codex-editor__fake-cursor`) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes fake cursor from a container
|
||||
*
|
||||
|
|
@ -379,7 +402,11 @@ export default class SelectionUtils {
|
|||
public static removeFakeCursor(container: HTMLElement = document.body): void {
|
||||
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
|
||||
|
||||
fakeCursor && fakeCursor.remove();
|
||||
if (!fakeCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
fakeCursor.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default class BlockTool extends BaseTool<IBlockTool> {
|
|||
/**
|
||||
* Returns Tool conversion configuration
|
||||
*/
|
||||
public get conversionConfig(): ConversionConfig {
|
||||
public get conversionConfig(): ConversionConfig | undefined {
|
||||
return this.constructable[InternalBlockToolSettings.ConversionConfig];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { BlockToolAPI } from '../block';
|
|||
import Shortcuts from '../utils/shortcuts';
|
||||
import BlockTool from '../tools/block';
|
||||
import ToolsCollection from '../tools/collection';
|
||||
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem } from '../../../types';
|
||||
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
|
||||
import EventsDispatcher from '../utils/events';
|
||||
import Popover, { PopoverEvent } from '../utils/popover';
|
||||
import I18n from '../i18n';
|
||||
|
|
@ -33,6 +33,19 @@ export enum ToolboxEvent {
|
|||
BlockAdded = 'toolbox-block-added',
|
||||
}
|
||||
|
||||
/**
|
||||
* Events fired by the Toolbox
|
||||
*
|
||||
* Event name -> payload
|
||||
*/
|
||||
export interface ToolboxEventMap {
|
||||
[ToolboxEvent.Opened]: undefined;
|
||||
[ToolboxEvent.Closed]: undefined;
|
||||
[ToolboxEvent.BlockAdded]: {
|
||||
block: BlockAPI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available i18n dict keys that should be passed to the constructor
|
||||
*/
|
||||
|
|
@ -45,7 +58,7 @@ type ToolboxTextLabelsKeys = 'filter' | 'nothingFound';
|
|||
*
|
||||
* @implements {EventsDispatcher} with some events, see {@link ToolboxEvent}
|
||||
*/
|
||||
export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
||||
export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
|
||||
/**
|
||||
* Returns True if Toolbox is Empty and nothing to show
|
||||
*
|
||||
|
|
@ -294,6 +307,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
|
|||
on: this.api.ui.nodes.redactor,
|
||||
handler: (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
||||
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
|
||||
|
||||
/**
|
||||
* Try to convert current Block to shortcut's tool
|
||||
* If conversion is not possible, insert a new Block below
|
||||
*/
|
||||
if (currentBlock) {
|
||||
try {
|
||||
this.api.blocks.convert(currentBlock.id, toolName);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.api.caret.setToBlock(currentBlockIndex, 'end');
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
this.insertNewBlock(toolName);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
72
src/components/utils/blocks.ts
Normal file
72
src/components/utils/blocks.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { ConversionConfig } from '../../../types/configs/conversion-config';
|
||||
import type { BlockToolData } from '../../../types/tools/block-tool-data';
|
||||
import type Block from '../block';
|
||||
import { isFunction, isString, log } from '../utils';
|
||||
|
||||
/**
|
||||
* Check if two blocks could be merged.
|
||||
*
|
||||
* We can merge two blocks if:
|
||||
* - they have the same type
|
||||
* - they have a merge function (.mergeable = true)
|
||||
*
|
||||
* @param targetBlock - block to merge to
|
||||
* @param blockToMerge - block to merge from
|
||||
*/
|
||||
export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean {
|
||||
return targetBlock.mergeable && targetBlock.name === blockToMerge.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using conversionConfig, convert block data to string.
|
||||
*
|
||||
* @param blockData - block data to convert
|
||||
* @param conversionConfig - tool's conversion config
|
||||
*/
|
||||
export function convertBlockDataToString(blockData: BlockToolData, conversionConfig?: ConversionConfig ): string {
|
||||
const exportProp = conversionConfig?.export;
|
||||
|
||||
if (isFunction(exportProp)) {
|
||||
return exportProp(blockData);
|
||||
} else if (isString(exportProp)) {
|
||||
return blockData[exportProp];
|
||||
} else {
|
||||
/**
|
||||
* Tool developer provides 'export' property, but it is not correct. Warn him.
|
||||
*/
|
||||
if (exportProp !== undefined) {
|
||||
log('Conversion «export» property must be a string or function. ' +
|
||||
'String means key of saved data object to export. Function should export processed string to export.');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using conversionConfig, convert string to block data.
|
||||
*
|
||||
* @param stringToImport - string to convert
|
||||
* @param conversionConfig - tool's conversion config
|
||||
*/
|
||||
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig): BlockToolData {
|
||||
const importProp = conversionConfig?.import;
|
||||
|
||||
if (isFunction(importProp)) {
|
||||
return importProp(stringToImport);
|
||||
} else if (isString(importProp)) {
|
||||
return {
|
||||
[importProp]: stringToImport,
|
||||
};
|
||||
} else {
|
||||
/**
|
||||
* Tool developer provides 'import' property, but it is not correct. Warn him.
|
||||
*/
|
||||
if (importProp !== undefined) {
|
||||
log('Conversion «import» property must be a string or function. ' +
|
||||
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,39 @@
|
|||
import { isEmpty } from '../utils';
|
||||
|
||||
/**
|
||||
* @class EventDispatcher
|
||||
* Event Dispatcher event listener
|
||||
*/
|
||||
type Listener<Data> = (data?: Data) => void;
|
||||
|
||||
/**
|
||||
* Mapped type with subscriptions list
|
||||
*
|
||||
* Has two important methods:
|
||||
* event name -> array of callbacks
|
||||
*/
|
||||
type Subscriptions<EventMap> = {
|
||||
[Key in keyof EventMap]: Listener<EventMap[Key]>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides methods for working with Event Bus:
|
||||
* - {Function} on - appends subscriber to the event. If event doesn't exist - creates new one
|
||||
* - {Function} emit - fires all subscribers with data
|
||||
* - {Function off - unsubscribes callback
|
||||
* @version 1.0.0
|
||||
* @typedef {Events} Events
|
||||
* @property {object} subscribers - all subscribers grouped by event name
|
||||
*/
|
||||
export default class EventsDispatcher<Events extends string = string> {
|
||||
export default class EventsDispatcher<EventMap> {
|
||||
/**
|
||||
* All subscribers grouped by event name
|
||||
* Object with events` names as key and array of callback functions as value
|
||||
*
|
||||
* @type {{}}
|
||||
*/
|
||||
private subscribers: {[name: string]: Array<(data?: object) => unknown>} = {};
|
||||
private subscribers = <Subscriptions<EventMap>>{};
|
||||
|
||||
/**
|
||||
* Subscribe any event on callback
|
||||
*
|
||||
* @param {string} eventName - event name
|
||||
* @param {Function} callback - subscriber
|
||||
* @param eventName - event name
|
||||
* @param callback - subscriber
|
||||
*/
|
||||
public on(eventName: Events, callback: (data: object) => unknown): void {
|
||||
public on<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
|
||||
if (!(eventName in this.subscribers)) {
|
||||
this.subscribers[eventName] = [];
|
||||
}
|
||||
|
|
@ -37,15 +45,15 @@ export default class EventsDispatcher<Events extends string = string> {
|
|||
/**
|
||||
* Subscribe any event on callback. Callback will be called once and be removed from subscribers array after call.
|
||||
*
|
||||
* @param {string} eventName - event name
|
||||
* @param {Function} callback - subscriber
|
||||
* @param eventName - event name
|
||||
* @param callback - subscriber
|
||||
*/
|
||||
public once(eventName: Events, callback: (data: object) => unknown): void {
|
||||
public once<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
|
||||
if (!(eventName in this.subscribers)) {
|
||||
this.subscribers[eventName] = [];
|
||||
}
|
||||
|
||||
const wrappedCallback = (data: object): unknown => {
|
||||
const wrappedCallback = (data: EventMap[typeof eventName]): void => {
|
||||
const result = callback(data);
|
||||
|
||||
const indexOfHandler = this.subscribers[eventName].indexOf(wrappedCallback);
|
||||
|
|
@ -64,10 +72,10 @@ export default class EventsDispatcher<Events extends string = string> {
|
|||
/**
|
||||
* Emit callbacks with passed data
|
||||
*
|
||||
* @param {string} eventName - event name
|
||||
* @param {object} data - subscribers get this data when they were fired
|
||||
* @param eventName - event name
|
||||
* @param data - subscribers get this data when they were fired
|
||||
*/
|
||||
public emit(eventName: Events, data?: object): void {
|
||||
public emit<Name extends keyof EventMap>(eventName: Name, data?: EventMap[Name]): void {
|
||||
if (isEmpty(this.subscribers) || !this.subscribers[eventName]) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -75,17 +83,17 @@ export default class EventsDispatcher<Events extends string = string> {
|
|||
this.subscribers[eventName].reduce((previousData, currentHandler) => {
|
||||
const newData = currentHandler(previousData);
|
||||
|
||||
return newData || previousData;
|
||||
return newData !== undefined ? newData : previousData;
|
||||
}, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe callback from event
|
||||
*
|
||||
* @param {string} eventName - event name
|
||||
* @param {Function} callback - event handler
|
||||
* @param eventName - event name
|
||||
* @param callback - event handler
|
||||
*/
|
||||
public off(eventName: Events, callback: (data: object) => unknown): void {
|
||||
public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
|
||||
for (let i = 0; i < this.subscribers[eventName].length; i++) {
|
||||
if (this.subscribers[eventName][i] === callback) {
|
||||
delete this.subscribers[eventName][i];
|
||||
|
|
|
|||
35
src/components/utils/mutations.ts
Normal file
35
src/components/utils/mutations.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Check if passed mutation belongs to a passed element
|
||||
*
|
||||
* @param mutationRecord - mutation to check
|
||||
* @param element - element that is expected to contain mutation
|
||||
*/
|
||||
export function isMutationBelongsToElement(mutationRecord: MutationRecord, element: Element): boolean {
|
||||
const { type, target, addedNodes, removedNodes } = mutationRecord;
|
||||
|
||||
/**
|
||||
* In case of removing the whole text in element, mutation type will be 'childList',
|
||||
* 'removedNodes' will contain text node that is not existed anymore, so we can't check it with 'contains' method
|
||||
* But Target will be the element itself, so we can detect it.
|
||||
*/
|
||||
if (target === element) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check typing and attributes changes
|
||||
*/
|
||||
if (['characterData', 'attributes'].includes(type)) {
|
||||
const targetElement = target.nodeType === Node.TEXT_NODE ? target.parentNode : target;
|
||||
|
||||
return element.contains(targetElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check new/removed nodes
|
||||
*/
|
||||
const addedNodesBelongsToBlock = Array.from(addedNodes).some(node => element.contains(node));
|
||||
const removedNodesBelongsToBlock = Array.from(removedNodes).some(node => element.contains(node));
|
||||
|
||||
return addedNodesBelongsToBlock || removedNodesBelongsToBlock;
|
||||
}
|
||||
|
|
@ -64,11 +64,18 @@ export enum PopoverEvent {
|
|||
Close = 'close'
|
||||
}
|
||||
|
||||
/**
|
||||
* Events fired by the Popover
|
||||
*/
|
||||
interface PopoverEventMap {
|
||||
[PopoverEvent.Close]: undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Class responsible for rendering popover and handling its behaviour
|
||||
*/
|
||||
export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||
export default class Popover extends EventsDispatcher<PopoverEventMap> {
|
||||
/**
|
||||
* Flipper - module for keyboard iteration between elements
|
||||
*/
|
||||
|
|
@ -203,7 +210,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns HTML element correcponding to the popover
|
||||
* Returns HTML element corresponding to the popover
|
||||
*/
|
||||
public getElement(): HTMLElement | null {
|
||||
return this.nodes.wrapper;
|
||||
|
|
@ -315,7 +322,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds seach to the popover
|
||||
* Adds search to the popover
|
||||
*/
|
||||
private addSearch(): void {
|
||||
this.search = new SearchInput({
|
||||
|
|
@ -474,10 +481,10 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
|||
/**
|
||||
* Toggles nothing found message visibility
|
||||
*
|
||||
* @param isDislayed - true if the message should be displayed
|
||||
* @param isDisplayed - true if the message should be displayed
|
||||
*/
|
||||
private toggleNothingFoundMessage(isDislayed: boolean): void {
|
||||
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDislayed);
|
||||
private toggleNothingFoundMessage(isDisplayed: boolean): void {
|
||||
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDisplayed);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -493,7 +500,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
|||
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
|
||||
*
|
||||
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
|
||||
* (All the other items with the same key get unactive, and the item gets active)
|
||||
* (All the other items with the same key get inactive, and the item gets active)
|
||||
*
|
||||
* @param clickedItem - popover item that was clicked
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
transition: transform 100ms ease, opacity 100ms ease;
|
||||
transform: translateY(-8px);
|
||||
left: -1px;
|
||||
width: 150px;
|
||||
width: 190px;
|
||||
margin-top: 5px;
|
||||
box-sizing: content-box;
|
||||
|
||||
|
|
@ -78,4 +78,19 @@
|
|||
animation: bounceIn 0.75s 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
&__secondary-label {
|
||||
color: var(--grayText);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.1em;
|
||||
padding-right: 5px;
|
||||
margin-bottom: -2px;
|
||||
opacity: 0.6;
|
||||
|
||||
@media (--mobile){
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
src/types-internal/module-config.d.ts
vendored
3
src/types-internal/module-config.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import { EditorConfig } from '../../types/index';
|
||||
import { EditorEventMap } from '../components/events';
|
||||
import EventsDispatcher from '../components/utils/events';
|
||||
|
||||
/**
|
||||
|
|
@ -6,5 +7,5 @@ import EventsDispatcher from '../components/utils/events';
|
|||
*/
|
||||
export interface ModuleConfig {
|
||||
config: EditorConfig;
|
||||
eventsDispatcher: EventsDispatcher;
|
||||
eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||
}
|
||||
|
|
|
|||
54
test/cypress/fixtures/tools/ToolMock.ts
Normal file
54
test/cypress/fixtures/tools/ToolMock.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { BlockTool, BlockToolConstructorOptions } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Simple structure for Tool data
|
||||
*/
|
||||
interface MockToolData {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common class for Tool mocking.
|
||||
* Extend this class to create a mock for your Tool with specific properties.
|
||||
*/
|
||||
export default class ToolMock implements BlockTool {
|
||||
/**
|
||||
* Tool data
|
||||
*/
|
||||
private data: MockToolData;
|
||||
|
||||
/**
|
||||
* Creates new Tool instance
|
||||
*
|
||||
* @param options - tool constructor options
|
||||
*/
|
||||
constructor(options: BlockToolConstructorOptions<MockToolData>) {
|
||||
this.data = options.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single content editable element as tools element
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
if (this.data && this.data.text) {
|
||||
contenteditable.innerHTML = this.data.text;
|
||||
}
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
|
||||
return contenteditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save method mock, returns block innerHTML
|
||||
*
|
||||
* @param block - element rendered by the render method
|
||||
*/
|
||||
public save(block: HTMLElement): MockToolData {
|
||||
return {
|
||||
text: block.innerHTML,
|
||||
};
|
||||
}
|
||||
}
|
||||
12
test/cypress/support/index.d.ts
vendored
12
test/cypress/support/index.d.ts
vendored
|
|
@ -66,4 +66,16 @@ declare global {
|
|||
EditorJS: typeof EditorJS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chai plugins
|
||||
*/
|
||||
namespace Chai {
|
||||
interface Assertion {
|
||||
/**
|
||||
* "containSubset" object properties matcher
|
||||
*/
|
||||
containSubset(subset: any): Assertion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ import '@cypress/code-coverage/support';
|
|||
*/
|
||||
import './commands';
|
||||
|
||||
import chaiSubset from 'chai-subset';
|
||||
|
||||
/**
|
||||
* "containSubset" object properties matcher
|
||||
*/
|
||||
chai.use(chaiSubset);
|
||||
|
||||
/**
|
||||
* Before-each hook for the cypress tests
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BlockMutationType } from '../../../../types/events/block/mutation-type';
|
||||
import EditorJS from '../../../../types';
|
||||
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
|
||||
|
||||
/**
|
||||
* There will be described test cases of BlockAPI
|
||||
|
|
@ -23,24 +23,23 @@ describe('BlockAPI', () => {
|
|||
*/
|
||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
||||
|
||||
beforeEach(function () {
|
||||
/**
|
||||
* Creates Editor instance
|
||||
*
|
||||
* @param [data] - data to render
|
||||
*/
|
||||
function createEditor(data = undefined): void {
|
||||
const config = {
|
||||
data: editorDataMock,
|
||||
onChange: (): void => {
|
||||
console.log('something changed');
|
||||
onChange: (api, event): void => {
|
||||
console.log('something changed', event);
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
cy.createEditor(config).as('editorInstance');
|
||||
|
||||
cy.spy(config, 'onChange').as('onChange');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.destroy();
|
||||
}
|
||||
});
|
||||
cy.createEditor(config).as('editorInstance');
|
||||
}
|
||||
|
||||
/**
|
||||
* block.dispatchChange();
|
||||
|
|
@ -50,18 +49,21 @@ describe('BlockAPI', () => {
|
|||
* Check that blocks.dispatchChange() triggers Editor 'onChange' callback
|
||||
*/
|
||||
it('should trigger onChange with corresponded block', () => {
|
||||
cy.get('@editorInstance').then(async (editor: unknown) => {
|
||||
const block = (editor as EditorJS).blocks.getById(firstBlock.id);
|
||||
createEditor(editorDataMock);
|
||||
|
||||
block.dispatchChange();
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const block = editor.blocks.getById(firstBlock.id);
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Changed,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
block.dispatchChange();
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type EditorJS from '../../../../types/index';
|
||||
import { ConversionConfig, ToolboxConfig } from '../../../../types';
|
||||
import ToolMock from '../../fixtures/tools/ToolMock';
|
||||
|
||||
/**
|
||||
* There will be described test cases of 'blocks.*' API
|
||||
*/
|
||||
|
|
@ -16,18 +19,6 @@ describe('api.blocks', () => {
|
|||
],
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* api.blocks.getById(id)
|
||||
*/
|
||||
|
|
@ -36,7 +27,11 @@ describe('api.blocks', () => {
|
|||
* 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) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const block = editor.blocks.getById(firstBlock.id);
|
||||
|
||||
expect(block).not.to.be.undefined;
|
||||
|
|
@ -48,7 +43,11 @@ describe('api.blocks', () => {
|
|||
* 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) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
expect(editor.blocks.getById('not-existed-id')).to.be.null;
|
||||
});
|
||||
});
|
||||
|
|
@ -62,7 +61,11 @@ describe('api.blocks', () => {
|
|||
* Check if block is updated in DOM
|
||||
*/
|
||||
it('should update block in DOM', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = firstBlock.id;
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
|
@ -83,7 +86,11 @@ describe('api.blocks', () => {
|
|||
* Check if block's data is updated after saving
|
||||
*/
|
||||
it('should update block in saved data', () => {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = firstBlock.id;
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
|
@ -91,7 +98,7 @@ describe('api.blocks', () => {
|
|||
|
||||
editor.blocks.update(idToUpdate, newBlockData);
|
||||
|
||||
const output = await (editor as any).save();
|
||||
const output = await editor.save();
|
||||
const text = output.blocks[0].data.text;
|
||||
|
||||
expect(text).to.be.eq(newBlockData.text);
|
||||
|
|
@ -102,7 +109,11 @@ describe('api.blocks', () => {
|
|||
* 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) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const idToUpdate = 'wrong-id-123';
|
||||
const newBlockData = {
|
||||
text: 'Updated text',
|
||||
|
|
@ -125,7 +136,11 @@ describe('api.blocks', () => {
|
|||
*/
|
||||
describe('.insert()', function () {
|
||||
it('should preserve block id if it is passed', function () {
|
||||
cy.get('@editorInstance').then(async (editor: any) => {
|
||||
cy.createEditor({
|
||||
data: editorDataMock,
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
|
||||
const type = 'paragraph';
|
||||
const data = { text: 'codex' };
|
||||
const config = undefined;
|
||||
|
|
@ -141,4 +156,167 @@ describe('api.blocks', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.convert()', function () {
|
||||
it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ConvertableTool extends ToolMock {
|
||||
/**
|
||||
* Specify how to import string data to this Tool
|
||||
*/
|
||||
public static get conversionConfig(): ConversionConfig {
|
||||
return {
|
||||
import: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
convertableTool: {
|
||||
class: ConvertableTool,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
convert(existingBlock.id, 'convertableTool');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers -- wait for block to be converted
|
||||
cy.wait(100);
|
||||
|
||||
/**
|
||||
* Check that block was converted
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1);
|
||||
expect(blocks[0].type).to.eq('convertableTool');
|
||||
expect(blocks[0].data.text).to.eq(existingBlock.data.text);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if nonexisting Block id passed', function () {
|
||||
cy.createEditor({}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with nonexisting Block id
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const fakeId = 'WRNG_ID';
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(fakeId, 'convertableTool');
|
||||
|
||||
expect(exec).to.throw(`Block with id "${fakeId}" not found`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if nonexisting Tool name passed', function () {
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with nonexisting tool name
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const nonexistingToolName = 'WRNG_TOOL_NAME';
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(existingBlock.id, nonexistingToolName);
|
||||
|
||||
expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if some tool does not provide "conversionConfig"', function () {
|
||||
const existingBlock = {
|
||||
id: 'test-id-123',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Some text',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock of Tool without conversionConfig
|
||||
*/
|
||||
class ToolWithoutConversionConfig extends ToolMock {}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
nonConvertableTool: {
|
||||
class: ToolWithoutConversionConfig,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
existingBlock,
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Call the 'convert' api method with tool that does not provide "conversionConfig"
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { convert } = editor.blocks;
|
||||
|
||||
const exec = (): void => convert(existingBlock.id, 'nonConvertableTool');
|
||||
|
||||
expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -130,4 +130,34 @@ describe('Block ids', () => {
|
|||
expect(data.blocks[2].id).to.eq(blocks[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be stored at the Block wrapper\'s data-id attribute', () => {
|
||||
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')
|
||||
.each(($block, index) => {
|
||||
expect($block.attr('data-id')).to.eq(blocks[index].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
describe('Keydown', function () {
|
||||
describe('enter', function () {
|
||||
it('should split block and remove selected fragment if some text fragment selected', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The block with some text',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.selectText('with so')
|
||||
.wait(0)
|
||||
.type('{enter}');
|
||||
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.then((blocks) => {
|
||||
/**
|
||||
* Check that there is two blocks after split
|
||||
*/
|
||||
expect(blocks.length).to.equal(2);
|
||||
|
||||
/**
|
||||
* Check that selected text fragment has been removed
|
||||
*/
|
||||
expect(blocks[0].textContent).to.equal('The block ');
|
||||
expect(blocks[1].textContent).to.equal('me text');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
380
test/cypress/tests/modules/BlockEvents/Backspace.cy.ts
Normal file
380
test/cypress/tests/modules/BlockEvents/Backspace.cy.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import type EditorJS from '../../../../../types/index';
|
||||
|
||||
/**
|
||||
* Creates Editor instance with list of Paragraph blocks of passed texts
|
||||
*
|
||||
* @param textBlocks - list of texts for Paragraph blocks
|
||||
*/
|
||||
function createEditorWithTextBlocks(textBlocks: string[]): void {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: textBlocks.map((text) => ({
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Backspace keydown', function () {
|
||||
it('should just delete chars (native behaviour) when some fragment is selected', function () {
|
||||
createEditorWithTextBlocks([
|
||||
'The first block',
|
||||
'The second block',
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click()
|
||||
.selectText('The ')
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.last()
|
||||
.should('have.text', 'second block');
|
||||
});
|
||||
|
||||
it('should just delete chars (native behaviour) when Caret is not at the start of the Block', function () {
|
||||
createEditorWithTextBlocks([
|
||||
'The first block',
|
||||
'The second block',
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click() // caret will be at the end of the block
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.last()
|
||||
.should('have.text', 'The second bloc'); // last char is removed
|
||||
});
|
||||
|
||||
it('should navigate previous input when Caret is not at the first input', function () {
|
||||
/**
|
||||
* Mock of tool with several inputs
|
||||
*/
|
||||
class ExampleOfToolWithSeveralInputs {
|
||||
/**
|
||||
* Render method mock
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
const input = document.createElement('div');
|
||||
const input2 = document.createElement('div');
|
||||
|
||||
container.setAttribute('data-cy', 'quote-tool');
|
||||
|
||||
input.setAttribute('contenteditable', 'true');
|
||||
input2.setAttribute('contenteditable', 'true');
|
||||
|
||||
container.append(input, input2);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving logic is not necessary for this test
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public save(): void {}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
quote: ExampleOfToolWithSeveralInputs,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'quote',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=quote-tool]')
|
||||
.find('div[contenteditable]')
|
||||
.last()
|
||||
.click()
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=quote-tool]')
|
||||
.find('div[contenteditable]')
|
||||
.first()
|
||||
.as('firstInput');
|
||||
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('@firstInput').should(($div) => {
|
||||
expect($div[0].contains(range.startContainer)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove previous Block if Caret at the start of the Block and previous Block is empty. Also, should close the Toolbox', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '', // empty block
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Not empty block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click()
|
||||
.type('{home}') // move caret to the beginning
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block2'); // second block is still here
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove current Block if it is empty, but previous is not. Also, should close the Toolbox and set Caret to the end of the prev Block', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Not empty block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '', // empty block
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click()
|
||||
.type('{backspace}');
|
||||
|
||||
/**
|
||||
* Current Block has been removed
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block1'); // second block is still here
|
||||
});
|
||||
|
||||
/**
|
||||
* Caret is set to the end of the previous Block
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should(($block) => {
|
||||
expect($block[0].contains(range.startContainer)).to.be.true;
|
||||
expect(range.startOffset).to.be.eq($block[0].textContent.length);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolbox has been closed
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-toolbar')
|
||||
.should('not.have.class', 'ce-toolbar--opened');
|
||||
});
|
||||
|
||||
it('should merge current Block with the previous one if Caret at the start of the Block and both Blocks are mergeable. Also, should close the Toolbox. Caret should be places in a place of glue', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'First block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click()
|
||||
.type('{home}') // move caret to the beginning
|
||||
.type('{backspace}');
|
||||
|
||||
/**
|
||||
* Current Block has been removed
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block1'); // second block is still here
|
||||
expect(blocks[0].data.text).to.eq('First blockSecond block'); // text has been merged
|
||||
});
|
||||
|
||||
/**
|
||||
* Caret is set to the place of merging
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should(($block) => {
|
||||
expect($block[0].contains(range.startContainer)).to.be.true;
|
||||
range.startContainer.normalize(); // glue merged text nodes
|
||||
expect(range.startOffset).to.be.eq('First block'.length);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolbox has been closed
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-toolbar')
|
||||
.should('not.have.class', 'ce-toolbar--opened');
|
||||
});
|
||||
|
||||
it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () {
|
||||
/**
|
||||
* Mock of tool without merge method
|
||||
*/
|
||||
class ExampleOfUnmergeableTool {
|
||||
/**
|
||||
* Render method mock
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.dataset.cy = 'unmergeable-tool';
|
||||
container.contentEditable = 'true';
|
||||
container.innerHTML = 'Unmergeable not empty tool';
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving logic is not necessary for this test
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public save(): void {}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
code: ExampleOfUnmergeableTool,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'code',
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.last()
|
||||
.click()
|
||||
.type('{home}')
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=unmergeable-tool]')
|
||||
.as('firstBlock');
|
||||
|
||||
/**
|
||||
* Caret is set to the previous Block
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('@firstBlock').should(($div) => {
|
||||
expect($div[0].contains(range.startContainer)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('at the start of the first Block', function () {
|
||||
it('should do nothing if Block is not empty', function () {
|
||||
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('{home}')
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should('have.length', 1)
|
||||
.should('have.text', 'The only block. Not empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
377
test/cypress/tests/modules/BlockEvents/Delete.cy.ts
Normal file
377
test/cypress/tests/modules/BlockEvents/Delete.cy.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import type EditorJS from '../../../../../types/index';
|
||||
|
||||
/**
|
||||
* Creates Editor instance with list of Paragraph blocks of passed texts
|
||||
*
|
||||
* @param textBlocks - list of texts for Paragraph blocks
|
||||
*/
|
||||
function createEditorWithTextBlocks(textBlocks: string[]): void {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: textBlocks.map((text) => ({
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Delete keydown', function () {
|
||||
it('should just delete chars (native behaviour) when some fragment is selected', function () {
|
||||
createEditorWithTextBlocks([
|
||||
'The first block',
|
||||
'The second block',
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.selectText('The ')
|
||||
.type('{del}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.first()
|
||||
.should('have.text', 'first block');
|
||||
});
|
||||
|
||||
it('should just delete chars (native behaviour) when Caret is not at the end of the Block', function () {
|
||||
createEditorWithTextBlocks([
|
||||
'The first block',
|
||||
'The second block',
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click() // caret will be at the end of the block
|
||||
.type('{leftarrow}') // now caret is not at the end
|
||||
.type('{del}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.first()
|
||||
.should('have.text', 'The first bloc'); // last char is removed
|
||||
});
|
||||
|
||||
it('should navigate next input when Caret is not at the last input', function () {
|
||||
/**
|
||||
* Mock of tool with several inputs
|
||||
*/
|
||||
class ExampleOfToolWithSeveralInputs {
|
||||
/**
|
||||
* Render method mock
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
const input = document.createElement('div');
|
||||
const input2 = document.createElement('div');
|
||||
|
||||
container.setAttribute('data-cy', 'quote-tool');
|
||||
|
||||
input.setAttribute('contenteditable', 'true');
|
||||
input2.setAttribute('contenteditable', 'true');
|
||||
|
||||
container.append(input, input2);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving logic is not necessary for this test
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public save(): void {}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
quote: ExampleOfToolWithSeveralInputs,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'quote',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=quote-tool]')
|
||||
.find('div[contenteditable]')
|
||||
.first()
|
||||
.click()
|
||||
.type('{del}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=quote-tool]')
|
||||
.find('div[contenteditable]')
|
||||
.last()
|
||||
.as('secondInput');
|
||||
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('@secondInput').should(($div) => {
|
||||
expect($div[0].contains(range.startContainer)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove next Block if Caret at the end of the Block and next Block is empty. Also, should close the Toolbox', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Not empty block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '', // empty block
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.type('{del}');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block1'); // first block is still here
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove current Block if it is empty, but next is not. Also, should close the Toolbox and set Caret to the start of the next Block', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Not empty block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.type('{backspace}') // remove '1' to make block empty
|
||||
.type('{del}');
|
||||
|
||||
/**
|
||||
* Current Block has been removed
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block2'); // second block is still here
|
||||
});
|
||||
|
||||
/**
|
||||
* Caret is set to the start of the next Block
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should(($block) => {
|
||||
expect($block[0].contains(range.startContainer)).to.be.true;
|
||||
expect(range.startOffset).to.be.eq(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolbox has been closed
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-toolbar')
|
||||
.should('not.have.class', 'ce-toolbar--opened');
|
||||
});
|
||||
|
||||
it('should merge current Block with the next one if Caret at the end of the Block and both Blocks are mergeable. Also, should close the Toolbox.', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'First block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.type('{del}');
|
||||
|
||||
/**
|
||||
* Current Block has been removed
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1); // one block has been removed
|
||||
expect(blocks[0].id).to.eq('block1'); // second block is still here
|
||||
expect(blocks[0].data.text).to.eq('First blockSecond block'); // text has been merged
|
||||
});
|
||||
|
||||
/**
|
||||
* Caret is set to the place of merging
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should(($block) => {
|
||||
expect($block[0].contains(range.startContainer)).to.be.true;
|
||||
range.startContainer.normalize(); // glue merged text nodes
|
||||
expect(range.startOffset).to.be.eq('First block'.length);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolbox has been closed
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-toolbar')
|
||||
.should('not.have.class', 'ce-toolbar--opened');
|
||||
});
|
||||
|
||||
it('should simply set Caret to the start of the next Block if Caret at the end of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () {
|
||||
/**
|
||||
* Mock of tool without merge method
|
||||
*/
|
||||
class ExampleOfUnmergeableTool {
|
||||
/**
|
||||
* Render method mock
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.dataset.cy = 'unmergeable-tool';
|
||||
container.contentEditable = 'true';
|
||||
container.innerHTML = 'Unmergeable not empty tool';
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving logic is not necessary for this test
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public save(): void {}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
code: ExampleOfUnmergeableTool,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'Second block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'code',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('{del}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('[data-cy=unmergeable-tool]')
|
||||
.as('secondBlock');
|
||||
|
||||
/**
|
||||
* Caret is set to the previous Block
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('@secondBlock').should(($div) => {
|
||||
expect($div[0].contains(range.startContainer)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('at the end of the last Block', function () {
|
||||
it('should do nothing', function () {
|
||||
createEditorWithTextBlocks([ 'The only block. Not empty' ]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('{del}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.should('have.length', 1)
|
||||
.should('have.text', 'The only block. Not empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
76
test/cypress/tests/modules/BlockEvents/Enter.cy.ts
Normal file
76
test/cypress/tests/modules/BlockEvents/Enter.cy.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
describe('Enter keydown', function () {
|
||||
it('should split block and remove selected fragment if some text fragment selected', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The block with some text',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.selectText('with so')
|
||||
.wait(0)
|
||||
.type('{enter}');
|
||||
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.then((blocks) => {
|
||||
/**
|
||||
* Check that there is two blocks after split
|
||||
*/
|
||||
expect(blocks.length).to.equal(2);
|
||||
|
||||
/**
|
||||
* Check that selected text fragment has been removed
|
||||
*/
|
||||
expect(blocks[0].textContent).to.equal('The block ');
|
||||
expect(blocks[1].textContent).to.equal('me text');
|
||||
});
|
||||
});
|
||||
|
||||
it('should set caret to the new block if it was created after Enter key press at very end of the block', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The block with some text',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('{enter}');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('div.ce-block')
|
||||
.last()
|
||||
.as('lastBlock');
|
||||
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get('@lastBlock').should(($block) => {
|
||||
expect($block[0].contains(range.startContainer)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type EditorJS from '../../../../types/index';
|
||||
import Header from '@editorjs/header';
|
||||
|
||||
describe('Saver module', function () {
|
||||
describe('save()', function () {
|
||||
|
|
@ -33,5 +34,53 @@ describe('Saver module', function () {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This test case covers Block@detectToolRootChange
|
||||
*/
|
||||
it('should correctly save block data if block\'s main container element have been changed', function () {
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
header: Header,
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
data: {
|
||||
text: 'The block with some text',
|
||||
level: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('span.ce-toolbar__settings-btn')
|
||||
.click();
|
||||
|
||||
/**
|
||||
* Change header level
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('.ce-settings .ce-popover-item:nth-child(3)')
|
||||
.click();
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const data = await editor.save();
|
||||
|
||||
expect(data.blocks[0].type).to.equal('header');
|
||||
expect(data.blocks[0].data.text).to.equal('The block with some text');
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
expect(data.blocks[0].data.level).to.equal(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
96
test/cypress/tests/modules/Ui.cy.ts
Normal file
96
test/cypress/tests/modules/Ui.cy.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type EditorJS from '../../../../types/index';
|
||||
|
||||
describe('Ui module', function () {
|
||||
describe('documentKeydown', function () {
|
||||
describe('Backspace', function () {
|
||||
it('should remove selected blocks', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The first block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The second block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Select two blocks by shift+down
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.type('{shift+downArrow}')
|
||||
.type('{backspace}');
|
||||
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
/**
|
||||
* Actually editor will contain 1 empty block, but save wont return it since it is empty
|
||||
*/
|
||||
expect(blocks.length).to.eq(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete', function () {
|
||||
it('should remove selected blocks', function () {
|
||||
cy.createEditor({
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The first block',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block2',
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The second block',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Select two blocks by shift+down
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.first()
|
||||
.click()
|
||||
.type('{shift+downArrow}')
|
||||
.type('{del}');
|
||||
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
/**
|
||||
* Actually editor will contain 1 empty block, but save wont return it since it is empty
|
||||
*/
|
||||
expect(blocks.length).to.eq(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,36 @@
|
|||
import Header from '@editorjs/header';
|
||||
import Code from '@editorjs/code';
|
||||
import Delimiter from '@editorjs/delimiter';
|
||||
import { BlockMutationType } from '../../../types/events/block/mutation-type';
|
||||
import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
|
||||
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
|
||||
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
|
||||
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
|
||||
|
||||
|
||||
/**
|
||||
* EditorJS API is passed as the first parameter of the onChange callback
|
||||
*/
|
||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
||||
|
||||
/**
|
||||
* Check if passed onChange method is called with an array of passed events
|
||||
*
|
||||
* @param $onChange - editor onChange spy
|
||||
* @param expectedEvents - batched events to check
|
||||
*/
|
||||
function beCalledWithBatchedEvents($onChange, expectedEvents): void {
|
||||
expect($onChange).to.be.calledOnce;
|
||||
expect($onChange).to.be.calledWithMatch(
|
||||
EditorJSApiMock,
|
||||
Cypress.sinon.match((events) => {
|
||||
return events.every((event, index) => {
|
||||
const eventToCheck = expectedEvents[index];
|
||||
|
||||
return expect(event).to.containSubset(eventToCheck);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo Add checks that correct block API object is passed to onChange
|
||||
|
|
@ -21,7 +50,7 @@ describe('onChange callback', () => {
|
|||
code: Code,
|
||||
},
|
||||
onChange: (api, event): void => {
|
||||
console.log('something changed', api, event);
|
||||
console.log('something changed', event);
|
||||
},
|
||||
data: blocks ? {
|
||||
blocks,
|
||||
|
|
@ -46,7 +75,7 @@ describe('onChange callback', () => {
|
|||
delimiter: Delimiter,
|
||||
},
|
||||
onChange: (api, event): void => {
|
||||
console.log('something changed', api, event);
|
||||
console.log('something changed', event);
|
||||
api.saver.save();
|
||||
},
|
||||
data: blocks ? {
|
||||
|
|
@ -59,21 +88,62 @@ describe('onChange callback', () => {
|
|||
cy.createEditor(config).as('editorInstance');
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorJS API is passed as the first parameter of the onChange callback
|
||||
*/
|
||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
||||
|
||||
it('should fire onChange callback with correct index on block insertion above the current (by pressing Enter at the start)', () => {
|
||||
createEditor();
|
||||
it('should batch events when several changes happened at once', () => {
|
||||
createEditor([
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The first paragraph',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('change')
|
||||
.type('{enter}');
|
||||
|
||||
cy.get('@onChange').should(($callback) => {
|
||||
return beCalledWithBatchedEvents($callback, [
|
||||
{
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out similar events on batching', () => {
|
||||
createEditor([
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'The first paragraph',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('first change')
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
.wait(100)
|
||||
.type('second change');
|
||||
|
||||
cy.get('@onChange').should('be.calledOnce');
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Added,
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
|
|
@ -83,27 +153,66 @@ describe('onChange callback', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('should fire onChange callback with correct index on block insertion below the current (by pressing enter at the end)', () => {
|
||||
it('should be fired with correct index on block insertion above the current (by pressing Enter at the start)', () => {
|
||||
createEditor();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('some text')
|
||||
.type('{enter}');
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Added,
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should be fired with only single "block-added" event by pressing Enter at the end of a block', () => {
|
||||
createEditor([ {
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
} ]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('{enter}');
|
||||
|
||||
cy.get('@onChange').should('be.calledOnce');
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockAddedMutationType,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should be fired with correct index on block insertion after the current (by pressing enter at the end)', () => {
|
||||
createEditor([ {
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
} ]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('{enter}');
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 1,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fire onChange callback on typing into block', () => {
|
||||
it('should be fired on typing into block', () => {
|
||||
createEditor();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
|
|
@ -112,14 +221,14 @@ describe('onChange callback', () => {
|
|||
.type('some text');
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Changed,
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fire onChange callback on block insertion with save inside onChange', () => {
|
||||
it('should be fired on block insertion with save inside onChange', () => {
|
||||
createEditorWithSave();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
|
|
@ -134,39 +243,40 @@ describe('onChange callback', () => {
|
|||
.get('div.ce-popover-item[data-item-name=delimiter]')
|
||||
.click();
|
||||
|
||||
cy.get('@onChange').should('be.calledThrice');
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Removed,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
cy.get('@onChange').should(($callback) => {
|
||||
return beCalledWithBatchedEvents($callback, [
|
||||
{
|
||||
type: BlockRemovedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Added,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'delimiter',
|
||||
{
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'delimiter',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Added,
|
||||
detail: {
|
||||
index: 1,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
{
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 1,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire onChange callback on block replacement for both of blocks', () => {
|
||||
it('should be fired on block replacement for both of blocks', () => {
|
||||
createEditor();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
|
|
@ -181,28 +291,31 @@ describe('onChange callback', () => {
|
|||
.get('div.ce-popover-item[data-item-name=header]')
|
||||
.click();
|
||||
|
||||
cy.get('@onChange').should('be.calledTwice');
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Removed,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
cy.get('@onChange').should(($callback) => {
|
||||
return beCalledWithBatchedEvents($callback, [
|
||||
{
|
||||
type: BlockRemovedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'paragraph',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Added,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'header',
|
||||
{
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
name: 'header',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire onChange callback on tune modifying', () => {
|
||||
it('should be fired on tune modifying', () => {
|
||||
createEditor([
|
||||
{
|
||||
type: 'header',
|
||||
|
|
@ -225,7 +338,7 @@ describe('onChange callback', () => {
|
|||
.click();
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Changed,
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
target: {
|
||||
|
|
@ -235,16 +348,15 @@ describe('onChange callback', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('should fire onChange callback when block is removed', () => {
|
||||
createEditor();
|
||||
|
||||
/**
|
||||
* The only block does not have Tune menu, so need to create at least 2 blocks to test deleting
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('some text');
|
||||
it('should be fired when block is removed', () => {
|
||||
createEditor([
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
|
|
@ -263,21 +375,45 @@ describe('onChange callback', () => {
|
|||
.get('div[data-item-name=delete]')
|
||||
.click();
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Removed,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
cy.get('@onChange').should(($callback) => {
|
||||
return beCalledWithBatchedEvents($callback, [
|
||||
/**
|
||||
* "block-removed" fired since we have deleted a block
|
||||
*/
|
||||
{
|
||||
type: BlockRemovedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* "block-added" fired since we have deleted the last block, so the new one is created
|
||||
*/
|
||||
{
|
||||
type: BlockAddedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire onChange callback when block is moved', () => {
|
||||
createEditor();
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('{enter}');
|
||||
it('should be fired when block is moved', () => {
|
||||
createEditor([
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'first block',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'second block',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('div.ce-block')
|
||||
|
|
@ -293,7 +429,7 @@ describe('onChange callback', () => {
|
|||
.click();
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Moved,
|
||||
type: BlockMovedMutationType,
|
||||
detail: {
|
||||
fromIndex: 1,
|
||||
toIndex: 0,
|
||||
|
|
@ -301,7 +437,7 @@ describe('onChange callback', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('should fire onChange if something changed inside native input', () => {
|
||||
it('should be fired if something changed inside native input', () => {
|
||||
createEditor([ {
|
||||
type: 'code',
|
||||
data: {
|
||||
|
|
@ -314,10 +450,129 @@ describe('onChange callback', () => {
|
|||
.type('Some input to the textarea');
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Changed,
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not be fired on fake cursor adding and removing', () => {
|
||||
createEditor([ {
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'some text',
|
||||
},
|
||||
} ]);
|
||||
|
||||
cy.get('[data-cy=editorjs')
|
||||
.get('div.ce-block')
|
||||
.click();
|
||||
|
||||
/**
|
||||
* Open Block Tunes, add fake cursor
|
||||
*/
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.get('span.ce-toolbar__settings-btn')
|
||||
.click();
|
||||
|
||||
/**
|
||||
* Close Block Tunes, remove fake cursor
|
||||
*/
|
||||
cy.get('[data-cy=editorjs')
|
||||
.get('div.ce-block')
|
||||
.click();
|
||||
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
|
||||
cy.wait(500).then(() => {
|
||||
cy.get('@onChange').should('have.callCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be fired when the whole text inside block is removed', () => {
|
||||
createEditor([ {
|
||||
type: 'paragraph',
|
||||
data: {
|
||||
text: 'a',
|
||||
},
|
||||
} ]);
|
||||
|
||||
cy.get('[data-cy=editorjs')
|
||||
.get('div.ce-block')
|
||||
.click()
|
||||
.type('{backspace}');
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not be fired when element with the "data-mutation-free" mark changes some attribute', () => {
|
||||
/**
|
||||
* Mock for tool wrapper which we will mutate in a test
|
||||
*/
|
||||
const toolWrapper = document.createElement('div');
|
||||
|
||||
/**
|
||||
* Mark it as mutation-free
|
||||
*/
|
||||
toolWrapper.dataset.mutationFree = 'true';
|
||||
|
||||
/**
|
||||
* Mock of tool with data-mutation-free attribute
|
||||
*/
|
||||
class ToolWithMutationFreeAttribute {
|
||||
/**
|
||||
* Simply return mocked element
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
return toolWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving logic is not necessary for this test
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public save(): void {}
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
tools: {
|
||||
testTool: ToolWithMutationFreeAttribute,
|
||||
},
|
||||
onChange: (api, event): void => {
|
||||
console.log('something changed', event);
|
||||
},
|
||||
data: {
|
||||
blocks: [
|
||||
{
|
||||
type: 'testTool',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
cy.spy(editorConfig, 'onChange').as('onChange');
|
||||
cy.createEditor(editorConfig).as('editorInstance');
|
||||
|
||||
/**
|
||||
* Emulate tool's internal attribute mutation
|
||||
*/
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
|
||||
cy.wait(100).then(() => {
|
||||
toolWrapper.setAttribute('some-changed-attr', 'some-new-value');
|
||||
});
|
||||
|
||||
/**
|
||||
* Check that onChange callback was not called
|
||||
*/
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
|
||||
cy.wait(500).then(() => {
|
||||
cy.get('@onChange').should('have.callCount', 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
103
test/cypress/tests/ui/toolbox.cy.ts
Normal file
103
test/cypress/tests/ui/toolbox.cy.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type EditorJS from '../../../../types/index';
|
||||
import { ConversionConfig, ToolboxConfig } from '../../../../types/index';
|
||||
import ToolMock from '../../fixtures/tools/ToolMock';
|
||||
|
||||
describe('Toolbox', function () {
|
||||
describe('Shortcuts', function () {
|
||||
it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ConvertableTool extends ToolMock {
|
||||
/**
|
||||
* Specify how to import string data to this Tool
|
||||
*/
|
||||
public static get conversionConfig(): ConversionConfig {
|
||||
return {
|
||||
import: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
convertableTool: {
|
||||
class: ConvertableTool,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('Some text')
|
||||
.type('{cmd}{shift}H'); // call a shortcut
|
||||
|
||||
/**
|
||||
* Check that block was converted
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(1);
|
||||
expect(blocks[0].type).to.eq('convertableTool');
|
||||
expect(blocks[0].data.text).to.eq('Some text');
|
||||
});
|
||||
});
|
||||
|
||||
it('should insert a Shortcuts\'s Block below the current if some (original or target) tool does not provide a "conversionConfig" ', function () {
|
||||
/**
|
||||
* Mock of Tool with conversionConfig
|
||||
*/
|
||||
class ToolWithoutConversionConfig extends ToolMock {
|
||||
/**
|
||||
* Specify how to display Tool in a Toolbox
|
||||
*/
|
||||
public static get toolbox(): ToolboxConfig {
|
||||
return {
|
||||
icon: '',
|
||||
title: 'Convertable tool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cy.createEditor({
|
||||
tools: {
|
||||
nonConvertableTool: {
|
||||
class: ToolWithoutConversionConfig,
|
||||
shortcut: 'CMD+SHIFT+H',
|
||||
},
|
||||
},
|
||||
}).as('editorInstance');
|
||||
|
||||
cy.get('[data-cy=editorjs]')
|
||||
.find('.ce-paragraph')
|
||||
.click()
|
||||
.type('Some text')
|
||||
.type('{cmd}{shift}H'); // call a shortcut
|
||||
|
||||
/**
|
||||
* Check that the new block was appended
|
||||
*/
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const { blocks } = await editor.save();
|
||||
|
||||
expect(blocks.length).to.eq(2);
|
||||
expect(blocks[1].type).to.eq('nonConvertableTool');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
11
types/api/blocks.d.ts
vendored
11
types/api/blocks.d.ts
vendored
|
|
@ -130,4 +130,15 @@ export interface Blocks {
|
|||
* @param data - the new data
|
||||
*/
|
||||
update(id: string, data: BlockToolData): void;
|
||||
|
||||
/**
|
||||
* Converts block to another type. Both blocks should provide the conversionConfig.
|
||||
*
|
||||
* @param id - id of the existed block to convert. Should provide 'conversionConfig.export' method
|
||||
* @param newType - new block type. Should provide 'conversionConfig.import' method
|
||||
* @param dataOverrides - optional data overrides for the new block
|
||||
*
|
||||
* @throws Error if conversion is not possible
|
||||
*/
|
||||
convert(id: string, newType: string, dataOverrides?: BlockToolData): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface ConversionConfig {
|
|||
* 1. String — the key of Tool data object to fill it with imported string on render.
|
||||
* 2. Function — method that accepts importing string and composes Tool data to render.
|
||||
*/
|
||||
import: ((data: string) => string) | string;
|
||||
import?: ((data: string) => string) | string;
|
||||
|
||||
/**
|
||||
* How to export this Tool to make other Block.
|
||||
|
|
@ -22,5 +22,5 @@ export interface ConversionConfig {
|
|||
* 1. String — which property of saved Tool data should be used as exported string.
|
||||
* 2. Function — accepts saved Tool data and create a string to export
|
||||
*/
|
||||
export: ((data: BlockToolData) => string) | string;
|
||||
export?: ((data: BlockToolData) => string) | string;
|
||||
}
|
||||
|
|
|
|||
9
types/configs/editor-config.d.ts
vendored
9
types/configs/editor-config.d.ts
vendored
|
|
@ -1,7 +1,8 @@
|
|||
import {ToolConstructable, ToolSettings} from '../tools';
|
||||
import {API, BlockAPI, LogLevels, OutputData} from '../index';
|
||||
import {API, LogLevels, OutputData} from '../index';
|
||||
import {SanitizerConfig} from './sanitizer-config';
|
||||
import {I18nConfig} from './i18n-config';
|
||||
import { BlockMutationEvent } from '../events/block';
|
||||
|
||||
export interface EditorConfig {
|
||||
/**
|
||||
|
|
@ -89,10 +90,10 @@ export interface EditorConfig {
|
|||
|
||||
/**
|
||||
* Fires when something changed in DOM
|
||||
* @param {API} api - editor.js api
|
||||
* @param event - custom event describing mutation
|
||||
* @param api - editor.js api
|
||||
* @param event - custom event describing mutation. If several mutations happened at once, they will be batched and you'll get an array of events here.
|
||||
*/
|
||||
onChange?(api: API, event: CustomEvent): void;
|
||||
onChange?(api: API, event: BlockMutationEvent | BlockMutationEvent[]): void;
|
||||
|
||||
/**
|
||||
* Defines default toolbar for all tools.
|
||||
|
|
|
|||
5
types/data-formats/block-data.d.ts
vendored
5
types/data-formats/block-data.d.ts
vendored
|
|
@ -1,10 +1,11 @@
|
|||
import {BlockToolData} from '../tools';
|
||||
import { BlockId } from './block-id';
|
||||
|
||||
/**
|
||||
* Tool's saved data
|
||||
*/
|
||||
export interface SavedData {
|
||||
id: string;
|
||||
id: BlockId;
|
||||
tool: string;
|
||||
data: BlockToolData;
|
||||
time: number;
|
||||
|
|
@ -14,7 +15,7 @@ export interface SavedData {
|
|||
* Tool's data after validation
|
||||
*/
|
||||
export interface ValidatedData {
|
||||
id?: string;
|
||||
id?: BlockId;
|
||||
tool?: string;
|
||||
data?: BlockToolData;
|
||||
time?: number;
|
||||
|
|
|
|||
4
types/data-formats/block-id.ts
Normal file
4
types/data-formats/block-id.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Unique identifier of a block
|
||||
*/
|
||||
export type BlockId = string;
|
||||
3
types/data-formats/output-data.d.ts
vendored
3
types/data-formats/output-data.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
import {BlockToolData} from '../tools';
|
||||
import {BlockTuneData} from '../block-tunes/block-tune-data';
|
||||
import { BlockId } from './block-id';
|
||||
|
||||
/**
|
||||
* Output of one Tool
|
||||
|
|
@ -11,7 +12,7 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
|
|||
/**
|
||||
* Unique Id of the block
|
||||
*/
|
||||
id?: string;
|
||||
id?: BlockId;
|
||||
/**
|
||||
* Tool type
|
||||
*/
|
||||
|
|
|
|||
11
types/events/block/Base.ts
Normal file
11
types/events/block/Base.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { BlockAPI } from '../../api';
|
||||
|
||||
/**
|
||||
* Details of CustomEvent fired on block mutation
|
||||
*/
|
||||
export interface BlockMutationEventDetail {
|
||||
/**
|
||||
* Affected block
|
||||
*/
|
||||
target: BlockAPI;
|
||||
}
|
||||
21
types/events/block/BlockAdded.ts
Normal file
21
types/events/block/BlockAdded.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block added event
|
||||
*/
|
||||
export const BlockAddedMutationType = 'block-added';
|
||||
|
||||
/**
|
||||
* Information about added block
|
||||
*/
|
||||
interface BlockAddedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of added block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when the new block is added to the editor
|
||||
*/
|
||||
export type BlockAddedEvent = CustomEvent<BlockAddedEventDetail>;
|
||||
21
types/events/block/BlockChanged.ts
Normal file
21
types/events/block/BlockChanged.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block changed event
|
||||
*/
|
||||
export const BlockChangedMutationType = 'block-changed';
|
||||
|
||||
/**
|
||||
* Information about changed block
|
||||
*/
|
||||
interface BlockChangedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of changed block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is changed
|
||||
*/
|
||||
export type BlockChangedEvent = CustomEvent<BlockChangedEventDetail>;
|
||||
26
types/events/block/BlockMoved.ts
Normal file
26
types/events/block/BlockMoved.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block moved event
|
||||
*/
|
||||
export const BlockMovedMutationType = 'block-moved';
|
||||
|
||||
/**
|
||||
* Information about moved block
|
||||
*/
|
||||
interface BlockMovedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Previous block position
|
||||
*/
|
||||
fromIndex: number;
|
||||
|
||||
/**
|
||||
* New block position
|
||||
*/
|
||||
toIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is moved to another position
|
||||
*/
|
||||
export type BlockMovedEvent = CustomEvent<BlockMovedEventDetail>;
|
||||
21
types/events/block/BlockRemoved.ts
Normal file
21
types/events/block/BlockRemoved.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block removed event
|
||||
*/
|
||||
export const BlockRemovedMutationType = 'block-removed';
|
||||
|
||||
/**
|
||||
* Information about removed block
|
||||
*/
|
||||
interface BlockRemovedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of removed block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is removed
|
||||
*/
|
||||
export type BlockRemovedEvent = CustomEvent<BlockRemovedEventDetail>;
|
||||
44
types/events/block/index.ts
Normal file
44
types/events/block/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { BlockAddedEvent, BlockAddedMutationType } from './BlockAdded';
|
||||
import { BlockChangedEvent, BlockChangedMutationType } from './BlockChanged';
|
||||
import { BlockMovedEvent, BlockMovedMutationType } from './BlockMoved';
|
||||
import { BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved';
|
||||
|
||||
/**
|
||||
* Map for Custom Events related to block mutation types
|
||||
*/
|
||||
export interface BlockMutationEventMap {
|
||||
/**
|
||||
* New Block added
|
||||
*/
|
||||
[BlockAddedMutationType]: BlockAddedEvent;
|
||||
|
||||
/**
|
||||
* On Block deletion
|
||||
*/
|
||||
[BlockRemovedMutationType]: BlockRemovedEvent;
|
||||
|
||||
/**
|
||||
* Moving of a Block
|
||||
*/
|
||||
[BlockMovedMutationType]: BlockMovedEvent;
|
||||
|
||||
/**
|
||||
* Any changes inside the Block
|
||||
*/
|
||||
[BlockChangedMutationType]: BlockChangedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* What kind of modification happened with the Block
|
||||
*/
|
||||
export type BlockMutationType = keyof BlockMutationEventMap;
|
||||
|
||||
/**
|
||||
* Returns a union type of values of passed object
|
||||
*/
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
/**
|
||||
* CustomEvent describing a change related to a block
|
||||
*/
|
||||
export type BlockMutationEvent = ValueOf<BlockMutationEventMap>;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* What kind of modification happened with the Block
|
||||
*/
|
||||
export enum BlockMutationType {
|
||||
/**
|
||||
* New Block added
|
||||
*/
|
||||
Added = 'block-added',
|
||||
|
||||
/**
|
||||
* On Block deletion
|
||||
*/
|
||||
Removed = 'block-removed',
|
||||
|
||||
/**
|
||||
* Moving of a Block
|
||||
*/
|
||||
Moved = 'block-moved',
|
||||
|
||||
/**
|
||||
* Any changes inside the Block
|
||||
*/
|
||||
Changed = 'block-changed',
|
||||
}
|
||||
19
types/index.d.ts
vendored
19
types/index.d.ts
vendored
|
|
@ -31,6 +31,11 @@ import {
|
|||
} from './api';
|
||||
|
||||
import { OutputData } from './data-formats';
|
||||
import { BlockMutationEventMap } from './events/block';
|
||||
import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded';
|
||||
import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged';
|
||||
import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved';
|
||||
import { BlockRemovedMutationType, BlockRemovedEvent } from './events/block/BlockRemoved';
|
||||
|
||||
/**
|
||||
* Interfaces used for development
|
||||
|
|
@ -76,8 +81,20 @@ export {
|
|||
PopoverItemWithConfirmation,
|
||||
PopoverItemWithoutConfirmation
|
||||
} from './configs';
|
||||
export {OutputData, OutputBlockData} from './data-formats/output-data';
|
||||
export { OutputData, OutputBlockData} from './data-formats/output-data';
|
||||
export { BlockId } from './data-formats/block-id';
|
||||
export { BlockAPI } from './api'
|
||||
export {
|
||||
BlockMutationEventMap,
|
||||
BlockAddedMutationType,
|
||||
BlockAddedEvent,
|
||||
BlockRemovedMutationType,
|
||||
BlockRemovedEvent,
|
||||
BlockMovedMutationType,
|
||||
BlockMovedEvent,
|
||||
BlockChangedMutationType,
|
||||
BlockChangedEvent,
|
||||
}
|
||||
|
||||
/**
|
||||
* We have a namespace API {@link ./api/index.d.ts} (APIMethods) but we can not use it as interface
|
||||
|
|
|
|||
|
|
@ -1353,6 +1353,11 @@ caseless@~0.12.0:
|
|||
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
|
||||
|
||||
chai-subset@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chai-subset/-/chai-subset-1.6.0.tgz#a5d0ca14e329a79596ed70058b6646bd6988cfe9"
|
||||
integrity sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==
|
||||
|
||||
chalk@4.1.2, chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue