Browse Source

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
release-v0.23.1-rc.0
Artur Arseniev 4 months ago
committed by GitHub
parent
commit
9eab886998
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .eslintrc.js
  2. 1
      .prettierignore
  3. 14
      docs/api/component.md
  4. 149
      docs/modules/Plugins.md
  5. 4
      packages/core/src/abstract/Module.ts
  6. 4
      packages/core/src/asset_manager/index.ts
  7. 57
      packages/core/src/dom_components/model/Component.ts
  8. 5
      packages/core/src/dom_components/model/types.ts
  9. 10
      packages/core/src/domain_abstract/model/TypeableCollection.ts
  10. 22
      packages/core/src/editor/config/config.ts
  11. 4
      packages/core/src/editor/index.ts
  12. 6
      packages/core/src/editor/model/Editor.ts
  13. 3
      packages/core/src/editor/types.ts
  14. 90
      packages/core/src/index.ts
  15. 274
      packages/core/src/plugin_manager/index.ts
  16. 26
      packages/core/src/plugin_manager/model/Plugin.ts
  17. 6
      packages/core/src/plugin_manager/model/Plugins.ts
  18. 112
      packages/core/src/plugin_manager/types.ts
  19. 114
      packages/core/src/plugin_manager/utils.ts
  20. 4
      packages/core/src/style_manager/index.ts
  21. 7
      packages/core/src/trait_manager/index.ts
  22. 98
      packages/core/test/specs/dom_components/model/Component.ts
  23. 129
      packages/core/test/specs/grapesjs/index.ts
  24. 27
      packages/core/test/specs/plugin_manager/index.js

2
.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'],
};

1
.prettierignore

