Browse Source

Setup new local plugin manager

new-plugin-system
Artur Arseniev 4 months ago
parent
commit
609079eab9
  1. 4
      packages/core/src/asset_manager/index.ts
  2. 10
      packages/core/src/domain_abstract/model/TypeableCollection.ts
  3. 4
      packages/core/src/editor/config/config.ts
  4. 4
      packages/core/src/editor/index.ts
  5. 6
      packages/core/src/editor/model/Editor.ts
  6. 3
      packages/core/src/editor/types.ts
  7. 89
      packages/core/src/index.ts
  8. 307
      packages/core/src/plugin_manager/index.ts
  9. 35
      packages/core/src/plugin_manager/model/Plugin.ts
  10. 6
      packages/core/src/plugin_manager/model/Plugins.ts
  11. 101
      packages/core/src/plugin_manager/types.ts
  12. 118
      packages/core/src/plugin_manager/utils.ts
  13. 4
      packages/core/src/style_manager/index.ts
  14. 7
      packages/core/src/trait_manager/index.ts
  15. 128
      packages/core/test/specs/grapesjs/index.ts

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

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;

4
packages/core/src/editor/config/config.ts

@ -15,7 +15,7 @@ import { RichTextEditorConfig } from '../../rich_text_editor/config/config';
import { SelectorManagerConfig } from '../../selector_manager/config/config';
import { StorageManagerConfig } from '../../storage_manager/config/config';
import { UndoManagerConfig } from '../../undo_manager/config';
import { Plugin } from '../../plugin_manager';
import { PluginInput } from '../../plugin_manager';
import { TraitManagerConfig } from '../../trait_manager/config/config';
import { CommandsConfig } from '../../commands/config/config';
import { StyleManagerConfig } from '../../style_manager/config/config';
@ -48,7 +48,7 @@ export interface EditorConfig {
* Array of plugins to execute on start.
* @default []
*/
plugins?: (string | Plugin<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,

89
packages/core/src/index.ts

@ -1,7 +1,7 @@
import { isElement } from 'underscore';
import Editor from './editor';
import { EditorConfig } from './editor/config/config';
import PluginManager, { Plugin, getPlugin, logPluginWarn } from './plugin_manager';
import { PluginInput, legacyGlobalPlugins, usePlugin as pluginUsePlugin } from './plugin_manager';
import $ from './utils/cash-dom';
import polyfills from './utils/polyfills';
@ -11,27 +11,15 @@ export interface InitEditorConfig extends EditorConfig {
polyfills();
const plugins = new PluginManager();
const editors: Editor[] = [];
export const usePlugin = <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 +59,7 @@ export const grapesjs = {
em.initModules();
// Load plugins
initConfig.plugins!.forEach((pluginId) => {
const plugin = getPlugin(pluginId, plugins);
const plgOptions = initConfig.pluginsOpts![pluginId as string] || {};
if (plugin) {
plugin(editor, plgOptions);
} else {
logPluginWarn(editor, pluginId as string);
}
});
initConfig.plugins?.forEach((pluginInput) => editor.Plugins.add(pluginInput as PluginInput));
// Execute `onLoad` on modules once all plugins are initialized.
// A plugin might have extended/added some custom type so this
@ -97,16 +76,16 @@ export const grapesjs = {
* @deprecated Changed to CategoryProperties
*/
export type { CategoryProperties as BlockCategoryProperties } from './abstract/ModuleCategory';
export type { ComponentDragEventProps } from './commands/view/ComponentDrag';
export type { CommandRegistryRun, CommandRegistryStop } from './commands/registry';
export type { ComponentDragEventProps } from './commands/view/ComponentDrag';
// Exports for TS
export type { default as Categories } from './abstract/ModuleCategories';
export type { default as Category } from './abstract/ModuleCategory';
export type { default as Asset } from './asset_manager/model/Asset';
export type { default as Assets } from './asset_manager/model/Assets';
export type { default as Block } from './block_manager/model/Block';
export type { default as Blocks } from './block_manager/model/Blocks';
export type { default as Categories } from './abstract/ModuleCategories';
export type { default as Category } from './abstract/ModuleCategory';
export type { default as Canvas } from './canvas/model/Canvas';
export type { default as CanvasSpot } from './canvas/model/CanvasSpot';
export type { default as CanvasSpots } from './canvas/model/CanvasSpots';
@ -114,6 +93,30 @@ export type { default as Frame } from './canvas/model/Frame';
export type { default as Frames } from './canvas/model/Frames';
export type { default as CssRule } from './css_composer/model/CssRule';
export type { default as CssRules } from './css_composer/model/CssRules';
export type { default as DataSourceManager } from './data_sources';
export type { default as ComponentDataVariable } from './data_sources/model/ComponentDataVariable';
export type { default as ComponentWithCollectionsState } from './data_sources/model/ComponentWithCollectionsState';
export type { ComponentWithDataResolver } from './data_sources/model/ComponentWithDataResolver';
export type { default as ComponentDataCondition } from './data_sources/model/conditional_variables/ComponentDataCondition';
export type {
DataCondition,
DataConditionProps,
ExpressionProps,
LogicGroupProps,
} from './data_sources/model/conditional_variables/DataCondition';
export type { default as ComponentDataCollection } from './data_sources/model/data_collection/ComponentDataCollection';
export type { default as DataRecord } from './data_sources/model/DataRecord';
export type { default as DataRecords } from './data_sources/model/DataRecords';
export type { default as DataSource } from './data_sources/model/DataSource';
export type { default as DataSources } from './data_sources/model/DataSources';
export type { default as DataVariable } from './data_sources/model/DataVariable';
export type {
DataBindingImportAction,
DataBindingImportContext,
DataBindingImportPolicy,
DataBindingImportSource,
DataBindingKind,
} from './data_sources/types';
export type { default as Device } from './device_manager/model/Device';
export type { default as Devices } from './device_manager/model/Devices';
export type { default as ComponentManager } from './dom_components';
@ -133,47 +136,23 @@ export type {
CustomParserCodeContext,
CustomParserCodeFunction,
HTMLParseResult,
ParsedNode,
ParsedCssRule,
ParsedNode,
} from './parser/types';
export type { default as Selector } from './selector_manager/model/Selector';
export type { default as Selectors } from './selector_manager/model/Selectors';
export type { default as State } from './selector_manager/model/State';
export type { default as Properties } from './style_manager/model/Properties';
export type { default as Property } from './style_manager/model/Property';
export type { default as PropertyComposite } from './style_manager/model/PropertyComposite';
export type { default as PropertyNumber } from './style_manager/model/PropertyNumber';
export type { default as PropertyRadio } from './style_manager/model/PropertyRadio';
export type { default as PropertySelect } from './style_manager/model/PropertySelect';
export type { default as PropertyNumber } from './style_manager/model/PropertyNumber';
export type { default as PropertySlider } from './style_manager/model/PropertySlider';
export type { default as PropertyComposite } from './style_manager/model/PropertyComposite';
export type { default as PropertyStack } from './style_manager/model/PropertyStack';
export type { default as Sector } from './style_manager/model/Sector';
export type { default as Sectors } from './style_manager/model/Sectors';
export type { default as Trait } from './trait_manager/model/Trait';
export type { default as Traits } from './trait_manager/model/Traits';
export type { default as DataSourceManager } from './data_sources';
export type { default as DataSources } from './data_sources/model/DataSources';
export type { default as DataSource } from './data_sources/model/DataSource';
export type { default as DataRecord } from './data_sources/model/DataRecord';
export type { default as DataRecords } from './data_sources/model/DataRecords';
export type { default as DataVariable } from './data_sources/model/DataVariable';
export type { default as ComponentDataVariable } from './data_sources/model/ComponentDataVariable';
export type { default as ComponentWithCollectionsState } from './data_sources/model/ComponentWithCollectionsState';
export type { ComponentWithDataResolver } from './data_sources/model/ComponentWithDataResolver';
export type { default as ComponentDataCollection } from './data_sources/model/data_collection/ComponentDataCollection';
export type { default as ComponentDataCondition } from './data_sources/model/conditional_variables/ComponentDataCondition';
export type {
DataCondition,
LogicGroupProps,
DataConditionProps,
ExpressionProps,
} from './data_sources/model/conditional_variables/DataCondition';
export type {
DataBindingImportAction,
DataBindingImportContext,
DataBindingImportPolicy,
DataBindingImportSource,
DataBindingKind,
} from './data_sources/types';
export default grapesjs;

307
packages/core/src/plugin_manager/index.ts

@ -1,83 +1,264 @@
import { isString } from 'underscore';
import Editor from '../editor';
import { getGlobal } from '../utils/mixins';
import { isFunction, isString } from 'underscore';
import { ItemManagerModule, ModuleConfig } from '../abstract/Module';
import BlocksEvents from '../block_manager/types';
import { ComponentsEvents } from '../dom_components/types';
import DeviceEvents from '../device_manager/types';
import EditorModel from '../editor/model/Editor';
import { KeymapsEvents } from '../keymaps/types';
import { StyleManagerEvents } from '../style_manager/types';
import PluginModel, { createPluginAdded } from './model/Plugin';
import Plugins from './model/Plugins';
import {
Plugin,
PluginAdded,
PluginCleanup,
PluginCleanupHandler,
PluginDescriptor,
PluginInput,
PluginOptions,
PluginWithMeta,
PluginsEvents,
} from './types';
import {
addLegacyPlugin,
getLegacyPlugin,
getLegacyPlugins,
getPluginId,
getPlugin,
isPluginDescriptor,
isPluginFunction,
logPluginWarn,
legacyGlobalPlugins,
unwrapPluginMeta,
usePlugin,
} from './utils';
type PluginOptions = Record<string, any>;
export type {
Plugin,
PluginAdded,
PluginCleanup,
PluginCleanupHandler,
PluginDescriptor,
PluginInput,
PluginOptions,
PluginWithMeta,
};
export { addLegacyPlugin, getLegacyPlugin, getLegacyPlugins, getPlugin, legacyGlobalPlugins, logPluginWarn, usePlugin };
export type Plugin<T extends PluginOptions = {}> = (editor: Editor, config: T) => void;
type PluginTarget = string | Plugin<any> | PluginModel;
const getPluginById = (pluginId: string, plugins: PluginManager) => {
let result = plugins.get(pluginId);
export default class PluginManager extends ItemManagerModule<ModuleConfig, Plugins> {
events = PluginsEvents;
storageKey = '';
// Try to search in global context
if (!result) {
const wplg = (getGlobal() as any)[pluginId];
result = wplg?.default || wplg;
constructor(em: EditorModel) {
super(em, 'PluginManager', new Plugins(), PluginsEvents, {});
}
return result;
};
get editor() {
return this.em.getEditor();
}
export const getPlugin = (plugin: string | Plugin<any>, plugins: PluginManager) => {
return isString(plugin)
? getPluginById(plugin, plugins)
: (plugin as unknown as { default: Plugin<any> })?.default || plugin;
};
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] || {} : {});
}
export const logPluginWarn = (editor: Editor, plugin: string) => {
editor.getModel().logWarning(`Plugin ${plugin} not found`, {
context: 'plugins',
plugin,
});
};
private generatePluginId() {
return `gjs-plugin:${this._createId(8)}`;
}
private resolvePluginId(input: string | Plugin<any>, plugin: Plugin<any>, explicitId?: string) {
return explicitId || (isString(input) ? input : '') || getPluginId(plugin) || this.generatePluginId();
}
private resolvePluginTarget(target: PluginTarget) {
if (target instanceof PluginModel) return target;
if (isString(target)) return this.get(target);
export default class PluginManager {
plugins: Record<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;
const { plugin } = unwrapPluginMeta(target);
if (!isPluginFunction(plugin)) return;
return this.getAll().find((item) => item.get('plugin') === plugin);
}
private normalizePlugin(input: PluginInput, options: PluginOptions = {}) {
const descriptor = isPluginDescriptor(input) ? input : undefined;
const sourcePlugin = (descriptor ? descriptor.plugin : input) as string | Plugin<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<[string, (...args: any[]) => void]> = [];
const listen = (event: string, callback: (...args: any[]) => void) => {
editor.on(event, callback);
listeners.push([event, callback]);
};
listen(BlocksEvents.add, (block: any) => {
const id = block.get?.('id');
if (!id || added.blocks.includes(id)) return;
added.blocks.push(id);
cleanup.push(() => editor.Blocks.remove(id));
});
listen(KeymapsEvents.add, (keymap: any) => {
const id = keymap.id;
if (!id || added.keymaps.includes(id)) return;
added.keymaps.push(id);
cleanup.push(() => editor.Keymaps.remove(id));
});
listen(ComponentsEvents.typeAdd, (type: any) => {
const id = type?.id;
if (!id || added.componentTypes.includes(id)) return;
added.componentTypes.push(id);
cleanup.push(() => editor.Components.removeType(id));
});
listen(DeviceEvents.add, (device: any) => {
const id = device?.id || device?.get?.('id');
if (!id || added.devices.includes(id)) return;
added.devices.push(id);
cleanup.push(() => editor.Devices.remove(id));
});
listen(StyleManagerEvents.sectorAdd, (sector: any) => {
const id = sector?.id || sector?.get?.('id');
if (!id || added.styleSectors.includes(id)) return;
added.styleSectors.push(id);
cleanup.push(() => editor.StyleManager.removeSector(id));
});
listen(StyleManagerEvents.propertyAdd, (property: any) => {
const sectorId = property?.collection?.sector?.get?.('id');
const id = property?.get?.('property') || property?.get?.('id');
const alreadyAdded = added.styleProperties.some((item) => item.sectorId === sectorId && item.id === id);
if (!sectorId || !id || alreadyAdded) return;
added.styleProperties.push({ sectorId, id });
cleanup.push(() => editor.StyleManager.removeProperty(sectorId, id));
});
return {
added,
stop: () => listeners.forEach(([event, callback]) => editor.off(event, callback)),
cleanup: () => {
cleanup
.slice()
.reverse()
.forEach((cb) => cb());
},
};
}
add(input: PluginInput, options: PluginOptions = {}) {
const normalized = this.normalizePlugin(input, options);
if (!normalized) return;
const existing = this.get(normalized.id);
if (existing) return existing;
const tracking = this.createAddedTracker();
let result: unknown;
try {
result = normalized.plugin(this.editor, normalized.options);
} catch (error) {
tracking.stop();
tracking.cleanup();
throw error;
}
tracking.stop();
const plugin = new PluginModel({
id: normalized.id,
plugin: normalized.plugin,
options: normalized.options,
added: tracking.added,
cleanup: () => {},
});
let cleaned = false;
const builtInCleanup = () => {
if (cleaned) return;
cleaned = true;
tracking.cleanup();
};
const cleanup = () => {
if (cleaned) return;
const customCleanup = isFunction(result) ? result : null;
if (customCleanup) {
customCleanup({
cleanup: builtInCleanup,
editor: this.editor,
plugin,
});
} else {
builtInCleanup();
}
};
plugin.set('cleanup', cleanup);
this.all.add(plugin);
return plugin;
}
/**
* Returns plugin by ID
* @param {string} id Plugin ID
* @return {Function|undefined} Plugin
* @example
* var plugin = PluginManager.get('some-plugin');
* plugin(editor);
*/
get<T extends PluginOptions>(id: string): Plugin<T> | undefined {
return this.plugins[id];
get(id: string): PluginModel | undefined {
return this.all.get(id);
}
/**
* Returns object with all plugins
*/
getAll() {
return this.plugins;
return [...this.all.models] as PluginModel[];
}
remove(target: PluginTarget, opts: Record<string, any> = {}) {
const plugin = this.resolvePluginTarget(target);
if (!plugin) return;
const { em, events } = this;
const rm = () => {
plugin.get('cleanup')?.();
this.all.remove(plugin, opts);
return plugin;
};
!opts.silent && em.trigger(events.removeBefore, plugin, rm, opts);
return !opts.abort ? rm() : undefined;
}
destroy() {
this.__destroy();
}
}

35
packages/core/src/plugin_manager/model/Plugin.ts

@ -0,0 +1,35 @@
import { Model } from '../../common';
import { Plugin as PluginFn, PluginAdded, PluginCleanup } from '../types';
export const createPluginAdded = (): PluginAdded => ({
blocks: [],
commands: [],
keymaps: [],
componentTypes: [],
devices: [],
traitTypes: [],
styleTypes: [],
assetTypes: [],
styleSectors: [],
styleProperties: [],
});
export interface PluginProperties {
id: string;
plugin: PluginFn<any>;
options: Record<string, any>;
added: PluginAdded;
cleanup: PluginCleanup;
}
export default class Plugin extends Model<PluginProperties> {
defaults(): PluginProperties {
return {
id: '',
plugin: (() => {}) as PluginFn<any>,
options: {},
added: createPluginAdded(),
cleanup: () => {},
};
}
}

6
packages/core/src/plugin_manager/model/Plugins.ts

@ -0,0 +1,6 @@
import { Collection } from '../../common';
import Plugin from './Plugin';
export default class Plugins extends Collection<Plugin> {}
Plugins.prototype.model = Plugin;

101
packages/core/src/plugin_manager/types.ts

@ -0,0 +1,101 @@
import type Editor from '../editor';
import {
EventCallbackAdd,
EventCallbackAll,
EventCallbackRemove,
EventCallbackRemoveBefore,
EventCallbackUpdate,
} from '../common';
import PluginModel from './model/Plugin';
export type PluginOptions = Record<string, any>;
export interface PluginAdded {
blocks: string[];
commands: string[];
keymaps: string[];
componentTypes: string[];
devices: string[];
traitTypes: string[];
styleTypes: string[];
assetTypes: string[];
styleSectors: string[];
styleProperties: Array<{ sectorId: string; id: string }>;
}
export type PluginCleanup = () => void;
export type PluginCleanupHandler = (ctx: { cleanup: PluginCleanup; plugin: PluginModel }) => void;
export type PluginResult = void | PluginCleanupHandler | object | null | undefined;
export interface Plugin<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 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<PluginModel>;
[PluginsEvents.remove]: EventCallbackRemove<PluginModel>;
[PluginsEvents.removeBefore]: EventCallbackRemoveBefore<PluginModel>;
[PluginsEvents.update]: EventCallbackUpdate<PluginModel>;
[PluginsEvents.all]: EventCallbackAll<PluginEvent, PluginModel>;
}
export default PluginsEvents;

118
packages/core/src/plugin_manager/utils.ts

@ -0,0 +1,118 @@
import { isFunction, isString } from 'underscore';
import type Editor from '../editor';
import EditorModel from '../editor/model/Editor';
import { getGlobal } from '../utils/mixins';
import type { Plugin, PluginDescriptor, PluginOptions, PluginWithMeta } from './types';
const LEGACY_PLUGIN_ERROR =
'grapesjs.plugins.add(...) is deprecated. Pass plugins directly via init({ plugins: [...] }) or use usePlugin(...).';
const legacyPlugins = new Map<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 as Plugin<any>);
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) => {
const globalPlugin = globalPlugins[id];
return globalPlugin === plugin || globalPlugin?.default === plugin;
});
};
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>) => {
const { plugin: unwrapped } = unwrapPluginMeta(plugin);
return isString(unwrapped)
? getPluginById(unwrapped)
: (unwrapped as unknown as { default?: Plugin<any> })?.default || unwrapped;
};
export const logPluginWarn = (editor: { getModel(): EditorModel }, 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 wrapped = ((editor: Editor) => {
const pluginResult = getPlugin(plugin);
if (pluginResult) {
pluginResult(editor, opts || {});
} else {
logPluginWarn(editor as { getModel(): EditorModel }, plugin as string);
}
}) as PluginWithMeta<any>;
wrapped.__gjsPluginMeta = {
plugin,
options: (opts || {}) as Record<string, any>,
id: typeof plugin === 'string' ? plugin : plugin.__gjsPluginId,
};
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

128
packages/core/test/specs/grapesjs/index.ts

@ -3,6 +3,7 @@ import CssRule from '../../../src/css_composer/model/CssRule';
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
import { EditorConfig } from '../../../src/editor/config/config';
import type { Plugin } from '../../../src/plugin_manager';
import PluginsEvents from '../../../src/plugin_manager/types';
import { StorageManagerConfig } from '../../../src/storage_manager/config/config';
import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common';
@ -61,12 +62,7 @@ describe('GrapesJS', () => {
});
afterEach(() => {
var plugins = grapesjs.plugins.getAll();
for (let id in plugins) {
if (plugins.hasOwnProperty(id)) {
delete plugins[id];
}
}
grapesjs.plugins.clear();
});
test('Main object should be loaded', () => {
@ -501,6 +497,16 @@ describe('GrapesJS', () => {
});
describe('Plugins', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
test('Adds new storage as plugin and store data there', async () => {
(config.storageManager as StorageManagerConfig).type = storageId;
config.plugins = [(e) => e.StorageManager.add(storageId, storageMock)];
@ -593,6 +599,102 @@ describe('GrapesJS', () => {
expect(editor.getModel().get('customValue')).toEqual('TEST');
});
test('Descriptor plugin requires explicit id and accepts wrapped plugin', () => {
const inlinePlugin: TestPlugin = (edt, opts) => {
edt.getModel().set('customValue', opts.cVal);
};
const editor = grapesjs.init({
...config,
plugins: [{ id: 'descriptor-plugin', plugin: usePlugin(inlinePlugin, { cVal: 'DESC' }) }],
});
const plugin = editor.Plugins.get('descriptor-plugin')!;
expect(editor.getModel().get('customValue')).toEqual('DESC');
expect(plugin.get('options')).toEqual({ cVal: 'DESC' });
});
test('Plugin id is resolved from __gjsPluginId', () => {
const inlinePlugin: TestPlugin = () => {};
inlinePlugin.__gjsPluginId = 'inline-plugin-id';
const editor = grapesjs.init({
...config,
plugins: [inlinePlugin],
});
expect(editor.Plugins.get('inline-plugin-id')).toBeTruthy();
});
test('Legacy plugin collector prints deprecation error on add', () => {
grapesjs.plugins.add('legacy-plugin', () => {});
expect(consoleErrorSpy).toHaveBeenCalled();
});
test('Editor-scoped plugin manager stores plugins in the all collection', () => {
const editor = grapesjs.init({
...config,
plugins: [{ id: 'scoped-plugin', plugin: () => {} }],
});
expect(editor.Plugins.all.length).toBe(1);
expect(editor.Plugins.get('scoped-plugin')).toBe(editor.Plugins.all.at(0));
});
test('Editor-scoped plugin manager triggers plugin events', () => {
const result: string[] = [];
const editor = grapesjs.init(config);
editor.on(PluginsEvents.add, (plugin) => result.push(`add:${plugin.id}`));
editor.on(PluginsEvents.remove, (plugin) => result.push(`remove:${plugin.id}`));
editor.Plugins.add({ id: 'evt-plugin', plugin: () => {} });
editor.Plugins.remove('evt-plugin');
expect(result).toEqual(['add:evt-plugin', 'remove:evt-plugin']);
});
test('Plugin manager removes tracked entities on cleanup', () => {
const editor = grapesjs.init({
...config,
plugins: [
{
id: 'cleanup-plugin',
plugin: (edt) => {
edt.Blocks.add('cleanup-block', { label: 'Cleanup', content: '<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 +726,20 @@ describe('GrapesJS', () => {
expect(varToTest).toEqual(optionValue);
});
test('Execute named plugin from plugin descriptor', () => {
let varToTest = '';
const optionValue = 'TEST-DESC';
const pluginName = 'descriptor-plugin-name';
grapesjs.plugins.add(pluginName, (edt, opts = {}) => {
varToTest = opts.cVal || '';
});
grapesjs.init({
...config,
plugins: [{ id: pluginName, plugin: usePlugin(pluginName, { cVal: optionValue }) }],
});
expect(varToTest).toEqual(optionValue);
});
test('Execute inline plugin', () => {
let varToTest = '';
const optionValue = 'TEST-inline';

Loading…
Cancel
Save