Source: cmd/ContextualMenuCmd.js

/**
 * Implementation of local command <code>contextualMenu</code>.
 * <p>This command has been designed to triggered by 
 * the <code>contextmenu</code> <code>MouseEvent</code>. 
 * <ul>
 * <li>If the mouse is clicked inside the selection, 
 * the <code>XMLEditor</code> contextual menu 
 * "configured" for this selection is displayed. 
 * <li>If the mouse is clicked outside the selection,
 * the current selection, if any, is canceled, then depending 
 * on the location of the mouse click:
 * <ul>
 * <li>Click on some generated content (e.g. the bullet of a list item), 
 * element margin, etc, the corresponding node is selected then 
 * the <code>XMLEditor</code> contextual menu "configured" 
 * for this new selection is displayed.
 * <li>Click elsewhere, the caret is moved nearby the mouse click. 
 * A subsequent mouse click inside the text node containing the caret 
 * will then display the <em>browser</em> "native" contextual menu 
 * (which is useful for fixing spell-checking errors).
 * </ul>
 * </ul>
 */
export class ContextualMenuCmd extends OnMouseEventCmd {
    constructor() {
        super(/*alwaysConsumeEvent*/ false);
    }
    
    executeImpl(docView, params, event) {
        if (docView.selectionContains(event)) {
            Command.consumeEvent(event);

            let targetElem = event.target;
            const clicked = NodeView.lookupView(targetElem);
            if (clicked === null ||
                !Object.is(clicked, docView.selected) ||
                docView.selected2 !== null) {
                targetElem = null;
            }
            this.showContextualMenu(docView, targetElem,
                                    event.clientX, event.clientY);
            return Promise.resolve(CommandResult.DONE);
        }
        
        // Not inside current node or text selection ---
            
        let [text, offset] = docView.pickTextualView(event);
        if (text !== null) {
            // Right-clicked inside a text node view ---

            if (docView.dot !== text ||
                docView.hasTextSelection() ||
                docView.hasNodeSelection()) {
                Command.consumeEvent(event);

                let marks = {
                    mark: "", selected2: "", selected: "",
                    dot: NodeView.getUID(text), dotOffset: offset
                };
                return docView.sendSetMarks(marks)
                    .then((dotMoved) => {
                        return dotMoved? CommandResult.DONE :
                            CommandResult.FAILED;
                    });
                   // No catch error here, DocumentView.sendSetMarks does that.

                // Chrome but not Firefox: sendSetMarks seems to prevent
                // the spell-check entries of browser menu from
                // replacing the misspelled text.
                // That's why, we consume the event and just move dot.
            } else {
                // Clicked inside the text node view containing dot
                // and there is currently no text or node selection.
                // DO NOT CONSUME EVENT.
                // Let the browser show its spell-checking menu at clicked
                // location THEN update the dot offset accordingly.

                const ces = docView.contenteditableState;
                if (ces !== null) {
                    ces.moveDotAt(docView, event);
                }
                
                return Promise.resolve(CommandResult.DONE);
            }
        } else {
            // Right-clicked outside a text node view, for example inside
            // some generated content ---

            Command.consumeEvent(event);

            const clicked = NodeView.lookupView(event.target);
            if (clicked !== null) {
                let marks = { mark: "", selected2: "",
                              selected: NodeView.getUID(clicked) };
                docView.ensureDotIsInside(clicked, marks);

                return docView.sendSetMarks(marks)
                    .then((nodeSelected) => {
                        if (nodeSelected) {
                            this.showContextualMenu(docView, event.target,
                                                  event.clientX, event.clientY);
                            return CommandResult.DONE;
                        } else {
                            return CommandResult.FAILED;
                        }
                    });
                   // No catch error here, DocumentView.sendSetMarks does that.
            } else {
                return Promise.resolve(CommandResult.FAILED);
            }
        }
    }

