From 609079eab97bbad5b09ba5d70e0b25136b331ac9 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Sat, 23 May 2026 00:58:12 +0400 Subject: [PATCH] Setup new local plugin manager --- packages/core/src/asset_manager/index.ts | 4 + .../model/TypeableCollection.ts | 10 + packages/core/src/editor/config/config.ts | 4 +- packages/core/src/editor/index.ts | 4 + packages/core/src/editor/model/Editor.ts | 6 + packages/core/src/editor/types.ts | 3 + packages/core/src/index.ts | 89 ++--- packages/core/src/plugin_manager/index.ts | 307 ++++++++++++++---- .../core/src/plugin_manager/model/Plugin.ts | 35 ++ .../core/src/plugin_manager/model/Plugins.ts | 6 + packages/core/src/plugin_manager/types.ts | 101 ++++++ packages/core/src/plugin_manager/utils.ts | 118 +++++++ packages/core/src/style_manager/index.ts | 4 + packages/core/src/trait_manager/index.ts | 7 + packages/core/test/specs/grapesjs/index.ts | 128 +++++++- 15 files changed, 700 insertions(+), 126 deletions(-) create mode 100644 packages/core/src/plugin_manager/model/Plugin.ts create mode 100644 packages/core/src/plugin_manager/model/Plugins.ts create mode 100644 packages/core/src/plugin_manager/types.ts create mode 100644 packages/core/src/plugin_manager/utils.ts diff --git a/packages/core/src/asset_manager/index.ts b/packages/core/src/asset_manager/index.ts index 523c3cf5a..ccca41ae5 100644 --- a/packages/core/src/asset_manager/index.ts +++ b/packages/core/src/asset_manager/index.ts @@ -285,6 +285,10 @@ export default class AssetManager extends ItemManagerModule= 0 && this.getTypes().splice(index, 1); + + return type; + }, }; export default TypeableCollection; diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index ebd83c217..8f1b000bc 100644 --- a/packages/core/src/editor/config/config.ts +++ b/packages/core/src/editor/config/config.ts @@ -15,7 +15,7 @@ import { RichTextEditorConfig } from '../../rich_text_editor/config/config'; import { SelectorManagerConfig } from '../../selector_manager/config/config'; import { StorageManagerConfig } from '../../storage_manager/config/config'; import { UndoManagerConfig } from '../../undo_manager/config'; -import { Plugin } from '../../plugin_manager'; +import { PluginInput } from '../../plugin_manager'; import { TraitManagerConfig } from '../../trait_manager/config/config'; import { CommandsConfig } from '../../commands/config/config'; import { StyleManagerConfig } from '../../style_manager/config/config'; @@ -48,7 +48,7 @@ export interface EditorConfig { * Array of plugins to execute on start. * @default [] */ - plugins?: (string | Plugin)[]; + plugins?: PluginInput[]; /** * Custom options for plugins diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 8074f02f4..bb5836c37 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -67,6 +67,7 @@ import ModalModule from '../modal_dialog'; import LayerManager from '../navigator'; import PageManager from '../pages'; import PanelManager from '../panels'; +import PluginManager from '../plugin_manager'; import ParserModule from '../parser'; import { CustomParserCss } from '../parser/config/config'; import RichTextEditorModule from '../rich_text_editor'; @@ -132,6 +133,9 @@ export default class Editor implements IBaseModule { get Commands(): CommandsModule { return this.em.Commands; } + get Plugins(): PluginManager { + return this.em.Plugins; + } get Keymaps(): KeymapsModule { return this.em.Keymaps; } diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 7c0b89815..52226f9a5 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -32,6 +32,7 @@ import CodeManagerModule from '../../code_manager'; import UndoManagerModule from '../../undo_manager'; import RichTextEditorModule from '../../rich_text_editor'; import CommandsModule from '../../commands'; +import PluginManager from '../../plugin_manager'; import StyleManager from '../../style_manager'; import CssRule from '../../css_composer/model/CssRule'; import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator'; @@ -68,6 +69,7 @@ const deps: (new (em: EditorModel) => IModule)[] = [ LayerManager, CanvasModule, CommandsModule, + PluginManager, BlockManager, DataSourceManager, ]; @@ -159,6 +161,10 @@ export default class EditorModel extends Model { return this.get('Commands'); } + get Plugins(): PluginManager { + return this.get('PluginManager'); + } + get Keymaps(): KeymapsModule { return this.get('Keymaps'); } diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 31691b7e5..dbe50ae24 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -12,6 +12,7 @@ import { ModalEvent, ModalEventCallback } from '../modal_dialog/types'; import { LayerEvent, LayerEventCallback } from '../navigator/types'; import { PageEvent, PagesEventCallback } from '../pages/types'; import { ParserEvent, ParserEventCallback } from '../parser/types'; +import { PluginEvent, PluginsEventCallback } from '../plugin_manager/types'; import { RichTextEditorEvent, RichTextEditorEventCallback } from '../rich_text_editor'; import { SelectorEvent, SelectorEventCallback } from '../selector_manager/types'; import type { ProjectData } from '../storage_manager'; @@ -41,6 +42,7 @@ type EditorBuiltInEvents = | LayerEvent | PageEvent | ParserEvent + | PluginEvent | StyleManagerEvent | StorageEvent | CanvasEvent @@ -109,6 +111,7 @@ export interface EditorEventCallbacks ModalEventCallback, PagesEventCallback, ParserEventCallback, + PluginsEventCallback, RichTextEditorEventCallback, SelectorEventCallback, StorageEventCallback, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4bb07a9cf..4f93c49b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,7 @@ import { isElement } from 'underscore'; import Editor from './editor'; import { EditorConfig } from './editor/config/config'; -import PluginManager, { Plugin, getPlugin, logPluginWarn } from './plugin_manager'; +import { PluginInput, legacyGlobalPlugins, usePlugin as pluginUsePlugin } from './plugin_manager'; import $ from './utils/cash-dom'; import polyfills from './utils/polyfills'; @@ -11,27 +11,15 @@ export interface InitEditorConfig extends EditorConfig { polyfills(); -const plugins = new PluginManager(); const editors: Editor[] = []; - -export const usePlugin =

| string>(plugin: P, opts?: P extends Plugin ? C : {}) => { - let pluginResult = getPlugin(plugin, plugins); - - return (editor: Editor) => { - if (pluginResult) { - pluginResult(editor, opts || {}); - } else { - logPluginWarn(editor, plugin as string); - } - }; -}; +export const usePlugin = pluginUsePlugin; export const grapesjs = { $, editors, - plugins, + plugins: legacyGlobalPlugins, usePlugin, @@ -71,16 +59,7 @@ export const grapesjs = { em.initModules(); // Load plugins - initConfig.plugins!.forEach((pluginId) => { - const plugin = getPlugin(pluginId, plugins); - const plgOptions = initConfig.pluginsOpts![pluginId as string] || {}; - - if (plugin) { - plugin(editor, plgOptions); - } else { - logPluginWarn(editor, pluginId as string); - } - }); + initConfig.plugins?.forEach((pluginInput) => editor.Plugins.add(pluginInput as PluginInput)); // Execute `onLoad` on modules once all plugins are initialized. // A plugin might have extended/added some custom type so this @@ -97,16 +76,16 @@ export const grapesjs = { * @deprecated Changed to CategoryProperties */ export type { CategoryProperties as BlockCategoryProperties } from './abstract/ModuleCategory'; -export type { ComponentDragEventProps } from './commands/view/ComponentDrag'; export type { CommandRegistryRun, CommandRegistryStop } from './commands/registry'; +export type { ComponentDragEventProps } from './commands/view/ComponentDrag'; // Exports for TS +export type { default as Categories } from './abstract/ModuleCategories'; +export type { default as Category } from './abstract/ModuleCategory'; export type { default as Asset } from './asset_manager/model/Asset'; export type { default as Assets } from './asset_manager/model/Assets'; export type { default as Block } from './block_manager/model/Block'; export type { default as Blocks } from './block_manager/model/Blocks'; -export type { default as Categories } from './abstract/ModuleCategories'; -export type { default as Category } from './abstract/ModuleCategory'; export type { default as Canvas } from './canvas/model/Canvas'; export type { default as CanvasSpot } from './canvas/model/CanvasSpot'; export type { default as CanvasSpots } from './canvas/model/CanvasSpots'; @@ -114,6 +93,30 @@ export type { default as Frame } from './canvas/model/Frame'; export type { default as Frames } from './canvas/model/Frames'; export type { default as CssRule } from './css_composer/model/CssRule'; export type { default as CssRules } from './css_composer/model/CssRules'; +export type { default as DataSourceManager } from './data_sources'; +export type { default as ComponentDataVariable } from './data_sources/model/ComponentDataVariable'; +export type { default as ComponentWithCollectionsState } from './data_sources/model/ComponentWithCollectionsState'; +export type { ComponentWithDataResolver } from './data_sources/model/ComponentWithDataResolver'; +export type { default as ComponentDataCondition } from './data_sources/model/conditional_variables/ComponentDataCondition'; +export type { + DataCondition, + DataConditionProps, + ExpressionProps, + LogicGroupProps, +} from './data_sources/model/conditional_variables/DataCondition'; +export type { default as ComponentDataCollection } from './data_sources/model/data_collection/ComponentDataCollection'; +export type { default as DataRecord } from './data_sources/model/DataRecord'; +export type { default as DataRecords } from './data_sources/model/DataRecords'; +export type { default as DataSource } from './data_sources/model/DataSource'; +export type { default as DataSources } from './data_sources/model/DataSources'; +export type { default as DataVariable } from './data_sources/model/DataVariable'; +export type { + DataBindingImportAction, + DataBindingImportContext, + DataBindingImportPolicy, + DataBindingImportSource, + DataBindingKind, +} from './data_sources/types'; export type { default as Device } from './device_manager/model/Device'; export type { default as Devices } from './device_manager/model/Devices'; export type { default as ComponentManager } from './dom_components'; @@ -133,47 +136,23 @@ export type { CustomParserCodeContext, CustomParserCodeFunction, HTMLParseResult, - ParsedNode, ParsedCssRule, + ParsedNode, } from './parser/types'; export type { default as Selector } from './selector_manager/model/Selector'; export type { default as Selectors } from './selector_manager/model/Selectors'; export type { default as State } from './selector_manager/model/State'; export type { default as Properties } from './style_manager/model/Properties'; export type { default as Property } from './style_manager/model/Property'; +export type { default as PropertyComposite } from './style_manager/model/PropertyComposite'; +export type { default as PropertyNumber } from './style_manager/model/PropertyNumber'; export type { default as PropertyRadio } from './style_manager/model/PropertyRadio'; export type { default as PropertySelect } from './style_manager/model/PropertySelect'; -export type { default as PropertyNumber } from './style_manager/model/PropertyNumber'; export type { default as PropertySlider } from './style_manager/model/PropertySlider'; -export type { default as PropertyComposite } from './style_manager/model/PropertyComposite'; export type { default as PropertyStack } from './style_manager/model/PropertyStack'; export type { default as Sector } from './style_manager/model/Sector'; export type { default as Sectors } from './style_manager/model/Sectors'; export type { default as Trait } from './trait_manager/model/Trait'; export type { default as Traits } from './trait_manager/model/Traits'; -export type { default as DataSourceManager } from './data_sources'; -export type { default as DataSources } from './data_sources/model/DataSources'; -export type { default as DataSource } from './data_sources/model/DataSource'; -export type { default as DataRecord } from './data_sources/model/DataRecord'; -export type { default as DataRecords } from './data_sources/model/DataRecords'; -export type { default as DataVariable } from './data_sources/model/DataVariable'; -export type { default as ComponentDataVariable } from './data_sources/model/ComponentDataVariable'; -export type { default as ComponentWithCollectionsState } from './data_sources/model/ComponentWithCollectionsState'; -export type { ComponentWithDataResolver } from './data_sources/model/ComponentWithDataResolver'; -export type { default as ComponentDataCollection } from './data_sources/model/data_collection/ComponentDataCollection'; -export type { default as ComponentDataCondition } from './data_sources/model/conditional_variables/ComponentDataCondition'; -export type { - DataCondition, - LogicGroupProps, - DataConditionProps, - ExpressionProps, -} from './data_sources/model/conditional_variables/DataCondition'; -export type { - DataBindingImportAction, - DataBindingImportContext, - DataBindingImportPolicy, - DataBindingImportSource, - DataBindingKind, -} from './data_sources/types'; export default grapesjs; diff --git a/packages/core/src/plugin_manager/index.ts b/packages/core/src/plugin_manager/index.ts index a5e505726..7db501184 100644 --- a/packages/core/src/plugin_manager/index.ts +++ b/packages/core/src/plugin_manager/index.ts @@ -1,83 +1,264 @@ -import { isString } from 'underscore'; -import Editor from '../editor'; -import { getGlobal } from '../utils/mixins'; +import { isFunction, isString } from 'underscore'; +import { ItemManagerModule, ModuleConfig } from '../abstract/Module'; +import BlocksEvents from '../block_manager/types'; +import { ComponentsEvents } from '../dom_components/types'; +import DeviceEvents from '../device_manager/types'; +import EditorModel from '../editor/model/Editor'; +import { KeymapsEvents } from '../keymaps/types'; +import { StyleManagerEvents } from '../style_manager/types'; +import PluginModel, { createPluginAdded } from './model/Plugin'; +import Plugins from './model/Plugins'; +import { + Plugin, + PluginAdded, + PluginCleanup, + PluginCleanupHandler, + PluginDescriptor, + PluginInput, + PluginOptions, + PluginWithMeta, + PluginsEvents, +} from './types'; +import { + addLegacyPlugin, + getLegacyPlugin, + getLegacyPlugins, + getPluginId, + getPlugin, + isPluginDescriptor, + isPluginFunction, + logPluginWarn, + legacyGlobalPlugins, + unwrapPluginMeta, + usePlugin, +} from './utils'; -type PluginOptions = Record; +export type { + Plugin, + PluginAdded, + PluginCleanup, + PluginCleanupHandler, + PluginDescriptor, + PluginInput, + PluginOptions, + PluginWithMeta, +}; +export { addLegacyPlugin, getLegacyPlugin, getLegacyPlugins, getPlugin, legacyGlobalPlugins, logPluginWarn, usePlugin }; -export type Plugin = (editor: Editor, config: T) => void; +type PluginTarget = string | Plugin | PluginModel; -const getPluginById = (pluginId: string, plugins: PluginManager) => { - let result = plugins.get(pluginId); +export default class PluginManager extends ItemManagerModule { + events = PluginsEvents; + storageKey = ''; - // Try to search in global context - if (!result) { - const wplg = (getGlobal() as any)[pluginId]; - result = wplg?.default || wplg; + constructor(em: EditorModel) { + super(em, 'PluginManager', new Plugins(), PluginsEvents, {}); } - return result; -}; + get editor() { + return this.em.getEditor(); + } -export const getPlugin = (plugin: string | Plugin, plugins: PluginManager) => { - return isString(plugin) - ? getPluginById(plugin, plugins) - : (plugin as unknown as { default: Plugin })?.default || plugin; -}; + private getConfigPluginOptions(id: string, sourcePlugin: string | Plugin) { + const pluginsOpts = this.editor.getConfig().pluginsOpts || {}; + const inlineKey = isFunction(sourcePlugin) ? sourcePlugin.toString() : ''; + return pluginsOpts[id] || (inlineKey ? pluginsOpts[inlineKey] || {} : {}); + } -export const logPluginWarn = (editor: Editor, plugin: string) => { - editor.getModel().logWarning(`Plugin ${plugin} not found`, { - context: 'plugins', - plugin, - }); -}; + private generatePluginId() { + return `gjs-plugin:${this._createId(8)}`; + } + + private resolvePluginId(input: string | Plugin, plugin: Plugin, explicitId?: string) { + return explicitId || (isString(input) ? input : '') || getPluginId(plugin) || this.generatePluginId(); + } + + private resolvePluginTarget(target: PluginTarget) { + if (target instanceof PluginModel) return target; + + if (isString(target)) return this.get(target); -export default class PluginManager { - plugins: Record = {}; - - /** - * Add new plugin. Plugins could not be overwritten - * @param {string} id Plugin ID - * @param {Function} plugin Function which contains all plugin logic - * @return {Function} The plugin function - * @deprecated Don't use named plugins, create plugins as simple functions. More about [Plugins](https://grapesjs.com/docs/modules/Plugins.html) - * @example - * PluginManager.add('some-plugin', function(editor) { - * editor.Commands.add('new-command', { - * run: function(editor, senderBtn){ - * console.log('Executed new-command'); - * } - * }) - * }); - */ - add(id: string, plugin: Plugin) { - const plg = this.get(id); - - if (plg) { - return plg; + const { plugin } = unwrapPluginMeta(target); + if (!isPluginFunction(plugin)) return; + + return this.getAll().find((item) => item.get('plugin') === plugin); + } + + private normalizePlugin(input: PluginInput, options: PluginOptions = {}) { + const descriptor = isPluginDescriptor(input) ? input : undefined; + const sourcePlugin = (descriptor ? descriptor.plugin : input) as string | Plugin; + const explicitId = descriptor?.id || ''; + const unwrapped = unwrapPluginMeta(sourcePlugin); + const resolvedPlugin = getPlugin(unwrapped.plugin); + let normalized; + + if (!resolvedPlugin || !isPluginFunction(resolvedPlugin)) { + const pluginId = isString(unwrapped.plugin) ? unwrapped.plugin : getPluginId(unwrapped.plugin) || explicitId; + if (explicitId) { + throw new Error(`Plugin ${explicitId} not found`); + } + logPluginWarn(this.editor, pluginId || 'unknown'); + } else { + const id = explicitId || this.resolvePluginId(sourcePlugin, resolvedPlugin); + normalized = { + id, + plugin: resolvedPlugin, + options: { + ...this.getConfigPluginOptions(id, sourcePlugin), + ...unwrapped.options, + ...options, + }, + }; } - // @ts-ignore - this.plugins[id] = plugin; + return normalized; + } + + private createAddedTracker() { + const added = createPluginAdded(); + const cleanup: PluginCleanup[] = []; + const { editor } = this; + const listeners: Array<[string, (...args: any[]) => void]> = []; + const listen = (event: string, callback: (...args: any[]) => void) => { + editor.on(event, callback); + listeners.push([event, callback]); + }; + + listen(BlocksEvents.add, (block: any) => { + const id = block.get?.('id'); + if (!id || added.blocks.includes(id)) return; + added.blocks.push(id); + cleanup.push(() => editor.Blocks.remove(id)); + }); + + listen(KeymapsEvents.add, (keymap: any) => { + const id = keymap.id; + if (!id || added.keymaps.includes(id)) return; + added.keymaps.push(id); + cleanup.push(() => editor.Keymaps.remove(id)); + }); + + listen(ComponentsEvents.typeAdd, (type: any) => { + const id = type?.id; + if (!id || added.componentTypes.includes(id)) return; + added.componentTypes.push(id); + cleanup.push(() => editor.Components.removeType(id)); + }); + + listen(DeviceEvents.add, (device: any) => { + const id = device?.id || device?.get?.('id'); + if (!id || added.devices.includes(id)) return; + added.devices.push(id); + cleanup.push(() => editor.Devices.remove(id)); + }); + listen(StyleManagerEvents.sectorAdd, (sector: any) => { + const id = sector?.id || sector?.get?.('id'); + if (!id || added.styleSectors.includes(id)) return; + added.styleSectors.push(id); + cleanup.push(() => editor.StyleManager.removeSector(id)); + }); + + listen(StyleManagerEvents.propertyAdd, (property: any) => { + const sectorId = property?.collection?.sector?.get?.('id'); + const id = property?.get?.('property') || property?.get?.('id'); + const alreadyAdded = added.styleProperties.some((item) => item.sectorId === sectorId && item.id === id); + if (!sectorId || !id || alreadyAdded) return; + added.styleProperties.push({ sectorId, id }); + cleanup.push(() => editor.StyleManager.removeProperty(sectorId, id)); + }); + + return { + added, + stop: () => listeners.forEach(([event, callback]) => editor.off(event, callback)), + cleanup: () => { + cleanup + .slice() + .reverse() + .forEach((cb) => cb()); + }, + }; + } + + add(input: PluginInput, options: PluginOptions = {}) { + const normalized = this.normalizePlugin(input, options); + if (!normalized) return; + + const existing = this.get(normalized.id); + if (existing) return existing; + + const tracking = this.createAddedTracker(); + let result: unknown; + + try { + result = normalized.plugin(this.editor, normalized.options); + } catch (error) { + tracking.stop(); + tracking.cleanup(); + throw error; + } + + tracking.stop(); + + const plugin = new PluginModel({ + id: normalized.id, + plugin: normalized.plugin, + options: normalized.options, + added: tracking.added, + cleanup: () => {}, + }); + + let cleaned = false; + const builtInCleanup = () => { + if (cleaned) return; + cleaned = true; + tracking.cleanup(); + }; + + const cleanup = () => { + if (cleaned) return; + const customCleanup = isFunction(result) ? result : null; + + if (customCleanup) { + customCleanup({ + cleanup: builtInCleanup, + editor: this.editor, + plugin, + }); + } else { + builtInCleanup(); + } + }; + + plugin.set('cleanup', cleanup); + this.all.add(plugin); return plugin; } - /** - * Returns plugin by ID - * @param {string} id Plugin ID - * @return {Function|undefined} Plugin - * @example - * var plugin = PluginManager.get('some-plugin'); - * plugin(editor); - */ - get(id: string): Plugin | undefined { - return this.plugins[id]; + get(id: string): PluginModel | undefined { + return this.all.get(id); } - /** - * Returns object with all plugins - */ getAll() { - return this.plugins; + return [...this.all.models] as PluginModel[]; + } + + remove(target: PluginTarget, opts: Record = {}) { + const plugin = this.resolvePluginTarget(target); + if (!plugin) return; + + const { em, events } = this; + const rm = () => { + plugin.get('cleanup')?.(); + this.all.remove(plugin, opts); + return plugin; + }; + + !opts.silent && em.trigger(events.removeBefore, plugin, rm, opts); + return !opts.abort ? rm() : undefined; + } + + destroy() { + this.__destroy(); } } diff --git a/packages/core/src/plugin_manager/model/Plugin.ts b/packages/core/src/plugin_manager/model/Plugin.ts new file mode 100644 index 000000000..83f1bf2c5 --- /dev/null +++ b/packages/core/src/plugin_manager/model/Plugin.ts @@ -0,0 +1,35 @@ +import { Model } from '../../common'; +import { Plugin as PluginFn, PluginAdded, PluginCleanup } from '../types'; + +export const createPluginAdded = (): PluginAdded => ({ + blocks: [], + commands: [], + keymaps: [], + componentTypes: [], + devices: [], + traitTypes: [], + styleTypes: [], + assetTypes: [], + styleSectors: [], + styleProperties: [], +}); + +export interface PluginProperties { + id: string; + plugin: PluginFn; + options: Record; + added: PluginAdded; + cleanup: PluginCleanup; +} + +export default class Plugin extends Model { + defaults(): PluginProperties { + return { + id: '', + plugin: (() => {}) as PluginFn, + options: {}, + added: createPluginAdded(), + cleanup: () => {}, + }; + } +} diff --git a/packages/core/src/plugin_manager/model/Plugins.ts b/packages/core/src/plugin_manager/model/Plugins.ts new file mode 100644 index 000000000..b7184c885 --- /dev/null +++ b/packages/core/src/plugin_manager/model/Plugins.ts @@ -0,0 +1,6 @@ +import { Collection } from '../../common'; +import Plugin from './Plugin'; + +export default class Plugins extends Collection {} + +Plugins.prototype.model = Plugin; diff --git a/packages/core/src/plugin_manager/types.ts b/packages/core/src/plugin_manager/types.ts new file mode 100644 index 000000000..9b86bdb27 --- /dev/null +++ b/packages/core/src/plugin_manager/types.ts @@ -0,0 +1,101 @@ +import type Editor from '../editor'; +import { + EventCallbackAdd, + EventCallbackAll, + EventCallbackRemove, + EventCallbackRemoveBefore, + EventCallbackUpdate, +} from '../common'; +import PluginModel from './model/Plugin'; + +export type PluginOptions = Record; + +export interface PluginAdded { + blocks: string[]; + commands: string[]; + keymaps: string[]; + componentTypes: string[]; + devices: string[]; + traitTypes: string[]; + styleTypes: string[]; + assetTypes: string[]; + styleSectors: string[]; + styleProperties: Array<{ sectorId: string; id: string }>; +} + +export type PluginCleanup = () => void; + +export type PluginCleanupHandler = (ctx: { cleanup: PluginCleanup; plugin: PluginModel }) => void; + +export type PluginResult = void | PluginCleanupHandler | object | null | undefined; + +export interface Plugin { + (editor: Editor, config: T): PluginResult; + __gjsPluginId?: string; +} + +export interface PluginDescriptor { + id: string; + plugin: Plugin; +} + +export interface PluginWithMeta extends Plugin { + __gjsPluginMeta?: { + plugin: string | Plugin; + options?: T; + id?: string; + }; +} + +export type PluginInput = string | Plugin | PluginDescriptor; + +export type PluginEvent = `${PluginsEvents}`; + +/**{START_EVENTS}*/ +export enum PluginsEvents { + /** + * @event `plugin:add` Plugin installed in the editor. + * @example + * editor.on('plugin:add', (plugin) => { ... }); + */ + add = 'plugin:add', + + /** + * @event `plugin:remove` Plugin removed from the editor. + * @example + * editor.on('plugin:remove', (plugin) => { ... }); + */ + remove = 'plugin:remove', + + /** + * @event `plugin:remove:before` Triggered before plugin removal. + * @example + * editor.on('plugin:remove:before', (plugin, remove, opts) => { ... }); + */ + removeBefore = 'plugin:remove:before', + + /** + * @event `plugin:update` Plugin updated. + * @example + * editor.on('plugin:update', (plugin, changes) => { ... }); + */ + update = 'plugin:update', + + /** + * @event `plugin` Catch-all event for plugin changes. + * @example + * editor.on('plugin', ({ event, model, ... }) => { ... }); + */ + all = 'plugin', +} +/**{END_EVENTS}*/ + +export interface PluginsEventCallback { + [PluginsEvents.add]: EventCallbackAdd; + [PluginsEvents.remove]: EventCallbackRemove; + [PluginsEvents.removeBefore]: EventCallbackRemoveBefore; + [PluginsEvents.update]: EventCallbackUpdate; + [PluginsEvents.all]: EventCallbackAll; +} + +export default PluginsEvents; diff --git a/packages/core/src/plugin_manager/utils.ts b/packages/core/src/plugin_manager/utils.ts new file mode 100644 index 000000000..45740fb58 --- /dev/null +++ b/packages/core/src/plugin_manager/utils.ts @@ -0,0 +1,118 @@ +import { isFunction, isString } from 'underscore'; +import type Editor from '../editor'; +import EditorModel from '../editor/model/Editor'; +import { getGlobal } from '../utils/mixins'; +import type { Plugin, PluginDescriptor, PluginOptions, PluginWithMeta } from './types'; + +const LEGACY_PLUGIN_ERROR = + 'grapesjs.plugins.add(...) is deprecated. Pass plugins directly via init({ plugins: [...] }) or use usePlugin(...).'; + +const legacyPlugins = new Map>(); + +export const isPluginFunction = (value: unknown): value is Plugin => isFunction(value); + +export const isPluginDescriptor = (value: unknown): value is PluginDescriptor => + !!value && typeof value === 'object' && !isFunction(value) && 'plugin' in value; + +export const unwrapPluginMeta = ( + plugin: string | Plugin, +): { plugin: string | Plugin; options: PluginOptions; id?: string } => { + let current = plugin; + const options: PluginOptions = {}; + let id: string | undefined; + + while (isPluginFunction(current) && (current as PluginWithMeta).__gjsPluginMeta) { + const meta = (current as PluginWithMeta).__gjsPluginMeta!; + current = meta.plugin; + Object.assign(options, meta.options || {}); + id = id || meta.id; + } + + return { plugin: current, options, id }; +}; + +export const getLegacyPlugin = (pluginId: string) => legacyPlugins.get(pluginId); + +export const getLegacyPlugins = () => legacyPlugins; + +export const clearLegacyPlugins = () => legacyPlugins.clear(); + +export const addLegacyPlugin = (id: string, plugin: Plugin) => { + console.error(LEGACY_PLUGIN_ERROR); + legacyPlugins.set(id, plugin as Plugin); + return plugin; +}; + +export const legacyGlobalPlugins = { + add: addLegacyPlugin, + get: getLegacyPlugin, + getAll: getLegacyPlugins, + clear: clearLegacyPlugins, +}; + +export const findLegacyPluginId = (plugin: Plugin) => { + let result = ''; + legacyPlugins.forEach((storedPlugin, id) => { + if (!result && storedPlugin === plugin) { + result = id; + } + }); + return result || undefined; +}; + +export const findGlobalPluginId = (plugin: Plugin) => { + const globalPlugins = getGlobal() as Record; + + return Object.keys(globalPlugins).find((id) => { + const globalPlugin = globalPlugins[id]; + return globalPlugin === plugin || globalPlugin?.default === plugin; + }); +}; + +export const getPluginId = (plugin: Plugin) => + plugin.__gjsPluginId || findLegacyPluginId(plugin) || findGlobalPluginId(plugin); + +export const getPluginById = (pluginId: string) => { + const legacy = getLegacyPlugin(pluginId); + + if (legacy) { + return legacy; + } + + const globalPlugin = (getGlobal() as any)[pluginId]; + return globalPlugin?.default || globalPlugin; +}; + +export const getPlugin = (plugin: string | Plugin) => { + const { plugin: unwrapped } = unwrapPluginMeta(plugin); + return isString(unwrapped) + ? getPluginById(unwrapped) + : (unwrapped as unknown as { default?: Plugin })?.default || unwrapped; +}; + +export const logPluginWarn = (editor: { getModel(): EditorModel }, plugin: string) => { + editor.getModel().logWarning(`Plugin ${plugin} not found`, { + context: 'plugins', + plugin, + }); +}; + +export const usePlugin =

| string>(plugin: P, opts?: P extends Plugin ? C : {}) => { + const wrapped = ((editor: Editor) => { + const pluginResult = getPlugin(plugin); + + if (pluginResult) { + pluginResult(editor, opts || {}); + } else { + logPluginWarn(editor as { getModel(): EditorModel }, plugin as string); + } + }) as PluginWithMeta; + + wrapped.__gjsPluginMeta = { + plugin, + options: (opts || {}) as Record, + id: typeof plugin === 'string' ? plugin : plugin.__gjsPluginId, + }; + + return wrapped; +}; diff --git a/packages/core/src/style_manager/index.ts b/packages/core/src/style_manager/index.ts index 04c59ac0c..3e8eb5380 100644 --- a/packages/core/src/style_manager/index.ts +++ b/packages/core/src/style_manager/index.ts @@ -636,6 +636,10 @@ export default class StyleManager extends ItemManagerModule< this.properties.addType(id, definition); } + removeType(id: string) { + return this.properties.removeType(id); + } + /** * Get type * @param {string} id Type ID diff --git a/packages/core/src/trait_manager/index.ts b/packages/core/src/trait_manager/index.ts index c089dae51..75017b93d 100644 --- a/packages/core/src/trait_manager/index.ts +++ b/packages/core/src/trait_manager/index.ts @@ -171,6 +171,13 @@ export default class TraitManager extends Module { this.types[name] = baseView.extend(methods); } + removeType(name: string) { + const type = this.getType(name); + if (!type) return; + delete this.types[name]; + return type; + } + /** * Get trait type * @param {string} name Type name diff --git a/packages/core/test/specs/grapesjs/index.ts b/packages/core/test/specs/grapesjs/index.ts index e8399e58e..6c0fcc190 100644 --- a/packages/core/test/specs/grapesjs/index.ts +++ b/packages/core/test/specs/grapesjs/index.ts @@ -3,6 +3,7 @@ import CssRule from '../../../src/css_composer/model/CssRule'; import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; import { EditorConfig } from '../../../src/editor/config/config'; import type { Plugin } from '../../../src/plugin_manager'; +import PluginsEvents from '../../../src/plugin_manager/types'; import { StorageManagerConfig } from '../../../src/storage_manager/config/config'; import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common'; @@ -61,12 +62,7 @@ describe('GrapesJS', () => { }); afterEach(() => { - var plugins = grapesjs.plugins.getAll(); - for (let id in plugins) { - if (plugins.hasOwnProperty(id)) { - delete plugins[id]; - } - } + grapesjs.plugins.clear(); }); test('Main object should be loaded', () => { @@ -501,6 +497,16 @@ describe('GrapesJS', () => { }); describe('Plugins', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + test('Adds new storage as plugin and store data there', async () => { (config.storageManager as StorageManagerConfig).type = storageId; config.plugins = [(e) => e.StorageManager.add(storageId, storageMock)]; @@ -593,6 +599,102 @@ describe('GrapesJS', () => { expect(editor.getModel().get('customValue')).toEqual('TEST'); }); + test('Descriptor plugin requires explicit id and accepts wrapped plugin', () => { + const inlinePlugin: TestPlugin = (edt, opts) => { + edt.getModel().set('customValue', opts.cVal); + }; + const editor = grapesjs.init({ + ...config, + plugins: [{ id: 'descriptor-plugin', plugin: usePlugin(inlinePlugin, { cVal: 'DESC' }) }], + }); + const plugin = editor.Plugins.get('descriptor-plugin')!; + expect(editor.getModel().get('customValue')).toEqual('DESC'); + expect(plugin.get('options')).toEqual({ cVal: 'DESC' }); + }); + + test('Plugin id is resolved from __gjsPluginId', () => { + const inlinePlugin: TestPlugin = () => {}; + inlinePlugin.__gjsPluginId = 'inline-plugin-id'; + const editor = grapesjs.init({ + ...config, + plugins: [inlinePlugin], + }); + + expect(editor.Plugins.get('inline-plugin-id')).toBeTruthy(); + }); + + test('Legacy plugin collector prints deprecation error on add', () => { + grapesjs.plugins.add('legacy-plugin', () => {}); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + test('Editor-scoped plugin manager stores plugins in the all collection', () => { + const editor = grapesjs.init({ + ...config, + plugins: [{ id: 'scoped-plugin', plugin: () => {} }], + }); + + expect(editor.Plugins.all.length).toBe(1); + expect(editor.Plugins.get('scoped-plugin')).toBe(editor.Plugins.all.at(0)); + }); + + test('Editor-scoped plugin manager triggers plugin events', () => { + const result: string[] = []; + const editor = grapesjs.init(config); + editor.on(PluginsEvents.add, (plugin) => result.push(`add:${plugin.id}`)); + editor.on(PluginsEvents.remove, (plugin) => result.push(`remove:${plugin.id}`)); + editor.Plugins.add({ id: 'evt-plugin', plugin: () => {} }); + editor.Plugins.remove('evt-plugin'); + expect(result).toEqual(['add:evt-plugin', 'remove:evt-plugin']); + }); + + test('Plugin manager removes tracked entities on cleanup', () => { + const editor = grapesjs.init({ + ...config, + plugins: [ + { + id: 'cleanup-plugin', + plugin: (edt) => { + edt.Blocks.add('cleanup-block', { label: 'Cleanup', content: '

cleanup
' }); + edt.Components.addType('cleanup-type', { model: { defaults: { tagName: 'div' } } }); + }, + }, + ], + }); + + expect(editor.Blocks.get('cleanup-block')).toBeTruthy(); + expect(editor.Components.getType('cleanup-type')).toBeTruthy(); + + editor.Plugins.remove('cleanup-plugin'); + + expect(editor.Blocks.get('cleanup-block')).toBeFalsy(); + expect(editor.Components.getType('cleanup-type')).toBeFalsy(); + }); + + test('Plugin manager runs custom cleanup once', () => { + const cleanupSpy = jest.fn(); + const editor = grapesjs.init({ + ...config, + plugins: [ + { + id: 'custom-cleanup-plugin', + plugin: (edt) => { + edt.Blocks.add('custom-cleanup-block', { label: 'Cleanup', content: '
cleanup
' }); + return ({ cleanup }) => { + cleanup(); + cleanupSpy(); + }; + }, + }, + ], + }); + + editor.Plugins.remove('custom-cleanup-plugin'); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(editor.Blocks.get('custom-cleanup-block')).toBeFalsy(); + }); + // Problems with iframe loading test('Init new editor with custom plugin overrides default commands', () => { var editor, @@ -624,6 +726,20 @@ describe('GrapesJS', () => { expect(varToTest).toEqual(optionValue); }); + test('Execute named plugin from plugin descriptor', () => { + let varToTest = ''; + const optionValue = 'TEST-DESC'; + const pluginName = 'descriptor-plugin-name'; + grapesjs.plugins.add(pluginName, (edt, opts = {}) => { + varToTest = opts.cVal || ''; + }); + grapesjs.init({ + ...config, + plugins: [{ id: pluginName, plugin: usePlugin(pluginName, { cVal: optionValue }) }], + }); + expect(varToTest).toEqual(optionValue); + }); + test('Execute inline plugin', () => { let varToTest = ''; const optionValue = 'TEST-inline';