mirror of
https://github.com/codex-team/editor.js
synced 2024-06-01 05:22:35 +02:00
chore(onChange): bugfix, batching, improvements of ModificationsObserver (#2349)
* block onchange stash * improve block filtration * update tool root * chore(block): remove willSelect und willUnselect * onchange events batching * get rid of CustomEvent extension, create custom event map instead * improve types of EventsDispatcher * fix tests * custom sinon + chai matchers * improve tests, add mutex for fake cursor * add test for fake-cursor mutex * test for batch filtering * fix caret setting by enter press at the end of the block * test for detectToolRootChange * remove resolved todos * changelog added * fix tests * Update CHANGELOG.md * rename FakeCursorAboutToBeSet -> FakeCursorAboutToBeToggled * update didMutated statements * move inputs cache clearing to a separate method * rm Record inheritance from Event maps * add type alisases * rename isElementContainsFakeCursor -> isFakeCursorInsideContainer * improve code style
This commit is contained in:
parent
aafab1d395
commit
b7b00fd060
|
@ -6,10 +6,20 @@
|
||||||
- `Refactoring` — Popover class refactored.
|
- `Refactoring` — Popover class refactored.
|
||||||
- `Improvement` — *Toolbox* — Number of `close()` method calls optimized.
|
- `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.
|
- `Improvement` — The `onChange` callback won't be triggered only if all mutations contain nodes with the `data-mutation-free` attributes.
|
||||||
|
- `Improvement` — **Breaking Change** — Batching added to the `onChange` callback. Now the second argument can contain an array of CustomEvents as well as a single one. If several changes will be made in short period of time, they will be batched under the single `onChange` call
|
||||||
|
- `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.
|
||||||
|
- `Fix` — The `onChange` won't be triggered with Block Tunes opening and closing.
|
||||||
|
- `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` — Now Blocks don't have own Mutation Observer. The single observer attached to the editor's blocks wrapper element. `ModificationsObserver` listens blocks wrapper changes, then emits an event into the common editor Event Bus. Each Block subscribes to such events and handle those who related to a particular Block.
|
||||||
|
- `Improvement` — Block's mutation handler now will be called on every block change (including background changes). Previously, it have worked only if a particular Block have been focused.
|
||||||
|
- `Refactoring` — Debounce have been removed from Block's mutation handler. The batching in `ModificationObserver` used instead.
|
||||||
|
- `Improvement` — Number of caret saving method calls optimized for Block Tunes opening/closing.
|
||||||
|
- `New` — Types for Block mutation events added.
|
||||||
- `Fix` — Resolve compiler error from importing the BlockToolData type.
|
- `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.
|
- `Fix` — Resolved a problem when document was being scrolled to the beginning after moving up a Block above the viewport.
|
||||||
- `Improvement` — Package size reduced by removing redundant files.
|
- `Improvement` — Package size reduced by removing redundant files.
|
||||||
- `Fix`- Several bugs caused by random browser extensions.
|
- `Fix`- Several bugs caused by random browser extensions. Now we are not searching for a block's container in the DOM on saving. Instead, we keep it in memory and update it when the tool changes a container element.
|
||||||
- `Improvement` — *Dependencies* — Upgrade TypeScript to v5.
|
- `Improvement` — *Dependencies* — Upgrade TypeScript to v5.
|
||||||
- `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.
|
- `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.
|
- `Improvement` — *Dependencies* — Upgrade Cypress to v12, upgrade related libraries to latest versions.
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
"@editorjs/paragraph": "^2.9.0",
|
"@editorjs/paragraph": "^2.9.0",
|
||||||
"@editorjs/simple-image": "^1.4.1",
|
"@editorjs/simple-image": "^1.4.1",
|
||||||
"@types/node": "^18.15.11",
|
"@types/node": "^18.15.11",
|
||||||
|
"chai-subset": "^1.6.0",
|
||||||
"codex-notifier": "^1.1.2",
|
"codex-notifier": "^1.1.2",
|
||||||
"codex-tooltip": "^1.0.5",
|
"codex-tooltip": "^1.0.5",
|
||||||
"core-js": "3.30.0",
|
"core-js": "3.30.0",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { EditorConfig } from '../../types';
|
||||||
import { ModuleConfig } from '../types-internal/module-config';
|
import { ModuleConfig } from '../types-internal/module-config';
|
||||||
import Listeners from './utils/listeners';
|
import Listeners from './utils/listeners';
|
||||||
import EventsDispatcher from './utils/events';
|
import EventsDispatcher from './utils/events';
|
||||||
|
import { EditorEventMap } from './events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type <T> of the Module generic.
|
* 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
|
* Editor event dispatcher class
|
||||||
*/
|
*/
|
||||||
protected eventsDispatcher: EventsDispatcher;
|
protected eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Util for bind/unbind DOM event listeners
|
* Util for bind/unbind DOM event listeners
|
||||||
|
|
|
@ -22,6 +22,9 @@ import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
||||||
import ToolsCollection from '../tools/collection';
|
import ToolsCollection from '../tools/collection';
|
||||||
import EventsDispatcher from '../utils/events';
|
import EventsDispatcher from '../utils/events';
|
||||||
import { TunesMenuConfigItem } from '../../../types/tools';
|
import { TunesMenuConfigItem } from '../../../types/tools';
|
||||||
|
import { isMutationBelongsToElement } from '../utils/mutations';
|
||||||
|
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||||
|
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface describes Block class constructor argument
|
* Interface describes Block class constructor argument
|
||||||
|
@ -84,9 +87,11 @@ export enum BlockToolAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance
|
||||||
|
@ -192,89 +197,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
private inputIndex = 0;
|
private inputIndex = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation observer to handle DOM mutations
|
* Common editor event bus
|
||||||
*
|
|
||||||
* @type {MutationObserver}
|
|
||||||
*/
|
*/
|
||||||
private mutationObserver: MutationObserver;
|
private readonly editorEventBus: EventsDispatcher<EditorEventMap> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounce Timer
|
* Link to editor dom change callback. Used to remove listener on remove
|
||||||
*
|
|
||||||
* @type {number}
|
|
||||||
*/
|
*/
|
||||||
private readonly modificationDebounceTimer = 450;
|
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current block API interface
|
* Current block API interface
|
||||||
|
@ -282,12 +212,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
private readonly blockAPI: BlockAPIInterface;
|
private readonly blockAPI: BlockAPIInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} options - block constructor options
|
* @param options - block constructor options
|
||||||
* @param {string} [options.id] - block's id. Will be generated if omitted.
|
* @param [options.id] - block's id. Will be generated if omitted.
|
||||||
* @param {BlockToolData} options.data - Tool's initial data
|
* @param options.data - Tool's initial data
|
||||||
* @param {BlockTool} options.tool — block's tool
|
* @param options.tool — block's tool
|
||||||
* @param options.api - Editor API module for pass it to the Block Tunes
|
* @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({
|
constructor({
|
||||||
id = _.generateBlockId(),
|
id = _.generateBlockId(),
|
||||||
|
@ -296,7 +227,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
api,
|
api,
|
||||||
readOnly,
|
readOnly,
|
||||||
tunesData,
|
tunesData,
|
||||||
}: BlockConstructorOptions) {
|
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.name = tool.name;
|
this.name = tool.name;
|
||||||
|
@ -304,10 +235,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
this.settings = tool.settings;
|
this.settings = tool.settings;
|
||||||
this.config = tool.settings.config || {};
|
this.config = tool.settings.config || {};
|
||||||
this.api = api;
|
this.api = api;
|
||||||
|
this.editorEventBus = eventBus || null;
|
||||||
this.blockAPI = new BlockAPI(this);
|
this.blockAPI = new BlockAPI(this);
|
||||||
|
|
||||||
this.mutationObserver = new MutationObserver(this.didMutated);
|
|
||||||
|
|
||||||
this.tool = tool;
|
this.tool = tool;
|
||||||
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
|
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
|
||||||
|
|
||||||
|
@ -319,6 +249,17 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
this.composeTunes(tunesData);
|
this.composeTunes(tunesData);
|
||||||
|
|
||||||
this.holder = this.compose();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -505,14 +446,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
* @param {boolean} state - 'true' to select, 'false' to remove selection
|
* @param {boolean} state - 'true' to select, 'false' to remove selection
|
||||||
*/
|
*/
|
||||||
public set selected(state: boolean) {
|
public set selected(state: boolean) {
|
||||||
if (state) {
|
this.holder.classList.toggle(Block.CSS.selected, state);
|
||||||
this.holder.classList.add(Block.CSS.selected);
|
|
||||||
|
|
||||||
SelectionUtils.addFakeCursor(this.holder);
|
const fakeCursorWillBeAdded = state === true && SelectionUtils.isRangeInsideContainer(this.holder);
|
||||||
} else {
|
const fakeCursorWillBeRemoved = state === false && SelectionUtils.isFakeCursorInsideContainer(this.holder);
|
||||||
this.holder.classList.remove(Block.CSS.selected);
|
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -711,38 +659,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
: SelectionUtils.anchorNode;
|
: 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
|
* 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.
|
* Can be useful for block changes invisible for editor core.
|
||||||
|
@ -755,6 +671,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
* Call Tool instance destroy method
|
* Call Tool instance destroy method
|
||||||
*/
|
*/
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
|
this.unwatchBlockMutations();
|
||||||
|
this.removeInputEvents();
|
||||||
|
|
||||||
super.destroy();
|
super.destroy();
|
||||||
|
|
||||||
if (_.isFunction(this.toolInstance.destroy)) {
|
if (_.isFunction(this.toolInstance.destroy)) {
|
||||||
|
@ -876,9 +795,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
*/
|
*/
|
||||||
private handleFocus = (): void => {
|
private handleFocus = (): void => {
|
||||||
/**
|
/**
|
||||||
* Drop cache
|
* Drop inputs cache to query the new ones
|
||||||
*/
|
*/
|
||||||
this.cachedInputs = [];
|
this.dropInputsCache();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update current input
|
* Update current input
|
||||||
|
@ -897,7 +816,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
* If input is native input add oninput listener to observe changes
|
* If input is native input add oninput listener to observe changes
|
||||||
*/
|
*/
|
||||||
if ($.isNativeInput(input)) {
|
if ($.isNativeInput(input)) {
|
||||||
input.addEventListener('input', this.didMutated);
|
input.addEventListener('input', this.didMutated as EventListener);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -910,8 +829,147 @@ export default class Block extends EventsDispatcher<BlockEvents> {
|
||||||
input.removeEventListener('focus', this.handleFocus);
|
input.removeEventListener('focus', this.handleFocus);
|
||||||
|
|
||||||
if ($.isNativeInput(input)) {
|
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 } = record;
|
||||||
|
const changedNodes = [
|
||||||
|
...Array.from(addedNodes),
|
||||||
|
...Array.from(removedNodes),
|
||||||
|
];
|
||||||
|
|
||||||
|
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 { CriticalError } from './errors/critical';
|
||||||
import EventsDispatcher from './utils/events';
|
import EventsDispatcher from './utils/events';
|
||||||
import Modules from './modules';
|
import Modules from './modules';
|
||||||
|
import { EditorEventMap } from './events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class Core
|
* Editor.js core class. Bootstraps modules.
|
||||||
* @classdesc Editor.js core class
|
|
||||||
* @property {EditorConfig} config - all settings
|
|
||||||
* @property {EditorModules} moduleInstances - constructed editor components
|
|
||||||
* @type {Core}
|
|
||||||
*/
|
*/
|
||||||
export default class Core {
|
export default class Core {
|
||||||
/**
|
/**
|
||||||
|
@ -31,9 +28,9 @@ export default class Core {
|
||||||
public isReady: Promise<void>;
|
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
|
* @param {EditorConfig} config - user configuration
|
||||||
|
|
|
@ -145,7 +145,7 @@ export default class Dom {
|
||||||
* @param {string} selector - searching string
|
* @param {string} selector - searching string
|
||||||
* @returns {Element}
|
* @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);
|
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;
|
||||||
|
}
|
|
@ -246,6 +246,13 @@ export default class BlockEvents extends Module {
|
||||||
*/
|
*/
|
||||||
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
|
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
|
||||||
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
|
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 {
|
} else {
|
||||||
/**
|
/**
|
||||||
* Split the Current Block into two blocks
|
* Split the Current Block into two blocks
|
||||||
|
|
|
@ -12,7 +12,12 @@ import Blocks from '../blocks';
|
||||||
import { BlockToolData, PasteEvent } from '../../../types';
|
import { BlockToolData, PasteEvent } from '../../../types';
|
||||||
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
|
||||||
import BlockAPI from '../block/api';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {BlockManager} BlockManager
|
* @typedef {BlockManager} BlockManager
|
||||||
|
@ -35,14 +40,6 @@ export default class BlockManager extends Module {
|
||||||
* @param {number} newIndex - index of Block to set as current
|
* @param {number} newIndex - index of Block to set as current
|
||||||
*/
|
*/
|
||||||
public set currentBlockIndex(newIndex: number) {
|
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;
|
this._currentBlockIndex = newIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +239,7 @@ export default class BlockManager extends Module {
|
||||||
api: this.Editor.API,
|
api: this.Editor.API,
|
||||||
readOnly,
|
readOnly,
|
||||||
tunesData,
|
tunesData,
|
||||||
});
|
}, this.eventsDispatcher);
|
||||||
|
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
this.bindBlockEvents(block);
|
this.bindBlockEvents(block);
|
||||||
|
@ -298,7 +295,7 @@ export default class BlockManager extends Module {
|
||||||
* we need to dispatch the 'block-removing' event for the replacing block
|
* we need to dispatch the 'block-removing' event for the replacing block
|
||||||
*/
|
*/
|
||||||
if (replace) {
|
if (replace) {
|
||||||
this.blockDidMutated(BlockMutationType.Removed, this.getBlockByIndex(newIndex), {
|
this.blockDidMutated(BlockRemovedMutationType, this.getBlockByIndex(newIndex), {
|
||||||
index: newIndex,
|
index: newIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -308,7 +305,7 @@ export default class BlockManager extends Module {
|
||||||
/**
|
/**
|
||||||
* Force call of didMutated event on Block insertion
|
* Force call of didMutated event on Block insertion
|
||||||
*/
|
*/
|
||||||
this.blockDidMutated(BlockMutationType.Added, block, {
|
this.blockDidMutated(BlockAddedMutationType, block, {
|
||||||
index: newIndex,
|
index: newIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -384,7 +381,7 @@ export default class BlockManager extends Module {
|
||||||
/**
|
/**
|
||||||
* Force call of didMutated event on Block insertion
|
* Force call of didMutated event on Block insertion
|
||||||
*/
|
*/
|
||||||
this.blockDidMutated(BlockMutationType.Added, block, {
|
this.blockDidMutated(BlockAddedMutationType, block, {
|
||||||
index,
|
index,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -460,7 +457,7 @@ export default class BlockManager extends Module {
|
||||||
/**
|
/**
|
||||||
* Force call of didMutated event on Block removal
|
* Force call of didMutated event on Block removal
|
||||||
*/
|
*/
|
||||||
this.blockDidMutated(BlockMutationType.Removed, blockToRemove, {
|
this.blockDidMutated(BlockRemovedMutationType, blockToRemove, {
|
||||||
index,
|
index,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -736,7 +733,7 @@ export default class BlockManager extends Module {
|
||||||
/**
|
/**
|
||||||
* Force call of didMutated event on Block movement
|
* Force call of didMutated event on Block movement
|
||||||
*/
|
*/
|
||||||
this.blockDidMutated(BlockMutationType.Moved, this.currentBlock, {
|
this.blockDidMutated(BlockMovedMutationType, this.currentBlock, {
|
||||||
fromIndex,
|
fromIndex,
|
||||||
toIndex,
|
toIndex,
|
||||||
});
|
});
|
||||||
|
@ -807,7 +804,7 @@ export default class BlockManager extends Module {
|
||||||
});
|
});
|
||||||
|
|
||||||
block.on('didMutated', (affectedBlock: Block) => {
|
block.on('didMutated', (affectedBlock: Block) => {
|
||||||
return this.blockDidMutated(BlockMutationType.Changed, affectedBlock, {
|
return this.blockDidMutated(BlockChangedMutationType, affectedBlock, {
|
||||||
index: this.getBlockIndex(affectedBlock),
|
index: this.getBlockIndex(affectedBlock),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -851,18 +848,25 @@ export default class BlockManager extends Module {
|
||||||
*
|
*
|
||||||
* @param mutationType - what happened with block
|
* @param mutationType - what happened with block
|
||||||
* @param block - mutated 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, {
|
const event = new CustomEvent(mutationType, {
|
||||||
detail: {
|
detail: {
|
||||||
target: new BlockAPI(block),
|
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;
|
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 Module from '../__module';
|
||||||
|
import { BlockChanged, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
|
||||||
import * as _ from '../utils';
|
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
|
* Single entry point for Block mutation events
|
||||||
*/
|
*/
|
||||||
|
@ -10,10 +19,74 @@ export default class ModificationsObserver extends Module {
|
||||||
*/
|
*/
|
||||||
private disabled = false;
|
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
|
* Enables onChange event
|
||||||
*/
|
*/
|
||||||
public enable(): void {
|
public enable(): void {
|
||||||
|
this.mutationObserver.observe(
|
||||||
|
this.Editor.UI.nodes.redactor,
|
||||||
|
{
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
attributes: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +94,7 @@ export default class ModificationsObserver extends Module {
|
||||||
* Disables onChange event
|
* Disables onChange event
|
||||||
*/
|
*/
|
||||||
public disable(): void {
|
public disable(): void {
|
||||||
|
this.mutationObserver.disconnect();
|
||||||
this.disabled = true;
|
this.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,11 +103,45 @@ export default class ModificationsObserver extends Module {
|
||||||
*
|
*
|
||||||
* @param event - some of our custom change events
|
* @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)) {
|
if (this.disabled || !_.isFunction(this.config.onChange)) {
|
||||||
return;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,10 @@ import I18n from '../../i18n';
|
||||||
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
import { I18nInternalNS } from '../../i18n/namespace-internal';
|
||||||
import Tooltip from '../../utils/tooltip';
|
import Tooltip from '../../utils/tooltip';
|
||||||
import { ModuleConfig } from '../../../types-internal/module-config';
|
import { ModuleConfig } from '../../../types-internal/module-config';
|
||||||
import { BlockAPI } from '../../../../types';
|
|
||||||
import Block from '../../block';
|
import Block from '../../block';
|
||||||
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
|
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
|
||||||
import { IconMenu, IconPlus } from '@codexteam/icons';
|
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)
|
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
|
||||||
|
@ -421,7 +421,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
||||||
this.Editor.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier);
|
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 { BlockManager, Caret } = this.Editor;
|
||||||
const newBlock = BlockManager.getBlockById(block.id);
|
const newBlock = BlockManager.getBlockById(block.id);
|
||||||
|
|
||||||
|
@ -489,7 +489,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
|
||||||
/**
|
/**
|
||||||
* Subscribe to the 'block-hovered' event
|
* 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
|
* Do not move toolbar if Block Settings or Toolbox opened
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -14,6 +14,7 @@ import Flipper from '../flipper';
|
||||||
import { mobileScreenBreakpoint } from '../utils';
|
import { mobileScreenBreakpoint } from '../utils';
|
||||||
|
|
||||||
import styles from '../../styles/main.css?inline';
|
import styles from '../../styles/main.css?inline';
|
||||||
|
import { BlockHovered } from '../events/BlockHovered';
|
||||||
/**
|
/**
|
||||||
* HTML Elements used for UI
|
* HTML Elements used for UI
|
||||||
*/
|
*/
|
||||||
|
@ -41,15 +42,6 @@ interface UINodes {
|
||||||
* @property {Element} nodes.redactor - <ce-redactor>
|
* @property {Element} nodes.redactor - <ce-redactor>
|
||||||
*/
|
*/
|
||||||
export default class UI extends Module<UINodes> {
|
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
|
* Editor.js UI CSS class names
|
||||||
*
|
*
|
||||||
|
@ -402,7 +394,7 @@ export default class UI extends Module<UINodes> {
|
||||||
|
|
||||||
blockHoveredEmitted = hoveredBlock;
|
blockHoveredEmitted = hoveredBlock;
|
||||||
|
|
||||||
this.eventsDispatcher.emit(this.events.blockHovered, {
|
this.eventsDispatcher.emit(BlockHovered, {
|
||||||
block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock),
|
block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock),
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
|
|
|
@ -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;
|
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');
|
const fakeCursor = $.make('span', 'codex-editor__fake-cursor');
|
||||||
|
|
||||||
fakeCursor.dataset.mutationFree = 'true';
|
fakeCursor.dataset.mutationFree = 'true';
|
||||||
|
|
||||||
if (!range || (container && !container.contains(range.startContainer))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
range.collapse();
|
range.collapse();
|
||||||
range.insertNode(fakeCursor);
|
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
|
* Removes fake cursor from a container
|
||||||
*
|
*
|
||||||
|
@ -379,7 +402,11 @@ export default class SelectionUtils {
|
||||||
public static removeFakeCursor(container: HTMLElement = document.body): void {
|
public static removeFakeCursor(container: HTMLElement = document.body): void {
|
||||||
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
|
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
|
||||||
|
|
||||||
fakeCursor && fakeCursor.remove();
|
if (!fakeCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeCursor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { BlockToolAPI } from '../block';
|
||||||
import Shortcuts from '../utils/shortcuts';
|
import Shortcuts from '../utils/shortcuts';
|
||||||
import BlockTool from '../tools/block';
|
import BlockTool from '../tools/block';
|
||||||
import ToolsCollection from '../tools/collection';
|
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 EventsDispatcher from '../utils/events';
|
||||||
import Popover, { PopoverEvent } from '../utils/popover';
|
import Popover, { PopoverEvent } from '../utils/popover';
|
||||||
import I18n from '../i18n';
|
import I18n from '../i18n';
|
||||||
|
@ -33,6 +33,19 @@ export enum ToolboxEvent {
|
||||||
BlockAdded = 'toolbox-block-added',
|
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
|
* 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}
|
* @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
|
* Returns True if Toolbox is Empty and nothing to show
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,31 +1,39 @@
|
||||||
import { isEmpty } from '../utils';
|
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} on - appends subscriber to the event. If event doesn't exist - creates new one
|
||||||
* - {Function} emit - fires all subscribers with data
|
* - {Function} emit - fires all subscribers with data
|
||||||
* - {Function off - unsubscribes callback
|
* - {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
|
* 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
|
* Subscribe any event on callback
|
||||||
*
|
*
|
||||||
* @param {string} eventName - event name
|
* @param eventName - event name
|
||||||
* @param {Function} callback - subscriber
|
* @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)) {
|
if (!(eventName in this.subscribers)) {
|
||||||
this.subscribers[eventName] = [];
|
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.
|
* Subscribe any event on callback. Callback will be called once and be removed from subscribers array after call.
|
||||||
*
|
*
|
||||||
* @param {string} eventName - event name
|
* @param eventName - event name
|
||||||
* @param {Function} callback - subscriber
|
* @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)) {
|
if (!(eventName in this.subscribers)) {
|
||||||
this.subscribers[eventName] = [];
|
this.subscribers[eventName] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrappedCallback = (data: object): unknown => {
|
const wrappedCallback = (data: EventMap[typeof eventName]): void => {
|
||||||
const result = callback(data);
|
const result = callback(data);
|
||||||
|
|
||||||
const indexOfHandler = this.subscribers[eventName].indexOf(wrappedCallback);
|
const indexOfHandler = this.subscribers[eventName].indexOf(wrappedCallback);
|
||||||
|
@ -64,10 +72,10 @@ export default class EventsDispatcher<Events extends string = string> {
|
||||||
/**
|
/**
|
||||||
* Emit callbacks with passed data
|
* Emit callbacks with passed data
|
||||||
*
|
*
|
||||||
* @param {string} eventName - event name
|
* @param eventName - event name
|
||||||
* @param {object} data - subscribers get this data when they were fired
|
* @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]) {
|
if (isEmpty(this.subscribers) || !this.subscribers[eventName]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -75,17 +83,17 @@ export default class EventsDispatcher<Events extends string = string> {
|
||||||
this.subscribers[eventName].reduce((previousData, currentHandler) => {
|
this.subscribers[eventName].reduce((previousData, currentHandler) => {
|
||||||
const newData = currentHandler(previousData);
|
const newData = currentHandler(previousData);
|
||||||
|
|
||||||
return newData || previousData;
|
return newData !== undefined ? newData : previousData;
|
||||||
}, data);
|
}, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribe callback from event
|
* Unsubscribe callback from event
|
||||||
*
|
*
|
||||||
* @param {string} eventName - event name
|
* @param eventName - event name
|
||||||
* @param {Function} callback - event handler
|
* @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++) {
|
for (let i = 0; i < this.subscribers[eventName].length; i++) {
|
||||||
if (this.subscribers[eventName][i] === callback) {
|
if (this.subscribers[eventName][i] === callback) {
|
||||||
delete this.subscribers[eventName][i];
|
delete this.subscribers[eventName][i];
|
||||||
|
|
26
src/components/utils/mutations.ts
Normal file
26
src/components/utils/mutations.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
Close = 'close'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events fired by the Popover
|
||||||
|
*/
|
||||||
|
interface PopoverEventMap {
|
||||||
|
[PopoverEvent.Close]: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class responsible for rendering popover and handling its behaviour
|
* 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
|
* 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 {
|
public getElement(): HTMLElement | null {
|
||||||
return this.nodes.wrapper;
|
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 {
|
private addSearch(): void {
|
||||||
this.search = new SearchInput({
|
this.search = new SearchInput({
|
||||||
|
@ -474,10 +481,10 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
|
||||||
/**
|
/**
|
||||||
* Toggles nothing found message visibility
|
* 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 {
|
private toggleNothingFoundMessage(isDisplayed: boolean): void {
|
||||||
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDislayed);
|
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.
|
* - 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.
|
* - 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
|
* @param clickedItem - popover item that was clicked
|
||||||
*/
|
*/
|
||||||
|
|
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 { EditorConfig } from '../../types/index';
|
||||||
|
import { EditorEventMap } from '../components/events';
|
||||||
import EventsDispatcher from '../components/utils/events';
|
import EventsDispatcher from '../components/utils/events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6,5 +7,5 @@ import EventsDispatcher from '../components/utils/events';
|
||||||
*/
|
*/
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
config: EditorConfig;
|
config: EditorConfig;
|
||||||
eventsDispatcher: EventsDispatcher;
|
eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||||
}
|
}
|
||||||
|
|
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
|
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 './commands';
|
||||||
|
|
||||||
|
import chaiSubset from 'chai-subset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "containSubset" object properties matcher
|
||||||
|
*/
|
||||||
|
chai.use(chaiSubset);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Before-each hook for the cypress tests
|
* Before-each hook for the cypress tests
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { BlockMutationType } from '../../../../types/events/block/mutation-type';
|
|
||||||
import EditorJS from '../../../../types';
|
import EditorJS from '../../../../types';
|
||||||
|
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There will be described test cases of BlockAPI
|
* There will be described test cases of BlockAPI
|
||||||
|
@ -23,24 +23,23 @@ describe('BlockAPI', () => {
|
||||||
*/
|
*/
|
||||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
const EditorJSApiMock = Cypress.sinon.match.any;
|
||||||
|
|
||||||
beforeEach(function () {
|
/**
|
||||||
|
* Creates Editor instance
|
||||||
|
*
|
||||||
|
* @param [data] - data to render
|
||||||
|
*/
|
||||||
|
function createEditor(data = undefined): void {
|
||||||
const config = {
|
const config = {
|
||||||
data: editorDataMock,
|
onChange: (api, event): void => {
|
||||||
onChange: (): void => {
|
console.log('something changed', event);
|
||||||
console.log('something changed');
|
|
||||||
},
|
},
|
||||||
|
data,
|
||||||
};
|
};
|
||||||
|
|
||||||
cy.createEditor(config).as('editorInstance');
|
|
||||||
|
|
||||||
cy.spy(config, 'onChange').as('onChange');
|
cy.spy(config, 'onChange').as('onChange');
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function () {
|
cy.createEditor(config).as('editorInstance');
|
||||||
if (this.editorInstance) {
|
}
|
||||||
this.editorInstance.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* block.dispatchChange();
|
* block.dispatchChange();
|
||||||
|
@ -50,18 +49,21 @@ describe('BlockAPI', () => {
|
||||||
* Check that blocks.dispatchChange() triggers Editor 'onChange' callback
|
* Check that blocks.dispatchChange() triggers Editor 'onChange' callback
|
||||||
*/
|
*/
|
||||||
it('should trigger onChange with corresponded block', () => {
|
it('should trigger onChange with corresponded block', () => {
|
||||||
cy.get('@editorInstance').then(async (editor: unknown) => {
|
createEditor(editorDataMock);
|
||||||
const block = (editor as EditorJS).blocks.getById(firstBlock.id);
|
|
||||||
|
|
||||||
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({
|
block.dispatchChange();
|
||||||
type: BlockMutationType.Changed,
|
|
||||||
detail: {
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
index: 0,
|
type: BlockChangedMutationType,
|
||||||
},
|
detail: {
|
||||||
}));
|
index: 0,
|
||||||
});
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -39,5 +39,40 @@ describe('Keydown', function () {
|
||||||
expect(blocks[1].textContent).to.equal('me text');
|
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 type EditorJS from '../../../../types/index';
|
||||||
|
import Header from '@editorjs/header';
|
||||||
|
|
||||||
describe('Saver module', function () {
|
describe('Saver module', function () {
|
||||||
describe('save()', 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,36 @@
|
||||||
import Header from '@editorjs/header';
|
import Header from '@editorjs/header';
|
||||||
import Code from '@editorjs/code';
|
import Code from '@editorjs/code';
|
||||||
import Delimiter from '@editorjs/delimiter';
|
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
|
* @todo Add checks that correct block API object is passed to onChange
|
||||||
|
@ -21,7 +50,7 @@ describe('onChange callback', () => {
|
||||||
code: Code,
|
code: Code,
|
||||||
},
|
},
|
||||||
onChange: (api, event): void => {
|
onChange: (api, event): void => {
|
||||||
console.log('something changed', api, event);
|
console.log('something changed', event);
|
||||||
},
|
},
|
||||||
data: blocks ? {
|
data: blocks ? {
|
||||||
blocks,
|
blocks,
|
||||||
|
@ -46,7 +75,7 @@ describe('onChange callback', () => {
|
||||||
delimiter: Delimiter,
|
delimiter: Delimiter,
|
||||||
},
|
},
|
||||||
onChange: (api, event): void => {
|
onChange: (api, event): void => {
|
||||||
console.log('something changed', api, event);
|
console.log('something changed', event);
|
||||||
api.saver.save();
|
api.saver.save();
|
||||||
},
|
},
|
||||||
data: blocks ? {
|
data: blocks ? {
|
||||||
|
@ -59,21 +88,62 @@ describe('onChange callback', () => {
|
||||||
cy.createEditor(config).as('editorInstance');
|
cy.createEditor(config).as('editorInstance');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
it('should batch events when several changes happened at once', () => {
|
||||||
* EditorJS API is passed as the first parameter of the onChange callback
|
createEditor([
|
||||||
*/
|
{
|
||||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
type: 'paragraph',
|
||||||
|
data: {
|
||||||
it('should fire onChange callback with correct index on block insertion above the current (by pressing Enter at the start)', () => {
|
text: 'The first paragraph',
|
||||||
createEditor();
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-block')
|
.get('div.ce-block')
|
||||||
.click()
|
.click()
|
||||||
|
.type('change')
|
||||||
.type('{enter}');
|
.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({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Added,
|
type: BlockChangedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
target: {
|
target: {
|
||||||
name: 'paragraph',
|
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();
|
createEditor();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-block')
|
.get('div.ce-block')
|
||||||
.click()
|
.click()
|
||||||
.type('some text')
|
|
||||||
.type('{enter}');
|
.type('{enter}');
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Added,
|
type: BlockAddedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
target: {
|
target: {
|
||||||
name: 'paragraph',
|
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,
|
index: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fire onChange callback on typing into block', () => {
|
it('should be fired on typing into block', () => {
|
||||||
createEditor();
|
createEditor();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
|
@ -112,14 +221,14 @@ describe('onChange callback', () => {
|
||||||
.type('some text');
|
.type('some text');
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Changed,
|
type: BlockChangedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
index: 0,
|
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();
|
createEditorWithSave();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
|
@ -134,39 +243,40 @@ describe('onChange callback', () => {
|
||||||
.get('div.ce-popover-item[data-item-name=delimiter]')
|
.get('div.ce-popover-item[data-item-name=delimiter]')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledThrice');
|
cy.get('@onChange').should(($callback) => {
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
return beCalledWithBatchedEvents($callback, [
|
||||||
type: BlockMutationType.Removed,
|
{
|
||||||
detail: {
|
type: BlockRemovedMutationType,
|
||||||
index: 0,
|
detail: {
|
||||||
target: {
|
index: 0,
|
||||||
name: 'paragraph',
|
target: {
|
||||||
|
name: 'paragraph',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
}));
|
type: BlockAddedMutationType,
|
||||||
|
detail: {
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
index: 0,
|
||||||
type: BlockMutationType.Added,
|
target: {
|
||||||
detail: {
|
name: 'delimiter',
|
||||||
index: 0,
|
},
|
||||||
target: {
|
},
|
||||||
name: 'delimiter',
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
}));
|
type: BlockAddedMutationType,
|
||||||
|
detail: {
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
index: 1,
|
||||||
type: BlockMutationType.Added,
|
target: {
|
||||||
detail: {
|
name: 'paragraph',
|
||||||
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();
|
createEditor();
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
|
@ -181,28 +291,31 @@ describe('onChange callback', () => {
|
||||||
.get('div.ce-popover-item[data-item-name=header]')
|
.get('div.ce-popover-item[data-item-name=header]')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledTwice');
|
cy.get('@onChange').should(($callback) => {
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
return beCalledWithBatchedEvents($callback, [
|
||||||
type: BlockMutationType.Removed,
|
{
|
||||||
detail: {
|
type: BlockRemovedMutationType,
|
||||||
index: 0,
|
detail: {
|
||||||
target: {
|
index: 0,
|
||||||
name: 'paragraph',
|
target: {
|
||||||
|
name: 'paragraph',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
}));
|
type: BlockAddedMutationType,
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
detail: {
|
||||||
type: BlockMutationType.Added,
|
index: 0,
|
||||||
detail: {
|
target: {
|
||||||
index: 0,
|
name: 'header',
|
||||||
target: {
|
},
|
||||||
name: 'header',
|
},
|
||||||
},
|
},
|
||||||
},
|
]);
|
||||||
}));
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fire onChange callback on tune modifying', () => {
|
it('should be fired on tune modifying', () => {
|
||||||
createEditor([
|
createEditor([
|
||||||
{
|
{
|
||||||
type: 'header',
|
type: 'header',
|
||||||
|
@ -225,7 +338,7 @@ describe('onChange callback', () => {
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Changed,
|
type: BlockChangedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
index: 0,
|
index: 0,
|
||||||
target: {
|
target: {
|
||||||
|
@ -235,16 +348,15 @@ describe('onChange callback', () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fire onChange callback when block is removed', () => {
|
it('should be fired when block is removed', () => {
|
||||||
createEditor();
|
createEditor([
|
||||||
|
{
|
||||||
/**
|
type: 'paragraph',
|
||||||
* The only block does not have Tune menu, so need to create at least 2 blocks to test deleting
|
data: {
|
||||||
*/
|
text: 'some text',
|
||||||
cy.get('[data-cy=editorjs]')
|
},
|
||||||
.get('div.ce-block')
|
},
|
||||||
.click()
|
]);
|
||||||
.type('some text');
|
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-block')
|
.get('div.ce-block')
|
||||||
|
@ -263,21 +375,45 @@ describe('onChange callback', () => {
|
||||||
.get('div[data-item-name=delete]')
|
.get('div[data-item-name=delete]')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should(($callback) => {
|
||||||
type: BlockMutationType.Removed,
|
return beCalledWithBatchedEvents($callback, [
|
||||||
detail: {
|
/**
|
||||||
index: 0,
|
* "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', () => {
|
it('should be fired when block is moved', () => {
|
||||||
createEditor();
|
createEditor([
|
||||||
|
{
|
||||||
cy.get('[data-cy=editorjs]')
|
type: 'paragraph',
|
||||||
.get('div.ce-block')
|
data: {
|
||||||
.click()
|
text: 'first block',
|
||||||
.type('{enter}');
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
data: {
|
||||||
|
text: 'second block',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
cy.get('[data-cy=editorjs]')
|
cy.get('[data-cy=editorjs]')
|
||||||
.get('div.ce-block')
|
.get('div.ce-block')
|
||||||
|
@ -293,7 +429,7 @@ describe('onChange callback', () => {
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Moved,
|
type: BlockMovedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
fromIndex: 1,
|
fromIndex: 1,
|
||||||
toIndex: 0,
|
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([ {
|
createEditor([ {
|
||||||
type: 'code',
|
type: 'code',
|
||||||
data: {
|
data: {
|
||||||
|
@ -314,10 +450,42 @@ describe('onChange callback', () => {
|
||||||
.type('Some input to the textarea');
|
.type('Some input to the textarea');
|
||||||
|
|
||||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||||
type: BlockMutationType.Changed,
|
type: BlockChangedMutationType,
|
||||||
detail: {
|
detail: {
|
||||||
index: 0,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
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 {ToolConstructable, ToolSettings} from '../tools';
|
||||||
import {API, BlockAPI, LogLevels, OutputData} from '../index';
|
import {API, LogLevels, OutputData} from '../index';
|
||||||
import {SanitizerConfig} from './sanitizer-config';
|
import {SanitizerConfig} from './sanitizer-config';
|
||||||
import {I18nConfig} from './i18n-config';
|
import {I18nConfig} from './i18n-config';
|
||||||
|
import { BlockMutationEvent } from '../events/block';
|
||||||
|
|
||||||
export interface EditorConfig {
|
export interface EditorConfig {
|
||||||
/**
|
/**
|
||||||
|
@ -89,10 +90,10 @@ export interface EditorConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires when something changed in DOM
|
* Fires when something changed in DOM
|
||||||
* @param {API} api - editor.js api
|
* @param api - editor.js api
|
||||||
* @param event - custom event describing mutation
|
* @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.
|
* 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 {BlockToolData} from '../tools';
|
||||||
|
import { BlockId } from './block-id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool's saved data
|
* Tool's saved data
|
||||||
*/
|
*/
|
||||||
export interface SavedData {
|
export interface SavedData {
|
||||||
id: string;
|
id: BlockId;
|
||||||
tool: string;
|
tool: string;
|
||||||
data: BlockToolData;
|
data: BlockToolData;
|
||||||
time: number;
|
time: number;
|
||||||
|
@ -14,7 +15,7 @@ export interface SavedData {
|
||||||
* Tool's data after validation
|
* Tool's data after validation
|
||||||
*/
|
*/
|
||||||
export interface ValidatedData {
|
export interface ValidatedData {
|
||||||
id?: string;
|
id?: BlockId;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
data?: BlockToolData;
|
data?: BlockToolData;
|
||||||
time?: number;
|
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 {BlockToolData} from '../tools';
|
||||||
import {BlockTuneData} from '../block-tunes/block-tune-data';
|
import {BlockTuneData} from '../block-tunes/block-tune-data';
|
||||||
|
import { BlockId } from './block-id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output of one Tool
|
* Output of one Tool
|
||||||
|
@ -11,7 +12,7 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
|
||||||
/**
|
/**
|
||||||
* Unique Id of the block
|
* Unique Id of the block
|
||||||
*/
|
*/
|
||||||
id?: string;
|
id?: BlockId;
|
||||||
/**
|
/**
|
||||||
* Tool type
|
* 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';
|
} from './api';
|
||||||
|
|
||||||
import { OutputData } from './data-formats';
|
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
|
* Interfaces used for development
|
||||||
|
@ -76,8 +81,20 @@ export {
|
||||||
PopoverItemWithConfirmation,
|
PopoverItemWithConfirmation,
|
||||||
PopoverItemWithoutConfirmation
|
PopoverItemWithoutConfirmation
|
||||||
} from './configs';
|
} 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 { 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
|
* 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"
|
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||||
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
|
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:
|
chalk@4.1.2, chalk@^4.1.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||||
|
|
Loading…
Reference in a new issue