UNPKG

wed

Version:

Wed is a schema-aware editor for XML documents.

454 lines 20.1 kB
/** * Controller managing the validation logic of a wed editor. * @author Louis-Dominique Dubeau * @license MPL 2.0 * @copyright Mangalam Research Center for Buddhist Languages */ define(["require", "exports", "salve", "salve-dom", "./dloc", "./domtypeguards", "./domutil", "./guiroot", "./task-runner", "./tasks/process-validation-errors", "./tasks/refresh-validation-errors", "./util", "./wed-util"], function (require, exports, salve_1, salve_dom_1, dloc_1, domtypeguards_1, domutil_1, guiroot_1, task_runner_1, process_validation_errors_1, refresh_validation_errors_1, util_1, wed_util_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stateToStr = {}; stateToStr[salve_dom_1.WorkingState.INCOMPLETE] = "stopped"; stateToStr[salve_dom_1.WorkingState.WORKING] = "working"; stateToStr[salve_dom_1.WorkingState.INVALID] = "invalid"; stateToStr[salve_dom_1.WorkingState.VALID] = "valid"; const stateToProgressType = {}; stateToProgressType[salve_dom_1.WorkingState.INCOMPLETE] = "info"; stateToProgressType[salve_dom_1.WorkingState.WORKING] = "info"; stateToProgressType[salve_dom_1.WorkingState.INVALID] = "danger"; stateToProgressType[salve_dom_1.WorkingState.VALID] = "success"; // This is a utility function for the method of the same name. If the mode is // set to not display attributes or if a custom decorator is set to not display // a specific attribute, then finding the GUI location of the attribute won't be // possible. In such case, we want to fail nicely rather than crash to the // ground. // // (NOTE: What we're talking about is not the label visibility level being such // that attributes are not *seen* but have DOM elements for them in the GUI // tree. We're talking about a situation in which the mode's decorator does not // create DOM elements for the attributes.) // function findInsertionPoint(editor, node, index) { const caretManager = editor.caretManager; try { return caretManager.fromDataLocation(node, index); } catch (ex) { if (ex instanceof guiroot_1.AttributeNotFound) { // This happens only if node points to an attribute. return caretManager.fromDataLocation(node.ownerElement, 0); } throw ex; } } /** * Add a list of elements to a ``DocumentFragment``. * * @param doc The document from which to create the fragment. * * @param items The elements to add to the new fragment. * * @returns A new fragment that contains the elements passed. */ function elementsToFrag(doc, items) { const frag = doc.createDocumentFragment(); for (const item of items) { frag.appendChild(item); } return frag; } /** * Convert the names in an error message from their expanded form to their * prefix, local name form. * * @param error The error. * * @param resolve The resolver to use to convert the names. * * @returns The converted names. */ function convertNames(error, resolver) { // Turn the names into qualified names. const convertedNames = []; const patterns = error.getNames(); for (const pattern of patterns) { const names = pattern.toArray(); let convertedName = ""; if (names !== null) { // Simple pattern, just translate all names one by one. const conv = []; for (const name of names) { conv.push(resolver.unresolveName(name.ns, name.name)); } convertedName = conv.join(" or "); } else { // We convert the complex pattern into something reasonable. convertedName = util_1.convertPatternObj(pattern.toObject(), resolver); } convertedNames.push(convertedName); } return convertedNames; } /** * Controls the validator and the tasks that pertain to error processing and * refreshing. Takes care of reporting errors to the user. */ class ValidationController { /** * @param editor The editor for which this controller is created. * * @param validator The validator which is under control. * * @param resolver A name resolver to resolve names in errors. * * @param scroller The scroller for the edited contents. * * @param guiRoot The DOM element representing the root of the edited * document. * * @param progressBar: The DOM element which contains the validation progress * bar. * * @param validationMessage: The DOM element which serves to report the * validation status. * * @param errorLayer: The layer that holds error markers. * * @param errorList: The DOM element which serves to contain the error list. * * @param errorItemHandler: An event handler for the markers. */ constructor(editor, validator, resolver, scroller, guiRoot, progressBar, validationMessage, errorLayer, errorList, errorItemHandler) { this.editor = editor; this.validator = validator; this.resolver = resolver; this.scroller = scroller; this.guiRoot = guiRoot; this.progressBar = progressBar; this.validationMessage = validationMessage; this.errorLayer = errorLayer; this.errorList = errorList; this.errorItemHandler = errorItemHandler; this.lastDoneShown = 0; /** * This holds the timeout set to process validation errors in batch. The * delay in ms before we consider a batch ready to process. */ this.processErrorsDelay = 500; this._errors = []; this.document = guiRoot.ownerDocument; this.$errorList = $(errorList); this.refreshErrorsRunner = new task_runner_1.TaskRunner(new refresh_validation_errors_1.RefreshValidationErrors(this)); this.processErrorsRunner = new task_runner_1.TaskRunner(new process_validation_errors_1.ProcessValidationErrors(this)); this.validator.events.addEventListener("state-update", this.onValidatorStateChange.bind(this)); this.validator.events.addEventListener("error", this.onValidatorError.bind(this)); this.validator.events.addEventListener("reset-errors", this.onResetErrors.bind(this)); } /** * @returns a shallow copy of the error list. */ copyErrorList() { return this._errors.slice(); } /** * Stops all tasks and the validator. */ stop() { this.refreshErrorsRunner.stop(); this.processErrorsRunner.stop(); this.validator.stop(); } /** * Resumes all tasks and the validator. */ resume() { this.refreshErrorsRunner.resume(); this.processErrorsRunner.resume(); this.validator.start(); // Yes, start is the right method. } /** * Handles changes in the validator state. Updates the progress bar and the * validation status. */ onValidatorStateChange(workingState) { const { state, partDone } = workingState; if (state === salve_dom_1.WorkingState.WORKING) { // Do not show changes less than 5% if (partDone - this.lastDoneShown < 0.05) { return; } } else if (state === salve_dom_1.WorkingState.VALID || state === salve_dom_1.WorkingState.INVALID) { // We're done so we might as well process the errors right now. this.processErrors(); } this.lastDoneShown = partDone; const percent = partDone * 100; const progress = this.progressBar; progress.style.width = `${percent}%`; progress.classList.remove("progress-bar-info", "progress-bar-success", "progress-bar-danger"); progress.classList.add(`progress-bar-${stateToProgressType[state]}`); this.validationMessage.textContent = stateToStr[state]; } /** * Handles a validation error reported by the validator. It records the error * and schedule future processing of the errors. */ onValidatorError(ev) { this._errors.push({ ev, marker: undefined, item: undefined, }); // We "batch" validation errors to process multiple of them in one shot // rather than call _processErrors each time. if (this.processErrorsTimeout === undefined) { this.processErrorsTimeout = setTimeout(this.processErrors.bind(this), this.processErrorsDelay); } } /** * Handles resets of the validation state. */ onResetErrors(ev) { if (ev.at !== 0) { throw new Error("internal error: wed does not yet support " + "resetting errors at an arbitrary location"); } this.lastDoneShown = 0; this.clearErrors(); } /** * Resets the state of the error processing task and resumes it * as soon as possible. */ processErrors() { // Clear the timeout... because this function may be called from somewhere // else than the timeout. if (this.processErrorsTimeout !== undefined) { clearTimeout(this.processErrorsTimeout); this.processErrorsTimeout = undefined; } this.processErrorsRunner.reset(); this.editor.resumeTaskWhenPossible(this.processErrorsRunner); } /** * Find where the error represented by the event passed should be marked. * * @param ev The error reported by the validator. * * @returns A location, if possible. */ findInsertionPoint(ev) { const { error, node: dataNode, index } = ev; if (dataNode == null) { throw new Error("error without a node"); } if (index === undefined) { throw new Error("error without an index"); } const isAttributeNameError = error instanceof salve_1.AttributeNameError; let insertAt; if (isAttributeNameError) { const guiNode = wed_util_1.getGUINodeIfExists(this.editor, dataNode); if (guiNode === undefined) { return undefined; } // Attribute name errors can have two causes: the attribute is not // allowed, or an attribute is missing. In the former case, the error // points to the attribute node. In the latter case, it points to the // element that's missing the attribute. let insertionNode; if (domtypeguards_1.isAttr(dataNode)) { // Spurious attribute. insertionNode = guiNode; } else { // Missing attribute. if (!domtypeguards_1.isElement(guiNode)) { throw new Error("attribute name errors should be associated with " + "elements"); } insertionNode = guiNode.querySelector("._gui.__start_label ._greater_than"); } insertAt = dloc_1.DLoc.mustMakeDLoc(this.guiRoot, insertionNode, 0); } else { insertAt = findInsertionPoint(this.editor, dataNode, index); if (insertAt === undefined) { return undefined; } insertAt = this.editor.caretManager.normalizeToEditableRange(insertAt); } return insertAt; } /** * Process a single error. This will compute the location of the error marker * and will create a marker to add to the error layer, and a list item to add * to the list of errors. * * @return ``false`` if there was no insertion point for the error, and thus * no marker or item were created. ``true`` otherwise. */ // tslint:disable-next-line:max-func-body-length processError(err) { this.editor.expandErrorPanelWhenNoNavigation(); const { ev } = err; const { error, node: dataNode } = ev; if (dataNode == null) { throw new Error("error without a node"); } const insertAt = this.findInsertionPoint(ev); if (insertAt === undefined) { return false; } let closestElement = insertAt.node; if (closestElement.nodeType === Node.TEXT_NODE) { closestElement = closestElement.parentNode; } if (!domtypeguards_1.isElement(closestElement)) { throw new Error("we should be landing on an element"); } let item; let marker = err.marker; // We may be getting here with an error that already has a marker. It has // already been "processed" and only needs its location updated. Otherwise, // this is a new error: create a list item and marker for it. if (marker === undefined) { // Turn the names into qualified names. const convertedNames = convertNames(error, this.resolver); const doc = insertAt.node.ownerDocument; item = doc.createElement("li"); const linkId = item.id = util_1.newGenericID(); if (domtypeguards_1.isAttr(dataNode) && domutil_1.isNotDisplayed(closestElement, insertAt.root)) { item.textContent = error.toStringWithNames(convertedNames); item.title = "This error belongs to an attribute " + "which is not currently displayed."; } else { marker = doc.createElement("span"); marker.className = "_phantom wed-validation-error"; const $marker = $(marker); $marker.mousedown(() => { this.$errorList.parents(".panel-collapse").collapse("show"); const $link = $(this.errorList.querySelector(`#${linkId}`)); const $scrollable = this.$errorList.parent(".panel-body"); $scrollable.animate({ scrollTop: $link.offset().top - $scrollable.offset().top + $scrollable[0].scrollTop, }); this.errorLayer.select(marker); $link.siblings().removeClass("selected"); $link.addClass("selected"); // We move the caret ourselves and prevent further processing of this // event. Older versions of wed let the event trickle up and be // handled by the general caret movement code but that would sometimes // result in a caret being put in a bad position. this.editor.caretManager.setCaret(insertAt); return false; }); const markerId = marker.id = util_1.newGenericID(); const link = doc.createElement("a"); link.href = `#${markerId}`; link.textContent = error.toStringWithNames(convertedNames); item.appendChild(link); $(item.firstElementChild).click(err, this.errorItemHandler); } } // Update the marker's location. if (marker !== undefined) { const { top, left } = wed_util_1.boundaryXY(insertAt); const { scrollTop, scrollLeft } = this.scroller; const scrollerPos = this.scroller.getBoundingClientRect(); const fontSize = parseFloat(this.editor.window.getComputedStyle(closestElement) .fontSize); const height = fontSize * 0.2; marker.style.height = `${height}px`; // We move down from the top of the box produced by boundaryXY because // when targeting parent, it may return a box which is as high as the // parent's contents. marker.style.top = `${top + fontSize - height - scrollerPos.top + scrollTop}px`; marker.style.left = `${left - scrollerPos.left + scrollLeft}px`; } if (err.item === undefined) { err.item = item; } err.marker = marker; return true; } /** * Clear all validation errors. This makes the editor forget and updates the * GUI to remove all displayed errors. */ clearErrors() { this._errors = []; this.refreshErrorsRunner.stop(); this.processErrorsRunner.stop(); this.errorLayer.clear(); const list = this.errorList; while (list.lastChild != null) { list.removeChild(list.lastChild); } this.refreshErrorsRunner.reset(); this.processErrorsRunner.reset(); } /** * Terminate the controller. This stops all runners and clears any unexpired * timeout. */ terminate() { if (this.processErrorsTimeout !== undefined) { clearTimeout(this.processErrorsTimeout); } this.stop(); } /** * This method updates the location markers of the errors. */ refreshErrors() { this.refreshErrorsRunner.reset(); this.editor.resumeTaskWhenPossible(this.refreshErrorsRunner); } /** * This method recreates the error messages and the error markers associated * with the errors that the editor already knows. */ recreateErrors() { this.errorLayer.clear(); const list = this.errorList; while (list.lastChild !== null) { list.removeChild(list.lastChild); } for (const error of this._errors) { error.marker = undefined; error.item = undefined; } this.processErrors(); } /** * Add items to the list of errors. * * @param items The items to add to the list of errors. */ appendItems(items) { this.errorList.appendChild(elementsToFrag(this.document, items)); } /** * Add markers to the layer that is used to contain error markers. * * @param markers The markers to add. */ appendMarkers(markers) { this.errorLayer.append(elementsToFrag(this.document, markers)); } } exports.ValidationController = ValidationController; }); // LocalWords: MPL scroller processErrors li markerId loc scrollerPos px // LocalWords: scrollTop scrollLeft //# sourceMappingURL=validation-controller.js.map