UNPKG

wed

Version:

Wed is a schema-aware editor for XML documents.

432 lines 20.7 kB
/** * Basic decoration facilities. * @author Louis-Dominique Dubeau * @license MPL 2.0 * @copyright Mangalam Research Center for Buddhist Languages */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; }; define(["require", "exports", "jquery", "./dloc", "./domtypeguards", "./domutil", "./gui/action-context-menu", "./util"], function (require, exports, jquery_1, dloc_1, domtypeguards_1, domutil, action_context_menu_1, util) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); jquery_1 = __importDefault(jquery_1); domutil = __importStar(domutil); util = __importStar(util); const indexOf = domutil.indexOf; const closestByClass = domutil.closestByClass; function tryToSetDataCaret(editor, dataCaret) { try { editor.caretManager.setCaret(dataCaret, { textEdit: true }); } catch (e) { // Do nothing. } } function attributeSelectorMatch(selector, name) { return selector === "*" || selector === name; } /** * A decorator is responsible for adding decorations to a tree of DOM * elements. Decorations are GUI elements. */ class Decorator { /** * @param domlistener The listener that the decorator must use to know when * the DOM tree has changed and must be redecorated. * * @param editor The editor instance for which this decorator was created. * * @param guiUpdater The updater to use to modify the GUI tree. All * modifications to the GUI must go through this updater. */ constructor(mode, editor) { this.mode = mode; this.editor = editor; this.domlistener = editor.domlistener; this.guiUpdater = editor.guiUpdater; this.namespaces = mode.getAbsoluteNamespaceMappings(); } /** * Start listening to changes to the DOM tree. */ startListening() { this.domlistener.startListening(); } listDecorator(el, sep) { if (this.editor.modeTree.getMode(el) !== this.mode) { // The element is not governed by this mode. return; } // We expect to work with a homogeneous list. That is, all children the same // element. const nameMap = Object.create(null); let child = el.firstElementChild; while (child !== null) { if (child.classList.contains("_real")) { nameMap[util.getOriginalName(child)] = 1; } child = child.nextElementSibling; } const tags = Object.keys(nameMap); if (tags.length > 1) { throw new Error("calling listDecorator on a non-homogeneous list."); } if (tags.length === 0) { return; } // Nothing to work with // First drop all children that are separators child = el.firstElementChild; while (child !== null) { // Grab it before the node is removed. const next = child.nextElementSibling; if (child.hasAttribute("data-wed--separator-for")) { this.guiUpdater.removeNode(child); } child = next; } const tagName = tags[0]; // If sep is a string, create an appropriate div. let sepNode; if (typeof sep === "string") { sepNode = el.ownerDocument.createElement("div"); sepNode.textContent = sep; } else { sepNode = sep; } sepNode.classList.add("_text"); sepNode.classList.add("_phantom"); sepNode.setAttribute("data-wed--separator-for", tagName); let first = true; child = el.firstElementChild; while (child !== null) { if (child.classList.contains("_real")) { if (!first) { this.guiUpdater.insertBefore(el, sepNode.cloneNode(true), child); } else { first = false; } } child = child.nextElementSibling; } } // tslint:disable-next-line:max-func-body-length elementDecorator(_root, el, level, preContextHandler, postContextHandler) { if (this.editor.modeTree.getMode(el) !== this.mode) { // The element is not governed by this mode. return; } if (level > this.editor.maxLabelLevel) { throw new Error(`level higher than the maximum set by the mode: ${level}`); } // Save the caret because the decoration may mess up the GUI caret. let dataCaret = this.editor.caretManager.getDataCaret(); if (dataCaret != null && !(domtypeguards_1.isAttr(dataCaret.node) && dataCaret.node.ownerElement === jquery_1.default.data(el, "wed_mirror_node"))) { dataCaret = undefined; } const dataNode = jquery_1.default.data(el, "wed_mirror_node"); this.setReadOnly(el, Boolean(this.editor.validator.getNodeProperty(dataNode, "PossibleDueToWildcard"))); const origName = util.getOriginalName(el); // _[name]_label is used locally to make the function idempotent. let cls = `_${origName}_label`; // We must grab a list of nodes to remove before we start removing them // because an element that has a placeholder in it is going to lose the // placeholder while we are modifying it. This could throw off the scan. const toRemove = domutil.childrenByClass(el, cls); for (const remove of toRemove) { // // This is really a workaround for a problem with how the decorator // works. We should use this.guiUpdater.removeChild. However, when this // removal merges text nodes, it causes elementDecorator to be reentered // and this causes problems. // // The decoration code should be revamped to listen on the data tree // rather than listen on the GUI tree. // // Listening on the GUI tree may be desirable sometimes but it should not // be the default wed behavior. // this.guiUpdater.removeTooltips(remove); el.removeChild(remove); } let attributesHTML = ""; let hiddenAttributes = false; const attributeHandling = this.editor.modeTree.getAttributeHandling(el); if (attributeHandling === "show" || attributeHandling === "edit") { // include the attributes const attributes = util.getOriginalAttributes(el); const names = Object.keys(attributes).sort(); for (const name of names) { const hideAttribute = this.mustHideAttribute(el, name); if (hideAttribute) { hiddenAttributes = true; } const extra = hideAttribute ? " _shown_when_caret_in_label" : ""; attributesHTML += ` \ <span class="_phantom _attribute${extra}">\ <span class="_phantom _attribute_name">${name}</span>=\ "<span class="_phantom _attribute_value">\ ${domutil.textToHTML(attributes[name])}</span>"</span>`; } } const doc = el.ownerDocument; cls += ` _label_level_${level}`; // Save the cls of the end label here so that we don't further modify it. const endCls = cls; if (hiddenAttributes) { cls += " _autohidden_attributes"; } const pre = doc.createElement("span"); pre.className = `_gui _phantom __start_label _start_wrapper ${cls} _label`; const prePh = doc.createElement("span"); prePh.className = "_phantom"; // tslint:disable-next-line:no-inner-html prePh.innerHTML = `&nbsp;<span class='_phantom _element_name'>${origName}\ </span>${attributesHTML}<span class='_phantom _greater_than'> >&nbsp;</span>`; pre.appendChild(prePh); this.guiUpdater.insertNodeAt(el, 0, pre); const post = doc.createElement("span"); post.className = `_gui _phantom __end_label _end_wrapper ${endCls} _label`; const postPh = doc.createElement("span"); postPh.className = "_phantom"; // tslint:disable-next-line:no-inner-html postPh.innerHTML = `<span class='_phantom _less_than'>&nbsp;&lt; </span>\ <span class='_phantom _element_name'>${origName}</span>&nbsp;`; post.appendChild(postPh); this.guiUpdater.insertBefore(el, post, null); // Setup a handler so that clicking one label highlights it and the other // label. jquery_1.default(pre).on("wed-context-menu", preContextHandler !== undefined ? preContextHandler : false); jquery_1.default(post).on("wed-context-menu", postContextHandler !== undefined ? postContextHandler : false); if (dataCaret != null) { tryToSetDataCaret(this.editor, dataCaret); } } /** * Determine whether an attribute must be hidden. The default implementation * calls upon the ``attributes.autohide`` section of the "wed options" that * were used by the mode in effect to determine whether an attribute should be * hidden or not. * * @param el The element in the GUI tree that we want to test. * * @param name The attribute name in "prefix:localName" format where "prefix" * is to be understood according to the absolute mapping defined by the mode. * * @returns ``true`` if the attribute must be hidden. ``false`` otherwise. */ mustHideAttribute(el, name) { const specs = this.editor.modeTree.getAttributeHidingSpecs(el); if (specs === null) { return false; } for (const element of specs.elements) { if (el.matches(element.selector)) { let matches = false; for (const attribute of element.attributes) { if (typeof attribute === "string") { // If we already matched, there's no need to try to match with // another selector. if (!matches) { matches = attributeSelectorMatch(attribute, name); } } else { // If we do not match yet, there's no need to try to exclude the // attribute. if (matches) { for (const exception of attribute.except) { matches = !attributeSelectorMatch(exception, name); // As soon as we stop matching, there's no need to continue // checking other exceptions. if (!matches) { break; } } } } } // An element selector that matches is terminal. return matches; } } return false; } /** * Add or remove the CSS class ``_readonly`` on the basis of the 2nd argument. * * @param el The element to modify. Must be in the GUI tree. * * @param readonly Whether the element is readonly or not. */ setReadOnly(el, readonly) { const cl = el.classList; (readonly ? cl.add : cl.remove).call(cl, "_readonly"); } /** * Context menu handler for the labels of elements decorated by * [[Decorator.elementDecorator]]. * * @param atStart Whether or not this event is for the start label. * * @param wedEv The DOM event that wed generated to trigger this handler. * * @param ev The DOM event that wed received. * * @returns To be interpreted the same way as for all DOM event handlers. */ // tslint:disable-next-line:max-func-body-length contextMenuHandler(atStart, wedEv, ev) { const editor = this.editor; const editingMenuManager = editor.editingMenuManager; let node = wedEv.target; const menuItems = []; const mode = editor.modeTree.getMode(node); function pushItem(data, tr, start) { const li = editingMenuManager.makeMenuItemForAction(tr, data, start); menuItems.push({ action: tr, item: li, data: data }); } function pushItems(data, trs, start) { if (trs === undefined) { return; } for (const tr of trs) { pushItem(data, tr, start); } } function processAttributeNameEvent(event, element) { const namePattern = event.params[1]; // The next if line causes tslint to inexplicably raise a failure. I am // able to reproduce it with something as small as: // // import { Name } from "salve"; // // export function func(p: Name): void { // if (p.simple()) { // document.body.textContent = "1"; // } // } // // tslint:disable-next-line:strict-boolean-expressions if (namePattern.simple()) { for (const name of namePattern.toArray()) { const unresolved = mode.getAbsoluteResolver().unresolveName(name.ns, name.name); if (unresolved === undefined) { throw new Error("cannot unresolve attribute"); } if (editor.isAttrProtected(unresolved, element)) { return; } pushItems({ name: unresolved, node: element }, mode.getContextualActions("add-attribute", unresolved, element)); } } else { pushItem(null, editor.complexPatternAction); } } const real = closestByClass(node, "_real", editor.guiRoot); const readonly = real !== null && real.classList.contains("_readonly"); const attrVal = closestByClass(node, "_attribute_value", editor.guiRoot); if (attrVal !== null) { const dataNode = editor.toDataNode(attrVal); const treeCaret = dloc_1.DLoc.mustMakeDLoc(editor.dataRoot, dataNode.ownerElement); const toAddTo = treeCaret.node.childNodes[treeCaret.offset]; editor.validator.possibleAt(treeCaret, true).forEach((event) => { if (event.params[0] !== "attributeName") { return; } processAttributeNameEvent(event, toAddTo); }); const name = dataNode.name; if (!editor.isAttrProtected(dataNode)) { pushItems({ name: name, node: dataNode }, mode.getContextualActions("delete-attribute", name, dataNode)); } } else { // We want the first real parent. const candidate = closestByClass(node, "_real", editor.guiRoot); if (candidate === null) { throw new Error("cannot find real parent"); } node = candidate; const topNode = (node.parentNode === editor.guiRoot); menuItems.push(...editingMenuManager.makeCommonItems(editor.toDataNode(node))); // We first gather the transformations that pertain to the node to which // the label belongs. const orig = util.getOriginalName(node); if (!topNode) { pushItems({ node: node, name: orig }, mode.getContextualActions(["unwrap", "delete-element"], orig, jquery_1.default.data(node, "wed_mirror_node"), 0)); } // Then we check what could be done before the node (if the // user clicked on an start element label) or after the node // (if the user clicked on an end element label). const parent = node.parentNode; let index = indexOf(parent.childNodes, node); // If we're on the end label, we want the events *after* the node. if (!atStart) { index++; } const treeCaret = editor.caretManager.toDataLocation(parent, index); if (treeCaret === undefined) { throw new Error("cannot get caret"); } if (atStart) { const toAddTo = treeCaret.node.childNodes[treeCaret.offset]; const attributeHandling = editor.modeTree.getAttributeHandling(toAddTo); if (attributeHandling === "edit") { editor.validator.possibleAt(treeCaret, true).forEach((event) => { if (event.params[0] !== "attributeName") { return; } processAttributeNameEvent(event, toAddTo); }); } } if (!topNode) { for (const tr of editor.getElementTransformationsAt(treeCaret, "insert")) { if (tr.name !== undefined) { // Regular case: we have a real transformation. pushItem({ name: tr.name, moveCaretTo: treeCaret }, tr.tr, atStart); } else { // It is an action rather than a transformation. pushItem(null, tr.tr); } } if (atStart) { // Move to inside the element and get the get the wrap-content // possibilities. const caretInside = treeCaret.make(treeCaret.node.childNodes[treeCaret.offset], 0); for (const tr of editor.getElementTransformationsAt(caretInside, "wrap-content")) { pushItem(tr.name !== undefined ? { name: tr.name, node: node } : null, tr.tr); } } } } // There's no menu to display, so let the event bubble up. if (menuItems.length === 0) { return true; } editingMenuManager.setupContextMenu(action_context_menu_1.ActionContextMenu, menuItems, readonly, ev); return false; } } exports.Decorator = Decorator; }); // LocalWords: attributeName unresolve func tslint readonly localName endCls // LocalWords: PossibleDueToWildcard Dubeau MPL Mangalam attributesHTML util // LocalWords: jquery validator domutil domlistener gui autohidden jQuery cls // LocalWords: listDecorator origName li nbsp lt el sep //# sourceMappingURL=decorator.js.map