/** * @module plugin */ import type { IExtraPlugin, IDictionary, IJodit, IPlugin, IPluginSystem, PluginInstance, PluginType, CanPromise, CanUndef, Nullable } from 'jodit/types'; import { isInitable, isDestructable, isFunction, isString, isArray } from 'jodit/core/helpers/checker'; import { appendScriptAsync, appendStyleAsync } from 'jodit/core/helpers/utils/append-script'; import { splitArray } from 'jodit/core/helpers/array'; import { kebabCase } from 'jodit/core/helpers/string'; import { callPromise } from 'jodit/core/helpers/utils/utils'; import { eventEmitter } from 'jodit/core/global'; /** * Jodit plugin system * @example * ```js * Jodit.plugins.add('emoji2', { * init() { * alert('emoji Inited2') * }, * destruct() {} * }); * ``` */ export class PluginSystem implements IPluginSystem { private normalizeName(name: string): string { return kebabCase(name).toLowerCase(); } private _items = new Map(); private items(filter: Nullable): Array<[string, PluginType]> { const results: Array<[string, PluginType]> = []; this._items.forEach((plugin, name) => { results.push([name, plugin]); }); return results.filter(([name]) => !filter || filter.includes(name)); } /** * Add plugin in store */ add(name: string, plugin: PluginType): void { this._items.set(this.normalizeName(name), plugin); eventEmitter.fire(`plugin:${name}:ready`); } /** * Get plugin from store */ get(name: string): PluginType | void { return this._items.get(this.normalizeName(name)); } /** * Remove plugin from store */ remove(name: string): void { this._items.delete(this.normalizeName(name)); } /** * Public method for async init all plugins */ init(jodit: IJodit): CanPromise { const extrasList: IExtraPlugin[] = jodit.o.extraPlugins.map(s => isString(s) ? { name: s } : s ), disableList = splitArray(jodit.o.disablePlugins).map(s => { const name = this.normalizeName(s); // @ts-ignore if (!isProd && !this._items.has(name)) { console.error(TypeError(`Unknown plugin disabled:${name}`)); } return name; }), doneList: string[] = [], promiseList: IDictionary = {}, plugins: PluginInstance[] = [], pluginsMap: IDictionary = {}, makeAndInit = ([name, plugin]: [string, PluginType]): void => { if ( disableList.includes(name) || doneList.includes(name) || promiseList[name] ) { return; } const requires = (plugin as any)?.requires as CanUndef< string[] >; if ( requires && isArray(requires) && this.hasDisabledRequires(disableList, requires) ) { return; } const instance = PluginSystem.makePluginInstance(jodit, plugin); if (instance) { this.initOrWait( jodit, name, instance, doneList, promiseList ); plugins.push(instance); pluginsMap[name] = instance; } }; const resultLoadExtras = this.loadExtras(jodit, extrasList); return callPromise(resultLoadExtras, () => { if (jodit.isInDestruct) { return; } this.items( jodit.o.safeMode ? jodit.o.safePluginsList.concat( extrasList.map(s => s.name) ) : null ).forEach(makeAndInit); this.addListenerOnBeforeDestruct(jodit, plugins); (jodit as any).__plugins = pluginsMap; }); } /** * Returns the promise to wait for the plugin to load. */ wait(name: string): Promise { return new Promise(resolve => { if (this.get(name)) { return resolve(); } const onReady = (): void => { resolve(); eventEmitter.off(`plugin:${name}:ready`, onReady); }; eventEmitter.on(`plugin:${name}:ready`, onReady); }); } /** * Plugin type has disabled requires */ private hasDisabledRequires( disableList: string[], requires: string[] ): boolean { return Boolean( requires?.length && disableList.some(disabled => requires.includes(disabled)) ); } /** * Create instance of plugin */ static makePluginInstance( jodit: IJodit, plugin: PluginType ): Nullable { try { try { // @ts-ignore return isFunction(plugin) ? new plugin(jodit) : plugin; } catch (e) { if (isFunction(plugin) && !plugin.prototype) { return (plugin as Function)(jodit); } } } catch (e) { console.error(e); // @ts-ignore if (!isProd) { throw e; } } return null; } /** * Init plugin if it has not dependencies in another case wait requires plugins will be init */ private initOrWait( jodit: IJodit, pluginName: string, instance: PluginInstance, doneList: string[], promiseList: IDictionary ): void { const initPlugin = (name: string, plugin: PluginInstance): boolean => { if (isInitable(plugin)) { const req = (plugin as IPlugin).requires; if ( !req?.length || req.every(name => doneList.includes(name)) ) { try { plugin.init(jodit); } catch (e) { console.error(e); // @ts-ignore if (!isProd) { throw e; } } doneList.push(name); } else { // @ts-ignore if (!isProd && !isTest && !promiseList[name]) { console.log('Await plugin: ', name); } promiseList[name] = plugin; return false; } } else { doneList.push(name); } if ((plugin as IPlugin).hasStyle) { PluginSystem.loadStyle(jodit, name); } return true; }; initPlugin(pluginName, instance); Object.keys(promiseList).forEach(name => { const plugin = promiseList[name]; if (!plugin) { return; } if (initPlugin(name, plugin)) { promiseList[name] = undefined; delete promiseList[name]; } }); } /** * Destroy all plugins before - Jodit will be destroyed */ private addListenerOnBeforeDestruct( jodit: IJodit, plugins: PluginInstance[] ): void { jodit.e.on('beforeDestruct', () => { plugins.forEach(instance => { if (isDestructable(instance)) { instance.destruct(jodit); } }); plugins.length = 0; delete (jodit as any).__plugins; }); } /** * Download plugins */ private load(jodit: IJodit, pluginList: IExtraPlugin[]): Promise { const reflect = (p: Promise): Promise => p.then( (v: any) => ({ v, status: 'fulfilled' }), (e: any) => ({ e, status: 'rejected' }) ); return Promise.all( pluginList.map(extra => { const url = extra.url || PluginSystem.getFullUrl(jodit, extra.name, true); return reflect(appendScriptAsync(jodit, url)); }) ); } private static async loadStyle( jodit: IJodit, pluginName: string ): Promise { const url = PluginSystem.getFullUrl(jodit, pluginName, false); if (this.styles.has(url)) { return; } this.styles.add(url); return appendStyleAsync(jodit, url); } private static styles: Set = new Set(); /** * Call full url to the script or style file */ private static getFullUrl( jodit: IJodit, name: string, js: boolean ): string { name = kebabCase(name); return ( jodit.basePath + 'plugins/' + name + '/' + name + '.' + (js ? 'js' : 'css') ); } private loadExtras( jodit: IJodit, extrasList: IExtraPlugin[] ): CanPromise { if (extrasList && extrasList.length) { try { const needLoadExtras = extrasList.filter( extra => !this._items.has(this.normalizeName(extra.name)) ); if (needLoadExtras.length) { return this.load(jodit, needLoadExtras); } } catch (e) { // @ts-ignore if (!isProd) { throw e; } } } } }