@ -1,5 +1,6 @@
docs/**/*.md
dist/
private/
pnpm-lock.yaml
packages/cli/src/template/**/*.*
**/locale/**

14
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]\<Component>**&#x20;
@ -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])**&#x20;
@ -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`

149
docs/modules/Plugins.md

@ -47,82 +47,10 @@ const editor = grapesjs.init({
});
```
<!--
## Named plugin
If you're distributing your plugin globally, you may want to make a named plugin. To keep thing cleaner, so you'll probably get a similar structure:
```
/your/path/to/grapesjs.min.js
/your/path/to/grapesjs-plugin.js
```
**Important:** The order that you load files matters. GrapesJS has to be loaded before the plugin. This sets up the `grapejs` global variable.
So, in your `grapesjs-plugin.js` file:
```js
export default grapesjs.plugins.add('my-plugin-name', (editor, options) => {
/*
* Here you should rely on GrapesJS APIs, so check 'API Reference' for more info
* For example, you could do something like this to add some new command:
*
* editor.Commands.add(...);
*/
})
```
The name `my-plugin-name` is an ID of your plugin and you'll use it to tell your editor to grab it.
Here is a complete generic example:
```html
<script src="http://code.jquery.com/jquery-2.2.0.min.js"></script>
<link rel="stylesheet" href="path/to/grapes.min.css">
<script src="path/to/grapes.min.js"></script>
<script src="path/to/grapesjs-plugin.js"></script>
<div id="gjs"></div>
<script type="text/javascript">
var editor = grapesjs.init({
container : '#gjs',
plugins: ['my-plugin-name']
});
</script>
```
-->
## Plugins with options
It's also possible to pass custom parameters to plugins in to make them more flexible.
<!--
```js
var editor = grapesjs.init({
container : '#gjs',
plugins: ['my-plugin-name'],
pluginsOpts: {
'my-plugin-name': {
customField: 'customValue'
}
}
});
```
Inside you plugin you'll get those options via `options` argument
```js
export default grapesjs.plugins.add('my-plugin-name', (editor, options) => {
console.log(options);
//{ customField: 'customValue' }
})
```
This also works with plugins that aren't named.
-->
```js
const myPluginWithOptions = (editor, options) => {
console.log(options);
@ -140,19 +68,6 @@ const editor = grapesjs.init({
});
```
<!--
## Named Plugins vs Non-Named Plugins
When you use a named plugin, then that name must be unique across all other plugins.
```js
grapesjs.plugins.add('my-plugin-name', fn);
```
In this example, the plugin name is `my-plugin-name` and can't be used by other plugins. To avoid namespace restrictions use basic plugins that are purely functional.
-->
## 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.

4
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;

4
packages/core/src/asset_manager/index.ts

@ -285,6 +285,10 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
this.getAll().addType(id, definition);
}
removeType(id: string) {
return this.getAll().removeType(id);
}
/**
* Get type
* @param {string} id Type ID

57
packages/core/src/dom_components/model/Component.ts

@ -62,6 +62,8 @@ import {
ComponentAdd,
ComponentDefinition,
ComponentDefinitionDefined,
ComponentFindOptions,
ComponentMatcher,
ComponentOptions,
ComponentProperties,
DragMode,
@ -79,6 +81,11 @@ export interface CheckIdOptions {
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
}
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<ComponentProperties> {
* 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<Component>}
* @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<ComponentProperties> {
/**
* 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<ComponentProperties> {
* 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();
}

5
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 {}

10
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;

22
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<any>)[];
plugins?: PluginInput[];
/**
* Custom options for plugins

4
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<EditorConfig> {
get Commands(): CommandsModule {
return this.em.Commands;
}
get Plugins(): PluginManager {
return this.em.Plugins;
}
get Keymaps(): KeymapsModule {
return this.em.Keymaps;
}

6
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');
}

3
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,

90
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 = <P extends Plugin<any> | string>(plugin: P, opts?: P extends Plugin<infer C> ? 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;

274
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<string, any>;
export default class PluginManager extends ItemManagerModule<ModuleConfig, Plugins> {
events = PluginsEvents;
storageKey = '';
export type Plugin<T extends PluginOptions = {}> = (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<any>) {
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<any>, plugin: Plugin<any>, explicitId?: string) {
return explicitId || (isString(input) ? input : '') || getPluginId(plugin) || this.generatePluginId();
}
return result;
};
export const getPlugin = (plugin: string | Plugin<any>, plugins: PluginManager) => {
return isString(plugin)
? getPluginById(plugin, plugins)
: (plugin as unknown as { default: Plugin<any> })?.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<string, Plugin> = {};
/**
* 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<T extends PluginOptions>(id: string, plugin: Plugin<T>) {
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<any>);
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<any>;
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 = <E extends EditorEvent & keyof EditorEventCallbacks>(
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<T extends PluginOptions>(id: string): Plugin<T> | 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<string, any> = {}): 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();
}
}

26
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<PluginItemProps> {
defaults(): PluginItemProps {
return {
id: '',
plugin: (() => {}) as PluginItemProps['plugin'],
options: {},
added: createPluginAdded(),
cleanup: () => {},
};
}
}

6
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<PluginModel> {}
Plugins.prototype.model = PluginModel;

112
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<string, any>;
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<T extends PluginOptions = {}> {
(editor: Editor, config: T): PluginResult;
__gjsPluginId?: string;
}
export interface PluginDescriptor {
id: string;
plugin: Plugin<any>;
}
export interface PluginWithMeta<T extends PluginOptions = {}> extends Plugin<T> {
__gjsPluginMeta?: {
plugin: string | Plugin<any>;
options?: T;
id?: string;
};
}
export type PluginInput = string | Plugin<any> | PluginDescriptor;
export interface PluginItemProps {
id: string;
plugin: Plugin<any>;
options: Record<string, any>;
added: PluginAdded;
cleanup: PluginCleanup;
}
export type PluginItem = Model<PluginItemProps>;
export type PluginTarget = string | Plugin<any> | 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<PluginItem>;
[PluginsEvents.remove]: EventCallbackRemove<PluginItem>;
[PluginsEvents.removeBefore]: EventCallbackRemoveBefore<PluginItem>;
[PluginsEvents.update]: EventCallbackUpdate<PluginItem>;
[PluginsEvents.all]: EventCallbackAll<PluginEvent, PluginItem>;
}
export default PluginsEvents;

114
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<string, Plugin<any>>();
export const isPluginFunction = (value: unknown): value is Plugin<any> => 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<any>,
): { plugin: string | Plugin<any>; options: PluginOptions; id?: string } => {
let current = plugin;
const options: PluginOptions = {};
let id: string | undefined;
while (isPluginFunction(current) && (current as PluginWithMeta<any>).__gjsPluginMeta) {
const meta = (current as PluginWithMeta<any>).__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 = <T extends PluginOptions>(id: string, plugin: Plugin<T>) => {
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<any>) => {
let result = '';
legacyPlugins.forEach((storedPlugin, id) => {
if (!result && storedPlugin === plugin) {
result = id;
}
});
return result || undefined;
};
export const findGlobalPluginId = (plugin: Plugin<any>) => {
const globalPlugins = getGlobal() as Record<string, any>;
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<any>) =>
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<any>): Plugin<any> | undefined => {
const { plugin: unwrapped } = unwrapPluginMeta(plugin);
return isString(unwrapped)
? getPluginById(unwrapped)
: (unwrapped as unknown as { default?: Plugin<any> })?.default || unwrapped;
};
export const logPluginWarn = (editor: Editor, plugin: string) => {
editor.getModel().logWarning(`Plugin ${plugin} not found`, { context: 'plugins', plugin });
};
export const usePlugin = <P extends Plugin<any> | string>(plugin: P, opts?: P extends Plugin<infer C> ? C : {}) => {
const options = opts || {};
const wrapped: PluginWithMeta<any> = (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;
};

4
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

7
packages/core/src/trait_manager/index.ts

@ -171,6 +171,13 @@ export default class TraitManager extends Module<TraitManagerConfigModule> {
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

98
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',

129
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: '<div>cleanup</div>' });
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: '<div>cleanup</div>' });
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';

27
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);
});
});
});

Loading…
Cancel
Save