wed
Version:
Wed is a schema-aware editor for XML documents.
1,045 lines • 151 kB
JavaScript
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'>@ <span> </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