UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

358 lines (350 loc) • 11.2 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import isEqual from 'lodash/isEqual'; import throttle from 'lodash/throttle'; import { corePlugin } from './core-plugin'; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any function hasGetSharedState( // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any return typeof plugin.getSharedState === 'function'; } function hasActions( // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any return typeof plugin.actions === 'object'; } function hasCommands( // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any return typeof plugin.commands === 'object'; } const filterPluginsWithListeners = ({ listeners, plugins }) => Array.from(listeners.keys()).map(pluginName => plugins.get(pluginName)).filter(plugin => plugin !== undefined && hasGetSharedState(plugin)); const extractSharedStateFromPlugins = ({ oldEditorState, newEditorState, plugins }) => { const isInitialization = !oldEditorState && newEditorState; const result = new Map(); for (const plugin of plugins) { if (!plugin || !hasGetSharedState(plugin)) { continue; } const nextSharedState = plugin.getSharedState(newEditorState); const prevSharedState = !isInitialization && oldEditorState ? plugin.getSharedState(oldEditorState) : undefined; const isSamePluginState = isEqual(prevSharedState, nextSharedState); if (isInitialization || !isSamePluginState) { result.set(plugin.name, { nextSharedState, prevSharedState }); } } return result; }; const THROTTLE_CALLS_FOR_MILLISECONDS = 0; const notifyListenersThrottled = throttle(({ listeners, updatesToNotifyQueue }) => { const callbacks = []; for (const [pluginName, diffs] of updatesToNotifyQueue.entries()) { const pluginListeners = listeners.get(pluginName) || []; pluginListeners.forEach(callback => { diffs.forEach(diff => { callbacks.push(callback.bind(callback, diff)); }); }); } updatesToNotifyQueue.clear(); if (callbacks.length === 0) { return; } callbacks.reverse().forEach(cb => { cb(); }); }, THROTTLE_CALLS_FOR_MILLISECONDS); export class PluginsData {} class ActionsAPI { createAPI( // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { if (!plugin || !hasActions(plugin)) { return {}; } return new Proxy(plugin.actions || {}, { get: function (target, prop, _receiver) { // We will be able to track perfomance here return Reflect.get(target, prop); } }); } } class EditorCommandsAPI { createAPI( // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { if (!plugin || !hasCommands(plugin)) { return {}; } return new Proxy(plugin.commands || {}, { get: function (target, prop, _receiver) { // We will be able to track perfomance here return Reflect.get(target, prop); } }); } } export class SharedStateAPI { constructor({ getEditorState }) { _defineProperty(this, "updatesToNotifyQueue", new Map()); this.getEditorState = getEditorState; this.listeners = new Map(); } createAPI(plugin // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any ) { if (!plugin) { return { currentState: () => undefined, onChange: _sub => { return () => {}; } }; } const pluginName = plugin.name; return { currentState: () => { if (!hasGetSharedState(plugin)) { return undefined; } const state = this.getEditorState(); return plugin.getSharedState(state); }, onChange: sub => { const pluginListeners = this.listeners.get(pluginName) || new Set(); pluginListeners.add(sub); this.listeners.set(pluginName, pluginListeners); return () => this.cleanupSubscription(pluginName, sub); } }; } cleanupSubscription(pluginName, sub) { (this.listeners.get(pluginName) || new Set()).delete(sub); } // Drop every listener and pending update for a plugin that is no longer // registered. Without this, callbacks (and their captured closures) for // evicted plugins would linger in `listeners` until destroy(), and every // transaction would still walk their keys via filterPluginsWithListeners. removePluginListeners(pluginName) { this.listeners.delete(pluginName); this.updatesToNotifyQueue.delete(pluginName); } notifyListeners({ newEditorState, oldEditorState, plugins }) { const { listeners, updatesToNotifyQueue } = this; const pluginsFiltered = filterPluginsWithListeners({ plugins, listeners }); const sharedStateDiffs = extractSharedStateFromPlugins({ oldEditorState, newEditorState, plugins: pluginsFiltered }); if (sharedStateDiffs.size === 0) { return; } for (const [pluginName, nextDiff] of sharedStateDiffs) { const currentDiffQueue = updatesToNotifyQueue.get(pluginName) || []; updatesToNotifyQueue.set(pluginName, [...currentDiffQueue, nextDiff]); } notifyListenersThrottled({ updatesToNotifyQueue, listeners }); } destroy() { this.listeners.clear(); this.updatesToNotifyQueue.clear(); } } const editorAPICache = new WeakMap(); export class EditorPluginInjectionAPI { constructor({ getEditorState, getEditorView, fireAnalyticsEvent, appearance }) { _defineProperty(this, "onEditorViewUpdated", ({ newEditorState, oldEditorState }) => { this.sharedStateAPI.notifyListeners({ newEditorState, oldEditorState, plugins: this.plugins }); }); _defineProperty(this, "onEditorPluginInitialized", plugin => { this.addPlugin(plugin); }); // Internal cleanup helper used by ReactEditorView's reconfigureState to // reconcile the registered plugin set with the current preset. Removes // every registered plugin not in `keptPluginNames`; `core` is always // preserved. Returns the names that were removed. Intentionally not on // PluginInjectionAPIDefinition: this is an editor-internal control, not // part of the injection-API contract that plugins or external consumers // depend on. _defineProperty(this, "retainPlugins", keptPluginNames => { const evicted = []; for (const name of this.plugins.keys()) { if (name !== 'core' && !keptPluginNames.has(name)) { evicted.push(name); } } for (const name of evicted) { this.plugins.delete(name); this.sharedStateAPI.removePluginListeners(name); } return evicted; }); // Internal: snapshot the names of currently-registered plugins. Used by // reconfigureState to capture the previous plugin set before the new // preset registers its own plugins via onEditorPluginInitialized. _defineProperty(this, "getRegisteredPluginNames", () => Array.from(this.plugins.keys())); _defineProperty(this, "addPlugin", plugin => { // Plugins other than `core` are checked by the preset itself // For some reason in some tests we have duplicates that are missed. // To follow-up in ED-19611 if (plugin.name === 'core' && this.plugins.has(plugin.name)) { throw new Error(`Plugin ${plugin.name} has already been initialised in the Editor API! There cannot be duplicate plugins or you will have unexpected behaviour`); } this.plugins.set(plugin.name, plugin); }); _defineProperty(this, "getPluginByName", pluginName => { const plugin = this.plugins.get(pluginName); return plugin; }); this.sharedStateAPI = new SharedStateAPI({ getEditorState }); this.plugins = new Map(); this.actionsAPI = new ActionsAPI(); this.commandsAPI = new EditorCommandsAPI(); // Special core plugin that is always added this.addPlugin(corePlugin({ config: { getEditorView, fireAnalyticsEvent, appearance } })); } /** * Returns PM plugins from internally-registered plugins (e.g. the core plugin) * that are not processed through the normal preset builder flow. */ // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any getInternalPMPlugins() { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = []; const corePlugin = this.plugins.get('core'); if (corePlugin && typeof corePlugin.pmPlugins === 'function') { const pmPlugins = corePlugin.pmPlugins(); if (pmPlugins) { result.push(...pmPlugins); } } return result; } createAPI() { const { sharedStateAPI, actionsAPI, commandsAPI, getPluginByName } = this; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Proxy({}, { get: function (target, prop, _receiver) { // If we pass this as a prop React hates us // Let's just reflect the result and ignore these if (prop === 'toJSON') { return Reflect.get(target, prop); } const plugin = getPluginByName(prop); if (!plugin) { return undefined; } const sharedState = sharedStateAPI.createAPI(plugin); const actions = actionsAPI.createAPI(plugin); const commands = commandsAPI.createAPI(plugin); // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any const proxyCoreAPI = { sharedState, actions, commands }; return proxyCoreAPI; } }); } api() { if (!editorAPICache.get(this)) { editorAPICache.set(this, this.createAPI()); } // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return editorAPICache.get(this); } }