UNPKG

wed

Version:

Wed is a schema-aware editor for XML documents.

1,045 lines 151 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; 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", "ajv", "jquery", "rxjs", "rxjs/operators", "salve", "salve-dom", "./action", "./caret-manager", "./clipboard", "./dloc", "./domlistener", "./domtypeguards", "./domutil", "./domutil", "./editor-actions", "./exceptions", "./gui-updater", "./gui/dialog-search-replace", "./gui/editing-menu-manager", "./gui/error-layer", "./gui/icon", "./gui/layer", "./gui/minibuffer", "./gui/modal", "./gui/notify", "./gui/quick-search", "./gui/scroller", "./gui/toolbar", "./gui/tooltip", "./guiroot", "./key", "./key-constants", "./log", "./mode-tree", "./onbeforeunload", "./onerror", "./options-schema.json", "./preferences", "./runtime", "./saver", "./selection-mode", "./stock-modals", "./task-runner", "./transformation", "./tree-updater", "./undo", "./undo-recorder", "./util", "./validation-controller", "./validator", "./wed-util", "./wundo", "bootstrap"], function (require, exports, ajv_1, jquery_1, rxjs_1, operators_1, salve, salve_dom_1, action_1, caret_manager_1, clipboard_1, dloc_1, domlistener, domtypeguards_1, domutil, domutil_1, editorActions, exceptions_1, gui_updater_1, dialog_search_replace_1, editing_menu_manager_1, error_layer_1, icon, layer_1, minibuffer_1, modal_1, notify_1, quick_search_1, scroller_1, toolbar_1, tooltip_1, guiroot_1, key_1, keyConstants, log, mode_tree_1, onbeforeunload, onerror, optionsSchema, preferences, runtime_1, saver_1, selection_mode_1, stock_modals_1, task_runner_1, transformation_1, tree_updater_1, undo_1, undo_recorder_1, util, validation_controller_1, validator_1, wed_util_1, wundo) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); ajv_1 = __importDefault(ajv_1); jquery_1 = __importDefault(jquery_1); salve = __importStar(salve); domlistener = __importStar(domlistener); domutil = __importStar(domutil); editorActions = __importStar(editorActions); icon = __importStar(icon); keyConstants = __importStar(keyConstants); log = __importStar(log); onbeforeunload = __importStar(onbeforeunload); onerror = __importStar(onerror); optionsSchema = __importStar(optionsSchema); preferences = __importStar(preferences); util = __importStar(util); wundo = __importStar(wundo); exports.version = "4.0.0"; // We don't put this in keyConstants because ESCAPE_KEYPRESS should never be // seen elsewhere. const ESCAPE_KEYPRESS = key_1.makeKey(27); function filterSaveEvents(name, ev) { return ev.name === name; } /** * An action for bringing up the complex pattern modal. */ class ComplexPatternAction extends action_1.Action { get modal() { if (this._modal === undefined) { const modal = this._modal = this.editor.makeModal(); modal.setTitle("Complex Name Pattern Encountered"); modal.setBody("<p>The schema contains here a complex name pattern modal. While wed \ has no problem validating such cases. It does not currently have facilities to \ add elements or attributes that match such patterns. You can continue editing \ your document but you will not be able to take advantage of the possibilities \ provided by the complex pattern here.</p>"); modal.addButton("Ok", true); } return this._modal; } execute() { this.modal.modal(); } } /** * The possible targets for some wed operations that generate events. It is * currently used to determine where to type keys when calling [[Editor.type]]. */ var WedEventTarget; (function (WedEventTarget) { /** The default target is the main editing panel. */ WedEventTarget[WedEventTarget["DEFAULT"] = 0] = "DEFAULT"; /** Target the minibuffer. */ WedEventTarget[WedEventTarget["MINIBUFFER"] = 1] = "MINIBUFFER"; })(WedEventTarget = exports.WedEventTarget || (exports.WedEventTarget = {})); var ClipboardEventHandling; (function (ClipboardEventHandling) { /** Set the DOM clipboard to the internal clipboard. */ ClipboardEventHandling[ClipboardEventHandling["SET_CLIPBOARD"] = 0] = "SET_CLIPBOARD"; /** Let the browser handle the event. */ ClipboardEventHandling[ClipboardEventHandling["PASS_TO_BROWSER"] = 1] = "PASS_TO_BROWSER"; /** Swallow the event: neither wed, nor the browser do anything. */ ClipboardEventHandling[ClipboardEventHandling["NOOP"] = 2] = "NOOP"; })(ClipboardEventHandling || (ClipboardEventHandling = {})); const FRAMEWORK_TEMPLATE = "\ <div class='row'>\ <div class='toolbar'></div>\ <div class='wed-frame col-sm-push-2 col-lg-10 col-md-10 col-sm-10'>\ <div class='row'>\ <div class='progress'>\ <span></span>\ <div class='wed-validation-progress progress-bar' style='width: 0%'></div>\ </div>\ </div>\ <div class='row'>\ <div class='wed-document-constrainer'>\ <input class='wed-comp-field' type='text'></input>\ <div class='wed-scroller'>\ <div class='wed-caret-layer'></div>\ <div class='wed-error-layer'></div>\ <div class='wed-document'><span class='root-here'></span></div>\ </div>\ </div>\ <div class='wed-minibuffer'></div>\ <div class='wed-location-bar'>@&nbsp;<span>&nbsp;</span></div>\ </div>\ </div>\ <div class='wed-sidebar col-sm-pull-10 col-lg-2 col-md-2 col-sm-2'>\ <div class='wed-save-and-modification-status'>\ <span class='wed-modification-status label label-success' \ title='Modification status'>\ <i class='fa fa-asterisk'></i>\ </span>\ <span class='wed-save-status label label-default'>\ <i class='fa fa-cloud-upload'></i> <span></span>\ </span>\ </div>\ <div id='sidebar-panel' class='panel-group wed-sidebar-panel'>\ <div class='panel panel-info wed-navigation-panel'>\ <div class='panel-heading'>\ <div class='panel-title'>\ <a class='accordion-toggle' data-toggle='collapse' \ data-parent='#sidebar-panel' href='#sb-nav-collapse'>Navigation</a>\ </div>\ </div>\ <div id='sb-nav-collapse' data-parent='#sidebar-panel' \ class='panel-collapse collapse in'>\ <div id='sb-nav' class='panel-body'>\ <ul id='navlist' class='nav nav-list'>\ <li class='inactive'>A list of navigation links will appear here</li>\ </ul>\ </div>\ </div>\ </div>\ <div class='panel panel-danger'>\ <div class='panel-heading'>\ <div class='panel-title'>\ <a class='accordion-toggle' data-toggle='collapse'\ data-parent='#sidebar-panel' href='#sb-errors-collapse'>Errors</a>\ </div>\ </div>\ <div id='sb-errors-collapse' data-parent='#sidebar-panel'\ class='panel-collapse collapse'>\ <div id='sb-errors' class='panel-body'>\ <ul id='sb-errorlist' class='nav nav-list wed-errorlist'>\ <li class='inactive'></li>\ </ul>\ </div>\ </div>\ </div>\ </div>\ </div>\ </div>"; /** * This is the class to instantiate for editing. */ class Editor { // tslint:disable-next-line:max-func-body-length constructor(widget, options) { this._firstValidationComplete = false; // tslint:disable-next-line:no-any this.modeData = {}; this.developmentMode = false; this.textUndoMaxLength = 10; this.taskRunners = []; this.taskSuspension = 0; this.clipboardAdd = false; // We may want to make this configurable in the future. this.normalizeEnteredSpaces = true; this.strippedSpaces = /\u200B/g; this.replacedSpaces = /\s+/g; this.destroyed = false; this.initialLabelLevel = 0; this.currentLabelLevel = 0; this._selectionMode = selection_mode_1.SelectionMode.SPAN; this.globalKeydownHandlers = []; this.updatingPlaceholder = 0; this.composing = false; this._transformations = new transformation_1.TransformationEventSubject(); this._selectionModeChanges = new rxjs_1.Subject(); this.name = ""; this.saveAction = new editorActions.Save(this); this.decreaseLabelVisibilityLevelAction = new editorActions.DecreaseLabelVisibilityLevel(this); this.increaseLabelVisibilityLevelAction = new editorActions.IncreaseLabelVisibilityLevel(this); this.undoAction = new editorActions.Undo(this); this.redoAction = new editorActions.Redo(this); this.toggleAttributeHidingAction = new editorActions.ToggleAttributeHiding(this); this.setSelectionModeToSpan = new editorActions.SetSelectionMode(this, "span", icon.makeHTML("spanSelectionMode"), selection_mode_1.SelectionMode.SPAN); this.setSelectionModeToUnit = new editorActions.SetSelectionMode(this, "unit", icon.makeHTML("unitSelectionMode"), selection_mode_1.SelectionMode.UNIT); this.transformations = this._transformations.asObservable(); this.selectionModeChanges = this._selectionModeChanges.asObservable(); this.maxLabelLevel = 0; // tslint:disable-next-line:promise-must-complete this.firstValidationComplete = new Promise((resolve) => { this.firstValidationCompleteResolve = resolve; }); // tslint:disable-next-line:promise-must-complete this.initialized = new Promise((resolve) => { this.initializedResolve = resolve; }); onerror.editors.push(this); this.widget = widget; this.$widget = jquery_1.default(this.widget); // We could be loaded in a frame in which case we should not alter anything // outside our frame. this.$frame = jquery_1.default(domutil_1.closest(this.widget, "html")); const doc = this.doc = this.$frame[0].ownerDocument; this.window = doc.defaultView; // It is possible to pass a runtime as "options" but if the user passed // actual options, then make a runtime from them. this.runtime = (options instanceof runtime_1.Runtime) ? options : new runtime_1.Runtime(options); options = this.runtime.options; this.modals = new stock_modals_1.StockModals(this); // ignore_module_config allows us to completely ignore the module config. In // some case, it may be difficult to just override individual values. // tslint:disable-next-line:no-any strict-boolean-expressions if (options.ignore_module_config) { console.warn("the option ignore_module_config is no longer useful"); } const ajv = new ajv_1.default(); const optionsValidator = ajv.compile(optionsSchema); if (!optionsValidator(options)) { // tslint:disable-next-line:prefer-template throw new Error("the options passed to wed are not valid: " + ajv.errorsText(optionsValidator.errors, { dataVar: "options", })); } if (options.ajaxlog !== undefined) { this.appender = log.addURL(options.ajaxlog.url, options.ajaxlog.headers); } this.name = options.name !== undefined ? options.name : ""; this.options = options; const docURL = this.options.docURL; this.docURL = docURL == null ? "./doc/index.html" : docURL; this.preferences = new preferences.Preferences({ tooltips: true, }); // This structure will wrap around the document to be edited. // // We duplicate data-parent on the toggles and on the collapsible // elements due to a bug in Bootstrap 3.0.0. See // https://github.com/twbs/bootstrap/issues/9933. // const framework = domutil_1.htmlToElements(FRAMEWORK_TEMPLATE, doc)[0]; // // Grab all the references we need while framework does not yet contain the // document to be edited. (Faster!) // const guiRoot = this.guiRoot = framework.getElementsByClassName("wed-document")[0]; this.$guiRoot = jquery_1.default(guiRoot); this.scroller = new scroller_1.Scroller(framework.getElementsByClassName("wed-scroller")[0]); this.constrainer = framework .getElementsByClassName("wed-document-constrainer")[0]; const toolbar = this.toolbar = new toolbar_1.Toolbar(); const toolbarPlaceholder = framework.getElementsByClassName("toolbar")[0]; toolbarPlaceholder.parentNode.insertBefore(toolbar.top, toolbarPlaceholder); toolbarPlaceholder.parentNode.removeChild(toolbarPlaceholder); this.inputField = framework.getElementsByClassName("wed-comp-field")[0]; this.$inputField = jquery_1.default(this.inputField); this.clipboard = new clipboard_1.Clipboard(); this.caretLayer = new layer_1.Layer(framework.getElementsByClassName("wed-caret-layer")[0]); this.errorLayer = new error_layer_1.ErrorLayer(framework.getElementsByClassName("wed-error-layer")[0]); this.wedLocationBar = framework.getElementsByClassName("wed-location-bar")[0]; this.minibuffer = new minibuffer_1.Minibuffer(framework.getElementsByClassName("wed-minibuffer")[0]); const sidebar = this.sidebar = framework.getElementsByClassName("wed-sidebar")[0]; this.validationProgress = framework .getElementsByClassName("wed-validation-progress")[0]; this.validationMessage = this.validationProgress.previousElementSibling; // Insert the framework and put the document in its proper place. const rootPlaceholder = framework.getElementsByClassName("root-here")[0]; if (this.widget.firstChild !== null) { // tslint:disable-next-line:no-any if (!(this.widget.firstChild instanceof this.window.Element)) { throw new Error("the data is populated with DOM elements constructed " + "from another window"); } rootPlaceholder.parentNode.insertBefore(this.widget.firstChild, rootPlaceholder); } rootPlaceholder.parentNode.removeChild(rootPlaceholder); this.widget.appendChild(framework); this.caretOwners = guiRoot.getElementsByClassName("_owns_caret"); this.clickedLabels = guiRoot.getElementsByClassName("_label_clicked"); this.withCaret = guiRoot.getElementsByClassName("_with_caret"); this.$modificationStatus = jquery_1.default(sidebar.getElementsByClassName("wed-modification-status")[0]); this.$saveStatus = jquery_1.default(sidebar.getElementsByClassName("wed-save-status")[0]); this.$navigationPanel = jquery_1.default(sidebar.getElementsByClassName("wed-navigation-panel")[0]); this.$navigationPanel.css("display", "none"); this.$navigationList = jquery_1.default(doc.getElementById("navlist")); this.$errorList = jquery_1.default(doc.getElementById("sb-errorlist")); this.$excludedFromBlur = jquery_1.default(); this.errorItemHandlerBound = this.errorItemHandler.bind(this); this._undo = new undo_1.UndoList(); this.complexPatternAction = new ComplexPatternAction(this, "Complex name pattern", undefined, icon.makeHTML("exclamation"), true); this.pasteTr = new transformation_1.Transformation(this, "add", "Paste", this.paste.bind(this)); this.pasteUnitTr = new transformation_1.Transformation(this, "add", "Paste Unit", this.pasteUnit.bind(this)); this.cutTr = new transformation_1.Transformation(this, "delete", "Cut", this.cut.bind(this)); this.cutUnitTr = new transformation_1.Transformation(this, "delete", "Cut Unit", this.cutUnit.bind(this)); this.deleteSelectionTr = new transformation_1.Transformation(this, "delete", "Delete Selection", this._deleteSelection.bind(this)); this.replaceRangeTr = new transformation_1.Transformation(this, "transform", "Replace Range", this.replaceRange.bind(this)); this.splitNodeTr = new transformation_1.Transformation(this, "split", "Split <name>", (editor, data) => { transformation_1.splitNode(editor, data.node); }); this.insertTextTr = new transformation_1.Transformation(this, "add", "Insert text", this._insertText.bind(this), { treatAsTextInput: true, }); this.deleteCharTr = new transformation_1.Transformation(this, "delete", "Insert text", this._deleteChar.bind(this), { treatAsTextInput: true, }); this.mergeWithPreviousHomogeneousSiblingTr = new transformation_1.Transformation(this, "merge-with-previous", "Merge <name> with previous", (editor, data) => { transformation_1.mergeWithPreviousHomogeneousSibling(editor, data.node); }); this.mergeWithNextHomogeneousSiblingTr = new transformation_1.Transformation(this, "merge-with-next", "Merge <name> with next", (editor, data) => { transformation_1.mergeWithNextHomogeneousSibling(editor, data.node); }); this.removeMarkupTr = new transformation_1.Transformation(this, "delete", "Remove mixed-content markup", transformation_1.removeMarkup, { abbreviatedDesc: "Remove mixed-content markup", iconHtml: "<i class='fa fa-eraser'></i>", needsInput: true, }); toolbar.addButton([this.saveAction.makeButton(), this.undoAction.makeButton(), this.redoAction.makeButton(), this.decreaseLabelVisibilityLevelAction.makeButton(), this.increaseLabelVisibilityLevelAction.makeButton(), this.removeMarkupTr.makeButton(), this.toggleAttributeHidingAction.makeButton(), this.setSelectionModeToSpan.makeButton(), this.setSelectionModeToUnit.makeButton()]); // Setup the cleanup code. jquery_1.default(this.window).on("unload.wed", { editor: this }, (e) => { e.data.editor.destroy(); }); jquery_1.default(this.window).on("popstate.wed", () => { if (document.location.hash === "") { this.guiRoot.scrollTop = 0; } }); } get undoEvents() { return this._undo.events; } get selectionMode() { return this._selectionMode; } set selectionMode(value) { const different = this._selectionMode !== value; if (different) { this.caretManager.collapseSelection(); this._selectionMode = value; this._selectionModeChanges.next({ name: "SelectionModeChange", value }); } } fireTransformation(tr, data) { // This is necessary because our context menu saves/restores the selection // using rangy. If we move on without this call, then the transformation // could destroy the markers that rangy put in and rangy will complain. this.editingMenuManager.dismiss(); let currentGroup = this._undo.getGroup(); let textUndo; if (tr.treatAsTextInput) { textUndo = this.initiateTextUndo(); } else if (currentGroup instanceof wundo.TextUndoGroup) { this._undo.endGroup(); } const newGroup = new wundo.UndoGroup(`Undo ${tr.getDescriptionFor(data)}`, this); this._undo.startGroup(newGroup); this.caretManager.mark.suspend(); this.enterTaskSuspension(); try { try { // We've separated the core of the work into a another method so that it // can be optimized. this._fireTransformation(tr, data); } catch (ex) { // We want to log it before we attempt to do anything else. if (!(ex instanceof exceptions_1.AbortTransformationException)) { log.handle(ex); } throw ex; } finally { if (textUndo !== undefined) { textUndo.recordCaretAfter(); } // It is possible for a transformation to create new subgroups without // going through fireTransformation. So we terminate all groups until // the last one we terminated is the one we created. do { currentGroup = this._undo.getGroup(); this._undo.endGroup(); } while (currentGroup !== newGroup); } } catch (ex) { this.undo(); if (!(ex instanceof exceptions_1.AbortTransformationException)) { throw ex; } } finally { this.caretManager.mark.resume(); this.exitTaskSuspension(); this.validationController.refreshErrors(); } } _fireTransformation(tr, data) { const node = data.node; if (node !== undefined) { // Convert the gui node to a data node if (this.guiRoot.contains(node)) { const dataNode = this.toDataNode(node); data.node = dataNode === null ? undefined : dataNode; } else { if (!domutil.contains(this.dataRoot, node)) { throw new Error("node is neither in the gui tree nor the data tree"); } } } const caret = data.moveCaretTo; if (caret !== undefined) { this.caretManager.setCaret(caret); } if (this.caretManager.caret === undefined) { throw new Error("transformation applied with undefined caret."); } const start = new transformation_1.TransformationEvent("StartTransformation", tr); this._transformations.next(start); start.throwIfAborted(); tr.handler(this, data); const end = new transformation_1.TransformationEvent("EndTransformation", tr); this._transformations.next(end); end.throwIfAborted(); } /** * Enter a state in which all tasks are suspended. It is possible to call this * method while the state is already in effect. Its sister method * ``exitTaskSuspension`` should be called the same number of times to resume * the tasks. */ enterTaskSuspension() { if (this.taskSuspension === 0) { this.stopAllTasks(); } this.taskSuspension++; } /** * Exit a state in which all tasks are suspended. For the state to be * effectively exited, this method needs to be called the same number of times * ``enterTaskSuspension`` was called. */ exitTaskSuspension() { this.taskSuspension--; if (this.taskSuspension < 0) { throw new Error("exitTaskSuspension underflow"); } if (this.taskSuspension === 0) { this.resumeAllTasks(); } } /** * Unconditionally stop all tasks. */ stopAllTasks() { for (const runner of this.taskRunners) { runner.stop(); } this.validationController.stop(); } /** * Unconditionally resume all tasks. */ resumeAllTasks() { for (const runner of this.taskRunners) { runner.resume(); } // The validator is a special case. And yes, ``start`` is the correct method // to call on it. this.validationController.resume(); } /** * If we are not in the task suspended state that is entered upon calling * ``enterTaskSuspension``, resume the task right away. Otherwise, this is a * no-op. */ resumeTaskWhenPossible(task) { if (this.taskSuspension === 0) { task.resume(); } } /** * Record an undo object in the list of undoable operations. * * Note that this method also provides the implementation for the restricted * method of the same name that allows only [["wed/undo".UndoMarker]] objects. * * @param undo The object to record. */ recordUndo(undo) { this._undo.record(undo); } undoAll() { while (this._undo.canUndo()) { this.undo(); } } undo() { // We need to replicate to some extent how fireTransformation inhibits // functions and reinstates them. this.caretManager.mark.suspend(); this.enterTaskSuspension(); this.undoRecorder.suppressRecording(true); this._undo.undo(); this.undoRecorder.suppressRecording(false); this.caretManager.mark.resume(); this.exitTaskSuspension(); } redo() { // We need to replicate to some extent how fireTransformation inhibits // functions and reinstates them. this.caretManager.mark.suspend(); this.enterTaskSuspension(); this.undoRecorder.suppressRecording(true); this._undo.redo(); this.undoRecorder.suppressRecording(false); this.caretManager.mark.resume(); this.exitTaskSuspension(); } dumpUndo() { // tslint:disable-next-line:no-console console.log(this._undo.toString()); } undoingOrRedoing() { return this._undo.undoingOrRedoing(); } isAttrProtected(attr, parent) { let name; if (typeof attr === "string") { name = attr; if (parent === undefined) { throw new Error("must specify a parent"); } } else if (domtypeguards_1.isAttr(attr)) { name = attr.name; } else if (domtypeguards_1.isElement(attr)) { name = domutil.siblingByClass(attr, "_attribute_name").textContent; } else { throw new Error("unexpected value for attr"); } return (name === "xmlns" || name.lastIndexOf("xmlns:", 0) === 0); } save() { return this.saver.save(); } initiateTextUndo() { // Handle undo information let currentGroup = this._undo.getGroup(); if (currentGroup === undefined || !(currentGroup instanceof wundo.TextUndoGroup)) { currentGroup = new wundo.TextUndoGroup("text", this, this._undo, this.textUndoMaxLength); this._undo.startGroup(currentGroup); } return currentGroup; } terminateTextUndo() { const currentGroup = this._undo.getGroup(); if (currentGroup instanceof wundo.TextUndoGroup) { this._undo.endGroup(); } } normalizeEnteredText(text) { if (!this.normalizeEnteredSpaces) { return text; } return text.replace(this.strippedSpaces, "") .replace(this.replacedSpaces, " "); } compensateForAdjacentSpaces(text, caret) { if (!this.normalizeEnteredSpaces) { return text; } const arCaret = caret.toArray(); // If there is previous text and the previous text // is a space, then we need to prevent a double // space. if (text[0] === " " && domutil.getCharacterImmediatelyBefore(arCaret) === " ") { text = text.slice(1); } // Same with the text that comes after. if (text.length > 0 && text[text.length - 1] === " " && domutil.getCharacterImmediatelyAt(arCaret) === " ") { text = text.slice(-1); } return text; } insertText(text) { // We remove zero-width spaces. text = this.normalizeEnteredText(text); if (text === "" || this.caretManager.caret === undefined) { return; } this.fireTransformation(this.insertTextTr, { text }); } _insertText(_editor, data) { this.closeAllTooltips(); const { caretManager } = this; let caret = caretManager.caret; if (caret === undefined) { return; } let { text } = data; const el = domutil_1.closestByClass(caret.node, "_real", this.guiRoot); // We do not operate on elements that are readonly. if (el === null || el.classList.contains("_readonly")) { return; } const attrVal = domutil_1.closestByClass(caret.node, "_attribute_value", this.guiRoot); if (attrVal === null) { caret = caretManager.getDataCaret(); text = this.compensateForAdjacentSpaces(text, caret); if (text === "") { return; } const { caret: newCaret } = this.dataUpdater.insertText(caret, text); caretManager.setCaret(newCaret, { textEdit: true }); } else { // Modifying an attribute... this.spliceAttribute(attrVal, caret.offset, 0, text); } } /** * Delete a single character of text at caret. * * @param key The keyboard key that performs the deletion. * * @returns Whether a character was deleted. */ deleteChar(key) { this.fireTransformation(this.deleteCharTr, { key }); } _deleteChar(_editor, data) { const { key } = data; const { caretManager } = this; let caret = caretManager.getDataCaret(); switch (key) { case keyConstants.BACKSPACE: { // If the container is not a text node, we may still be just behind a // text node from which we can delete. Handle this. if (!domtypeguards_1.isText(caret.node)) { const last = caret.node.childNodes[caret.offset - 1]; // tslint:disable-next-line:no-any const length = last.length; caret = caret.make(last, length); if (!domtypeguards_1.isText(caret.node)) { return; } } // At start of text, nothing to delete. if (caret.offset === 0) { return; } caret = caret.makeWithOffset(caret.offset - 1); break; } case keyConstants.DELETE: { // If the container is not a text node, we may still be just AT a text // node from which we can delete. Handle this. if (!domtypeguards_1.isText(caret.node)) { caret = caret.make(caret.node.childNodes[caret.offset], 0); if (!domtypeguards_1.isText(caret.node)) { return; } } break; } default: throw new Error(`cannot handle deleting with key ${key}`); } // We need to grab the parent and offset before we do the transformation, // because the node may be removed from its tree. const parent = caret.node.parentNode; const offset = domutil_1.indexOf(parent.childNodes, caret.node); this.dataUpdater.deleteText(caret, 1); // Don't set the caret inside a node that has been deleted. if (caret.node.parentNode !== null) { caretManager.setCaret(caret, { textEdit: true }); } else { caretManager.setCaret(parent, offset, { textEdit: true }); } } spliceAttribute(attrVal, offset, count, add) { if (offset < 0) { return; } // We ignore changes to protected attributes. if (this.isAttrProtected(attrVal)) { return; } // We ignore changes to non-editable attributes. if (this.modeTree.getAttributeHandling(attrVal) !== "edit") { return; } let val = this.toDataNode(attrVal).value; if (offset > val.length) { return; } if (offset === val.length && count > 0) { return; } if (this.normalizeEnteredSpaces) { if (add[0] === " " && val[offset - 1] === " ") { add = add.slice(1); } if (add[add.length - 1] === " " && val[offset + count] === " ") { add = add.slice(-1); } } val = val.slice(0, offset) + add + val.slice(offset + count); offset += add.length; const dataReal = jquery_1.default.data(domutil_1.closestByClass(attrVal, "_real"), "wed_mirror_node"); const guiPath = this.nodeToPath(attrVal); const name = domutil.siblingByClass(attrVal, "_attribute_name").textContent; const mode = this.modeTree.getMode(attrVal); const resolved = mode.getAbsoluteResolver().resolveName(name, true); if (resolved === undefined) { throw new Error(`cannot resolve ${name}`); } this.dataUpdater.setAttributeNS(dataReal, resolved.ns, resolved.name, val); // Redecoration of the attribute's element may have destroyed our old // attrVal node. Refetch. And after redecoration, the attribute value // element may not have a child. Not only that, but the attribute may no // longer be shown at all. let moveTo; try { moveTo = this.pathToNode(guiPath); if (moveTo.firstChild !== null) { moveTo = moveTo.firstChild; } } catch (ex) { if (!(ex instanceof guiroot_1.AttributeNotFound)) { throw ex; } } // We don't have an attribute to go back to. Go back to the element that // held the attribute. if (moveTo == null) { moveTo = dataReal; offset = 0; } this.caretManager.setCaret(moveTo, offset, { textEdit: true }); } insertTransientPlaceholderAt(loc) { const ph = // tslint:disable-next-line:no-jquery-raw-elements jquery_1.default("<span class='_placeholder _transient'> </span>", loc.node.ownerDocument)[0]; this.guiUpdater.insertNodeAt(loc, ph); return ph; } toDataNode(node) { if (domtypeguards_1.isElement(node)) { const ret = jquery_1.default.data(node, "wed_mirror_node"); // We can bypass the whole pathToNode, nodeToPath thing. if (ret != null) { return ret; } } return this.dataUpdater.pathToNode(this.nodeToPath(node)); } fromDataNode(node) { if (domtypeguards_1.isElement(node)) { const ret = jquery_1.default.data(node, "wed_mirror_node"); // We can bypass the whole pathToNode, nodeToPath thing. if (ret != null) { return ret; } } return this.pathToNode(this.dataUpdater.nodeToPath(node)); } onSaverSaved() { notify_1.notify("Saved", { type: "success" }); this.refreshSaveStatus(); } onSaverAutosaved() { notify_1.notify("Autosaved", { type: "success" }); this.refreshSaveStatus(); } onSaverChanged() { this.refreshSaveStatus(); } onSaverFailed(event) { this.refreshSaveStatus(); const error = event.error; if (error.type === "too_old") { // Reload when the modal is dismissed. this.modals.getModal("tooOld").modal(this.window.location.reload.bind(this.window.location)); } else if (error.type === "save_disconnected") { this.modals.getModal("disconnect").modal(() => { // tslint:disable-next-line:no-floating-promises this.save(); }); } else if (error.type === "save_edited") { this.modals.getModal("editedByOther").modal(() => { this.window.location.reload(); }); } else { notify_1.notify(`Failed to save!\n${error.msg}`, { type: "danger" }); } } nodeToPath(node) { return this.guiDLocRoot.nodeToPath(node); } pathToNode(path) { return this.guiDLocRoot.pathToNode(path); } // tslint:disable-next-line:no-any getModeData(key) { return this.modeData[key]; } // tslint:disable-next-line:no-any setModeData(key, value) { this.modeData[key] = value; } destroy() { if (this.destroyed) { return; } const myIndex = onerror.editors.indexOf(this); if (myIndex >= 0) { onerror.editors.splice(myIndex, 1); } // // This is imperfect, but the goal here is to do as much work as possible, // even if things have not been initialized fully. // // The last recorded exception will be rethrown at the end. // // Turn off autosaving. if (this.saver !== undefined) { this.saver.setAutosaveInterval(0); } if (this.saveStatusInterval !== undefined) { clearInterval(this.saveStatusInterval); } try { if (this.validationController !== undefined) { this.validationController.terminate(); } } catch (ex) { log.unhandled(ex); } if (this.taskRunners !== undefined) { for (const runner of this.taskRunners) { try { runner.stop(); } catch (ex) { log.unhandled(ex); } } } try { if (this.domlistener !== undefined) { this.domlistener.stopListening(); this.domlistener.clearPending(); } } catch (ex) { log.unhandled(ex); } if (this.editingMenuManager !== undefined) { this.editingMenuManager.dismiss(); } // These ought to prevent jQuery leaks. try { this.$widget.empty(); this.$frame.find("*").off(".wed"); // This will also remove handlers on the window. jquery_1.default(this.window).off(".wed"); } catch (ex) { log.unhandled(ex); } // Trash our variables: this will likely cause immediate failure if the // object is used again. for (const key of Object.keys(this)) { // tslint:disable-next-line:no-any delete this[key]; } if (this.appender !== undefined) { log.removeAppender(this.appender); } // ... but keep these two. Calling destroy over and over is okay. this.destroyed = true; // tslint:disable-next-line:no-empty this.destroy = function fakeDestroy() { }; } init(xmlData) { return __awaiter(this, void 0, void 0, function* () { const parser = new this.window.DOMParser(); if (xmlData !== undefined && xmlData !== "") { this.dataRoot = parser.parseFromString(xmlData, "text/xml"); this._dataChild = this.dataRoot.firstChild; } else { this.dataRoot = parser.parseFromString("<div></div>", "text/xml"); this._dataChild = undefined; } this.dataRoot.removeChild(this.dataRoot.firstChild); // $dataRoot is the document we are editing, $guiRoot will become decorated // with all kinds of HTML elements so we keep the two separate. this.$dataRoot = jquery_1.default(this.dataRoot); this.guiDLocRoot = new guiroot_1.GUIRoot(this.guiRoot); this.dataDLocRoot = new dloc_1.DLocRoot(this.dataRoot); this.dataUpdater = new tree_updater_1.TreeUpdater(this.dataRoot); this.guiUpdater = new gui_updater_1.GUIUpdater(this.guiRoot, this.dataUpdater); this.undoRecorder = new undo_recorder_1.UndoRecorder(this, this.dataUpdater); this.guiUpdater.events.subscribe((ev) => { switch (ev.name) { case "BeforeInsertNodeAt": if (domtypeguards_1.isElement(ev.node)) { this.newContentHandler(ev); } break; default: } }); // This is a workaround for a problem in Bootstrap >= 3.0.0 <= 3.2.0. When // removing a Node that has an tooltip associated with it and the trigger is // delayed, a timeout is started which may timeout *after* the Node and its // tooltip are removed from the DOM. This causes a crash. // // All versions >= 3.0.0 also suffer from leaving the tooltip up if the Node // associated with it is deleted from the DOM. This does not cause a crash // but must be dealt with to avoid leaving orphan tooltips around. // this.guiUpdater.events.subscribe((ev) => { if (ev.name !== "BeforeDeleteNode") { return; } const { node } = ev; if (domtypeguards_1.isElement(node)) { this.guiUpdater.removeTooltips(node); } }); this.domlistener = new domlistener.DOMListener(this.guiRoot, this.guiUpdater); this.modeTree = new mode_tree_1.ModeTree(this, this.options.mode); yield this.modeTree.init(); return this.onModeChange(this.modeTree.getMode(this.guiRoot)); }); } onModeChange(mode) { return __awaiter(this, void 0, void 0, function* () { // We purposely do not raise an error here so that calls to destroy can be // done as early as possible. It aborts the initialization sequence without // causing an error. if (this.destroyed) { return this; } this.maxLabelLevel = this.modeTree.getMaxLabelLevel(); this.initialLabelLevel = this.modeTree.getInitialLabelLevel(); this.currentLabelLevel = this.initialLabelLevel; const styles = this.modeTree.getStylesheets(); const $head = this.$frame.children("head"); for (const style of styles) { $head.append(`<link rel="stylesheet" href="${style}" type="text/css" />`); } this.guiRoot.setAttribute("tabindex", "-1"); this.$guiRoot.focus(); this.caretManager = new caret_manager_1.CaretManager(this.guiDLocRoot, this.dataDLocRoot, this.inputField, this.guiUpdater, this.caretLayer, this.scroller, this.modeTree); this.editingMenuManager = new editing_menu_manager_1.EditingMenuManager(this); this.caretManager.events.subscribe(this.caretChange.bind(this)); this.resizeHandler(); let schema; const schemaOption = this.options.schema; if (schemaOption instanceof salve.Grammar) { schema = schemaOption; } else if (typeof schemaOption === "string") { const schemaText = yield this.runtime.resolveToString(schemaOption); schema = salve.readTreeFromJSON(schemaText); } else { throw new Error("unexpected value for schema"); } this.validator = new validator_1.Validator(schema, this.dataRoot, this.modeTree.getValidators()); this.validator.events.addEventListener("state-update", this.onValidatorStateChange.bind(this)); this.validator.events.addEventListener("possible-due-to-wildcard-change", this.onPossibleDueToWildcardChange.bind(this)); this.validationController = new validation_controller_1.ValidationController(this, this.validator, mode.getAbsoluteResolver(), this.scroller, this.guiRoot, this.validationProgress, this.validationMessage, this.errorLayer, this.$errorList[0], this.errorItemHandlerBound); return this.postInitialize(); }); } // tslint:disable-next-line:max-func-body-length postInitialize() { return __awaiter(this, void 0, void 0, function* () { if (this.destroyed) { return this; } // Make the validator revalidate the structure from the point where a change // occurred. this.domlistener.addHandler("children-changed", "._real, ._phantom_wrap, .wed-document", (_root, added, removed, _prev, _next, target) => { for (const child of added.concat(removed)) { if (domtypeguards_1.isText(child) || (domtypeguards_1.isElement(child) && (child.classList.contains("_real") || child.classList.contains("_phantom_wrap")))) { this.validator.resetTo(target); break; } } }); // Revalidate on attribute change. this.domlistener.addHandler("attribute-changed", "._real", (_root, el, names