    showContextualMenu(docView, targetElem, clientX, clientY) {
        let extraMenuItems = null;
        let customControl = null;
        
        let ancestor = targetElem;
        while (ancestor !== null) {
            if (ancestor.localName.startsWith("xxe-")) {
                const menuItems = ancestor.contextualMenuItems;
                if (Array.isArray(menuItems)) {
                    customControl = ancestor;
                    let done = new CommandResult(COMMAND_RESULT_DONE,
                                                 JSON.stringify(menuItems));
                    extraMenuItems = Promise.resolve(done);
                }
                // Done.
                break;
            }
            ancestor = ancestor.parentElement;
        }

        // ---
        
        if (extraMenuItems === null) {
            extraMenuItems = docView.executeCommand(EXECUTE_HELPER,
                                                   "contextualMenuItems", null);
        }
        extraMenuItems.then((result) => {
            let menuItems = ContextualMenuCmd.MENU_ITEMS;
            if (CommandResult.isDone(result)) {
                let entries = null;
                try {
                    entries = JSON.parse(result.value);
                    if (!Array.isArray(entries)) {
                        entries = null;
                    }
                } catch {}

                if (entries !== null) {
                    menuItems = [...menuItems];
                    menuItems[0].separator = true;

                    for (let i = entries.length-1; i >= 0; --i) {
                        const entry = entries[i];
                        if (entry === null ||
                            !entry.label || !entry.cmdName) {
                            // Assume it's a separator.
                            menuItems[0].separator = true;
                            continue;
                        }

                        let menuItem = {};
                        menuItem.text = entry.label;
                        if (entry.iconName) {
                            menuItem.icon = entry.iconName;
                        } else if (entry.iconData) {
                            menuItem.icon = "url(" + entry.iconData + ")";
                        }
                        const cmdName = entry.cmdName;
                        menuItem.name = cmdName;
                        if ("cmdParam" in entry) {
                            menuItem.name += "\n" + entry.cmdParam;
                        }
                        menuItem.enabled = ("enabled" in entry);

                        if (cmdName.endsWith("()") && // Pseudo-command.
                            customControl !== null) { 
                            const action = customControl[
                                cmdName.substring(0, cmdName.length-2)];
                            if (action) {
                                menuItem.customControlAction = action;
                            }
                        }
                        
                        menuItems.unshift(menuItem);
                    }
                }
            }

            this.openContextualMenu(docView, menuItems, clientX, clientY);
        });
    }
    
    openContextualMenu(docView, menuItems, clientX, clientY) {
        ContextualMenuCmd.addKeyboardShortcuts(docView);
         
        // MENU_ITEMS are copied, not referenced, by XUI.Menu.
        const menu = XUI.Menu.create(menuItems);
        menu.addEventListener("menuitemselected", (event) => {
            this.menuItemSelected(docView, event);
        });

        // Editing context changed event is received *before* this
        // showContextualMenu is invoked because this method is invoked after
        // the Promise of sendSetMarks is fulfilled.
        this.enableMenuItems(docView, menu);
        
        menu.open([clientX, clientY]);
    }

    static addKeyboardShortcuts(docView) {
        if (!ContextualMenuCmd.MENU_ITEMS[0].detail) {
            for (let item of ContextualMenuCmd.MENU_ITEMS) {
                let [cmdName, cmdParam] = ContextualMenuCmd.ACTION[item.name];
                let binding = docView.getBindingForCommand(cmdName, cmdParam);

                if (binding !== null) {
                    let keyboardShortcut = binding.getUserInputLabel();
                    if ("text" in item) {
                        item.detail = keyboardShortcut;
                    } else if ("tooltip" in item) {
                        // Multi-line tooltip?
                        let tooltipLines = null;
                        
                        let tooltip = item.tooltip;
                        let pos = tooltip.indexOf('\n');
                        if (pos > 0 && pos+1 < tooltip.length) {
                            tooltipLines = tooltip.substring(pos);
                            tooltip = tooltip.substring(0, pos);
                        }
                        
                        tooltip = tooltip +
                          "\u00A0\u00A0\u00A0\u00A0[" + keyboardShortcut + "]";
                        if (tooltipLines !== null) {
                            tooltip += tooltipLines;
                        }
                        
                        item.tooltip = tooltip;
                    }
                }
            }
        }
    }
    
