adobe/brackets

View on GitHub
src/document/TextRange.js

Summary

Maintainability
A
2 hrs
Test Coverage
/*
 * Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/**
 */
define(function (require, exports, module) {
    "use strict";

    var EventDispatcher = require("utils/EventDispatcher");


    /**
     * Stores a range of lines that is automatically maintained as the Document changes. The range
     * MAY drop out of sync with the Document in certain edge cases; startLine & endLine will become
     * null when that happens.
     *
     * Important: you must dispose() a TextRange when you're done with it. Because TextRange addRef()s
     * the Document (in order to listen to it), you will leak Documents otherwise.
     *
     * TextRange dispatches these events:
     *  - change -- When the range boundary line numbers change (due to a Document change)
     *  - contentChange -- When the actual content of the range changes. This might or might not
     *    be accompanied by a change in the boundary line numbers.
     *  - lostSync -- When the backing Document changes in such a way that the range can no longer
     *    accurately be maintained. Generally, occurs whenever an edit spans a range boundary.
     *    After this, startLine & endLine will be unusable (set to null).
     *    Also occurs when the document is deleted, though startLine & endLine won't be modified
     * These events only ever occur in response to Document changes, so if you are already listening
     * to the Document, you could ignore the TextRange events and just read its updated value in your
     * own Document change handler.
     *
     * @constructor
     *
     * @param {!Document} document
     * @param {number} startLine First line in range (0-based, inclusive)
     * @param {number} endLine   Last line in range (0-based, inclusive)
     */
    function TextRange(document, startLine, endLine) {
        this.startLine = startLine;
        this.endLine = endLine;

        this.document = document;
        document.addRef();
        // store this-bound versions of listeners so we can remove them later
        this._handleDocumentChange = this._handleDocumentChange.bind(this);
        this._handleDocumentDeleted = this._handleDocumentDeleted.bind(this);
        document.on("change", this._handleDocumentChange);
        document.on("deleted", this._handleDocumentDeleted);
    }
    EventDispatcher.makeEventDispatcher(TextRange.prototype);


    /** Detaches from the Document. The TextRange will no longer update or send change events */
    TextRange.prototype.dispose = function (editor, change) {
        // Disconnect from Document
        this.document.releaseRef();
        this.document.off("change", this._handleDocumentChange);
        this.document.off("deleted", this._handleDocumentDeleted);
    };


    /**
     * Containing document
     * @type {!Document}
     */
    TextRange.prototype.document = null;

    /**
     * Starting Line
     * @type {?number} Null after "lostSync" is dispatched
     */
    TextRange.prototype.startLine = null;

    /**
     * Ending Line
     * @type {?number} Null after "lostSync" is dispatched
     */
    TextRange.prototype.endLine = null;


    /**
     * Applies a single Document change object (out of the linked list of multiple such objects)
     * to this range.
     * @param {Object} change The CodeMirror change record.
     * @return {{hasChanged: boolean, hasContentChanged: boolean}} Whether the range boundary
     *     and/or content has changed.
     */
    TextRange.prototype._applySingleChangeToRange = function (change) {
        // console.log(this + " applying change to (" +
        //         (change.from && (change.from.line+","+change.from.ch)) + " - " +
        //         (change.to && (change.to.line+","+change.to.ch)) + ")");

        // Special case: the range is no longer meaningful since the entire text was replaced
        if (!change.from || !change.to) {
            this.startLine = null;
            this.endLine = null;
            return {hasChanged: true, hasContentChanged: true};

        // Special case: certain changes around the edges of the range are problematic, because
        // if they're undone, we'll be unable to determine how to fix up the range to include the
        // undone content. (The "undo" will just look like an insertion outside our bounds.) So
        // in those cases, we destroy the range instead of fixing it up incorrectly. The specific
        // cases are:
        // 1. Edit crosses the start boundary of the inline editor (defined as character 0
        //    of the first line).
        // 2. Edit crosses the end boundary of the inline editor (defined as the newline at
        //    the end of the last line).
        // Note: we also used to disallow edits that start at the beginning of the range (character 0
        //    of the first line) if they crossed a newline. This was a vestige from before case #1
        //    was added; now that edits crossing the top boundary (actually, undos of such edits) are
        //    out of the picture, edits on the first line of the range unambiguously belong inside it.
        } else if ((change.from.line < this.startLine && change.to.line >= this.startLine) ||
                   (change.from.line <= this.endLine && change.to.line > this.endLine)) {
            this.startLine = null;
            this.endLine = null;
            return {hasChanged: true, hasContentChanged: true};

        // Normal case: update the range end points if any content was added before them. Note that
        // we don't rely on line handles for this since we want to gracefully handle cases where the
        // start or end line was deleted during a change.
        } else {
            var numAdded = change.text.length - (change.to.line - change.from.line + 1);
            var result = {hasChanged: false, hasContentChanged: false};

            // This logic is so simple because we've already excluded all cases where the change
            // crosses the range boundaries
            if (numAdded !== 0) {
                if (change.to.line < this.startLine) {
                    this.startLine += numAdded;
                    result.hasChanged = true;
                }
                if (change.to.line <= this.endLine) {
                    this.endLine += numAdded;
                    result.hasChanged = true;
                }
            }
            if (change.from.line >= this.startLine && change.from.line <= this.endLine) {
                // Since we know the change doesn't cross the range boundary, as long as the
                // start of the change is within the range, we know the content changed.
                result.hasContentChanged = true;
            }

            // console.log("Now " + this);

            return result;
        }
    };

    /**
     * Updates the range based on the changeList from a Document "change" event. Dispatches a
     * "change" event if the range was adjusted at all. Dispatches a "lostSync" event instead if the
     * range can no longer be accurately maintained.
     */
    TextRange.prototype._applyChangesToRange = function (changeList) {
        var hasChanged = false, hasContentChanged = false;
        var i;
        for (i = 0; i < changeList.length; i++) {
            // Apply this step of the change list
            var result = this._applySingleChangeToRange(changeList[i]);
            hasChanged = hasChanged || result.hasChanged;
            hasContentChanged = hasContentChanged || result.hasContentChanged;

            // If we lost sync with the range, just bail now
            if (this.startLine === null || this.endLine === null) {
                this.trigger("lostSync");
                break;
            }
        }

        if (hasChanged) {
            this.trigger("change");
        }
        if (hasContentChanged) {
            this.trigger("contentChange");
        }
    };

    TextRange.prototype._handleDocumentChange = function (event, doc, changeList) {
        this._applyChangesToRange(changeList);
    };

    TextRange.prototype._handleDocumentDeleted = function (event) {
        this.trigger("lostSync");
    };


    /* (pretty toString(), to aid debugging) */
    TextRange.prototype.toString = function () {
        return "[TextRange " + this.startLine + "-" + this.endLine + " in " + this.document + "]";
    };


    // Define public API
    exports.TextRange = TextRange;
});