-
Notifications
You must be signed in to change notification settings - Fork 167
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
15 changed files
with
431 additions
and
226 deletions.
There are no files selected for viewing
127 changes: 127 additions & 0 deletions
127
packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
169 changes: 169 additions & 0 deletions
169
packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'], | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.