    menuItemSelected(docView, event) {
        const name = event.detail.menuItem.name;
        if (name.endsWith("()")) { // Pseudo-command.
            const customControlAction =
                  event.detail.menuItem.getOption("customControlAction");
            if (customControlAction) {
                try {
                    customControlAction(event);
                } catch (error) {
                    console.error(`Could not execute pseudo-command "${name}": \
${error}`);
                }
            }
            return;
        }

        // ---
        
        const action = ContextualMenuCmd.ACTION[name];
        let cmdName, cmdParam;
        if (!action) {
            // Config specific contextual menu item.
            let pos = name.indexOf('\n');
            if (pos < 0) {
                cmdName = name;
                cmdParam = null;
            } else {
                cmdName = name.substring(0, pos);
                cmdParam = name.substring(pos+1);
            }
        } else {
            cmdName = action[0];
            cmdParam = action[1];
        }
        docView.executeCommand(EXECUTE_NORMAL, cmdName, cmdParam, event);
    }

    enableMenuItems(docView, menu) {
        if (menu !== null) {
            for (let item of menu.getAllItems()) {
                const action = ContextualMenuCmd.ACTION[item.name];
                if (!action) {
                    // Skip
                    // - config-specific contextual menu item,
                    // - menu item invoking pseudo-command (name ends with "()")
                    // which has already been enabled/disabled.
                    continue;
                }
                
                let enabled = false;
                let tooltip = null;
                let state = docView.getCommandState(action[0], action[1]);
                if (state !== null) {
                    enabled = state[0];
                    tooltip = state[1];
                }
                
                item.setOption("enabled", enabled);
                if (item.name === "repeat") {
                    item.setOption("tooltip", tooltip); // null tooltip is OK.
                }
            }
        }
    }
}

// If modified, update accordingly ../editor/DefaultContextualCommands.js
ContextualMenuCmd.MENU_ITEMS = [
    { name: "repeat", icon: "repeat", text: "Repeat", enabled: false },

    { name: "cut", icon: "cut", tooltip: "Cut", enabled: false,
      separator: true },
    { name: "copy", icon: "copy",
      tooltip: "Copy\n\n\u2022 Shift-click to copy as text.",
      enabled: false, separator: true },
    { name: "pasteBefore", icon: "pasteBefore",
      tooltip: "Paste Before", enabled: false,
      separator: true },
    { name: "paste", icon: "paste", tooltip: "Paste", enabled: false },
    { name: "pasteAfter", icon: "pasteAfter",
      tooltip: "Paste After", enabled: false },
    { name: "delete", icon: "delete", tooltip: "Delete", enabled: false,
      separator: true },

    { name: "replace", icon: "replace", text: "Replace...",
      enabled: false, separator: true },
    { name: "insertBefore", icon: "insertBefore",
      text: "Insert Before...", enabled: false },
    { name: "insert", icon: "insert", text: "Insert Into...",
      enabled: false },
    { name: "insertAfter", icon: "insertAfter",
      text: "Insert After...", enabled: false },
    { name: "convert", icon: "convert", text: "Convert...",
      enabled: false },
    { name: "wrap", icon: "wrap", text: "Wrap...", enabled: false },
    
    { name: "editAttributes", icon: "editAttributes",
      text: "Edit Attributes...", enabled: false,
      separator: true }
];

ContextualMenuCmd.ACTION = {
    "repeat": [ "repeat", null ],
    "cut": [ "cut", "[implicitElement]" ],
    "copy": [ "copy", "[implicitElement]" ],
    "pasteBefore": [ "paste", "before[implicitElement]" ],
    "paste": [ "paste", "toOrInto" ],
    "pasteAfter": [ "paste", "after[implicitElement]" ],
    "delete": [ "delete", "[implicitElement]" ],
    "replace": [ "replace", "[implicitElement]" ],
    "insertBefore": [ "insert", "before[implicitElement]" ],
    "insert": [ "insert", "into" ],
    "insertAfter": [ "insert", "after[implicitElement]" ],
    "convert": [ "convert", "[implicitElement]" ],
    "wrap": [ "wrap", "[implicitElement]" ],
    "editAttributes": [ "editAttributes", "[implicitElement]" ]
};

ALL_LOCAL_COMMANDS.contextualMenu = new ContextualMenuCmd();