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:
Peter Savchenko 2023-05-12 20:50:48 +03:00 committed by GitHub
parent aafab1d395
commit b7b00fd060
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1151 additions and 363 deletions

View file

@ -6,10 +6,20 @@
- `Refactoring` — Popover class refactored.
- `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`**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` — 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.
- `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.
- `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.

View file

@ -406,4 +406,4 @@
})
</script>
</body>
</html>
</html>

View file

@ -47,6 +47,7 @@
"@editorjs/paragraph": "^2.9.0",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^18.15.11",
"chai-subset": "^1.6.0",
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.5",
"core-js": "3.30.0",

View file

@ -3,6 +3,7 @@ import { EditorConfig } from '../../types';
import { ModuleConfig } from '../types-internal/module-config';
import Listeners from './utils/listeners';
import EventsDispatcher from './utils/events';
import { EditorEventMap } from './events';
/**
* The type <T> of the Module generic.
@ -42,7 +43,7 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
/**
* Editor event dispatcher class
*/
protected eventsDispatcher: EventsDispatcher;
protected eventsDispatcher: EventsDispatcher<EditorEventMap>;
/**
* Util for bind/unbind DOM event listeners

View file

@ -22,6 +22,9 @@ import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import ToolsCollection from '../tools/collection';
import EventsDispatcher from '../utils/events';
import { TunesMenuConfigItem } from '../../../types/tools';
import { isMutationBelongsToElement } from '../utils/mutations';
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
/**
* 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
@ -192,89 +197,14 @@ export default class Block extends EventsDispatcher<BlockEvents> {
private inputIndex = 0;
/**
* Mutation observer to handle DOM mutations
*
* @type {MutationObserver}
* Common editor event bus
*/
private mutationObserver: MutationObserver;
private readonly editorEventBus: EventsDispatcher<EditorEventMap> | null = null;
/**
* Debounce Timer
*
* @type {number}
* Link to editor dom change callback. Used to remove listener on remove
*/
private readonly modificationDebounceTimer = 450;
/**
* Is fired when DOM mutation has been happened
*
* mutationsOrInputEvent actual changes
* - MutationRecord[] - any DOM change
* - InputEvent <input> change
* - undefined manual triggering of block.dispatchChange()
*/
private didMutated = _.debounce((mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
/**
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
*/
let shouldFireUpdate;
if (mutationsOrInputEvent === undefined) {
shouldFireUpdate = true;
} else if (mutationsOrInputEvent instanceof InputEvent) {
shouldFireUpdate = true;
} else {
/**
* Update from 2023, Feb 17:
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
* since there could be a real mutations same-time with mutation-free changes,
* for example when Block Tune change: block is changing along with FakeCursor (mutation-free) removing
* we should fire 'didMutated' event in that case
*/
const everyRecordIsMutationFree = mutationsOrInputEvent.length > 0 && mutationsOrInputEvent.every((record) => {
const { addedNodes, removedNodes } = record;
const changedNodes = [
...Array.from(addedNodes),
...Array.from(removedNodes),
];
return changedNodes.some((node) => {
if ($.isElement(node) === false) {
return false;
}
return (node as HTMLElement).dataset.mutationFree === 'true';
});
});
if (everyRecordIsMutationFree) {
shouldFireUpdate = false;
} else {
shouldFireUpdate = true;
}
}
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event
*/
if (!shouldFireUpdate) {
return;
}
/**
* Drop cache
*/
this.cachedInputs = [];
/**
* Update current input
*/
this.updateCurrentInput();
this.call(BlockToolAPI.UPDATED);
this.emit('didMutated', this);
}, this.modificationDebounceTimer);
private redactorDomChangedCallback: (payload: RedactorDomChangedPayload) => void;
/**
* Current block API interface
@ -282,12 +212,13 @@ export default class Block extends EventsDispatcher<BlockEvents> {
private readonly blockAPI: BlockAPIInterface;
/**
* @param {object} options - block constructor options
* @param {string} [options.id] - block's id. Will be generated if omitted.
* @param {BlockToolData} options.data - Tool's initial data
* @param {BlockTool} options.tool block's tool
* @param options - block constructor options
* @param [options.id] - block's id. Will be generated if omitted.
* @param options.data - Tool's initial data
* @param options.tool block's tool
* @param options.api - Editor API module for pass it to the Block Tunes
* @param {boolean} options.readOnly - Read-Only flag
* @param options.readOnly - Read-Only flag
* @param [eventBus] - Editor common event bus. Allows to subscribe on some Editor events. Could be omitted when "virtual" Block is created. See BlocksAPI@composeBlockData.
*/
constructor({
id = _.generateBlockId(),
@ -296,7 +227,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
api,
readOnly,
tunesData,
}: BlockConstructorOptions) {
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
super();
this.name = tool.name;
@ -304,10 +235,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.settings = tool.settings;
this.config = tool.settings.config || {};
this.api = api;
this.editorEventBus = eventBus || null;
this.blockAPI = new BlockAPI(this);
this.mutationObserver = new MutationObserver(this.didMutated);
this.tool = tool;
this.toolInstance = tool.create(data, this.blockAPI, readOnly);
@ -319,6 +249,17 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.composeTunes(tunesData);
this.holder = this.compose();
/**
* Start watching block mutations
*/
this.watchBlockMutations();
/**
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();
}
/**
@ -505,14 +446,21 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @param {boolean} state - 'true' to select, 'false' to remove selection
*/
public set selected(state: boolean) {
if (state) {
this.holder.classList.add(Block.CSS.selected);
this.holder.classList.toggle(Block.CSS.selected, state);
SelectionUtils.addFakeCursor(this.holder);
} else {
this.holder.classList.remove(Block.CSS.selected);
const fakeCursorWillBeAdded = state === true && SelectionUtils.isRangeInsideContainer(this.holder);
const fakeCursorWillBeRemoved = state === false && SelectionUtils.isFakeCursorInsideContainer(this.holder);
SelectionUtils.removeFakeCursor(this.holder);
if (fakeCursorWillBeAdded || fakeCursorWillBeRemoved) {
this.editorEventBus?.emit(FakeCursorAboutToBeToggled, { state }); // mutex
if (fakeCursorWillBeAdded) {
SelectionUtils.addFakeCursor();
} else {
SelectionUtils.removeFakeCursor(this.holder);
}
this.editorEventBus?.emit(FakeCursorHaveBeenSet, { state });
}
}
@ -711,38 +659,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
: SelectionUtils.anchorNode;
}
/**
* Is fired when Block will be selected as current
*/
public willSelect(): void {
/**
* Observe DOM mutations to update Block inputs
*/
this.mutationObserver.observe(
this.holder.firstElementChild,
{
childList: true,
subtree: true,
characterData: true,
attributes: true,
}
);
/**
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();
}
/**
* Is fired when Block will be unselected
*/
public willUnselect(): void {
this.mutationObserver.disconnect();
this.removeInputEvents();
}
/**
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
@ -755,6 +671,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* Call Tool instance destroy method
*/
public destroy(): void {
this.unwatchBlockMutations();
this.removeInputEvents();
super.destroy();
if (_.isFunction(this.toolInstance.destroy)) {
@ -876,9 +795,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
private handleFocus = (): void => {
/**
* Drop cache
* Drop inputs cache to query the new ones
*/
this.cachedInputs = [];
this.dropInputsCache();
/**
* Update current input
@ -897,7 +816,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* If input is native input add oninput listener to observe changes
*/
if ($.isNativeInput(input)) {
input.addEventListener('input', this.didMutated);
input.addEventListener('input', this.didMutated as EventListener);
}
});
}
@ -910,8 +829,147 @@ export default class Block extends EventsDispatcher<BlockEvents> {
input.removeEventListener('focus', this.handleFocus);
if ($.isNativeInput(input)) {
input.removeEventListener('input', this.didMutated);
input.removeEventListener('input', this.didMutated as EventListener);
}
});
}
/**
* Is fired when DOM mutation has been happened
*
* @param mutationsOrInputEvent - actual changes
* - MutationRecord[] - any DOM change
* - InputEvent <input> change
* - undefined manual triggering of block.dispatchChange()
*/
private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent = undefined): void => {
/**
* Block API have dispatchChange() method. In this case, mutations list will be undefined.
*/
const isManuallyDispatched = mutationsOrInputEvent === undefined;
/**
* True if didMutated has been called as "input" event handler
*/
const isInputEventHandler = mutationsOrInputEvent instanceof InputEvent;
/**
* If tool updates its own root element, we need to renew it in our memory
*/
if (!isManuallyDispatched && !isInputEventHandler) {
this.detectToolRootChange(mutationsOrInputEvent);
}
/**
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
*/
let shouldFireUpdate;
if (isManuallyDispatched) {
shouldFireUpdate = true;
} else if (isInputEventHandler) {
shouldFireUpdate = true;
} else {
/**
* Update from 2023, Feb 17:
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
* since there could be a real mutations same-time with mutation-free changes,
* for example when Block Tune change: block is changing along with FakeCursor (mutation-free) removing
* we should fire 'didMutated' event in that case
*/
const everyRecordIsMutationFree = mutationsOrInputEvent.length > 0 && mutationsOrInputEvent.every((record) => {
const { addedNodes, removedNodes } = 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 = [];
}
}

View file

@ -6,13 +6,10 @@ import I18n from './i18n';
import { CriticalError } from './errors/critical';
import EventsDispatcher from './utils/events';
import Modules from './modules';
import { EditorEventMap } from './events';
/**
* @class Core
* @classdesc Editor.js core class
* @property {EditorConfig} config - all settings
* @property {EditorModules} moduleInstances - constructed editor components
* @type {Core}
* Editor.js core class. Bootstraps modules.
*/
export default class Core {
/**
@ -31,9 +28,9 @@ export default class Core {
public isReady: Promise<void>;
/**
* Event Dispatcher util
* Common Editor Event Bus
*/
private eventsDispatcher: EventsDispatcher = new EventsDispatcher();
private eventsDispatcher: EventsDispatcher<EditorEventMap> = new EventsDispatcher();
/**
* @param {EditorConfig} config - user configuration

View file

@ -145,7 +145,7 @@ export default class Dom {
* @param {string} selector - searching string
* @returns {Element}
*/
public static find(el: Element | Document = document, selector: string): Element {
public static find(el: Element | Document = document, selector: string): Element | null {
return el.querySelector(selector);
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View file

@ -246,6 +246,13 @@ export default class BlockEvents extends Module {
*/
if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) {
this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex);
/**
* If caret is at very end of the block, just append the new block without splitting
* to prevent unnecessary dom mutation observing
*/
} else if (this.Editor.Caret.isAtEnd) {
newCurrent = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1);
} else {
/**
* Split the Current Block into two blocks

View file

@ -12,7 +12,12 @@ import Blocks from '../blocks';
import { BlockToolData, PasteEvent } from '../../../types';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import BlockAPI from '../block/api';
import { BlockMutationType } from '../../../types/events/block/mutation-type';
import { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block';
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
import { BlockChanged } from '../events';
/**
* @typedef {BlockManager} BlockManager
@ -35,14 +40,6 @@ export default class BlockManager extends Module {
* @param {number} newIndex - index of Block to set as current
*/
public set currentBlockIndex(newIndex: number) {
if (this._blocks[this._currentBlockIndex]) {
this._blocks[this._currentBlockIndex].willUnselect();
}
if (this._blocks[newIndex]) {
this._blocks[newIndex].willSelect();
}
this._currentBlockIndex = newIndex;
}
@ -242,7 +239,7 @@ export default class BlockManager extends Module {
api: this.Editor.API,
readOnly,
tunesData,
});
}, this.eventsDispatcher);
if (!readOnly) {
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
*/
if (replace) {
this.blockDidMutated(BlockMutationType.Removed, this.getBlockByIndex(newIndex), {
this.blockDidMutated(BlockRemovedMutationType, this.getBlockByIndex(newIndex), {
index: newIndex,
});
}
@ -308,7 +305,7 @@ export default class BlockManager extends Module {
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockMutationType.Added, block, {
this.blockDidMutated(BlockAddedMutationType, block, {
index: newIndex,
});
@ -384,7 +381,7 @@ export default class BlockManager extends Module {
/**
* Force call of didMutated event on Block insertion
*/
this.blockDidMutated(BlockMutationType.Added, block, {
this.blockDidMutated(BlockAddedMutationType, block, {
index,
});
@ -460,7 +457,7 @@ export default class BlockManager extends Module {
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockMutationType.Removed, blockToRemove, {
this.blockDidMutated(BlockRemovedMutationType, blockToRemove, {
index,
});
@ -736,7 +733,7 @@ export default class BlockManager extends Module {
/**
* Force call of didMutated event on Block movement
*/
this.blockDidMutated(BlockMutationType.Moved, this.currentBlock, {
this.blockDidMutated(BlockMovedMutationType, this.currentBlock, {
fromIndex,
toIndex,
});
@ -807,7 +804,7 @@ export default class BlockManager extends Module {
});
block.on('didMutated', (affectedBlock: Block) => {
return this.blockDidMutated(BlockMutationType.Changed, affectedBlock, {
return this.blockDidMutated(BlockChangedMutationType, affectedBlock, {
index: this.getBlockIndex(affectedBlock),
});
});
@ -851,18 +848,25 @@ export default class BlockManager extends Module {
*
* @param mutationType - what happened with block
* @param block - mutated block
* @param details - additional data to pass with change event
* @param detailData - additional data to pass with change event
*/
private blockDidMutated(mutationType: BlockMutationType, block: Block, details: Record<string, unknown> = {}): Block {
private blockDidMutated<Type extends BlockMutationType>(mutationType: Type, block: Block, detailData: BlockMutationEventDetailWithoutTarget<Type>): Block {
const event = new CustomEvent(mutationType, {
detail: {
target: new BlockAPI(block),
...details,
...detailData as BlockMutationEventDetailWithoutTarget<Type>,
},
});
this.Editor.ModificationsObserver.onChange(event);
this.eventsDispatcher.emit(BlockChanged, {
event: event as BlockMutationEventMap[Type],
});
return block;
}
}
/**
* Type alias for Block Mutation event without 'target' field, used in 'blockDidMutated' method
*/
type BlockMutationEventDetailWithoutTarget<Type extends BlockMutationType> = Omit<BlockMutationEventMap[Type]['detail'], 'target'>;

View file

@ -1,6 +1,15 @@
import { BlockId } from '../../../types';
import { BlockMutationEvent, BlockMutationType } from '../../../types/events/block';
import { ModuleConfig } from '../../types-internal/module-config';
import Module from '../__module';
import { BlockChanged, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import * as _ from '../utils';
/**
* We use map of block mutations to filter only unique events
*/
type UniqueBlockMutationKey = `block:${BlockId}:event:${BlockMutationType}`;
/**
* Single entry point for Block mutation events
*/
@ -10,10 +19,74 @@ export default class ModificationsObserver extends Module {
*/
private disabled = false;
/**
* Blocks wrapper mutation observer instance
*/
private readonly mutationObserver: MutationObserver;
/**
* Timeout used to batched several events in a single onChange call
*/
private batchingTimeout: null | ReturnType<typeof setTimeout> = null;
/**
* Array of onChange events used to batch them
*
* Map is used to filter duplicated events related to the same block
*/
private batchingOnChangeQueue = new Map<UniqueBlockMutationKey, BlockMutationEvent>();
/**
* Fired onChange events will be batched by this time
*/
private readonly batchTime = 400;
/**
* Prepare the module
*
* @param options - options used by the modification observer module
* @param options.config - Editor configuration object
* @param options.eventsDispatcher - common Editor event bus
*/
constructor({ config, eventsDispatcher }: ModuleConfig) {
super({
config,
eventsDispatcher,
});
this.mutationObserver = new MutationObserver((mutations) => {
this.redactorChanged(mutations);
});
this.eventsDispatcher.on(BlockChanged, (payload) => {
this.particularBlockChanged(payload.event);
});
/**
* Mutex for fake cursor setting/removing operation
*/
this.eventsDispatcher.on(FakeCursorAboutToBeToggled, () => {
this.disable();
});
this.eventsDispatcher.on(FakeCursorHaveBeenSet, () => {
this.enable();
});
}
/**
* Enables onChange event
*/
public enable(): void {
this.mutationObserver.observe(
this.Editor.UI.nodes.redactor,
{
childList: true,
subtree: true,
characterData: true,
attributes: true,
}
);
this.disabled = false;
}
@ -21,6 +94,7 @@ export default class ModificationsObserver extends Module {
* Disables onChange event
*/
public disable(): void {
this.mutationObserver.disconnect();
this.disabled = true;
}
@ -29,11 +103,45 @@ export default class ModificationsObserver extends Module {
*
* @param event - some of our custom change events
*/
public onChange(event: CustomEvent): void {
private particularBlockChanged(event: BlockMutationEvent): void {
if (this.disabled || !_.isFunction(this.config.onChange)) {
return;
}
this.config.onChange(this.Editor.API.methods, event);
this.batchingOnChangeQueue.set(`block:${event.detail.target.id}:event:${event.type as BlockMutationType}`, event);
if (this.batchingTimeout) {
clearTimeout(this.batchingTimeout);
}
this.batchingTimeout = setTimeout(() => {
let eventsToEmit;
/**
* Ih we have only 1 event in a queue, unwrap it
*/
if (this.batchingOnChangeQueue.size === 1) {
eventsToEmit = this.batchingOnChangeQueue.values().next().value;
} else {
eventsToEmit = Array.from(this.batchingOnChangeQueue.values());
}
if (this.config.onChange) {
this.config.onChange(this.Editor.API.methods, eventsToEmit);
}
this.batchingOnChangeQueue.clear();
}, this.batchTime);
}
/**
* Fired on every blocks wrapper dom change
*
* @param mutations - mutations happened
*/
private redactorChanged(mutations: MutationRecord[]): void {
this.eventsDispatcher.emit(RedactorDomChanged, {
mutations,
});
}
}

View file

@ -5,10 +5,10 @@ import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import { BlockAPI } from '../../../../types';
import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
import { IconMenu, IconPlus } from '@codexteam/icons';
import { BlockHovered } from '../../events/BlockHovered';
/**
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
@ -421,7 +421,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.Editor.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier);
});
this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }: {block: BlockAPI }) => {
this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }) => {
const { BlockManager, Caret } = this.Editor;
const newBlock = BlockManager.getBlockById(block.id);
@ -489,7 +489,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Subscribe to the 'block-hovered' event
*/
this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => {
this.eventsDispatcher.on(BlockHovered, (data) => {
/**
* Do not move toolbar if Block Settings or Toolbox opened
*/

View file

@ -14,6 +14,7 @@ import Flipper from '../flipper';
import { mobileScreenBreakpoint } from '../utils';
import styles from '../../styles/main.css?inline';
import { BlockHovered } from '../events/BlockHovered';
/**
* HTML Elements used for UI
*/
@ -41,15 +42,6 @@ interface UINodes {
* @property {Element} nodes.redactor - <ce-redactor>
*/
export default class UI extends Module<UINodes> {
/**
* Events could be emitted by this module.
*/
public get events(): { blockHovered: string } {
return {
blockHovered: 'block-hovered',
};
}
/**
* Editor.js UI CSS class names
*
@ -402,7 +394,7 @@ export default class UI extends Module<UINodes> {
blockHoveredEmitted = hoveredBlock;
this.eventsDispatcher.emit(this.events.blockHovered, {
this.eventsDispatcher.emit(BlockHovered, {
block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock),
});
// eslint-disable-next-line @typescript-eslint/no-magic-numbers

View file

@ -353,24 +353,47 @@ export default class SelectionUtils {
}
/**
* Adds fake cursor to the current range
* Check if current range exists and belongs to container
*
* @param [container] - if passed cursor will be added only if container contains current range
* @param container - where range should be
*/
public static addFakeCursor(container?: HTMLElement): void {
public static isRangeInsideContainer(container: HTMLElement): boolean {
const range = SelectionUtils.range;
if (range === null) {
return false;
}
return container.contains(range.startContainer);
}
/**
* Adds fake cursor to the current range
*/
public static addFakeCursor(): void {
const range = SelectionUtils.range;
if (range === null) {
return;
}
const fakeCursor = $.make('span', 'codex-editor__fake-cursor');
fakeCursor.dataset.mutationFree = 'true';
if (!range || (container && !container.contains(range.startContainer))) {
return;
}
range.collapse();
range.insertNode(fakeCursor);
}
/**
* Check if passed element contains a fake cursor
*
* @param el - where to check
*/
public static isFakeCursorInsideContainer(el: HTMLElement): boolean {
return $.find(el, `.codex-editor__fake-cursor`) !== null;
}
/**
* Removes fake cursor from a container
*
@ -379,7 +402,11 @@ export default class SelectionUtils {
public static removeFakeCursor(container: HTMLElement = document.body): void {
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
fakeCursor && fakeCursor.remove();
if (!fakeCursor) {
return;
}
fakeCursor.remove();
}
/**

View file

@ -3,7 +3,7 @@ import { BlockToolAPI } from '../block';
import Shortcuts from '../utils/shortcuts';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem } from '../../../types';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
import EventsDispatcher from '../utils/events';
import Popover, { PopoverEvent } from '../utils/popover';
import I18n from '../i18n';
@ -33,6 +33,19 @@ export enum ToolboxEvent {
BlockAdded = 'toolbox-block-added',
}
/**
* Events fired by the Toolbox
*
* Event name -> payload
*/
export interface ToolboxEventMap {
[ToolboxEvent.Opened]: undefined;
[ToolboxEvent.Closed]: undefined;
[ToolboxEvent.BlockAdded]: {
block: BlockAPI
};
}
/**
* Available i18n dict keys that should be passed to the constructor
*/
@ -45,7 +58,7 @@ type ToolboxTextLabelsKeys = 'filter' | 'nothingFound';
*
* @implements {EventsDispatcher} with some events, see {@link ToolboxEvent}
*/
export default class Toolbox extends EventsDispatcher<ToolboxEvent> {
export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
/**
* Returns True if Toolbox is Empty and nothing to show
*

View file

@ -1,31 +1,39 @@
import { isEmpty } from '../utils';
/**
* @class EventDispatcher
* Event Dispatcher event listener
*/
type Listener<Data> = (data?: Data) => void;
/**
* Mapped type with subscriptions list
*
* Has two important methods:
* event name -> array of callbacks
*/
type Subscriptions<EventMap> = {
[Key in keyof EventMap]: Listener<EventMap[Key]>[];
};
/**
* Provides methods for working with Event Bus:
* - {Function} on - appends subscriber to the event. If event doesn't exist - creates new one
* - {Function} emit - fires all subscribers with data
* - {Function off - unsubscribes callback
* @version 1.0.0
* @typedef {Events} Events
* @property {object} subscribers - all subscribers grouped by event name
*/
export default class EventsDispatcher<Events extends string = string> {
export default class EventsDispatcher<EventMap> {
/**
* All subscribers grouped by event name
* Object with events` names as key and array of callback functions as value
*
* @type {{}}
*/
private subscribers: {[name: string]: Array<(data?: object) => unknown>} = {};
private subscribers = <Subscriptions<EventMap>>{};
/**
* Subscribe any event on callback
*
* @param {string} eventName - event name
* @param {Function} callback - subscriber
* @param eventName - event name
* @param callback - subscriber
*/
public on(eventName: Events, callback: (data: object) => unknown): void {
public on<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
if (!(eventName in this.subscribers)) {
this.subscribers[eventName] = [];
}
@ -37,15 +45,15 @@ export default class EventsDispatcher<Events extends string = string> {
/**
* Subscribe any event on callback. Callback will be called once and be removed from subscribers array after call.
*
* @param {string} eventName - event name
* @param {Function} callback - subscriber
* @param eventName - event name
* @param callback - subscriber
*/
public once(eventName: Events, callback: (data: object) => unknown): void {
public once<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
if (!(eventName in this.subscribers)) {
this.subscribers[eventName] = [];
}
const wrappedCallback = (data: object): unknown => {
const wrappedCallback = (data: EventMap[typeof eventName]): void => {
const result = callback(data);
const indexOfHandler = this.subscribers[eventName].indexOf(wrappedCallback);
@ -64,10 +72,10 @@ export default class EventsDispatcher<Events extends string = string> {
/**
* Emit callbacks with passed data
*
* @param {string} eventName - event name
* @param {object} data - subscribers get this data when they were fired
* @param eventName - event name
* @param data - subscribers get this data when they were fired
*/
public emit(eventName: Events, data?: object): void {
public emit<Name extends keyof EventMap>(eventName: Name, data?: EventMap[Name]): void {
if (isEmpty(this.subscribers) || !this.subscribers[eventName]) {
return;
}
@ -75,17 +83,17 @@ export default class EventsDispatcher<Events extends string = string> {
this.subscribers[eventName].reduce((previousData, currentHandler) => {
const newData = currentHandler(previousData);
return newData || previousData;
return newData !== undefined ? newData : previousData;
}, data);
}
/**
* Unsubscribe callback from event
*
* @param {string} eventName - event name
* @param {Function} callback - event handler
* @param eventName - event name
* @param callback - event handler
*/
public off(eventName: Events, callback: (data: object) => unknown): void {
public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
for (let i = 0; i < this.subscribers[eventName].length; i++) {
if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i];

View 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;
}

View file

@ -64,11 +64,18 @@ export enum PopoverEvent {
Close = 'close'
}
/**
* Events fired by the Popover
*/
interface PopoverEventMap {
[PopoverEvent.Close]: undefined;
}
/**
* Class responsible for rendering popover and handling its behaviour
*/
export default class Popover extends EventsDispatcher<PopoverEvent> {
export default class Popover extends EventsDispatcher<PopoverEventMap> {
/**
* Flipper - module for keyboard iteration between elements
*/
@ -203,7 +210,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
}
/**
* Returns HTML element correcponding to the popover
* Returns HTML element corresponding to the popover
*/
public getElement(): HTMLElement | null {
return this.nodes.wrapper;
@ -315,7 +322,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
}
/**
* Adds seach to the popover
* Adds search to the popover
*/
private addSearch(): void {
this.search = new SearchInput({
@ -474,10 +481,10 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
/**
* Toggles nothing found message visibility
*
* @param isDislayed - true if the message should be displayed
* @param isDisplayed - true if the message should be displayed
*/
private toggleNothingFoundMessage(isDislayed: boolean): void {
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDislayed);
private toggleNothingFoundMessage(isDisplayed: boolean): void {
this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDisplayed);
}
/**
@ -493,7 +500,7 @@ export default class Popover extends EventsDispatcher<PopoverEvent> {
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
*
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
* (All the other items with the same key get unactive, and the item gets active)
* (All the other items with the same key get inactive, and the item gets active)
*
* @param clickedItem - popover item that was clicked
*/

View file

@ -1,4 +1,5 @@
import { EditorConfig } from '../../types/index';
import { EditorEventMap } from '../components/events';
import EventsDispatcher from '../components/utils/events';
/**
@ -6,5 +7,5 @@ import EventsDispatcher from '../components/utils/events';
*/
export interface ModuleConfig {
config: EditorConfig;
eventsDispatcher: EventsDispatcher;
eventsDispatcher: EventsDispatcher<EditorEventMap>;
}

View file

@ -66,4 +66,16 @@ declare global {
EditorJS: typeof EditorJS
}
}
/**
* Chai plugins
*/
namespace Chai {
interface Assertion {
/**
* "containSubset" object properties matcher
*/
containSubset(subset: any): Assertion;
}
}
}

View file

@ -13,6 +13,13 @@ import '@cypress/code-coverage/support';
*/
import './commands';
import chaiSubset from 'chai-subset';
/**
* "containSubset" object properties matcher
*/
chai.use(chaiSubset);
/**
* Before-each hook for the cypress tests
*/

View file

@ -1,5 +1,5 @@
import { BlockMutationType } from '../../../../types/events/block/mutation-type';
import EditorJS from '../../../../types';
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
/**
* There will be described test cases of BlockAPI
@ -23,24 +23,23 @@ describe('BlockAPI', () => {
*/
const EditorJSApiMock = Cypress.sinon.match.any;
beforeEach(function () {
/**
* Creates Editor instance
*
* @param [data] - data to render
*/
function createEditor(data = undefined): void {
const config = {
data: editorDataMock,
onChange: (): void => {
console.log('something changed');
onChange: (api, event): void => {
console.log('something changed', event);
},
data,
};
cy.createEditor(config).as('editorInstance');
cy.spy(config, 'onChange').as('onChange');
});
afterEach(function () {
if (this.editorInstance) {
this.editorInstance.destroy();
}
});
cy.createEditor(config).as('editorInstance');
}
/**
* block.dispatchChange();
@ -50,18 +49,21 @@ describe('BlockAPI', () => {
* Check that blocks.dispatchChange() triggers Editor 'onChange' callback
*/
it('should trigger onChange with corresponded block', () => {
cy.get('@editorInstance').then(async (editor: unknown) => {
const block = (editor as EditorJS).blocks.getById(firstBlock.id);
createEditor(editorDataMock);
block.dispatchChange();
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const block = editor.blocks.getById(firstBlock.id);
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Changed,
detail: {
index: 0,
},
}));
});
block.dispatchChange();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockChangedMutationType,
detail: {
index: 0,
},
}));
});
});
});
});

