From 9eab8869988e9a067b29281788eb94314e1eb161 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 25 May 2026 15:27:39 +0400 Subject: [PATCH] Add support for dynamic plugins (#6768) * Setup new local plugin manager * Clean up exports * Cleanup tracking * Cleanups * Cleanup plugins doc * Update findGlobalPluginId * Update plugin docs * Update plugin docs * Add ComponentMatcher to find methods * Update findType * Update old plugin manager tests * Up ignore files * Up TS --- .eslintrc.js | 2 +- .prettierignore | 1 + docs/api/component.md | 14 +- docs/modules/Plugins.md | 149 ++++------ packages/core/src/abstract/Module.ts | 4 +- packages/core/src/asset_manager/index.ts | 4 + .../src/dom_components/model/Component.ts | 57 +++- .../core/src/dom_components/model/types.ts | 5 + .../model/TypeableCollection.ts | 10 + packages/core/src/editor/config/config.ts | 22 +- 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 | 90 +++--- packages/core/src/plugin_manager/index.ts | 274 +++++++++++++----- .../core/src/plugin_manager/model/Plugin.ts | 26 ++ .../core/src/plugin_manager/model/Plugins.ts | 6 + packages/core/src/plugin_manager/types.ts | 112 +++++++ packages/core/src/plugin_manager/utils.ts | 114 ++++++++ packages/core/src/style_manager/index.ts | 4 + packages/core/src/trait_manager/index.ts | 7 + .../specs/dom_components/model/Component.ts | 98 +++++++ packages/core/test/specs/grapesjs/index.ts | 129 ++++++++- .../core/test/specs/plugin_manager/index.js | 27 +- 24 files changed, 912 insertions(+), 256 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/.eslintrc.js b/.eslintrc.js index 9e0de5ac0..e94e33248 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,5 +52,5 @@ module.exports = { 'max-len': ['error', { code: 300 }], 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }], }, - ignorePatterns: ['*/docs/api/*', 'dist', 'packages/cli/src/template/**/*.*', '*/locale/*', 'stats.json'], + ignorePatterns: ['*/docs/api/*', 'dist', 'private', 'packages/cli/src/template/**/*.*', '*/locale/*', 'stats.json'], }; diff --git a/.prettierignore b/.prettierignore index 2a75b23ed..4f9ca3b24 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ docs/**/*.md dist/ +private/ pnpm-lock.yaml packages/cli/src/template/**/*.* **/locale/** diff --git a/docs/api/component.md b/docs/api/component.md index 5d8c281f3..9b6e4086c 100644 --- a/docs/api/component.md +++ b/docs/api/component.md @@ -177,13 +177,17 @@ also before rendering the component ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function +* `opts` **[Object][2]?** Search options +* `opts.max` **[Number][12]?** Maximum number of matches before exiting ### Examples ```javascript const allImages = component.findType('image'); console.log(allImages[0]) // prints the first found component + +const someComponents = component.findType((cmp) => cmp.getType() === 'something', { max: 2 }); ``` Returns **[Array][5]\** @@ -195,7 +199,7 @@ If no component is found, it returns `undefined`. ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function ### Examples @@ -204,6 +208,8 @@ const image = component.findFirstType('image'); if (image) { console.log(image); } + +const firstImage = component.findFirstType((cmp) => cmp.is('image')); ``` Returns **(Component | [undefined][11])** @@ -234,13 +240,15 @@ also before rendering the component ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function ### Examples ```javascript const Section = component.closestType('section'); console.log(Section); + +const namedSection = component.closestType((cmp) => cmp.getName() === 'Section'); ``` Returns **Component** Found component, otherwise `undefined` diff --git a/docs/modules/Plugins.md b/docs/modules/Plugins.md index 18b541d34..5c0918acf 100644 --- a/docs/modules/Plugins.md +++ b/docs/modules/Plugins.md @@ -47,82 +47,10 @@ const editor = grapesjs.init({ }); ``` - - ## Plugins with options It's also possible to pass custom parameters to plugins in to make them more flexible. - - ```js const myPluginWithOptions = (editor, options) => { console.log(options); @@ -140,19 +68,6 @@ const editor = grapesjs.init({ }); ``` - - ## Usage with TS If you're using TypeScript, for a better type safety, we recommend using the `usePlugin` helper. @@ -179,6 +94,70 @@ grapesjs.init({ }); ``` +## Dynamic plugin management + +::: warning +Available from GrapesJS v0.23.1 +::: + +Plugins can also be added and removed dynamically at runtime via `editor.Plugins`. + +This is useful when: + +- you want to enable/disable editor features on demand +- you need to inspect which plugins are currently active +- you want automatic cleanup of editor-level registrations added by a plugin + +```ts +const editor = grapesjs.init({ + // ... + plugins: [ + // Load plugin on init... + { id: 'my-plugin-1', plugin: usePlugin(myPlugin, { opt1: 'A' }) } + ], +}); + +// ... or add it dynamically +const plugin = editor.Plugins.add({ + id: 'my-plugin-2', + plugin: usePlugin(myPlugin, { opt1: 'A' }), +}); + +const hasPlugin = !!editor.Plugins.get('my-plugin-2'); +editor.Plugins.getAll(); +editor.Plugins.remove('my-plugin-2'); +``` + +GrapesJS stores active plugins inside `editor.Plugins` module, so the plugin can be identified and removed later. + +For this reason, the object descriptor form requires an explicit `id`. + +```ts +editor.Plugins.add({ + id: 'my-plugin', + plugin: usePlugin(myPlugin, { opt1: 'A' }), +}); +``` + +When possible, GrapesJS also tracks editor-level entities added during plugin execution and removes them automatically when the plugin is removed. This currently applies to runtime/editor configuration features such as blocks, component types, keymaps, etc. + +The automatic cleanup is intentionally limited to editor-level registrations. It does not remove project data such as pages, components already added to the canvas, or any other persisted content. + +If a plugin needs additional teardown logic, it can return a cleanup function. + +```ts +const myPlugin: Plugin = (editor) => { + const interval = setInterval(() => { + // ... + }, 1000); + + return ({ cleanup }) => { + cleanup(); + clearInterval(interval); + }; +}; +``` + ## Boilerplate For fast plugin development, we highly recommend using [grapesjs-cli](https://github.com/GrapesJS/cli) which helps to avoid the hassle of setting up all the dependencies and configurations for development and building (no need to touch Webpack or Babel configurations). For more information check the repository. diff --git a/packages/core/src/abstract/Module.ts b/packages/core/src/abstract/Module.ts index 4d8f4d06e..7eda2262e 100644 --- a/packages/core/src/abstract/Module.ts +++ b/packages/core/src/abstract/Module.ts @@ -307,9 +307,9 @@ export abstract class ItemManagerModule< __onAllEvent() {} - _createId(len = 16) { + _createId(len = 16, increase?: number) { const all = this.getAll(); - const ln = all.length + len; + const ln = (increase ?? all.length) + len; const allMap = this.getAllMap(); let id; 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; } +const getComponentMatcher = (query: ComponentMatcher) => (isString(query) ? (cmp: Component) => cmp.is(query) : query); + +const getComponentFindMax = ({ max }: ComponentFindOptions = {}) => + typeof max === 'number' && isFinite(max) && max > 0 ? Math.max(1, Math.floor(max)) : undefined; + const escapeRegExp = (str: string) => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); }; @@ -620,19 +627,40 @@ export default class Component extends StyleableModel { * Find all inner components by component type. * The advantage of this method over `find` is that you can use it * also before rendering the component - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function + * @param {Object} [opts={}] Search options + * @param {Number} [opts.max] Maximum number of matches before exiting * @returns {Array} * @example * const allImages = component.findType('image'); * console.log(allImages[0]) // prints the first found component + * const someComponents = component.findType((cmp) => cmp.getType() === 'something', { max: 2 }); */ - findType(type: string) { + findType(query: ComponentMatcher, opts: ComponentFindOptions = {}) { const result: Component[] = []; - const find = (components: Components) => - components.forEach((item) => { - item.is(type) && result.push(item); - find(item.components()); - }); + const matcher = getComponentMatcher(query); + const max = getComponentFindMax(opts); + const find = (components: Components) => { + const { models } = components; + + for (let i = 0; i < models.length; i++) { + const item = models[i]; + if (matcher(item)) { + result.push(item); + + if (max && result.length >= max) { + return true; + } + } + + if (find(item.components())) { + return true; + } + } + + return false; + }; + find(this.components()); return result; } @@ -640,16 +668,17 @@ export default class Component extends StyleableModel { /** * Find the first inner component by component type. * If no component is found, it returns `undefined`. - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function * @returns {Component|undefined} * @example * const image = component.findFirstType('image'); * if (image) { * console.log(image); * } + * const firstImage = component.findFirstType((cmp) => cmp.is('image')); */ - findFirstType(type: string): Component | undefined { - return this.findType(type).at(0); + findFirstType(query: ComponentMatcher): Component | undefined { + return this.findType(query, { max: 1 }).at(0); } /** @@ -670,16 +699,18 @@ export default class Component extends StyleableModel { * Find the closest parent component by its type. * The advantage of this method over `closest` is that you can use it * also before rendering the component - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function * @returns {Component} Found component, otherwise `undefined` * @example * const Section = component.closestType('section'); * console.log(Section); + * const namedSection = component.closestType((cmp) => cmp.getName() === 'Section'); */ - closestType(type: string) { + closestType(query: ComponentMatcher) { + const matcher = getComponentMatcher(query); let parent = this.parent(); - while (parent && !parent.is(type)) { + while (parent && !matcher(parent)) { parent = parent.parent(); } diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 1ca71d09c..63689e747 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -17,6 +17,11 @@ import { ParsedNode } from '../../parser/types'; export type DragMode = 'translate' | 'absolute' | ''; export type DraggableDroppableFn = (source: Component, target: Component, index?: number) => boolean | void; +export type ComponentMatcher = string | ((cmp: Component) => boolean); + +export interface ComponentFindOptions { + max?: number; +} export interface AddComponentsOption extends AddOptions, OptionAsDocument {} diff --git a/packages/core/src/domain_abstract/model/TypeableCollection.ts b/packages/core/src/domain_abstract/model/TypeableCollection.ts index ec216b801..9af02f0c4 100644 --- a/packages/core/src/domain_abstract/model/TypeableCollection.ts +++ b/packages/core/src/domain_abstract/model/TypeableCollection.ts @@ -142,6 +142,16 @@ const TypeableCollection = { this.getTypes().unshift(definition); } }, + + removeType(id) { + const type = this.getType(id); + if (!type) return; + + const index = this.getTypes().indexOf(type); + index >= 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..db5857aee 100644 --- a/packages/core/src/editor/config/config.ts +++ b/packages/core/src/editor/config/config.ts @@ -2,28 +2,28 @@ import { AssetManagerConfig } from '../../asset_manager/config/config'; import { BlockManagerConfig } from '../../block_manager/config/config'; import { CanvasConfig } from '../../canvas/config/config'; import { CodeManagerConfig } from '../../code_manager/config/config'; +import { CssGeneratorBuildOptions } from '../../code_manager/model/CssGenerator'; +import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator'; +import { CommandsConfig } from '../../commands/config/config'; +import { ObjectAny } from '../../common'; import { CssComposerConfig } from '../../css_composer/config/config'; +import type { DataSourcesConfig } from '../../data_sources/config/config'; import { DeviceManagerConfig } from '../../device_manager/config/config'; +import { DomComponentsConfig } from '../../dom_components/config/config'; import { I18nConfig } from '../../i18n/config'; +import { KeymapsConfig } from '../../keymaps/config'; import { ModalConfig } from '../../modal_dialog/config/config'; import { LayerManagerConfig } from '../../navigator/config/config'; -import { KeymapsConfig } from '../../keymaps/config'; import { PageManagerConfig } from '../../pages/types'; import { PanelsConfig } from '../../panels/config/config'; import { ParserConfig } from '../../parser/config/config'; +import { PluginInput } from '../../plugin_manager/types'; 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 { TraitManagerConfig } from '../../trait_manager/config/config'; -import { CommandsConfig } from '../../commands/config/config'; import { StyleManagerConfig } from '../../style_manager/config/config'; -import { DomComponentsConfig } from '../../dom_components/config/config'; -import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator'; -import { CssGeneratorBuildOptions } from '../../code_manager/model/CssGenerator'; -import { ObjectAny } from '../../common'; -import type { DataSourcesConfig } from '../../data_sources/config/config'; +import { TraitManagerConfig } from '../../trait_manager/config/config'; +import { UndoManagerConfig } from '../../undo_manager/config'; import { ColorPickerOptions } from '../../utils/ColorPicker'; export interface EditorConfig { @@ -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..4154d3e5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,8 @@ import { isElement } from 'underscore'; import Editor from './editor'; import { EditorConfig } from './editor/config/config'; -import PluginManager, { Plugin, getPlugin, logPluginWarn } from './plugin_manager'; +import { PluginInput } from './plugin_manager/types'; +import { legacyGlobalPlugins, usePlugin as pluginUsePlugin } from './plugin_manager/utils'; import $ from './utils/cash-dom'; import polyfills from './utils/polyfills'; @@ -11,27 +12,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 +60,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 +77,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 +94,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 +137,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..632d5aa26 100644 --- a/packages/core/src/plugin_manager/index.ts +++ b/packages/core/src/plugin_manager/index.ts @@ -1,83 +1,223 @@ -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 DeviceEvents from '../device_manager/types'; +import { ComponentsEvents } from '../dom_components/types'; +import EditorModel from '../editor/model/Editor'; +import type { EditorEvent, EditorEventCallbacks } from '../editor/types'; +import { KeymapsEvents } from '../keymaps/types'; +import { StyleManagerEvents } from '../style_manager/types'; +import PluginModel, { createPluginAdded } from './model/Plugin'; +import Plugins from './model/Plugins'; +import { Plugin, PluginCleanup, PluginInput, PluginItem, PluginOptions, PluginsEvents, PluginTarget } from './types'; +import { getPlugin, getPluginId, isPluginDescriptor, isPluginFunction, logPluginWarn, unwrapPluginMeta } from './utils'; -type PluginOptions = Record; +export default class PluginManager extends ItemManagerModule { + events = PluginsEvents; + storageKey = ''; -export type Plugin = (editor: Editor, config: T) => void; + constructor(em: EditorModel) { + super(em, 'PluginManager', new Plugins(), PluginsEvents, {}); + } + + get editor() { + return this.em.getEditor(); + } + + 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] || {} : {}); + } -const getPluginById = (pluginId: string, plugins: PluginManager) => { - let result = plugins.get(pluginId); + private generatePluginId() { + return `gjs-plugin:${this._createId(16, 0)}`; + } - // Try to search in global context - if (!result) { - const wplg = (getGlobal() as any)[pluginId]; - result = wplg?.default || wplg; + private resolvePluginId(input: string | Plugin, plugin: Plugin, explicitId?: string) { + return explicitId || (isString(input) ? input : '') || getPluginId(plugin) || this.generatePluginId(); } - return result; -}; - -export const getPlugin = (plugin: string | Plugin, plugins: PluginManager) => { - return isString(plugin) - ? getPluginById(plugin, plugins) - : (plugin as unknown as { default: Plugin })?.default || plugin; -}; - -export const logPluginWarn = (editor: Editor, plugin: string) => { - editor.getModel().logWarning(`Plugin ${plugin} not found`, { - context: 'plugins', - plugin, - }); -}; - -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; + private resolvePluginTarget(target: PluginTarget) { + if (target instanceof PluginModel) return target; + + if (isString(target)) return this.get(target); + + const { plugin } = unwrapPluginMeta(target as Plugin); + 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<[EditorEvent, (...args: any[]) => void]> = []; + const listen = ( + event: E, + callback: (...args: EditorEventCallbacks[E]) => void, + ) => { + editor.on(event, callback as any); + listeners.push([event, callback]); + }; + + listen(BlocksEvents.add, (block) => { + 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) => { + 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) => { + 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) => { + const 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) => { + const id = sector.get('id'); + if (!id || added.styleSectors.includes(id)) return; + added.styleSectors.push(id); + cleanup.push(() => editor.StyleManager.removeSector(id)); + }); + return { + added, + stop: () => listeners.forEach(([event, callback]) => editor.off(event, callback)), + cleanup: () => { + cleanup + .slice() + .reverse() + .forEach((cb) => cb()); + }, + }; + } + + add(input: PluginInput, options: PluginOptions = {}): PluginItem | undefined { + 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): PluginItem | undefined { + return this.all.get(id); + } + + getAll(): PluginItem[] { + return [...this.all.models]; + } + + remove(target: PluginTarget, opts: Record = {}): PluginItem | undefined { + 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; } - /** - * Returns object with all plugins - */ - getAll() { - return this.plugins; + 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..19f25fff1 --- /dev/null +++ b/packages/core/src/plugin_manager/model/Plugin.ts @@ -0,0 +1,26 @@ +import { Model } from '../../common'; +import type { PluginItemProps, PluginAdded } from '../types'; + +export const createPluginAdded = (): PluginAdded => ({ + blocks: [], + commands: [], + keymaps: [], + componentTypes: [], + devices: [], + traitTypes: [], + styleTypes: [], + assetTypes: [], + styleSectors: [], +}); + +export default class PluginModel extends Model { + defaults(): PluginItemProps { + return { + id: '', + plugin: (() => {}) as PluginItemProps['plugin'], + 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..0e92db762 --- /dev/null +++ b/packages/core/src/plugin_manager/model/Plugins.ts @@ -0,0 +1,6 @@ +import { Collection } from '../../common'; +import PluginModel from './Plugin'; + +export default class Plugins extends Collection {} + +Plugins.prototype.model = PluginModel; diff --git a/packages/core/src/plugin_manager/types.ts b/packages/core/src/plugin_manager/types.ts new file mode 100644 index 000000000..66d0ea6c6 --- /dev/null +++ b/packages/core/src/plugin_manager/types.ts @@ -0,0 +1,112 @@ +import { + EventCallbackAdd, + EventCallbackAll, + EventCallbackRemove, + EventCallbackRemoveBefore, + EventCallbackUpdate, + Model, +} from '../common'; +import type Editor from '../editor'; + +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[]; +} + +export type PluginCleanup = () => 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 interface PluginItemProps { + id: string; + plugin: Plugin; + options: Record; + added: PluginAdded; + cleanup: PluginCleanup; +} + +export type PluginItem = Model; + +export type PluginTarget = string | Plugin | PluginItem; + +export type PluginCleanupHandler = (ctx: { cleanup: PluginCleanup; plugin: PluginItem }) => void; + +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..3a92544b2 --- /dev/null +++ b/packages/core/src/plugin_manager/utils.ts @@ -0,0 +1,114 @@ +import { isFunction, isString } from 'underscore'; +import type Editor from '../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); + 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) => { + try { + const globalPlugin = globalPlugins[id]; + return globalPlugin === plugin || globalPlugin?.default === plugin; + } catch (error) { + return false; + } + }); +}; + +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): Plugin | undefined => { + const { plugin: unwrapped } = unwrapPluginMeta(plugin); + return isString(unwrapped) + ? getPluginById(unwrapped) + : (unwrapped as unknown as { default?: Plugin })?.default || unwrapped; +}; + +export const logPluginWarn = (editor: Editor, plugin: string) => { + editor.getModel().logWarning(`Plugin ${plugin} not found`, { context: 'plugins', plugin }); +}; + +export const usePlugin =

| string>(plugin: P, opts?: P extends Plugin ? C : {}) => { + const options = opts || {}; + const wrapped: PluginWithMeta = (editor: Editor) => { + const pluginResult = getPlugin(plugin); + + if (pluginResult) { + pluginResult(editor, options); + } else { + logPluginWarn(editor, plugin as string); + } + }; + + const id = typeof plugin === 'string' ? plugin : plugin.__gjsPluginId; + wrapped.__gjsPluginMeta = { id, plugin, options }; + + 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/dom_components/model/Component.ts b/packages/core/test/specs/dom_components/model/Component.ts index ef1694663..5f984efac 100644 --- a/packages/core/test/specs/dom_components/model/Component.ts +++ b/packages/core/test/specs/dom_components/model/Component.ts @@ -273,6 +273,63 @@ describe('Component', () => { expect(result.class).toEqual(undefined); }); + test('findType returns all matching components in depth-first order', () => { + const image1 = new ComponentImage({}, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const text = new ComponentText({}, compOpts); + const image2 = new ComponentImage({}, compOpts); + + group.append([text, image2]); + obj.append([image1, group]); + + expect(obj.findType('image')).toEqual([image1, image2]); + }); + + test('findType accepts a predicate matcher', () => { + const target1 = new Component({ type: 'something' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const target2 = new Component({ type: 'something' }, compOpts); + const text = new ComponentText({}, compOpts); + + group.append([text, target2]); + obj.append([target1, group]); + + expect(obj.findType((cmp) => cmp.getType() === 'something')).toEqual([target1, target2]); + }); + + test('findType supports max occurrences', () => { + const target1 = new Component({ type: 'something' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const target2 = new Component({ type: 'something' }, compOpts); + const target3 = new Component({ type: 'something' }, compOpts); + + group.append(target2); + obj.append([target1, group, target3]); + + expect(obj.findType((cmp) => cmp.getType() === 'something', { max: 2 })).toEqual([target1, target2]); + }); + + test('findType stops traversing once max occurrences are reached', () => { + const target = new Component({ type: 'something' }, compOpts); + const nested = new Component({ type: 'nested' }, compOpts); + const sibling = new Component({ type: 'other' }, compOpts); + let calls = 0; + + target.append(nested); + obj.append([target, sibling]); + + const result = obj.findType( + (cmp) => { + calls++; + return cmp.getType() === 'something'; + }, + { max: 1 }, + ); + + expect(result).toEqual([target]); + expect(calls).toBe(1); + }); + test('findFirstType returns first component of specified type', () => { const image1 = new ComponentImage({}, compOpts); const text = new ComponentText({}, compOpts); @@ -299,6 +356,47 @@ describe('Component', () => { expect(result).toBeUndefined(); }); + test('findFirstType accepts a predicate matcher', () => { + const text = new ComponentText({}, compOpts); + const image = new ComponentImage({}, compOpts); + + obj.append([text, image]); + + expect(obj.findFirstType((cmp) => cmp.is('image'))).toBe(image); + }); + + test('findFirstType returns undefined for a missing predicate match', () => { + const text = new ComponentText({}, compOpts); + + obj.append(text); + + expect(obj.findFirstType((cmp) => cmp.is('image'))).toBeUndefined(); + }); + + test('closestType accepts a predicate matcher', () => { + const section = new Component({ type: 'section' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const image = new ComponentImage({}, compOpts); + + group.append(image); + section.append(group); + obj.append(section); + + expect(image.closestType((cmp) => cmp.getType() === 'section')).toBe(section); + }); + + test('closestType still accepts a string matcher', () => { + const section = new Component({ type: 'section' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const image = new ComponentImage({}, compOpts); + + group.append(image); + section.append(group); + obj.append(section); + + expect(image.closestType('section')).toBe(section); + }); + test('setAttributes', () => { obj.setAttributes({ id: 'test', diff --git a/packages/core/test/specs/grapesjs/index.ts b/packages/core/test/specs/grapesjs/index.ts index e8399e58e..d04c2a82a 100644 --- a/packages/core/test/specs/grapesjs/index.ts +++ b/packages/core/test/specs/grapesjs/index.ts @@ -2,7 +2,7 @@ import grapesjs, { Component, Editor, usePlugin } from '../../../src'; 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, { Plugin } from '../../../src/plugin_manager/types'; import { StorageManagerConfig } from '../../../src/storage_manager/config/config'; import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common'; @@ -61,12 +61,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 +496,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 +598,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 +725,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'; diff --git a/packages/core/test/specs/plugin_manager/index.js b/packages/core/test/specs/plugin_manager/index.js index ae5940232..2c6396df5 100644 --- a/packages/core/test/specs/plugin_manager/index.js +++ b/packages/core/test/specs/plugin_manager/index.js @@ -1,18 +1,23 @@ -import PluginManager from 'plugin_manager'; +import { setupTestEditor } from '../../common'; describe('PluginManager', () => { describe('Main', () => { - var obj; - var val; - var testPlugin = (e) => { - val = e; + let editor; + let obj; + let val; + const testPlugin = (ed) => { + val = ed; }; beforeEach(() => { - obj = new PluginManager(); + val = null; + ({ editor } = setupTestEditor()); + obj = editor.Plugins; }); afterEach(() => { + editor?.destroy(); + editor = null; obj = null; }); @@ -21,19 +26,17 @@ describe('PluginManager', () => { }); test('No plugins inside', () => { - expect(obj.getAll()).toEqual({}); + expect(obj.getAll()).toEqual([]); }); test('Add new plugin', () => { - obj.add('test', testPlugin); + obj.add({ id: 'test', plugin: testPlugin }); expect(obj.get('test')).toBeTruthy(); }); test('Added plugin is working', () => { - obj.add('test', testPlugin); - var plugin = obj.get('test'); - plugin('tval'); - expect(val).toEqual('tval'); + obj.add({ id: 'test', plugin: testPlugin }); + expect(val).toBe(editor); }); }); });