mirror of
https://github.com/codex-team/editor.js
synced 2024-05-02 22:53:23 +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.
|
||||
- `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.
|
||||
|
|
|
@ -406,4 +406,4 @@
|
|||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
"@editorjs/paragraph": "^2.9.0",
|
||||
"@editorjs/simple-image": "^1.4.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"chai-subset": "^1.6.0",
|
||||
"codex-notifier": "^1.1.2",
|
||||
"codex-tooltip": "^1.0.5",
|
||||
"core-js": "3.30.0",
|
||||
|
|
|
@ -3,6 +3,7 @@ import { EditorConfig } from '../../types';
|
|||
import { ModuleConfig } from '../types-internal/module-config';
|
||||
import Listeners from './utils/listeners';
|
||||
import EventsDispatcher from './utils/events';
|
||||
import { EditorEventMap } from './events';
|
||||
|
||||
/**
|
||||
* The type <T> of the Module generic.
|
||||
|
@ -42,7 +43,7 @@ export default class Module<T extends ModuleNodes = Record<string, HTMLElement>>
|
|||
/**
|
||||
* Editor event dispatcher class
|
||||
*/
|
||||
protected eventsDispatcher: EventsDispatcher;
|
||||
protected eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||
|
||||
/**
|
||||
* Util for bind/unbind DOM event listeners
|
||||
|
|
|
@ -22,6 +22,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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,10 @@ import I18n from './i18n';
|
|||
import { CriticalError } from './errors/critical';
|
||||
import EventsDispatcher from './utils/events';
|
||||
import Modules from './modules';
|
||||
import { EditorEventMap } from './events';
|
||||
|
||||
/**
|
||||
* @class Core
|
||||
* @classdesc Editor.js core class
|
||||
* @property {EditorConfig} config - all settings
|
||||
* @property {EditorModules} moduleInstances - constructed editor components
|
||||
* @type {Core}
|
||||
* Editor.js core class. Bootstraps modules.
|
||||
*/
|
||||
export default class Core {
|
||||
/**
|
||||
|
@ -31,9 +28,9 @@ export default class Core {
|
|||
public isReady: Promise<void>;
|
||||
|
||||
/**
|
||||
* Event Dispatcher util
|
||||
* Common Editor Event Bus
|
||||
*/
|
||||
private eventsDispatcher: EventsDispatcher = new EventsDispatcher();
|
||||
private eventsDispatcher: EventsDispatcher<EditorEventMap> = new EventsDispatcher();
|
||||
|
||||
/**
|
||||
* @param {EditorConfig} config - user configuration
|
||||
|
|
|
@ -145,7 +145,7 @@ export default class Dom {
|
|||
* @param {string} selector - searching string
|
||||
* @returns {Element}
|
||||
*/
|
||||
public static find(el: Element | Document = document, selector: string): Element {
|
||||
public static find(el: Element | Document = document, selector: string): Element | null {
|
||||
return el.querySelector(selector);
|
||||
}
|
||||
|
||||
|
|
16
src/components/events/BlockChanged.ts
Normal file
16
src/components/events/BlockChanged.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { BlockMutationEvent } from '../../../types/events/block';
|
||||
|
||||
/**
|
||||
* Fired when some block state has changed
|
||||
*/
|
||||
export const BlockChanged = 'block changed';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface BlockChangedPayload {
|
||||
/**
|
||||
* CustomEvent describing a block change
|
||||
*/
|
||||
event: BlockMutationEvent;
|
||||
}
|
16
src/components/events/BlockHovered.ts
Normal file
16
src/components/events/BlockHovered.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type Block from '../block';
|
||||
|
||||
/**
|
||||
* Fired when some block is hovered by user
|
||||
*/
|
||||
export const BlockHovered = 'block hovered';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface BlockHoveredPayload {
|
||||
/**
|
||||
* Hovered block
|
||||
*/
|
||||
block: Block;
|
||||
}
|
17
src/components/events/FakeCursorAboutToBeToggled.ts
Normal file
17
src/components/events/FakeCursorAboutToBeToggled.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Fired before we're adding/removing a fake cursor.
|
||||
*
|
||||
* Allows to disable mutation observer to skip this block change
|
||||
*/
|
||||
export const FakeCursorAboutToBeToggled = 'fake cursor is about to be toggled';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface FakeCursorAboutToBeToggledPayload {
|
||||
/**
|
||||
* true - when added a cursor
|
||||
* false - when removed
|
||||
*/
|
||||
state: boolean;
|
||||
}
|
17
src/components/events/FakeCursorHaveBeenSet.ts
Normal file
17
src/components/events/FakeCursorHaveBeenSet.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Fired after we've added/removed a fake cursor.
|
||||
*
|
||||
* Allows to enable mutation observer which was disabled before setting
|
||||
*/
|
||||
export const FakeCursorHaveBeenSet = 'fake cursor have been set';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface FakeCursorHaveBeenSetPayload {
|
||||
/**
|
||||
* true - when added a cursor
|
||||
* false - when removed
|
||||
*/
|
||||
state: boolean;
|
||||
}
|
14
src/components/events/RedactorDomChanged.ts
Normal file
14
src/components/events/RedactorDomChanged.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Fired when blocks wrapper (.codex-editor-redactor) dom changed
|
||||
*/
|
||||
export const RedactorDomChanged = 'redactor dom changed';
|
||||
|
||||
/**
|
||||
* Payload that will be passed with the event
|
||||
*/
|
||||
export interface RedactorDomChangedPayload {
|
||||
/**
|
||||
* Mutations happened with blocks wrapper
|
||||
*/
|
||||
mutations: MutationRecord[];
|
||||
}
|
26
src/components/events/index.ts
Normal file
26
src/components/events/index.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { RedactorDomChanged, RedactorDomChangedPayload } from './RedactorDomChanged';
|
||||
import { BlockChanged, BlockChangedPayload } from './BlockChanged';
|
||||
import { BlockHovered, BlockHoveredPayload } from './BlockHovered';
|
||||
import { FakeCursorAboutToBeToggled, FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled';
|
||||
import { FakeCursorHaveBeenSet, FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet';
|
||||
|
||||
/**
|
||||
* Events fired by Editor Event Dispatcher
|
||||
*/
|
||||
export {
|
||||
RedactorDomChanged,
|
||||
BlockChanged,
|
||||
FakeCursorAboutToBeToggled,
|
||||
FakeCursorHaveBeenSet
|
||||
};
|
||||
|
||||
/**
|
||||
* Event name -> Event payload
|
||||
*/
|
||||
export interface EditorEventMap {
|
||||
[BlockHovered]: BlockHoveredPayload;
|
||||
[RedactorDomChanged]: RedactorDomChangedPayload;
|
||||
[BlockChanged]: BlockChangedPayload;
|
||||
[FakeCursorAboutToBeToggled]: FakeCursorAboutToBeToggledPayload;
|
||||
[FakeCursorHaveBeenSet]: FakeCursorHaveBeenSetPayload;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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];
|
||||
|
|
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
3
src/types-internal/module-config.d.ts
vendored
3
src/types-internal/module-config.d.ts
vendored
|
@ -1,4 +1,5 @@
|
|||
import { EditorConfig } from '../../types/index';
|
||||
import { EditorEventMap } from '../components/events';
|
||||
import EventsDispatcher from '../components/utils/events';
|
||||
|
||||
/**
|
||||
|
@ -6,5 +7,5 @@ import EventsDispatcher from '../components/utils/events';
|
|||
*/
|
||||
export interface ModuleConfig {
|
||||
config: EditorConfig;
|
||||
eventsDispatcher: EventsDispatcher;
|
||||
eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||
}
|
||||
|
|
12
test/cypress/support/index.d.ts
vendored
12
test/cypress/support/index.d.ts
vendored
|
@ -66,4 +66,16 @@ declare global {
|
|||
EditorJS: typeof EditorJS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chai plugins
|
||||
*/
|
||||
namespace Chai {
|
||||
interface Assertion {
|
||||
/**
|
||||
* "containSubset" object properties matcher
|
||||
*/
|
||||
containSubset(subset: any): Assertion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,13 @@ import '@cypress/code-coverage/support';
|
|||
*/
|
||||
import './commands';
|
||||
|
||||
import chaiSubset from 'chai-subset';
|
||||
|
||||
/**
|
||||
* "containSubset" object properties matcher
|
||||
*/
|
||||
chai.use(chaiSubset);
|
||||
|
||||
/**
|
||||
* Before-each hook for the cypress tests
|
||||
*/
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { BlockMutationType } from '../../../../types/events/block/mutation-type';
|
||||
import EditorJS from '../../../../types';
|
||||
import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged';
|
||||
|
||||
/**
|
||||
* There will be described test cases of BlockAPI
|
||||
|
@ -23,24 +23,23 @@ describe('BlockAPI', () => {
|
|||
*/
|
||||
const EditorJSApiMock = Cypress.sinon.match.any;
|
||||
|
||||
beforeEach(function () {
|
||||
/**
|
||||
* Creates Editor instance
|
||||
*
|
||||
* @param [data] - data to render
|
||||
*/
|
||||
function createEditor(data = undefined): void {
|
||||
const config = {
|
||||
data: editorDataMock,
|
||||
onChange: (): void => {
|
||||
console.log('something changed');
|
||||
onChange: (api, event): void => {
|
||||
console.log('something changed', event);
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
cy.createEditor(config).as('editorInstance');
|
||||
|
||||
cy.spy(config, 'onChange').as('onChange');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.destroy();
|
||||
}
|
||||
});
|
||||
cy.createEditor(config).as('editorInstance');
|
||||
}
|
||||
|
||||
/**
|
||||
* block.dispatchChange();
|
||||
|
@ -50,18 +49,21 @@ describe('BlockAPI', () => {
|
|||
* Check that blocks.dispatchChange() triggers Editor 'onChange' callback
|
||||
*/
|
||||
it('should trigger onChange with corresponded block', () => {
|
||||
cy.get('@editorInstance').then(async (editor: unknown) => {
|
||||
const block = (editor as EditorJS).blocks.getById(firstBlock.id);
|
||||
createEditor(editorDataMock);
|
||||
|
||||
block.dispatchChange();
|
||||
cy.get<EditorJS>('@editorInstance')
|
||||
.then(async (editor) => {
|
||||
const block = editor.blocks.getById(firstBlock.id);
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockMutationType.Changed,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
block.dispatchChange();
|
||||
|
||||
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
|
||||
type: BlockChangedMutationType,
|
||||
detail: {
|
||||
index: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
9
types/configs/editor-config.d.ts
vendored
9
types/configs/editor-config.d.ts
vendored
|
@ -1,7 +1,8 @@
|
|||
import {ToolConstructable, ToolSettings} from '../tools';
|
||||
import {API, BlockAPI, LogLevels, OutputData} from '../index';
|
||||
import {API, LogLevels, OutputData} from '../index';
|
||||
import {SanitizerConfig} from './sanitizer-config';
|
||||
import {I18nConfig} from './i18n-config';
|
||||
import { BlockMutationEvent } from '../events/block';
|
||||
|
||||
export interface EditorConfig {
|
||||
/**
|
||||
|
@ -89,10 +90,10 @@ export interface EditorConfig {
|
|||
|
||||
/**
|
||||
* Fires when something changed in DOM
|
||||
* @param {API} api - editor.js api
|
||||
* @param event - custom event describing mutation
|
||||
* @param api - editor.js api
|
||||
* @param event - custom event describing mutation. If several mutations happened at once, they will be batched and you'll get an array of events here.
|
||||
*/
|
||||
onChange?(api: API, event: CustomEvent): void;
|
||||
onChange?(api: API, event: BlockMutationEvent | BlockMutationEvent[]): void;
|
||||
|
||||
/**
|
||||
* Defines default toolbar for all tools.
|
||||
|
|
5
types/data-formats/block-data.d.ts
vendored
5
types/data-formats/block-data.d.ts
vendored
|
@ -1,10 +1,11 @@
|
|||
import {BlockToolData} from '../tools';
|
||||
import { BlockId } from './block-id';
|
||||
|
||||
/**
|
||||
* Tool's saved data
|
||||
*/
|
||||
export interface SavedData {
|
||||
id: string;
|
||||
id: BlockId;
|
||||
tool: string;
|
||||
data: BlockToolData;
|
||||
time: number;
|
||||
|
@ -14,7 +15,7 @@ export interface SavedData {
|
|||
* Tool's data after validation
|
||||
*/
|
||||
export interface ValidatedData {
|
||||
id?: string;
|
||||
id?: BlockId;
|
||||
tool?: string;
|
||||
data?: BlockToolData;
|
||||
time?: number;
|
||||
|
|
4
types/data-formats/block-id.ts
Normal file
4
types/data-formats/block-id.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Unique identifier of a block
|
||||
*/
|
||||
export type BlockId = string;
|
3
types/data-formats/output-data.d.ts
vendored
3
types/data-formats/output-data.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
import {BlockToolData} from '../tools';
|
||||
import {BlockTuneData} from '../block-tunes/block-tune-data';
|
||||
import { BlockId } from './block-id';
|
||||
|
||||
/**
|
||||
* Output of one Tool
|
||||
|
@ -11,7 +12,7 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
|
|||
/**
|
||||
* Unique Id of the block
|
||||
*/
|
||||
id?: string;
|
||||
id?: BlockId;
|
||||
/**
|
||||
* Tool type
|
||||
*/
|
||||
|
|
11
types/events/block/Base.ts
Normal file
11
types/events/block/Base.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { BlockAPI } from '../../api';
|
||||
|
||||
/**
|
||||
* Details of CustomEvent fired on block mutation
|
||||
*/
|
||||
export interface BlockMutationEventDetail {
|
||||
/**
|
||||
* Affected block
|
||||
*/
|
||||
target: BlockAPI;
|
||||
}
|
21
types/events/block/BlockAdded.ts
Normal file
21
types/events/block/BlockAdded.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block added event
|
||||
*/
|
||||
export const BlockAddedMutationType = 'block-added';
|
||||
|
||||
/**
|
||||
* Information about added block
|
||||
*/
|
||||
interface BlockAddedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of added block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when the new block is added to the editor
|
||||
*/
|
||||
export type BlockAddedEvent = CustomEvent<BlockAddedEventDetail>;
|
21
types/events/block/BlockChanged.ts
Normal file
21
types/events/block/BlockChanged.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block changed event
|
||||
*/
|
||||
export const BlockChangedMutationType = 'block-changed';
|
||||
|
||||
/**
|
||||
* Information about changed block
|
||||
*/
|
||||
interface BlockChangedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of changed block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is changed
|
||||
*/
|
||||
export type BlockChangedEvent = CustomEvent<BlockChangedEventDetail>;
|
26
types/events/block/BlockMoved.ts
Normal file
26
types/events/block/BlockMoved.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block moved event
|
||||
*/
|
||||
export const BlockMovedMutationType = 'block-moved';
|
||||
|
||||
/**
|
||||
* Information about moved block
|
||||
*/
|
||||
interface BlockMovedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Previous block position
|
||||
*/
|
||||
fromIndex: number;
|
||||
|
||||
/**
|
||||
* New block position
|
||||
*/
|
||||
toIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is moved to another position
|
||||
*/
|
||||
export type BlockMovedEvent = CustomEvent<BlockMovedEventDetail>;
|
21
types/events/block/BlockRemoved.ts
Normal file
21
types/events/block/BlockRemoved.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { BlockMutationEventDetail } from './Base';
|
||||
|
||||
/**
|
||||
* Type name of CustomEvent related to block removed event
|
||||
*/
|
||||
export const BlockRemovedMutationType = 'block-removed';
|
||||
|
||||
/**
|
||||
* Information about removed block
|
||||
*/
|
||||
interface BlockRemovedEventDetail extends BlockMutationEventDetail {
|
||||
/**
|
||||
* Index of removed block
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event will be fired when some block is removed
|
||||
*/
|
||||
export type BlockRemovedEvent = CustomEvent<BlockRemovedEventDetail>;
|
44
types/events/block/index.ts
Normal file
44
types/events/block/index.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { BlockAddedEvent, BlockAddedMutationType } from './BlockAdded';
|
||||
import { BlockChangedEvent, BlockChangedMutationType } from './BlockChanged';
|
||||
import { BlockMovedEvent, BlockMovedMutationType } from './BlockMoved';
|
||||
import { BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved';
|
||||
|
||||
/**
|
||||
* Map for Custom Events related to block mutation types
|
||||
*/
|
||||
export interface BlockMutationEventMap {
|
||||
/**
|
||||
* New Block added
|
||||
*/
|
||||
[BlockAddedMutationType]: BlockAddedEvent;
|
||||
|
||||
/**
|
||||
* On Block deletion
|
||||
*/
|
||||
[BlockRemovedMutationType]: BlockRemovedEvent;
|
||||
|
||||
/**
|
||||
* Moving of a Block
|
||||
*/
|
||||
[BlockMovedMutationType]: BlockMovedEvent;
|
||||
|
||||
/**
|
||||
* Any changes inside the Block
|
||||
*/
|
||||
[BlockChangedMutationType]: BlockChangedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* What kind of modification happened with the Block
|
||||
*/
|
||||
export type BlockMutationType = keyof BlockMutationEventMap;
|
||||
|
||||
/**
|
||||
* Returns a union type of values of passed object
|
||||
*/
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
/**
|
||||
* CustomEvent describing a change related to a block
|
||||
*/
|
||||
export type BlockMutationEvent = ValueOf<BlockMutationEventMap>;
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* What kind of modification happened with the Block
|
||||
*/
|
||||
export enum BlockMutationType {
|
||||
/**
|
||||
* New Block added
|
||||
*/
|
||||
Added = 'block-added',
|
||||
|
||||
/**
|
||||
* On Block deletion
|
||||
*/
|
||||
Removed = 'block-removed',
|
||||
|
||||
/**
|
||||
* Moving of a Block
|
||||
*/
|
||||
Moved = 'block-moved',
|
||||
|
||||
/**
|
||||
* Any changes inside the Block
|
||||
*/
|
||||
Changed = 'block-changed',
|
||||
}
|
19
types/index.d.ts
vendored
19
types/index.d.ts
vendored
|
@ -31,6 +31,11 @@ import {
|
|||
} from './api';
|
||||
|
||||
import { OutputData } from './data-formats';
|
||||
import { BlockMutationEventMap } from './events/block';
|
||||
import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded';
|
||||
import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged';
|
||||
import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved';
|
||||
import { BlockRemovedMutationType, BlockRemovedEvent } from './events/block/BlockRemoved';
|
||||
|
||||
/**
|
||||
* Interfaces used for development
|
||||
|
@ -76,8 +81,20 @@ export {
|
|||
PopoverItemWithConfirmation,
|
||||
PopoverItemWithoutConfirmation
|
||||
} from './configs';
|
||||
export {OutputData, OutputBlockData} from './data-formats/output-data';
|
||||
export { OutputData, OutputBlockData} from './data-formats/output-data';
|
||||
export { BlockId } from './data-formats/block-id';
|
||||
export { BlockAPI } from './api'
|
||||
export {
|
||||
BlockMutationEventMap,
|
||||
BlockAddedMutationType,
|
||||
BlockAddedEvent,
|
||||
BlockRemovedMutationType,
|
||||
BlockRemovedEvent,
|
||||
BlockMovedMutationType,
|
||||
BlockMovedEvent,
|
||||
BlockChangedMutationType,
|
||||
BlockChangedEvent,
|
||||
}
|
||||
|
||||
/**
|
||||
* We have a namespace API {@link ./api/index.d.ts} (APIMethods) but we can not use it as interface
|
||||
|
|
|
@ -1353,6 +1353,11 @@ caseless@~0.12.0:
|
|||
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
|
||||
|
||||
chai-subset@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chai-subset/-/chai-subset-1.6.0.tgz#a5d0ca14e329a79596ed70058b6646bd6988cfe9"
|
||||
integrity sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==
|
||||
|
||||
chalk@4.1.2, chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
|
|
Loading…
Reference in a new issue