wed
Version:
Wed is a schema-aware editor for XML documents.
514 lines • 23.4 kB
JavaScript
define(["require", "exports", "../dloc", "../domtypeguards", "../domutil", "../transformation", "./action-context-menu", "./completion-menu", "./icon", "./replacement-menu", "./typeahead-popup"], function (require, exports, dloc_1, domtypeguards_1, domutil_1, transformation_1, action_context_menu_1, completion_menu_1, icon_1, replacement_menu_1, typeahead_popup_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const atStartToTxt = {
undefined: "",
true: " before this element",
false: " after this element",
};
/**
* Manages the editing menus for a specific editing view. An "editing menu" is a
* menu that appears in the editing pane. The context menu and completion menu
* are editing menus.
*
* Only one editing menu may be shown at any given time.
*/
class EditingMenuManager {
/**
* @param editor The editor for which the manager is created.
*/
constructor(editor) {
this.editor = editor;
this.caretManager = editor.caretManager;
this.modeTree = editor.modeTree;
this.guiRoot = editor.guiRoot;
this.dataRoot = editor.dataRoot;
this.doc = this.guiRoot.ownerDocument;
}
/**
* This is the default menu handler called when the user right-clicks in the
* contents of a document or uses the keyboard shortcut.
*
* The menu handler which is invoked when a user right-clicks on an element
* start or end label is defined by the decorator that the mode is using.
*/
contextMenuHandler(e) {
const sel = this.caretManager.sel;
if (sel === undefined || (!sel.collapsed && !sel.wellFormed)) {
return false;
}
let node = sel.focus.node;
let offset = sel.focus.offset;
if (!domtypeguards_1.isElement(node)) {
const parent = node.parentNode;
if (parent === null) {
throw new Error("contextMenuHandler invoked on detached node");
}
offset = domutil_1.indexOf(parent.childNodes, node);
node = parent;
}
// Move out of any placeholder
const ph = domutil_1.closestByClass(node, "_placeholder", this.guiRoot);
if (ph !== null) {
const parent = ph.parentNode;
if (parent === null) {
throw new Error("contextMenuHandler invoked on detached node");
}
offset = domutil_1.indexOf(parent.childNodes, ph);
node = parent;
}
const real = domutil_1.closestByClass(node, "_real", this.guiRoot);
const readonly = real !== null && real.classList.contains("_readonly");
const method = domutil_1.closestByClass(node, "_attribute_value", this.guiRoot) !== null ?
this.getMenuItemsForAttribute :
this.getMenuItemsForElement;
const menuItems = method.call(this, node, offset, !sel.collapsed);
// There's no menu to display, so let the event bubble up.
if (menuItems.length === 0) {
return true;
}
this.setupContextMenu(action_context_menu_1.ActionContextMenu, menuItems, readonly, e);
return false;
}
/**
* Dismiss the menu currently shown. If there is no menu currently shown, does
* nothing.
*/
dismiss() {
// We may be called when there is no menu active.
if (this.currentDropdown !== undefined) {
this.currentDropdown.dismiss();
}
if (this.currentTypeahead !== undefined) {
this.currentTypeahead.dismiss();
}
}
/**
* Compute an appropriate position for a context menu, and display it. This is
* a convenience function that essentially combines [[computeMenuPosition]]
* and [[displayContextMenu]].
*
* @param cmClass See [[displayContextMenu]].
*
* @param items See [[displayContextMenu]].
*
* @param readonly See [[displayContextMenu]].
*
* @param e See [[computeMenuPosition]].
*
* @param bottom See [[computeMenuPosition]].
*/
// @ts-ignore
setupContextMenu(cmClass, items, readonly, e, bottom) {
const pos = this.computeMenuPosition(e, bottom);
this.displayContextMenu(action_context_menu_1.ActionContextMenu, pos.left, pos.top, items, readonly);
}
/**
* Display a context menu.
*
* @param cmClass The class to use to create the menu.
*
* @param x The position of the menu.
*
* @param y The position of the menu.
*
* @param items The menu items to show.
*
* @param readonly If true, don't include in the menu any operation that
* would trigger a ``Transformation``.
*/
displayContextMenu(cmClass, x, y, items, readonly) {
// Eliminate duplicate items. We perform a check only in the description of
// the action, and on ``data.name``.
const seen = Object.create(null);
items = items.filter((item) => {
// "\0" not a legitimate value in descriptions.
let actionKey = `${(item.action !== null ?
item.action.getDescription() : "")}\0`;
if (item.data !== null) {
actionKey += item.data.name;
}
const keep = !seen[actionKey];
seen[actionKey] = true;
if (!keep || !readonly) {
return keep;
}
// If we get here, then we need to filter out anything that transforms the
// tree.
return !(item.action instanceof transformation_1.Transformation);
});
this.dismiss();
this.caretManager.pushSelection();
this.currentDropdown = new cmClass(this.doc, x, y, items, () => {
this.currentDropdown = undefined;
this.caretManager.popSelection();
});
}
getMenuItemsForAttribute() {
return [];
}
getMenuItemsForElement(node, offset, wrap) {
let actualNode = node;
// If we are in a phantom, we want to get to the first parent which is not
// phantom.
let lastPhantomChild;
while (actualNode !== null && actualNode.classList.contains("_phantom")) {
lastPhantomChild = actualNode;
actualNode = actualNode.parentNode;
}
if (actualNode === null || !this.guiRoot.contains(actualNode)) {
return [];
}
if (lastPhantomChild !== undefined) {
// The actualNode exists and is in our GUI tree. If the offset is outside
// editable contents, move it into editable contents.
({ offset } = this.caretManager
.normalizeToEditableRange(dloc_1.DLoc.mustMakeDLoc(this.guiRoot, lastPhantomChild)));
}
const menuItems = [];
const pushItem = (data, tr) => {
const li = this.makeMenuItemForAction(tr, data);
menuItems.push({ action: tr, item: li, data: data });
};
if ( // Should not be part of a gui element.
!actualNode.parentNode.classList.contains("_gui")) {
// We want the data node, not the gui node.
const treeCaret = this.caretManager.toDataLocation(actualNode, offset);
if (treeCaret === undefined) {
throw new Error("cannot find tree caret");
}
// We are cheating a bit here. treeCaret.node cannot be a text node
// because of the way this method is invoked. It cannot be an attribute
// either. However, it could be a Document, which happens if the edited
// document is empty.
const dataNode = treeCaret.node;
const tagName = dataNode.tagName;
const mode = this.modeTree.getMode(dataNode);
menuItems.push(...this.makeCommonItems(dataNode));
const trs = this.editor.getElementTransformationsAt(treeCaret, wrap ? "wrap" : "insert");
for (const tr of trs) {
// If tr.name is not undefined we have a real transformation.
// Otherwise, it is an action.
pushItem((tr.name !== undefined) ? { name: tr.name } : null, tr.tr);
}
if (dataNode !== this.dataRoot.firstChild && dataNode !== this.dataRoot) {
const actions = mode.getContextualActions(["unwrap", "delete-parent", "split"], tagName, dataNode, 0);
for (const action of actions) {
pushItem({ node: dataNode, name: tagName }, action);
}
}
}
const $sep = $(actualNode).parents().addBack()
.siblings("[data-wed--separator-for]").first();
const sepFor = $sep[0] !== undefined ?
$sep[0].getAttribute("data-wed--separator-for") : null;
if (sepFor !== null) {
const transformationNode = $sep.siblings()
.filter(function filter() {
// Node.contains() will return true if this === node, whereas
// jQuery.has() only looks at descendants, so this can't be replaced
// with .has().
return this.contains(actualNode);
})[0];
const mode = this.modeTree.getMode(transformationNode);
const actions = mode.getContextualActions(["merge-with-next", "merge-with-previous", "append", "prepend"], sepFor, $.data(transformationNode, "wed_mirror_node"), 0);
for (const action of actions) {
pushItem({ node: transformationNode, name: sepFor }, action);
}
}
return menuItems;
}
/**
* Make the menu items that should appear in all contextual menus.
*
* @param dataNode The element for which we are creating the menu.
*
* @returns Menu items.
*/
makeCommonItems(dataNode) {
const menuItems = [];
if (domtypeguards_1.isElement(dataNode)) {
const tagName = dataNode.tagName;
const mode = this.modeTree.getMode(dataNode);
const docURL = mode.documentationLinkFor(tagName);
if (docURL != null) {
const li = this.makeDocumentationMenuItem(docURL);
menuItems.push({ action: null, item: li, data: null });
}
}
return menuItems;
}
/**
* Make a standardized menu item for a specific action. This method formats
* the menu item and sets an even handler appropriate to invoke the action's
* event handler.
*
* @param action The action for which we make a menu item.
*
* @param data The data that accompanies the action.
*
* @param start This parameter determines whether we are creating an item for
* a start label (``true``) an end label (``false``) or
* something which is neither a start or end label
* (``undefined``).
*
* @returns A HTML element which is fit to serve as a menu item.
*/
makeMenuItemForAction(action, data, start) {
const icon = action.getIcon();
const li = domutil_1.htmlToElements(`<li><a tabindex='0' href='#'>${icon !== undefined ? `${icon} ` : ""}\
</a></li>`, this.doc)[0];
if (action instanceof transformation_1.Transformation && action.kind !== undefined) {
li.setAttribute("data-kind", action.kind);
}
const a = li.firstElementChild;
// We do it this way so that to avoid an HTML interpretation of
// action.getDescriptionFor()`s return value.
const text = this.doc.createTextNode(action.getDescriptionFor(data) +
atStartToTxt[String(start)]);
a.appendChild(text);
a.normalize();
$(a).click(data, action.boundTerminalHandler);
return li;
}
/**
* Makes an HTML link to open the documentation of an element.
*
* @param docUrl The URL to the documentation to open.
*
* @returns A ``<a>`` element that links to the documentation.
*/
makeDocumentationMenuItem(docURL) {
const iconHtml = icon_1.makeHTML("documentation");
const li = domutil_1.htmlToElements(`<li><a tabindex='0' href='#'>${iconHtml} \
Element's documentation.</a></li>`, this.doc)[0];
const a = li.firstElementChild;
$(a).click(() => {
this.editor.openDocumentationLink(docURL);
});
return li;
}
getPossibleAttributeValues() {
const sel = this.caretManager.sel;
// We must not have an actual range in effect
if (sel === undefined || !sel.collapsed) {
return [];
}
// If we have a selection, we necessarily have a caret.
const caret = this.caretManager.getNormalizedCaret();
const node = caret.node;
const attrVal = domutil_1.closestByClass(node, "_attribute_value", this.guiRoot);
if (attrVal === null ||
domutil_1.isNotDisplayed(attrVal, this.guiRoot)) {
return [];
}
// If we have a selection, we necessarily have a caret.
const dataCaret = this.caretManager.getDataCaret();
// The node is necessarily an attribute.
const dataNode = dataCaret.node;
// First see if the mode has something to say.
const mode = this.modeTree.getMode(dataNode);
const possible = mode.getAttributeCompletions(dataNode);
if (possible.length === 0) {
// Nothing from the mode, use the validator.
this.editor.validator.possibleAt(dataCaret.node, 0)
.forEach((ev) => {
if (ev.params[0] !== "attributeValue") {
return;
}
const text = ev.params[1];
if (text instanceof RegExp) {
return;
}
possible.push(text);
});
}
return possible;
}
setupCompletionMenu() {
this.dismiss();
const possible = this.getPossibleAttributeValues();
// Nothing to complete.
if (possible.length === 0) {
return;
}
const dataCaret = this.caretManager.getDataCaret();
if (dataCaret === undefined) {
return;
}
// The node is necessarily an attribute, otherwise possible would have a
// length of 0.
const dataNode = dataCaret.node;
// We complete only at the end of an attribute value.
if (dataCaret.offset !== dataNode.value.length) {
return;
}
const narrowed = [];
for (const possibility of possible) {
if (possibility.lastIndexOf(dataNode.value, 0) === 0) {
narrowed.push(possibility);
}
}
// The current value in the attribute is not one that can be
// completed.
if (narrowed.length === 0 ||
(narrowed.length === 1 && narrowed[0] === dataNode.value)) {
return;
}
const pos = this.computeMenuPosition(undefined, true);
this.caretManager.pushSelection();
const menu = this.currentDropdown = new completion_menu_1.CompletionMenu(this.editor, this.guiRoot.ownerDocument, pos.left, pos.top, dataNode.value, possible, () => {
this.currentDropdown = undefined;
// If the focus moved from the document to the completion menu, we
// want to restore the caret. Otherwise, leave it as is.
if (menu.focused) {
this.caretManager.popSelection();
}
else {
this.caretManager.popSelectionAndDiscard();
}
});
}
setupReplacementMenu() {
this.dismiss();
const possible = this.getPossibleAttributeValues();
// Nothing to complete.
if (possible.length === 0) {
return;
}
const dataCaret = this.caretManager.getDataCaret();
if (dataCaret === undefined) {
return;
}
const pos = this.computeMenuPosition(undefined, true);
this.caretManager.pushSelection();
this.currentDropdown = new replacement_menu_1.ReplacementMenu(this.editor, this.guiRoot.ownerDocument, pos.left, pos.top, possible, (selected) => {
this.currentDropdown = undefined;
this.caretManager.popSelection();
if (selected === undefined) {
return;
}
// The node is necessarily an attribute, otherwise possible would have a
// length of 0.
const dataNode = dataCaret.node;
const uri = dataNode.namespaceURI !== null ? dataNode.namespaceURI : "";
this.editor.dataUpdater.setAttributeNS(dataNode.ownerElement, uri, dataNode.name, selected);
});
}
/**
* Compute an appropriate position for a typeahead popup, and display it. This
* is a convenience function that essentially combines [[computeMenuPosition]]
* and [[displayTypeaheadPopup]].
*
* @param width See [[displayTypeaheadPopup]].
*
* @param placeholder See [[displayTypeaheadPopup]].
*
* @param options See [[displayTypeaheadPopup]].
*
* @param dismissCallback See [[displayTypeaheadPopup]].
*
* @param e See [[computeMenuPosition]].
*
* @param bottom See [[computeMenuPosition]].
*
* @returns The popup that was created.
*/
setupTypeaheadPopup(width, placeholder,
// tslint:disable-next-line:no-any
options,
// tslint:disable-next-line:no-any
dismissCallback, e, bottom) {
const pos = this.computeMenuPosition(e, bottom);
return this.displayTypeaheadPopup(pos.left, pos.top, width, placeholder, options, dismissCallback);
}
/**
* Brings up a typeahead popup.
*
* @param x The position of the popup.
*
* @param y The position of the popup.
*
* @param width The width of the popup.
*
* @param placeholder Placeholder text to put in the input field.
*
* @param options Options for Twitter Typeahead.
*
* @param dismissCallback The callback to be called upon dismissal. It will be
* called with the object that was selected, if any.
*
* @returns The popup that was created.
*/
displayTypeaheadPopup(x, y, width, placeholder,
// tslint:disable-next-line:no-any
options,
// tslint:disable-next-line:no-any
dismissCallback) {
this.dismiss();
this.caretManager.pushSelection();
this.currentTypeahead = new typeahead_popup_1.TypeaheadPopup(this.doc, x, y, width, placeholder, options, (obj) => {
this.currentTypeahead = undefined;
this.caretManager.popSelection();
if (dismissCallback !== undefined) {
dismissCallback(obj);
}
});
return this.currentTypeahead;
}
/**
* Computes where a menu should show up, depending on the event that triggered
* it.
*
* @param e The event that triggered the menu. If no event is passed, it is
* assumed that the menu was not triggered by a mouse event.
*
* @param bottom Only used when the event was not triggered by a mouse event
* (``e === undefined``). If ``bottom`` is true, use the bottom of the DOM
* entity used to compute the ``left`` coordinate. Otherwise, use its middle
* to determine the ``left`` coordinate.
*
* @returns The top and left coordinates where the menu should appear.
*/
computeMenuPosition(e, bottom = false) {
if (e === undefined) {
// tslint:disable-next-line:no-object-literal-type-assertion
e = {};
}
// Take care of cases where the user is using the mouse.
if (e.type === "mousedown" || e.type === "mouseup" || e.type === "click" ||
e.type === "contextmenu") {
return { left: e.clientX, top: e.clientY };
}
// The next conditions happen only if the user is using the keyboard
const mark = this.caretManager.mark;
if (mark.inDOM) {
mark.scrollIntoView();
// We need to refresh immediately and acquire the client rectangle of the
// caret.
mark.refresh();
const rect = mark.getBoundingClientRect();
return {
top: bottom ? rect.bottom : (rect.top + (rect.height / 2)),
left: rect.left,
};
}
const gui = domutil_1.closestByClass(this.caretManager.caret.node, "_gui", this.guiRoot);
if (gui !== null) {
const rect = gui.getBoundingClientRect();
// Middle of the region.
return {
top: bottom ? rect.bottom : (rect.top + (rect.height / 2)),
left: rect.left + (rect.width / 2),
};
}
throw new Error("no position for displaying the menu");
}
}
exports.EditingMenuManager = EditingMenuManager;
});
// LocalWords: MPL contextMenuHandler readonly actualNode treeCaret jQuery li
// LocalWords: prepend tabindex href getDescriptionFor iconHtml mousedown
// LocalWords: attributeValue mouseup contextmenu computeMenuPosition
// LocalWords: displayContextMenu
//# sourceMappingURL=editing-menu-manager.js.map