Skip to content

Commit

Permalink
Port ContextMenu plugin (#2366)
Browse files Browse the repository at this point in the history
* Port ContextMenu plugin

* add test
  • Loading branch information
JiuqingSong authored Jan 29, 2024
1 parent 7998d03 commit fea345f
Show file tree
Hide file tree
Showing 15 changed files with 431 additions and 226 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode';
import type {
ContextMenuPluginState,
ContextMenuProvider,
IStandaloneEditor,
PluginWithState,
StandaloneEditorOptions,
} from 'roosterjs-content-model-types';

const ContextMenuButton = 2;

/**
* Edit Component helps handle Content edit features
*/
class ContextMenuPlugin implements PluginWithState<ContextMenuPluginState> {
private editor: IStandaloneEditor | null = null;
private state: ContextMenuPluginState;
private disposer: (() => void) | null = null;

/**
* Construct a new instance of EditPlugin
* @param options The editor options
*/
constructor(options: StandaloneEditorOptions) {
this.state = {
contextMenuProviders:
options.plugins?.filter<ContextMenuProvider<any>>(isContextMenuProvider) || [],
};
}

/**
* Get a friendly name of this plugin
*/
getName() {
return 'ContextMenu';
}

/**
* Initialize this plugin. This should only be called from Editor
* @param editor Editor instance
*/
initialize(editor: IStandaloneEditor) {
this.editor = editor;
this.disposer = this.editor.attachDomEvent({
contextmenu: {
beforeDispatch: this.onContextMenuEvent,
},
});
}

/**
* Dispose this plugin
*/
dispose() {
this.disposer?.();
this.disposer = null;
this.editor = null;
}

/**
* Get plugin state object
*/
getState() {
return this.state;
}

private onContextMenuEvent = (e: Event) => {
if (this.editor) {
const allItems: any[] = [];
const mouseEvent = e as MouseEvent;

// ContextMenu event can be triggered from mouse right click or keyboard (e.g. Shift+F10 on Windows)
// Need to check if this is from keyboard, we need to get target node from selection because in that case
// event.target is always the element that attached context menu event, here it will be editor content div.
const targetNode =
mouseEvent.button == ContextMenuButton
? (mouseEvent.target as Node)
: this.getFocusedNode(this.editor);

if (targetNode) {
this.state.contextMenuProviders.forEach(provider => {
const items = provider.getContextMenuItems(targetNode) ?? [];
if (items?.length > 0) {
if (allItems.length > 0) {
allItems.push(null);
}

allItems.push(...items);
}
});
}

this.editor?.triggerEvent('contextMenu', {
rawEvent: mouseEvent,
items: allItems,
});
}
};

private getFocusedNode(editor: IStandaloneEditor) {
const selection = editor.getDOMSelection();

if (selection) {
if (selection.type == 'range') {
selection.range.collapse(true /*toStart*/);
}

return getSelectionRootNode(selection) || null;
} else {
return null;
}
}
}

function isContextMenuProvider(source: unknown): source is ContextMenuProvider<any> {
return !!(<ContextMenuProvider<any>>source)?.getContextMenuItems;
}

/**
* @internal
* Create a new instance of EditPlugin.
*/
export function createContextMenuPlugin(
options: StandaloneEditorOptions
): PluginWithState<ContextMenuPluginState> {
return new ContextMenuPlugin(options);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createContentModelCachePlugin } from './ContentModelCachePlugin';
import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin';
import { createContentModelFormatPlugin } from './ContentModelFormatPlugin';
import { createContextMenuPlugin } from './ContextMenuPlugin';
import { createDOMEventPlugin } from './DOMEventPlugin';
import { createEntityPlugin } from './EntityPlugin';
import { createLifecyclePlugin } from './LifecyclePlugin';
Expand Down Expand Up @@ -28,6 +29,7 @@ export function createStandaloneEditorCorePlugins(
lifecycle: createLifecyclePlugin(options, contentDiv),
entity: createEntityPlugin(),
selection: createSelectionPlugin(options),
contextMenu: createContextMenuPlugin(options),
undo: createUndoPlugin(options),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createStandaloneEditorCore(
corePlugins.entity,
...(options.plugins ?? []).filter(x => !!x),
corePlugins.undo,
corePlugins.contextMenu,
corePlugins.lifecycle,
],
environment: createEditorEnvironment(contentDiv),
Expand Down Expand Up @@ -88,6 +89,7 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): PluginState {
lifecycle: corePlugins.lifecycle.getState(),
entity: corePlugins.entity.getState(),
selection: corePlugins.selection.getState(),
contextMenu: corePlugins.contextMenu.getState(),
undo: corePlugins.undo.getState(),
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode';
import { createContextMenuPlugin } from '../../lib/corePlugin/ContextMenuPlugin';
import {
ContextMenuPluginState,
DOMEventRecord,
IStandaloneEditor,
PluginWithState,
} from 'roosterjs-content-model-types';

describe('ContextMenu handle other event', () => {
let plugin: PluginWithState<ContextMenuPluginState>;
let eventMap: Record<string, any>;
let triggerEventSpy: jasmine.Spy;
let getDOMSelectionSpy: jasmine.Spy;
let attachDOMEventSpy: jasmine.Spy;
let getSelectionRootNodeSpy: jasmine.Spy;
let editor: IStandaloneEditor;

beforeEach(() => {
triggerEventSpy = jasmine.createSpy('triggerEvent');
getDOMSelectionSpy = jasmine.createSpy('getDOMSelection');
getSelectionRootNodeSpy = spyOn(getSelectionRootNode, 'getSelectionRootNode');
attachDOMEventSpy = jasmine
.createSpy('attachDOMEvent')
.and.callFake((handlers: Record<string, DOMEventRecord>) => {
eventMap = handlers;
});

editor = <IStandaloneEditor>(<any>{
getDOMSelection: getDOMSelectionSpy,
attachDomEvent: attachDOMEventSpy,
triggerEvent: triggerEventSpy,
});
});

afterEach(() => {
plugin.dispose();
});

it('Ctor with parameter', () => {
const mockedPlugin1 = {} as any;
const mockedPlugin2 = {
getContextMenuItems: () => {},
} as any;

plugin = createContextMenuPlugin({
plugins: [mockedPlugin1, mockedPlugin2],
});

plugin.initialize(editor);

expect(attachDOMEventSpy).toHaveBeenCalledTimes(1);

const state = plugin.getState();

expect(state).toEqual({
contextMenuProviders: [mockedPlugin2],
});
});

it('Trigger contextmenu event, skip reselect', () => {
plugin = createContextMenuPlugin({});
plugin.initialize(editor);

const state = plugin.getState();
const mockedItems1 = ['Item1', 'Item2'];
const mockedItems2 = ['Item3', 'Item4'];

const getContextMenuItemSpy1 = jasmine
.createSpy('getContextMenu 1')
.and.returnValue(mockedItems1);
const getContextMenuItemSpy2 = jasmine
.createSpy('getContextMenu 2')
.and.returnValue(mockedItems2);

state.contextMenuProviders = [
{
getContextMenuItems: getContextMenuItemSpy1,
} as any,
{
getContextMenuItems: getContextMenuItemSpy2,
} as any,
];

const mockedTarget = 'TARGET' as any;
const mockedEvent = {
button: 2,
target: mockedTarget,
};
const collapseSpy = jasmine.createSpy('collapse');
const mockedRange = {
collapse: collapseSpy,
};
const mockedSelection = {
type: 'range',
range: mockedRange,
};
const mockedNode = 'NODE';

getDOMSelectionSpy.and.returnValue(mockedSelection);
getSelectionRootNodeSpy.and.returnValue(mockedNode);

eventMap.contextmenu.beforeDispatch(mockedEvent);

expect(collapseSpy).not.toHaveBeenCalled();
expect(getDOMSelectionSpy).not.toHaveBeenCalled();
expect(getSelectionRootNodeSpy).not.toHaveBeenCalled();
expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedTarget);
expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedTarget);
expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', {
rawEvent: mockedEvent,
items: ['Item1', 'Item2', null, 'Item3', 'Item4'],
});
});

