/**
* 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();