wed
Version:
Wed is a schema-aware editor for XML documents.
580 lines • 23.5 kB
JavaScript
/**
* Transformation framework.
* @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", "lodash", "rxjs", "./action", "./domtypeguards", "./domutil", "./exceptions", "./gui/icon"], function (require, exports, lodash_1, rxjs_1, action_1, domtypeguards_1, domutil_1, exceptions_1, icon) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
lodash_1 = __importDefault(lodash_1);
icon = __importStar(icon);
const TYPE_TO_KIND = lodash_1.default.extend(Object.create(null), {
// These are not actually type names. It is possible to use a kind name as a
// type name if the transformation is not more specific. In this case the kind
// === type.
add: "add",
delete: "delete",
transform: "transform",
insert: "add",
"delete-element": "delete",
"delete-parent": "delete",
wrap: "wrap",
"wrap-content": "wrap",
"merge-with-next": "transform",
"merge-with-previous": "transform",
"swap-with-next": "transform",
"swap-with-previous": "transform",
split: "transform",
append: "add",
prepend: "add",
unwrap: "unwrap",
"add-attribute": "add",
"delete-attribute": "delete",
});
const TYPE_TO_NODE_TYPE = lodash_1.default.extend(Object.create(null), {
// These are not actually type names. These are here to handle the
// case where the type is actually a kind name. Since they are not
// more specific, the node type is set to "other". Note that
// "wrap" and "unwrap" are always about elements so there is no
// way to have a "wrap/unwrap" which has "other" for the node
// type.
add: "other",
delete: "other",
transform: "other",
insert: "element",
"delete-element": "element",
"delete-parent": "element",
wrap: "element",
"wrap-content": "element",
"merge-with-next": "element",
"merge-with-previous": "element",
"swap-with-next": "element",
"swap-with-previous": "element",
split: "element",
append: "element",
prepend: "element",
unwrap: "element",
"add-attribute": "attribute",
"delete-attribute": "attribute",
});
function computeIconHtml(iconHtml, transformationType) {
if (iconHtml !== undefined) {
return iconHtml;
}
const kind = TYPE_TO_KIND[transformationType];
if (kind !== undefined) {
return icon.makeHTML(kind);
}
return undefined;
}
/**
* An operation that transforms the data tree.
*/
class Transformation extends action_1.Action {
/**
* @param editor The editor for which this transformation is created.
*
* @param transformationType The type of transformation.
*
* @param desc The description of this transformation. A transformation's
* [[getDescriptionFor]] method will replace ``<name>`` with the name of the
* node actually being processed. So a string like ``Remove <name>`` would
* become ``Remove foo`` when the transformation is called for the element
* ``foo``.
*
* @param handler The handler to call when this transformation is executed.
*
* @param options Additional options.
*/
constructor(editor, transformationType, desc, handler, options) {
const actualOpts = options !== undefined ? options : {};
super(editor, desc, actualOpts.abbreviatedDesc, computeIconHtml(actualOpts.iconHtml, transformationType), actualOpts.needsInput);
if (handler === undefined) {
throw new Error("did not specify a handler");
}
this.handler = handler;
this.transformationType = transformationType;
this.kind = TYPE_TO_KIND[transformationType];
this.nodeType = TYPE_TO_NODE_TYPE[transformationType];
this.treatAsTextInput = actualOpts.treatAsTextInput !== undefined ?
actualOpts.treatAsTextInput : false;
}
getDescriptionFor(data) {
if (data.name === undefined) {
return this.desc;
}
return this.desc.replace(/<name>/, data.name);
}
/**
* Calls the ``fireTransformation`` method on this transformation's editor.
*
* @param data The data object to pass.
*/
execute(data) {
this.editor.fireTransformation(this, data);
}
}
exports.Transformation = Transformation;
/**
* Transformation events are generated by an editor before and after a
* transformation is executed. The ``StartTransformation`` event is generated
* before, and the ``EndTransformation`` is generated after. These events allow
* modes to perform additional processing before or after a transformation, or
* to abort a transformation while it is being processed.
*/
class TransformationEvent {
/**
* @param name The name of the event.
* @param transformation The transformation to which the event pertains.
*/
constructor(name, transformation) {
this.name = name;
this.transformation = transformation;
this._aborted = false;
}
/** Whether the transformation is aborted. */
get aborted() {
return this._aborted;
}
/**
* Mark the transformation as aborted. Once aborted, a transformation cannot
* be unaborted.
*/
abort(message) {
this._aborted = true;
this._abortMessage = message;
}
/**
* Raise an [[AbortTransformationException]] if the event was marked as
* aborted.
*/
throwIfAborted() {
if (this.aborted) {
throw new exceptions_1.AbortTransformationException(this._abortMessage);
}
}
}
exports.TransformationEvent = TransformationEvent;
/**
* A subject that emits [[TransformationEvent]] objects and immediately stops
* calling subscribers when the [[TransformationEvent]] object it is processing
* is aborted.
*/
class TransformationEventSubject extends rxjs_1.Subject {
next(value) {
if (this.closed) {
throw new rxjs_1.ObjectUnsubscribedError();
}
if (this.isStopped || value.aborted) {
return;
}
for (const observer of this.observers.slice()) {
observer.next(value);
if (value.aborted) {
break;
}
}
}
}
exports.TransformationEventSubject = TransformationEventSubject;
/**
* Makes an element appropriate for a wed data tree.
*
* @param doc The document for which to make the element.
*
* @param ns The URI of the namespace to use for the new element.
*
* @param name The name of the new element.
*
* @param attrs An object whose fields will become attributes for the new
* element.
*
* @returns The new element.
*/
function makeElement(doc, ns, name, attrs) {
const e = doc.createElementNS(ns, name);
if (attrs !== undefined) {
// Create attributes
const keys = Object.keys(attrs).sort();
for (const key of keys) {
e.setAttribute(key, attrs[key]);
}
}
return e;
}
exports.makeElement = makeElement;
/**
* Insert an element in a wed data tree.
*
* @param dataUpdater A tree updater through which to update the DOM tree.
*
* @param parent The parent of the new node.
*
* @param index Offset in the parent where to insert the new node.
*
* @param ns The URI of the namespace to use for the new element.
*
* @param name The name of the new element.
*
* @param attrs An object whose fields will become attributes for the new
* element.
*
* @returns The new element.
*/
function insertElement(dataUpdater, parent, index, ns, name, attrs) {
const ownerDocument = domtypeguards_1.isDocument(parent) ? parent : parent.ownerDocument;
const el = makeElement(ownerDocument, ns, name, attrs);
dataUpdater.insertAt(parent, index, el);
return el;
}
exports.insertElement = insertElement;
/**
* Wraps a span of text in a new element.
*
* @param dataUpdater A tree updater through which to update the DOM tree.
*
* @param node The DOM node where to wrap. Must be a text node.
*
* @param offset The offset in the node. This parameter specifies where to start
* wrapping.
*
* @param endOffset Offset in the node. This parameter specifies where to end
* wrapping.
*
* @param ns The URI of the namespace to use for the new element.
*
* @param name The name of the wrapping element.
*
* @param attrs An object whose fields will become attributes for the new
* element.
*
* @returns The new element.
*/
function wrapTextInElement(dataUpdater, node, offset, endOffset, ns, name, attrs) {
const textToWrap = node.data.slice(offset, endOffset);
const parent = node.parentNode;
if (parent === null) {
throw new Error("detached node");
}
const nodeOffset = domutil_1.indexOf(parent.childNodes, node);
dataUpdater.deleteText(node, offset, textToWrap.length);
const newElement = makeElement(node.ownerDocument, ns, name, attrs);
if (textToWrap !== "") {
// It is okay to manipulate the DOM directly as long as the DOM tree being
// manipulated is not *yet* inserted into the data tree. That is the case
// here.
newElement.appendChild(node.ownerDocument.createTextNode(textToWrap));
}
if (node.parentNode === null) {
// The entire node was removed.
dataUpdater.insertAt(parent, nodeOffset, newElement);
}
else {
dataUpdater.insertAt(node, offset, newElement);
}
return newElement;
}
exports.wrapTextInElement = wrapTextInElement;
/**
* Utility function for [[wrapInElement]].
*
* @param dataUpdater A tree updater through which to update the DOM tree.
*
* @param container The text node to split.
*
* @param offset Where to split the node
*
* @returns A caret location marking where the split occurred.
*/
function _wie_splitTextNode(dataUpdater, container, offset) {
const parent = container.parentNode;
if (parent === null) {
throw new Error("detached node");
}
const containerOffset = domutil_1.indexOf(parent.childNodes, container);
// The first two cases here just return a caret outside of the text node
// rather than make a split that will create a useless empty text node.
if (offset === 0) {
offset = containerOffset;
}
else if (offset >= container.length) {
offset = containerOffset + 1;
}
else {
const text = container.data.slice(offset);
dataUpdater.setTextNode(container, container.data.slice(0, offset));
dataUpdater.insertNodeAt(parent, containerOffset + 1, container.ownerDocument.createTextNode(text));
offset = containerOffset + 1;
}
return [parent, offset];
}
/**
* Wraps a well-formed span in a new element. This span can contain text and
* element nodes.
*
* @param dataUpdater A tree updater through which to update the DOM tree.
*
* @param startContainer The node where to start wrapping.
*
* @param startOffset The offset where to start wrapping.
*
* @param endContainer The node where to end wrapping.
*
* @param endOffset The offset where to end wrapping.
*
* @param ns The URI of the namespace to use for the new element.
*
* @param name The name of the new element.
*
* @param [attrs] An object whose fields will become attributes for the new
* element.
*
* @returns The new element.
*
* @throws {Error} If the range is malformed or if there is an internal error.
*/
function wrapInElement(dataUpdater, startContainer, startOffset, endContainer, endOffset, ns, name, attrs) {
if (!domutil_1.isWellFormedRange({ startContainer, startOffset, endContainer,
endOffset })) {
throw new Error("malformed range");
}
if (domtypeguards_1.isText(startContainer)) {
// We already have an algorithm for this case.
if (startContainer === endContainer) {
return wrapTextInElement(dataUpdater, startContainer, startOffset, endOffset, ns, name, attrs);
}
[startContainer, startOffset] =
_wie_splitTextNode(dataUpdater, startContainer, startOffset);
}
if (domtypeguards_1.isText(endContainer)) {
[endContainer, endOffset] =
_wie_splitTextNode(dataUpdater, endContainer, endOffset);
}
if (startContainer !== endContainer) {
throw new Error("startContainer and endContainer are not the same;" +
"probably due to an algorithmic mistake");
}
const newElement = makeElement(startContainer.ownerDocument, ns, name, attrs);
while (--endOffset >= startOffset) {
const endNode = endContainer.childNodes[endOffset];
dataUpdater.deleteNode(endNode);
// Okay to change a tree which is not yet connected to the data tree.
newElement.insertBefore(endNode, newElement.firstChild);
}
dataUpdater.insertAt(startContainer, startOffset, newElement);
return newElement;
}
exports.wrapInElement = wrapInElement;
/**
* Replaces an element with its contents.
*
* @param dataUpdater A tree updater through which to update the DOM tree.
*
* @param node The element to unwrap.
*
* @returns The contents of the element.
*/
function unwrap(dataUpdater, node) {
const parent = node.parentNode;
if (parent === null) {
throw new Error("detached node");
}
const children = Array.prototype.slice.call(node.childNodes);
const prev = node.previousSibling;
const next = node.nextSibling;
// This does not merge text nodes, which is what we want. We also want to
// remove it first so that we don't generate so many update events.
dataUpdater.deleteNode(node);
// We want to calculate this index *after* removal.
let nextIx = (next !== null) ? domutil_1.indexOf(parent.childNodes, next) :
parent.childNodes.length;
const lastChild = node.lastChild;
// This also does not merge text nodes.
while (node.firstChild != null) {
dataUpdater.insertNodeAt(parent, nextIx++, node.firstChild);
}
// The order of the next two calls is important. We start at the end because
// going the other way around could cause lastChild to leave the DOM tree.
// Merge possible adjacent text nodes: the last child of the node that was
// removed in the unwrapping and the node that was after the node that was
// removed in the unwrapping.
if (lastChild !== null) {
dataUpdater.mergeTextNodes(lastChild);
}
// Merge the possible adjacent text nodes: the one before the start of the
// children we unwrapped and the first child that was unwrapped. There may not
// be a prev so we use the NF form of the call.
dataUpdater.mergeTextNodesNF(prev);
return children;
}
exports.unwrap = unwrap;
/**
* This function splits a node at the position of the caret. If the caret is not
* inside the node or its descendants, an exception is raised.
*
* @param editor The editor on which we are to perform the transformation.
*
* @param node The node to split.
*
* @throws {Error} If the caret is not inside the node or its descendants.
*/
function splitNode(editor, node) {
const caret = editor.caretManager.getDataCaret();
if (caret === undefined) {
throw new Error("no caret");
}
if (!node.contains(caret.node)) {
throw new Error("caret outside node");
}
const pair = editor.dataUpdater.splitAt(node, caret);
// Find the deepest location at the start of the 2nd element.
editor.caretManager.setCaret(domutil_1.firstDescendantOrSelf(pair[1]), 0);
}
exports.splitNode = splitNode;
/**
* This function merges an element with a previous element of the same name. For
* the operation to go forward, the element must have a previous sibling and
* this sibling must have the same name as the element being merged.
*
* @param editor The editor on which we are to perform the transformation.
*
* @param node The element to merge with previous.
*/
function mergeWithPreviousHomogeneousSibling(editor, node) {
const prev = node.previousElementSibling;
if (prev === null) {
return;
}
if (prev.localName !== node.localName ||
prev.namespaceURI !== node.namespaceURI) {
return;
}
// We need to record these to set the caret to a good position.
const caretPos = prev.childNodes.length;
const lastChild = prev.lastChild;
const wasText = domtypeguards_1.isText(lastChild);
// We need to record this *now* for future use, because it is possible that
// the next loop could modify lastChild in place.
const textLen = wasText ? lastChild.length : 0;
const insertionPoint = prev.childNodes.length;
// Reverse order
for (let i = node.childNodes.length - 1; i >= 0; --i) {
editor.dataUpdater.insertAt(prev, insertionPoint, node.childNodes[i].cloneNode(true));
}
if (wasText) {
// If wasText is true, lastChild cannot be null.
editor.dataUpdater.mergeTextNodes(lastChild);
editor.caretManager.setCaret(prev.childNodes[caretPos - 1], textLen);
}
else {
editor.caretManager.setCaret(prev, caretPos);
}
editor.dataUpdater.removeNode(node);
}
exports.mergeWithPreviousHomogeneousSibling = mergeWithPreviousHomogeneousSibling;
/**
* This function merges an element with a next element of the same name. For the
* operation to go forward, the element must have a next sibling and this
* sibling must have the same name as the element being merged.
*
* @param editor The editor on which we are to perform the transformation.
*
* @param node The element to merge with next.
*/
function mergeWithNextHomogeneousSibling(editor, node) {
const next = node.nextElementSibling;
if (next === null) {
return;
}
mergeWithPreviousHomogeneousSibling(editor, next);
}
exports.mergeWithNextHomogeneousSibling = mergeWithNextHomogeneousSibling;
/**
* This function swaps an element with a previous element of the same name. For
* the operation to go forward, the element must have a previous sibling and
* this sibling must have the same name as the element being merged.
*
* @param editor The editor on which we are to perform the transformation.
*
* @param node The element to swap with previous.
*/
function swapWithPreviousHomogeneousSibling(editor, node) {
const prev = node.previousElementSibling;
if (prev === null) {
return;
}
if (prev.localName !== node.localName ||
prev.namespaceURI !== node.namespaceURI) {
return;
}
const parent = prev.parentNode;
if (parent === null) {
throw new Error("detached node");
}
editor.dataUpdater.removeNode(node);
editor.dataUpdater.insertBefore(parent, node, prev);
editor.caretManager.setCaret(node);
}
exports.swapWithPreviousHomogeneousSibling = swapWithPreviousHomogeneousSibling;
/**
* This function swaps an element with a next element of the same name. For the
* operation to go forward, the element must have a next sibling and this
* sibling must have the same name as the element being merged.
*
* @param editor The editor on which we are to perform the transformation.
*
* @param node The element to swap with next.
*/
function swapWithNextHomogeneousSibling(editor, node) {
const next = node.nextElementSibling;
if (next === null) {
return;
}
swapWithPreviousHomogeneousSibling(editor, next);
}
exports.swapWithNextHomogeneousSibling = swapWithNextHomogeneousSibling;
/**
* Remove markup from the current selection. This turns mixed content into pure
* text. The selection must be well-formed, otherwise the transformation is
* aborted.
*
* @param editor The editor for which we are doing the transformation.
*/
function removeMarkup(editor) {
const selection = editor.caretManager.sel;
// Do nothing if we don't have a selection.
if (selection === undefined || selection.collapsed) {
return;
}
if (!selection.wellFormed) {
editor.modals.getModal("straddling").modal();
throw new exceptions_1.AbortTransformationException("selection is not well-formed");
}
const [start, end] = selection.asDataCarets();
const cutRet = editor.dataUpdater.cut(start, end);
let newText = "";
const cutNodes = cutRet[1];
for (const el of cutNodes) {
newText += el.textContent;
}
const insertRet = editor.dataUpdater.insertText(cutRet[0], newText);
editor.caretManager.setRange(start.make(insertRet.node, insertRet.isNew ? cutRet[0].offset : 0), insertRet.caret);
}
exports.removeMarkup = removeMarkup;
});
// LocalWords: wasText endOffset prepend endContainer startOffset html DOM
// LocalWords: startContainer Mangalam Dubeau previousSibling nextSibling MPL
// LocalWords: insertNodeAt deleteNode mergeTextNodes lastChild prev Prepend
// LocalWords: deleteText domutil
//# sourceMappingURL=transformation.js.map