All files / roosterjs-content-model-core/lib/corePlugin/contextMenu ContextMenuPlugin.ts

95.65% Statements 44/46
75.93% Branches 41/54
91.67% Functions 11/12
95.56% Lines 43/45

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 1361x                   1x         1x 111x   111x           111x 111x                 1x               111x 111x     111x     2x     111x           1x 78x 78x 78x           1x 111x     111x 2x 2x 2x 2x           2x           2x 2x 4x 4x 4x 2x     4x         2x             1x 1x   1x 1x 1x     1x         1x     97x             1x     111x    
import { getSelectionRootNode } from 'roosterjs-content-model-dom';
import type {
    ContextMenuPluginState,
    ContextMenuProvider,
    IEditor,
    PluginWithState,
    EditorOptions,
    DOMEventRecord,
} from 'roosterjs-content-model-types';
 
const ContextMenuButton = 2;
 
/**
 * Edit Component helps handle Content edit features
 */
class ContextMenuPlugin implements PluginWithState<ContextMenuPluginState> {
    private editor: IEditor | null = null;
    private state: ContextMenuPluginState;
    private disposer: (() => void) | null = null;
 
    /**
     * Construct a new instance of EditPlugin
     * @param options The editor options
     */
    constructor(options: EditorOptions) {
        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: IEditor) {
        this.editor = editor;
        const eventHandlers: Partial<
            { [P in keyof HTMLElementEventMap]: DOMEventRecord<HTMLElementEventMap[P]> }
        > = {
            contextmenu: {
                beforeDispatch: (event: MouseEvent | PointerEvent) =>
                    this.onContextMenuEvent(event),
            },
        };
        this.disposer = this.editor.attachDomEvent(<Record<string, DOMEventRecord>>eventHandlers);
    }
 
    /**
     * Dispose this plugin
     */
    dispose() {
        this.disposer?.();
        this.disposer = null;
        this.editor = null;
    }
 
    /**
     * Get plugin state object
     */
    getState() {
        return this.state;
    }
 
    private onContextMenuEvent = (e: MouseEvent | PointerEvent) => {
        Eif (this.editor) {
            const allItems: any[] = [];
            const mouseEvent = e as MouseEvent;
            const pointerEvent = e as PointerEvent;
 
            // 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)
                    : pointerEvent?.pointerType === 'touch' || pointerEvent?.pointerType === 'pen'
                    ? (pointerEvent.target as Node)
                    : this.getFocusedNode(this.editor);
 
            Eif (targetNode) {
                this.state.contextMenuProviders.forEach(provider => {
                    const items = provider.getContextMenuItems(targetNode) ?? [];
                    Eif (items?.length > 0) {
                        if (allItems.length > 0) {
                            allItems.push(null);
                        }
 
                        allItems.push(...items);
                    }
                });
            }
 
            this.editor?.triggerEvent('contextMenu', {
                rawEvent: mouseEvent,
                items: allItems,
            });
        }
    };
 
    private getFocusedNode(editor: IEditor) {
        const selection = editor.getDOMSelection();
 
        Eif (selection) {
            Eif (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: EditorOptions
): PluginWithState<ContextMenuPluginState> {
    return new ContextMenuPlugin(options);
}