it('Trigger contextmenu event using keyboard', () => {
plugin = createContextMenuPlugin({});
plugin.initialize(editor);

const state = plugin.getState();
const mockedItems1 = ['Item1', 'Item2'];
const mockedItems2 = ['Item3', 'Item4'];
const getContextMenuItemSpy1 = jasmine
.createSpy('getContextMenu 1')
.and.returnValue(mockedItems1);
const getContextMenuItemSpy2 = jasmine
.createSpy('getContextMenu 2')
.and.returnValue(mockedItems2);

state.contextMenuProviders = [
{
getContextMenuItems: getContextMenuItemSpy1,
} as any,
{
getContextMenuItems: getContextMenuItemSpy2,
} as any,
];

const mockedTarget = 'TARGET' as any;
const mockedEvent = {
button: -1,
target: mockedTarget,
};
const collapseSpy = jasmine.createSpy('collapse');
const mockedRange = {
collapse: collapseSpy,
};
const mockedSelection = {
type: 'range',
range: mockedRange,
};
const mockedNode = 'NODE';

getDOMSelectionSpy.and.returnValue(mockedSelection);
getSelectionRootNodeSpy.and.returnValue(mockedNode);

eventMap.contextmenu.beforeDispatch(mockedEvent);

expect(collapseSpy).toHaveBeenCalledWith(true);
expect(getDOMSelectionSpy).toHaveBeenCalledWith();
expect(getSelectionRootNodeSpy).toHaveBeenCalledWith(mockedSelection);
expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedNode);
expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedNode);
expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', {
rawEvent: mockedEvent,
items: ['Item1', 'Item2', null, 'Item3', 'Item4'],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('createEditorCore', () => {
const mockedEntityPlugin = createMockedPlugin('entity');
const mockedSelectionPlugin = createMockedPlugin('selection');
const mockedUndoPlugin = createMockedPlugin('undo');
const mockedContextMenuPlugin = createMockedPlugin('contextMenu');
const mockedPlugins = {
cache: mockedCachePlugin,
format: mockedFormatPlugin,
Expand All @@ -34,6 +35,7 @@ describe('createEditorCore', () => {
entity: mockedEntityPlugin,
selection: mockedSelectionPlugin,
undo: mockedUndoPlugin,
contextMenu: mockedContextMenuPlugin,
};
const mockedDarkColorHandler = 'DARKCOLOR' as any;
const mockedDomToModelSettings = 'DOMTOMODEL' as any;
Expand Down Expand Up @@ -76,6 +78,7 @@ describe('createEditorCore', () => {
mockedSelectionPlugin,
mockedEntityPlugin,
mockedUndoPlugin,
mockedContextMenuPlugin,
mockedLifeCyclePlugin,
],
environment: {
Expand All @@ -95,6 +98,7 @@ describe('createEditorCore', () => {
entity: 'entity' as any,
selection: 'selection' as any,
undo: 'undo' as any,
contextMenu: 'contextMenu' as any,
domHelper: mockedDOMHelper,
disposeErrorHandler: undefined,
zoomScale: 1,
Expand Down Expand Up @@ -166,6 +170,7 @@ describe('createEditorCore', () => {
mockedPlugin1,
mockedPlugin2,
mockedUndoPlugin,
mockedContextMenuPlugin,
mockedLifeCyclePlugin,
],
darkColorHandler: mockedDarkColorHandler,
Expand Down
Loading

0 comments on commit fea345f

Please sign in to comment.