View file

@ -39,5 +39,40 @@ describe('Keydown', function () {
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;
});
});
});
});
});

View file

@ -1,4 +1,5 @@
import type EditorJS from '../../../../types/index';
import Header from '@editorjs/header';
describe('Saver module', function () {
describe('save()', function () {
@ -33,5 +34,53 @@ describe('Saver module', function () {
});
});
});
/**
* This test case covers Block@detectToolRootChange
*/
it('should correctly save block data if block\'s main container element have been changed', function () {
cy.createEditor({
tools: {
header: Header,
},
data: {
blocks: [
{
type: 'header',
data: {
text: 'The block with some text',
level: 1,
},
},
],
},
})
.as('editorInstance');
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
cy.get('[data-cy=editorjs]')
.get('span.ce-toolbar__settings-btn')
.click();
/**
* Change header level
*/
cy.get('[data-cy=editorjs]')
.get('.ce-settings .ce-popover-item:nth-child(3)')
.click();
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const data = await editor.save();
expect(data.blocks[0].type).to.equal('header');
expect(data.blocks[0].data.text).to.equal('The block with some text');
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
expect(data.blocks[0].data.level).to.equal(3);
});
});
});
});

View file

@ -1,7 +1,36 @@
import Header from '@editorjs/header';
import Code from '@editorjs/code';
import Delimiter from '@editorjs/delimiter';
import { BlockMutationType } from '../../../types/events/block/mutation-type';
import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded';
import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged';
import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved';
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
const EditorJSApiMock = Cypress.sinon.match.any;
/**
* Check if passed onChange method is called with an array of passed events
*
* @param $onChange - editor onChange spy
* @param expectedEvents - batched events to check
*/
function beCalledWithBatchedEvents($onChange, expectedEvents): void {
expect($onChange).to.be.calledOnce;
expect($onChange).to.be.calledWithMatch(
EditorJSApiMock,
Cypress.sinon.match((events) => {
return events.every((event, index) => {
const eventToCheck = expectedEvents[index];
return expect(event).to.containSubset(eventToCheck);
});
})
);
}
/**
* @todo Add checks that correct block API object is passed to onChange
@ -21,7 +50,7 @@ describe('onChange callback', () => {
code: Code,
},
onChange: (api, event): void => {
console.log('something changed', api, event);
console.log('something changed', event);
},
data: blocks ? {
blocks,
@ -46,7 +75,7 @@ describe('onChange callback', () => {
delimiter: Delimiter,
},
onChange: (api, event): void => {
console.log('something changed', api, event);
console.log('something changed', event);
api.saver.save();
},
data: blocks ? {
@ -59,21 +88,62 @@ describe('onChange callback', () => {
cy.createEditor(config).as('editorInstance');
}
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
const EditorJSApiMock = Cypress.sinon.match.any;
it('should fire onChange callback with correct index on block insertion above the current (by pressing Enter at the start)', () => {
createEditor();
it('should batch events when several changes happened at once', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'The first paragraph',
},
},
]);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('change')
.type('{enter}');
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockChangedMutationType,
detail: {
index: 0,
},
},
{
type: BlockAddedMutationType,
detail: {
index: 1,
},
},
]);
});
});
it('should filter out similar events on batching', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'The first paragraph',
},
},
]);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('first change')
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
.wait(100)
.type('second change');
cy.get('@onChange').should('be.calledOnce');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
type: BlockChangedMutationType,
detail: {
target: {
name: 'paragraph',
@ -83,27 +153,66 @@ describe('onChange callback', () => {
}));
});
it('should fire onChange callback with correct index on block insertion below the current (by pressing enter at the end)', () => {
it('should be fired with correct index on block insertion above the current (by pressing Enter at the start)', () => {
createEditor();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('some text')
.type('{enter}');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
type: BlockAddedMutationType,
detail: {
target: {
name: 'paragraph',
},
index: 0,
},
}));
});
it('should be fired with only single "block-added" event by pressing Enter at the end of a block', () => {
createEditor([ {
type: 'paragraph',
data: {
text: 'some text',
},
} ]);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('{enter}');
cy.get('@onChange').should('be.calledOnce');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockAddedMutationType,
}));
});
it('should be fired with correct index on block insertion after the current (by pressing enter at the end)', () => {
createEditor([ {
type: 'paragraph',
data: {
text: 'some text',
},
} ]);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('{enter}');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockAddedMutationType,
detail: {
index: 1,
},
}));
});
it('should fire onChange callback on typing into block', () => {
it('should be fired on typing into block', () => {
createEditor();
cy.get('[data-cy=editorjs]')
@ -112,14 +221,14 @@ describe('onChange callback', () => {
.type('some text');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Changed,
type: BlockChangedMutationType,
detail: {
index: 0,
},
}));
});
it('should fire onChange callback on block insertion with save inside onChange', () => {
it('should be fired on block insertion with save inside onChange', () => {
createEditorWithSave();
cy.get('[data-cy=editorjs]')
@ -134,39 +243,40 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=delimiter]')
.click();
cy.get('@onChange').should('be.calledThrice');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Removed,
detail: {
index: 0,
target: {
name: 'paragraph',
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
},
},
},
}));
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
detail: {
index: 0,
target: {
name: 'delimiter',
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'delimiter',
},
},
},
},
}));
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
detail: {
index: 1,
target: {
name: 'paragraph',
{
type: BlockAddedMutationType,
detail: {
index: 1,
target: {
name: 'paragraph',
},
},
},
},
}));
]);
});
});
it('should fire onChange callback on block replacement for both of blocks', () => {
it('should be fired on block replacement for both of blocks', () => {
createEditor();
cy.get('[data-cy=editorjs]')
@ -181,28 +291,31 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=header]')
.click();
cy.get('@onChange').should('be.calledTwice');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Removed,
detail: {
index: 0,
target: {
name: 'paragraph',
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
{
type: BlockRemovedMutationType,
detail: {
index: 0,
target: {
name: 'paragraph',
},
},
},
},
}));
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Added,
detail: {
index: 0,
target: {
name: 'header',
{
type: BlockAddedMutationType,
detail: {
index: 0,
target: {
name: 'header',
},
},
},
},
}));
]);
});
});
it('should fire onChange callback on tune modifying', () => {
it('should be fired on tune modifying', () => {
createEditor([
{
type: 'header',
@ -225,7 +338,7 @@ describe('onChange callback', () => {
.click();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Changed,
type: BlockChangedMutationType,
detail: {
index: 0,
target: {
@ -235,16 +348,15 @@ describe('onChange callback', () => {
}));
});
it('should fire onChange callback when block is removed', () => {
createEditor();
/**
* The only block does not have Tune menu, so need to create at least 2 blocks to test deleting
*/
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('some text');
it('should be fired when block is removed', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'some text',
},
},
]);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
@ -263,21 +375,45 @@ describe('onChange callback', () => {
.get('div[data-item-name=delete]')
.click();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Removed,
detail: {
index: 0,
},
}));
cy.get('@onChange').should(($callback) => {
return beCalledWithBatchedEvents($callback, [
/**
* "block-removed" fired since we have deleted a block
*/
{
type: BlockRemovedMutationType,
detail: {
index: 0,
},
},
/**
* "block-added" fired since we have deleted the last block, so the new one is created
*/
{
type: BlockAddedMutationType,
detail: {
index: 0,
},
},
]);
});
});
it('should fire onChange callback when block is moved', () => {
createEditor();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('{enter}');
it('should be fired when block is moved', () => {
createEditor([
{
type: 'paragraph',
data: {
text: 'first block',
},
},
{
type: 'paragraph',
data: {
text: 'second block',
},
},
]);
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
@ -293,7 +429,7 @@ describe('onChange callback', () => {
.click();
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Moved,
type: BlockMovedMutationType,
detail: {
fromIndex: 1,
toIndex: 0,
@ -301,7 +437,7 @@ describe('onChange callback', () => {
}));
});
it('should fire onChange if something changed inside native input', () => {
it('should be fired if something changed inside native input', () => {
createEditor([ {
type: 'code',
data: {
@ -314,10 +450,42 @@ describe('onChange callback', () => {
.type('Some input to the textarea');
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Changed,
type: BlockChangedMutationType,
detail: {
index: 0,
},
}));
});
it('should not be fired on fake cursor adding and removing', () => {
createEditor([ {
type: 'paragraph',
data: {
text: 'some text',
},
} ]);
cy.get('[data-cy=editorjs')
.get('div.ce-block')
.click();
/**
* Open Block Tunes, add fake cursor
*/
cy.get('[data-cy=editorjs]')
.get('span.ce-toolbar__settings-btn')
.click();
/**
* Close Block Tunes, remove fake cursor
*/
cy.get('[data-cy=editorjs')
.get('div.ce-block')
.click();
// eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers
cy.wait(500).then(() => {
cy.get('@onChange').should('have.callCount', 0);
});
});
});

View file

@ -1,7 +1,8 @@
import {ToolConstructable, ToolSettings} from '../tools';
import {API, BlockAPI, LogLevels, OutputData} from '../index';
import {API, LogLevels, OutputData} from '../index';
import {SanitizerConfig} from './sanitizer-config';
import {I18nConfig} from './i18n-config';
import { BlockMutationEvent } from '../events/block';
export interface EditorConfig {
/**
@ -89,10 +90,10 @@ export interface EditorConfig {
/**
* Fires when something changed in DOM
* @param {API} api - editor.js api
* @param event - custom event describing mutation
* @param api - editor.js api
* @param event - custom event describing mutation. If several mutations happened at once, they will be batched and you'll get an array of events here.
*/
onChange?(api: API, event: CustomEvent): void;
onChange?(api: API, event: BlockMutationEvent | BlockMutationEvent[]): void;
/**
* Defines default toolbar for all tools.

View file

@ -1,10 +1,11 @@
import {BlockToolData} from '../tools';
import { BlockId } from './block-id';
/**
* Tool's saved data
*/
export interface SavedData {
id: string;
id: BlockId;
tool: string;
data: BlockToolData;
time: number;
@ -14,7 +15,7 @@ export interface SavedData {
* Tool's data after validation
*/
export interface ValidatedData {
id?: string;
id?: BlockId;
tool?: string;
data?: BlockToolData;
time?: number;

View file

@ -0,0 +1,4 @@
/**
* Unique identifier of a block
*/
export type BlockId = string;

View file

@ -1,5 +1,6 @@
import {BlockToolData} from '../tools';
import {BlockTuneData} from '../block-tunes/block-tune-data';
import { BlockId } from './block-id';
/**
* Output of one Tool
@ -11,7 +12,7 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
/**
* Unique Id of the block
*/
id?: string;
id?: BlockId;
/**
* Tool type
*/

View file

@ -0,0 +1,11 @@
import { BlockAPI } from '../../api';
/**
* Details of CustomEvent fired on block mutation
*/
export interface BlockMutationEventDetail {
/**
* Affected block
*/
target: BlockAPI;
}

View 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>;

View 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>;

View 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>;

View 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>;

View 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>;

View file

@ -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
View file

@ -31,6 +31,11 @@ import {
} from './api';
import { OutputData } from './data-formats';
import { BlockMutationEventMap } from './events/block';
import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded';
import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged';
import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved';
import { BlockRemovedMutationType, BlockRemovedEvent } from './events/block/BlockRemoved';
/**
* Interfaces used for development
@ -76,8 +81,20 @@ export {
PopoverItemWithConfirmation,
PopoverItemWithoutConfirmation
} from './configs';
export {OutputData, OutputBlockData} from './data-formats/output-data';
export { OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockId } from './data-formats/block-id';
export { BlockAPI } from './api'
export {
BlockMutationEventMap,
BlockAddedMutationType,
BlockAddedEvent,
BlockRemovedMutationType,
BlockRemovedEvent,
BlockMovedMutationType,
BlockMovedEvent,
BlockChangedMutationType,
BlockChangedEvent,
}
/**
* We have a namespace API {@link ./api/index.d.ts} (APIMethods) but we can not use it as interface

View file

@ -1353,6 +1353,11 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
chai-subset@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/chai-subset/-/chai-subset-1.6.0.tgz#a5d0ca14e329a79596ed70058b6646bd6988cfe9"
integrity sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==
chalk@4.1.2, chalk@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"