diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index 0eb1d7a106a..5ddc1f36732 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -5,6 +5,7 @@ import { IgnoredPluginNames } from '../editor/IgnoredPluginNames'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import type { EditorPlugin as LegacyEditorPlugin, + PluginEvent as LegacyPluginEvent, ContextMenuProvider as LegacyContextMenuProvider, IEditor as ILegacyEditor, ExperimentalFeatures, @@ -14,6 +15,7 @@ import type { DarkColorHandler, } from 'roosterjs-editor-types'; import type { ContextMenuProvider, IEditor, PluginEvent } from 'roosterjs-content-model-types'; +import type { MixedPlugin } from '../publicTypes/MixedPlugin'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; const OldEventKey = '__OldEventFromNewEvent'; @@ -97,7 +99,13 @@ export class BridgePlugin implements ContextMenuProvider { initialize(editor: IEditor) { const outerEditor = this.onInitialize(this.createEditorCore(editor)); - this.legacyPlugins.forEach(plugin => plugin.initialize(outerEditor)); + this.legacyPlugins.forEach(plugin => { + plugin.initialize(outerEditor); + + if (isMixedPlugin(plugin)) { + plugin.initializeV9(editor); + } + }); } /** @@ -122,9 +130,9 @@ export class BridgePlugin implements ContextMenuProvider { const exclusivelyHandleEventPlugin = this.cacheGetExclusivelyHandlePlugin(event); if (exclusivelyHandleEventPlugin) { - exclusivelyHandleEventPlugin.onPluginEvent?.(oldEvent); + this.handleEvent(exclusivelyHandleEventPlugin, oldEvent, event); } else { - this.legacyPlugins.forEach(plugin => plugin.onPluginEvent?.(oldEvent)); + this.legacyPlugins.forEach(plugin => this.handleEvent(plugin, oldEvent, event)); } Object.assign(event, oldEventToNewEvent(oldEvent, event)); @@ -164,6 +172,10 @@ export class BridgePlugin implements ContextMenuProvider { if (plugin.willHandleEventExclusively?.(oldEvent)) { return plugin; } + + if (isMixedPlugin(plugin) && plugin.willHandleEventExclusivelyV9?.(event)) { + return plugin; + } } } @@ -185,6 +197,18 @@ export class BridgePlugin implements ContextMenuProvider { contextMenuProviders: this.contextMenuProviders, }; } + + private handleEvent( + plugin: LegacyEditorPlugin, + oldEvent: LegacyPluginEvent, + newEvent: PluginEvent + ) { + plugin.onPluginEvent?.(oldEvent); + + if (isMixedPlugin(plugin)) { + plugin.onPluginEventV9?.(newEvent); + } + } } /** @@ -200,3 +224,7 @@ function isContextMenuProvider( ): source is LegacyContextMenuProvider { return !!(>source)?.getContextMenuItems; } + +function isMixedPlugin(plugin: LegacyEditorPlugin): plugin is MixedPlugin { + return !!(plugin as MixedPlugin).initializeV9; +} diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts index 3646c7e890c..96ced6346a6 100644 --- a/packages/roosterjs-editor-adapter/lib/index.ts +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -1,5 +1,6 @@ export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; +export { MixedPlugin } from './publicTypes/MixedPlugin'; export { EditorAdapter } from './editor/EditorAdapter'; diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/MixedPlugin.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/MixedPlugin.ts new file mode 100644 index 00000000000..5618b180058 --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/MixedPlugin.ts @@ -0,0 +1,35 @@ +import type { PluginEvent, IEditor } from 'roosterjs-content-model-types'; +import type { EditorPlugin } from 'roosterjs-editor-types'; + +/** + * Represents a mixed version plugin that can handle both v8 and v9 events. + * This is not commonly used, but just for transitioning from v8 to v9 plugins + */ +export interface MixedPlugin extends EditorPlugin { + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + + initializeV9: (editor: IEditor) => void; + + /** + * Check if the plugin should handle the given event exclusively. + * Handle an event exclusively means other plugin will not receive this event in + * onPluginEvent method. + * If two plugins will return true in willHandleEventExclusively() for the same event, + * the final result depends on the order of the plugins are added into editor + * @param event The event to check: + */ + willHandleEventExclusivelyV9?: (event: PluginEvent) => boolean; + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEventV9?: (event: PluginEvent) => void; +} diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index 7011ce96419..a0db7f68882 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -511,4 +511,101 @@ describe('BridgePlugin', () => { expect(disposeSpy).toHaveBeenCalledTimes(2); }); + + it('MixedPlugin', () => { + const initializeV8Spy = jasmine.createSpy('initializeV8'); + const initializeV9Spy = jasmine.createSpy('initializeV9'); + const onPluginEventV8Spy = jasmine.createSpy('onPluginEventV8'); + const onPluginEventV9Spy = jasmine.createSpy('onPluginEventV9'); + const willHandleEventExclusivelyV8Spy = jasmine.createSpy('willHandleEventExclusivelyV8'); + const willHandleEventExclusivelyV9Spy = jasmine.createSpy('willHandleEventExclusivelyV9'); + const disposeSpy = jasmine.createSpy('dispose'); + + const mockedPlugin = { + initialize: initializeV8Spy, + initializeV9: initializeV9Spy, + onPluginEvent: onPluginEventV8Spy, + onPluginEventV9: onPluginEventV9Spy, + willHandleEventExclusively: willHandleEventExclusivelyV8Spy, + willHandleEventExclusivelyV9: willHandleEventExclusivelyV9Spy, + dispose: disposeSpy, + getName: () => '', + } as any; + const mockedEditor = {} as any; + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [mockedPlugin]); + + expect(initializeV8Spy).not.toHaveBeenCalled(); + expect(initializeV9Spy).not.toHaveBeenCalled(); + expect(onPluginEventV8Spy).not.toHaveBeenCalled(); + expect(onPluginEventV9Spy).not.toHaveBeenCalled(); + expect(disposeSpy).not.toHaveBeenCalled(); + expect(onInitializeSpy).not.toHaveBeenCalled(); + expect(willHandleEventExclusivelyV8Spy).not.toHaveBeenCalled(); + expect(willHandleEventExclusivelyV9Spy).not.toHaveBeenCalled(); + + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerDarkColorHandler = 'INNERCOLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedInnerDarkColorHandler, + } as any; + + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedColorManager); + + plugin.initialize(mockedInnerEditor); + + expect(onInitializeSpy).toHaveBeenCalledWith({ + customData: {}, + experimentalFeatures: [], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedColorManager, + edit: 'edit', + contextMenuProviders: [], + } as any); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); + expect(initializeV8Spy).toHaveBeenCalledTimes(1); + expect(initializeV9Spy).toHaveBeenCalledTimes(1); + expect(disposeSpy).not.toHaveBeenCalled(); + expect(initializeV8Spy).toHaveBeenCalledWith(mockedEditor); + expect(initializeV9Spy).toHaveBeenCalledWith(mockedInnerEditor); + expect(onPluginEventV8Spy).not.toHaveBeenCalled(); + expect(onPluginEventV9Spy).not.toHaveBeenCalled(); + expect(willHandleEventExclusivelyV8Spy).not.toHaveBeenCalled(); + expect(willHandleEventExclusivelyV9Spy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); + + expect(onPluginEventV8Spy).toHaveBeenCalledTimes(1); + expect(onPluginEventV9Spy).toHaveBeenCalledTimes(1); + expect(onPluginEventV8Spy).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + eventDataCache: undefined, + }); + expect(onPluginEventV9Spy).toHaveBeenCalledWith({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + eventDataCache: undefined, + }); + expect(willHandleEventExclusivelyV8Spy).toHaveBeenCalledTimes(1); + expect(willHandleEventExclusivelyV9Spy).toHaveBeenCalledTimes(1); + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalledTimes(1); + }); });