adobe/brackets

View on GitHub
src/editor/MultiRangeInlineEditor.js

Summary

Maintainability
C
1 day
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.
 *
 */

// FUTURE: Merge part (or all) of this class with InlineTextEditor

/**
 * An inline editor for displaying and editing multiple text ranges. Each range corresponds to a
 * contiguous set of lines in a file.
 *
 * In the current implementation, only one range is visible at a time. A list on the right side
 * of the editor allows the user to select which range is visible.
 *
 * This module does not dispatch any events.
 */
define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    // Load dependent modules
    var TextRange           = require("document/TextRange").TextRange,
        InlineTextEditor    = require("editor/InlineTextEditor").InlineTextEditor,
        EditorManager       = require("editor/EditorManager"),
        FileUtils           = require("file/FileUtils"),
        PreferencesManager  = require("preferences/PreferencesManager"),
        ProjectManager      = require("project/ProjectManager"),
        Commands            = require("command/Commands"),
        Strings             = require("strings"),
        CommandManager      = require("command/CommandManager");

    var _prevMatchCmd, _nextMatchCmd;

    /**
     * Remove trailing "px" from a style size value.
     * @param {!JQuery} $target Element in DOM
     * @param {!string} styleName Style name to query
     * @return {number} Style value converted from string to number, removing "px" units
     */
    function _parseStyleSize($target, styleName) {
        return parseInt($target.css(styleName), 10);
    }

    /** Returns a 'context' object for getting/setting project-specific preferences */
    function _getPrefsContext() {
        var projectRoot = ProjectManager.getProjectRoot();  // note: null during unit tests!
        return { location : { scope: "user", layer: "project", layerID: projectRoot && projectRoot.fullPath } };
    }


    /**
     * Stores one search result: its source file, line range, etc. plus the DOM node representing it
     * in the results list.
     * @constructor
     */
    function SearchResultItem(rangeResult) {
        this.name = rangeResult.name;
        this.textRange = new TextRange(rangeResult.document, rangeResult.lineStart, rangeResult.lineEnd);
        // this.$listItem is assigned in load()
    }
    SearchResultItem.prototype.name = null;
    SearchResultItem.prototype.textRange = null;
    SearchResultItem.prototype.$listItem = null;

    function _updateRangeLabel(listItem, range, labelCB) {
        if (labelCB) {
            range.name = labelCB(range.textRange);
        }
        var text = _.escape(range.name) + " <span class='related-file'>:" + (range.textRange.startLine + 1) + "</span>";
        listItem.html(text);
        listItem.attr("title", listItem.text());
    }


    /**
     * @constructor
     * @param {Array.<{name:String,document:Document,lineStart:number,lineEnd:number}>} ranges The text
     *      ranges to display. Results within the same file are expected to be contiguous in this array.
     * @param {?function(): $.Promise} messageCB Optional; returns a promise resolved with a message to
     *      show when no matches are available. The message should be already-escaped HTML.
     * @param {?function(range): string} labelCB Optional; returns an updated label string for the given
     *      range. Called when we detect that the content of a range has changed. The label is plain
     *      text, not HTML.
     * @param {?function(!File, !File):number} fileComparator Optional comparison function for sorting
     *      the results list (based on range.document.file). Defaults to FileUtils.comparePaths().
     * @extends {InlineTextEditor}
     */
    function MultiRangeInlineEditor(ranges, messageCB, labelCB, fileComparator) {
        InlineTextEditor.call(this);

        // Store the results to show in the range list. This creates TextRanges bound to the Document,
        // which will stay up to date automatically (but we must be sure to detach them later)
        this._ranges = ranges.map(function (rangeResult) {
            return new SearchResultItem(rangeResult);
        });
        this._messageCB = messageCB;
        this._labelCB = labelCB;

        this._selectedRangeIndex = -1;
        this._collapsedFiles = {};

        // Set up list sort order
        this._fileComparator = fileComparator || function defaultComparator(file1, file2) {
            return FileUtils.comparePaths(file1.fullPath, file2.fullPath);
        };
        this._ranges.sort(function (result1, result2) {
            return this._fileComparator(result1.textRange.document.file, result2.textRange.document.file);
        }.bind(this));
    }
    MultiRangeInlineEditor.prototype = Object.create(InlineTextEditor.prototype);
    MultiRangeInlineEditor.prototype.constructor = MultiRangeInlineEditor;
    MultiRangeInlineEditor.prototype.parentClass = InlineTextEditor.prototype;

    MultiRangeInlineEditor.prototype.$messageDiv = null;
    MultiRangeInlineEditor.prototype.$relatedContainer = null;
    MultiRangeInlineEditor.prototype.$related = null;
    MultiRangeInlineEditor.prototype.$selectedMarker = null;

    /** Includes all the _ranges[i].$listItem items, as well as section headers */
    MultiRangeInlineEditor.prototype.$rangeList = null;

    /**
     * List of search results. Section headers are not represented in this list (they are implied before each group of
     * of consecutive results from the same Document).
     * @type {!Array.<SearchResultItem>}
     */
    MultiRangeInlineEditor.prototype._ranges = null;

    /** Index into this._ranges - indices do not include section headers */
    MultiRangeInlineEditor.prototype._selectedRangeIndex = null;

    /**
     * Map from fullPath to true if collapsed. May not agree with preferences, in cases where multiple inline editors make
     * concurrent changes.
     * @type {!Object.<string, boolean>}
     */
    MultiRangeInlineEditor.prototype._collapsedFiles = null;

    MultiRangeInlineEditor.prototype._messageCB = null;
    MultiRangeInlineEditor.prototype._labelCB = null;
    MultiRangeInlineEditor.prototype._fileComparator = null;

    /** @type {!Object.<string, jQueryObject>} Map from fullPath to section header DOM node */
    MultiRangeInlineEditor.prototype._$headers = null;


    /**
     * @private
     * Add a new result item <li> to the range list UI ($rangeList) and saves it in range.$listItem
     * @param {SearchResultItem} range The range to add.
     */
    MultiRangeInlineEditor.prototype._createListItem = function (range) {
        var self = this,
            $rangeItem = $("<li/>");

        // Attach filename for unit test use
        $rangeItem.data("filename", range.textRange.document.file.name);

        $rangeItem.appendTo(this.$rangeList);

        _updateRangeLabel($rangeItem, range);
        $rangeItem.mousedown(function () {
            self.setSelectedIndex(self._ranges.indexOf(range));
        });

        range.$listItem = $rangeItem;
    };

    /** Collapses/expands a file section in the range list UI */
    MultiRangeInlineEditor.prototype._toggleSection = function (fullPath, duringInit) {
        var $headerItem = this._$headers[fullPath];
        var $disclosureIcon = $headerItem.find(".disclosure-triangle");
        var isCollapsing = $disclosureIcon.hasClass("expanded");
        $disclosureIcon.toggleClass("expanded");
        $headerItem.nextUntil(".section-header").toggle(!isCollapsing);  // explicit visibility arg, since during load() jQ doesn't think nodes are visible

        // Update instance-specific state...
        this._collapsedFiles[fullPath] = isCollapsing;
        // ...AND persist as per-project view state
        if (!duringInit) {
            var setting = PreferencesManager.getViewState("inlineEditor.collapsedFiles", _getPrefsContext()) || {};
            if (isCollapsing) {
                setting[fullPath] = true;
            } else {
                delete setting[fullPath];
            }
            PreferencesManager.setViewState("inlineEditor.collapsedFiles", setting, _getPrefsContext());
        }

        // Show/hide selection indicator if selection was in collapsed section
        this._updateSelectedMarker(false);

        // Changing height of rule list may change ht of overall editor
        this._ruleListHeightChanged();

        // If user expands collapsed section and nothing selected yet, select first result in this section
        if (this._selectedRangeIndex === -1 && !isCollapsing && !duringInit) {
            var index = _.findIndex(this._ranges, function (resultItem) {
                return resultItem.textRange.document.file.fullPath === fullPath;
            });
            this.setSelectedIndex(index);
        }
    };

    /** Adds a file section header <li> to the range list UI ($rangeList) and adds it to the this._$headers map */
    MultiRangeInlineEditor.prototype._createHeaderItem = function (doc) {
        var $headerItem = $("<li class='section-header'><span class='disclosure-triangle expanded'/><span class='filename'>" + _.escape(doc.file.name) + "</span></li>")
            .attr("title", ProjectManager.makeProjectRelativeIfPossible(doc.file.fullPath))
            .appendTo(this.$rangeList);

        $headerItem.click(function () {
            this._toggleSection(doc.file.fullPath);
        }.bind(this));

        this._$headers[doc.file.fullPath] = $headerItem;
    };

    /** Refresh the contents of $rangeList */
    MultiRangeInlineEditor.prototype._renderList = function () {
        this.$rangeList.empty();
        this._$headers = {};

        var self = this,
            lastSectionDoc,
            numItemsInSection = 0;

        // After seeing all results for a given file, update its header with total # of results
        function finalizeSection() {
            if (lastSectionDoc) {
                self._$headers[lastSectionDoc.file.fullPath].append(" (" + numItemsInSection + ")");
                if (self._collapsedFiles[lastSectionDoc.file.fullPath]) {
                    self._toggleSection(lastSectionDoc.file.fullPath, true);
                }
            }
        }

        this._ranges.forEach(function (resultItem) {
            if (lastSectionDoc !== resultItem.textRange.document) {
                // Finalize previous section
                finalizeSection();

                // Initialize new section
                lastSectionDoc = resultItem.textRange.document;
                numItemsInSection = 0;

                // Create filename header for new section
                this._createHeaderItem(lastSectionDoc);
            }
            numItemsInSection++;
            this._createListItem(resultItem);
        }, this);

        // Finalize last section
        finalizeSection();
    };


    /**
     * @override
     * @param {!Editor} hostEditor  Outer Editor instance that inline editor will sit within.
     *
     */
    MultiRangeInlineEditor.prototype.load = function (hostEditor) {
        MultiRangeInlineEditor.prototype.parentClass.load.apply(this, arguments);

        // Create the message area
        this.$messageDiv = $("<div/>")
            .addClass("inline-editor-message");

        // Prevent touch scroll events from bubbling up to the parent editor.
        this.$editorHolder.on("mousewheel.MultiRangeInlineEditor", function (e) {
            e.stopPropagation();
        });

        // Outer container for border-left and scrolling
        this.$relatedContainer = $("<div/>").addClass("related-container");

        // List "selection" highlight
        this.$selectedMarker = $("<div/>").appendTo(this.$relatedContainer).addClass("selection");

        // Inner container
        this.$related = $("<div/>").appendTo(this.$relatedContainer).addClass("related");

        // Range list
        this.$rangeList = $("<ul/>").appendTo(this.$related);

        // Determine which sections are initially collapsed (the actual collapsing happens after onAdded(),
        // because jQuery.hide() requires the computed value of 'display' to work properly)
        var toCollapse = PreferencesManager.getViewState("inlineEditor.collapsedFiles", _getPrefsContext()) || {};
        Object.keys(toCollapse).forEach(function (fullPath) {
            this._collapsedFiles[fullPath] = true;
        }.bind(this));

        // Render list & section headers (matching collapsed state set above)
        this._renderList();

        if (this._ranges.length > 1) {      // attach to main container
            this.$wrapper.before(this.$relatedContainer);
        }

        // Add TextRange listeners to update UI as text changes
        var self = this;
        this._ranges.forEach(function (range, index) {
            // Update list item as TextRange changes
            range.textRange.on("change", function () {
                _updateRangeLabel(range.$listItem, range);
            }).on("contentChange", function () {
                _updateRangeLabel(range.$listItem, range, self._labelCB);
            });

            // If TextRange lost sync, remove it from the list (and close the widget if no other ranges are left)
            range.textRange.on("lostSync", function () {
                self._removeRange(range);
            });
        });

        // Initial selection is the first non-collapsed result item
        var indexToSelect = _.findIndex(this._ranges, function (range) {
            return !this._collapsedFiles[range.textRange.document.file.fullPath];
        }.bind(this));
        if (this._ranges.length === 1 && indexToSelect === -1) {
            // If no right-hand rule list shown, select the one result even if it's in a collapsed file (since no way to expand)
            indexToSelect = 0;
        }

        if (indexToSelect !== -1) {
            // select the first visible range
            this.setSelectedIndex(indexToSelect);
        } else {
            // force the message div to show
            this.setSelectedIndex(-1);
        }

        // Listen for clicks directly on us, so we can set focus back to the editor
        var clickHandler = this._onClick.bind(this);
        this.$htmlContent.on("click.MultiRangeInlineEditor", clickHandler);
        // Also handle mouseup in case the user drags a little bit
        this.$htmlContent.on("mouseup.MultiRangeInlineEditor", clickHandler);

        // Update the rule list navigation menu items when we gain/lose focus.
        this.$htmlContent
            .on("focusin.MultiRangeInlineEditor", this._updateCommands.bind(this))
            .on("focusout.MultiRangeInlineEditor", this._updateCommands.bind(this));
    };

    /**
     * @private
     * Updates the enablement for the rule list navigation commands.
     */
    MultiRangeInlineEditor.prototype._updateCommands = function () {
        var enabled = (this.hasFocus() && this._ranges.length > 1);
        _prevMatchCmd.setEnabled(enabled && this._selectedRangeIndex > 0);
        _nextMatchCmd.setEnabled(enabled && this._selectedRangeIndex !== -1 && this._selectedRangeIndex < this._ranges.length - 1);
    };

    /**
     * @override
     */
    MultiRangeInlineEditor.prototype.onAdded = function () {
        // Set the initial position of the selected marker now that we're laid out.
        this._updateSelectedMarker(false);

        // Call super
        MultiRangeInlineEditor.prototype.parentClass.onAdded.apply(this, arguments);

        // Initially size the inline widget (calls sizeInlineWidgetToContents())
        this._ruleListHeightChanged();

        this._updateCommands();
    };

    /**
     * Specify the range that is shown in the editor.
     *
     * @param {!number} index The index of the range to select, or -1 to deselect all. Index into this._ranges,
     *      so section headers are not included in the sequence.
     * @param {boolean} force Whether to re-select the item even if we think it's already selected
     *     (used if the range list has changed).
     */
    MultiRangeInlineEditor.prototype.setSelectedIndex = function (index, force) {
        var newIndex = Math.min(Math.max(-1, index), this._ranges.length - 1),
            self = this;

        if (!force && newIndex !== -1 && newIndex === this._selectedRangeIndex) {
            return;
        }

        // Remove selected class(es)
        var $previousItem = (this._selectedRangeIndex >= 0) ? this._ranges[this._selectedRangeIndex].$listItem : null;
        if ($previousItem) {
            $previousItem.removeClass("selected");
        }

        // Clear our listeners on the previous editor since it'll be destroyed in setInlineContent().
        if (this.editor) {
            this.editor.off(".MultiRangeInlineEditor");
        }

        this._selectedRangeIndex = newIndex;

        if (newIndex === -1) {
            // show the message div
            this.setInlineContent(null);
            var hasHiddenMatches = this._ranges.length > 0;
            if (hasHiddenMatches) {
                this.$messageDiv.text(Strings.INLINE_EDITOR_HIDDEN_MATCHES);
            } else if (this._messageCB) {
                this._messageCB(hasHiddenMatches).done(function (msg) {
                    self.$messageDiv.html(msg);
                });
            } else {
                this.$messageDiv.text(Strings.INLINE_EDITOR_NO_MATCHES);
            }
            this.$htmlContent.append(this.$messageDiv);
            this.sizeInlineWidgetToContents();
        } else {
            this.$messageDiv.remove();

            var range = this._getSelectedRange();
            range.$listItem.addClass("selected");

            // Add new editor
            this.setInlineContent(range.textRange.document, range.textRange.startLine, range.textRange.endLine);
            this.editor.focus();

            this._updateEditorMinHeight();
            this.editor.refresh();

            // Ensure the cursor position is visible in the host editor as the user is arrowing around.
            this.editor.on("cursorActivity.MultiRangeInlineEditor", this._ensureCursorVisible.bind(this));

            // ensureVisibility is set to false because we don't want to scroll the main editor when the user selects a view
            this.sizeInlineWidgetToContents();

            this._updateSelectedMarker(true);
        }

        this._updateCommands();
    };

    /**
     * Ensures that the editor's min-height is set so it never gets shorter than the rule list.
     * This is necessary to make sure the editor's horizontal scrollbar stays at the bottom of the
     * widget.
     */
    MultiRangeInlineEditor.prototype._updateEditorMinHeight = function () {
        if (!this.editor) {
            return;
        }

        // Set the scroller's min-height to the natural height of the rule list, so the editor
        // always stays at least as tall as the rule list.
        var ruleListNaturalHeight = this.$related.outerHeight(),
            headerHeight = $(".inline-editor-header", this.$htmlContent).outerHeight();

        // If the widget isn't fully loaded yet, bail--we'll get called again in onAdded().
        if (!ruleListNaturalHeight || !headerHeight) {
            return;
        }

        // We have to set this on the scroller instead of the wrapper because:
        // * we want the wrapper's actual height to remain "auto"
        // * if we set a min-height on the wrapper, the scroller's height: 100% doesn't
        //   respect it (height: 100% doesn't seem to work properly with min-height on the parent)
        $(this.editor.getScrollerElement())
            .css("min-height", (ruleListNaturalHeight - headerHeight) + "px");
    };

    /** Update inline widget height to reflect changed rule-list height */
    MultiRangeInlineEditor.prototype._ruleListHeightChanged = function () {
        // Editor's min height depends on rule list height
        this._updateEditorMinHeight();

        // Overall widget height may have changed too
        this.sizeInlineWidgetToContents();
    };

    MultiRangeInlineEditor.prototype._removeRange = function (range) {
        // If this is the last range, just close the whole widget
        if (this._ranges.length <= 1) {
            this.close();
            return;  // note: the dispose() that would normally happen below is covered by close()
        }

        // Now we know there is at least one other range -> found out which one this is
        var index = this._ranges.indexOf(range);

        // If the range to be removed is the selected one, first switch to another one
        if (index === this._selectedRangeIndex) {
            // If possible, select the one below, else select the one above
            if (index + 1 < this._ranges.length) {
                this.setSelectedIndex(index + 1);
            } else {
                this.setSelectedIndex(index - 1);
            }
        }

        // Now we can remove this range
        range.textRange.dispose();
        this._ranges.splice(index, 1);

        // Re-render list & section headers
        this._renderList();

        // Move selection highlight if deletion affected its position
        if (index < this._selectedRangeIndex) {
            this._selectedRangeIndex--;
            this._updateSelectedMarker(true);
        }

        if (this._ranges.length === 1) {
            this.$relatedContainer.remove();

            // Refresh the height of the inline editor since we remove
            // the entire selector list.
            if (this.editor) {
                this.editor.refresh();
            }
        }

        this._updateCommands();
    };

    /**
     * Adds a new range to the inline editor and selects it. The range will be inserted
     * immediately below the last range for the same document, or at the end of the list
     * if there are no other ranges for that document.
     * @param {string} name The label for the new range.
     * @param {Document} doc The document the range is in.
     * @param {number} lineStart The starting line of the range, 0-based, inclusive.
     * @param {number} lineEnd The ending line of the range, 0-based, inclusive.
     */
    MultiRangeInlineEditor.prototype.addAndSelectRange = function (name, doc, lineStart, lineEnd) {
        var newRange = new SearchResultItem({
                name: name,
                document: doc,
                lineStart: lineStart,
                lineEnd: lineEnd
            }),
            i;

        // Insert the new range after the last range from the same doc, or at the
        // end of the list.
        for (i = 0; i < this._ranges.length; i++) {
            if (this._fileComparator(this._ranges[i].textRange.document.file, doc.file) > 0) {
                break;
            }
        }
        this._ranges.splice(i, 0, newRange);

        // Update rule list display
        this._renderList();

        // Ensure rule list is visible if there are now multiple results
        if (this._ranges.length > 1 && !this.$relatedContainer.parent().length) {
            this.$wrapper.before(this.$relatedContainer);
        }

        // If added rule is in a collapsed item, expand it for clarity
        if (this._collapsedFiles[doc.file.fullPath]) {
            this._toggleSection(doc.file.fullPath);
        }

        // Select new range, showing it in the editor
        this.setSelectedIndex(i, true);  // force, since i might be same as before

        this._updateCommands();
    };

    MultiRangeInlineEditor.prototype._updateSelectedMarker = function (animate) {
        // If no selection or selection is in a collapsed section, just hide the marker
        if (this._selectedRangeIndex < 0 || this._collapsedFiles[this._getSelectedRange().textRange.document.file.fullPath]) {
            this.$selectedMarker.hide();
            return;
        }

        var $rangeItem = this._ranges[this._selectedRangeIndex].$listItem;

        // scroll the selection to the rangeItem
        var containerHeight = this.$relatedContainer.height(),
            itemTop = $rangeItem.position().top,
            scrollTop = this.$relatedContainer.scrollTop();

        this.$selectedMarker
            .show()
            .toggleClass("animate", animate)
            .css("top", itemTop)
            .height($rangeItem.outerHeight());

        if (containerHeight <= 0) {
            return;
        }

        var paddingTop = _parseStyleSize($rangeItem.parent(), "paddingTop");

        if ((itemTop - paddingTop) < scrollTop) {
            this.$relatedContainer.scrollTop(itemTop - paddingTop);
        } else {
            var itemBottom = itemTop + $rangeItem.height() + _parseStyleSize($rangeItem.parent(), "paddingBottom");

            if (itemBottom > (scrollTop + containerHeight)) {
                this.$relatedContainer.scrollTop(itemBottom - containerHeight);
            }
        }
    };

    /**
     * Called any time inline is closed, whether manually (via closeThisInline()) or automatically
     */
    MultiRangeInlineEditor.prototype.onClosed = function () {
        // Superclass onClosed() destroys editor
        MultiRangeInlineEditor.prototype.parentClass.onClosed.apply(this, arguments);

        // de-ref all the Documents in the search results
        this._ranges.forEach(function (searchResult) {
            searchResult.textRange.dispose();
        });

        // Remove event handlers
        this.$htmlContent.off(".MultiRangeInlineEditor");
        this.$editorHolder.off(".MultiRangeInlineEditor");
    };

    /**
     * Prevent clicks in the dead areas of the inlineWidget from changing the focus and insertion point in the editor.
     * This is done by detecting clicks in the inlineWidget that are not inside the editor or the range list and
     * restoring focus and the insertion point.
     */
    MultiRangeInlineEditor.prototype._onClick = function (event) {
        if (!this.editor) {
            return;
        }

        var childEditor = this.editor,
            editorRoot = childEditor.getRootElement(),
            editorPos = $(editorRoot).offset();

        function containsClick($parent) {
            return $parent.find(event.target).length > 0 || $parent[0] === event.target;
        }

        // Ignore clicks in editor and clicks on filename link
        // Check clicks on filename link in the context of the current inline widget.
        if (!containsClick($(editorRoot)) && !containsClick($(".filename", this.$htmlContent))) {
            childEditor.focus();
            // Only set the cursor if the click isn't in the range list.
            if (!containsClick(this.$relatedContainer)) {
                if (event.pageY < editorPos.top) {
                    childEditor.setCursorPos(0, 0);
                } else if (event.pageY > editorPos.top + $(editorRoot).height()) {
                    var lastLine = childEditor.getLastVisibleLine();
                    childEditor.setCursorPos(lastLine, childEditor.document.getLine(lastLine).length);
                }
            }
        }
    };

    /**
     * Based on the position of the cursor in the inline editor, determine whether we need to change the
     * vertical scroll position of the host editor to ensure that the cursor is visible.
     */
    MultiRangeInlineEditor.prototype._ensureCursorVisible = function () {
        if (!this.editor) {
            return;
        }

        if ($.contains(this.editor.getRootElement(), window.document.activeElement)) {
            var hostScrollPos = this.hostEditor.getScrollPos(),
                cursorCoords = this.editor._codeMirror.cursorCoords();

            // Vertically, we want to set the scroll position relative to the overall host editor, not
            // the lineSpace of the widget itself. We don't want to modify the horizontal scroll position.
            var scrollerTop = this.hostEditor.getVirtualScrollAreaTop();
            this.hostEditor._codeMirror.scrollIntoView({
                left: hostScrollPos.x,
                top: cursorCoords.top - scrollerTop,
                right: hostScrollPos.x,
                bottom: cursorCoords.bottom - scrollerTop
            });
        }
    };

    /**
     * Overwrite InlineTextEditor's _onLostContent to do nothing if the document's file is deleted
     * (deletes are handled via TextRange's lostSync).
     */
    MultiRangeInlineEditor.prototype._onLostContent = function (event, cause) {
        // Ignore when the editor's content got lost due to a deleted file
        if (cause && cause.type === "deleted") { return; }
        // Else yield to the parent's implementation
        return MultiRangeInlineEditor.prototype.parentClass._onLostContent.apply(this, arguments);
    };

    /**
     * @return {Array.<SearchResultItem>}
     */
    MultiRangeInlineEditor.prototype._getRanges = function () {
        return this._ranges;
    };

    /**
     * @return {!SearchResultItem}
     */
    MultiRangeInlineEditor.prototype._getSelectedRange = function () {
        return this._selectedRangeIndex >= 0 ? this._ranges[this._selectedRangeIndex] : null;
    };

    /**
     * Move the selection up or down, skipping any collapsed groups. If selection is currently IN a
     * collapsed group, we expand it first so that other items in the same file are eligible.
     */
    MultiRangeInlineEditor.prototype._selectNextPrev = function (dir) {
        if (this._selectedRangeIndex === -1) {
            return;
        }

        // Traverse up or down the list until we find an item eligible for selection
        var origDoc = this._ranges[this._selectedRangeIndex].textRange.document,
            i;
        for (i = this._selectedRangeIndex + dir; i >= 0 && i < this._ranges.length; i += dir) {
            var doc = this._ranges[i].textRange.document;

            // If first candidate is in same collapsed group as current selection, expand it
            if (doc === origDoc && this._collapsedFiles[doc.file.fullPath]) {
                this._toggleSection(doc.file.fullPath);
            }

            // Only consider expanded groups now
            if (!this._collapsedFiles[doc.file.fullPath]) {
                this.setSelectedIndex(i);
                return;
            }
        }
        // If we got here, we couldn't find any eligible item - so do nothing. Happens if selection is
        // already the first/last item, or if all remaining items above/below the selection are collapsed.
    };

    /**
     * Display the next range in the range list
     */
    MultiRangeInlineEditor.prototype._selectNextRange = function () {
        this._selectNextPrev(1);
    };

    /**
     *  Display the previous range in the range list
     */
    MultiRangeInlineEditor.prototype._selectPreviousRange = function () {
        this._selectNextPrev(-1);
    };

    /**
     * Sizes the inline widget height to be the maximum between the range list height and the editor height
     * @override
     */
    MultiRangeInlineEditor.prototype.sizeInlineWidgetToContents = function () {
        // Size the code mirror editors height to the editor content
        MultiRangeInlineEditor.prototype.parentClass.sizeInlineWidgetToContents.call(this);

        // Size the widget height to the max between the editor/message content and the related ranges list
        var widgetHeight = Math.max(this.$related.height(),
                                    this.$header.outerHeight() +
                                        (this._selectedRangeIndex === -1 ? this.$messageDiv.outerHeight() : this.$editorHolder.height()));

        if (widgetHeight) {
            this.hostEditor.setInlineWidgetHeight(this, widgetHeight, false);
        }
    };

    /**
     * Called when the editor containing the inline is made visible. Updates UI based on
     * state that might have changed while the editor was hidden.
     */
    MultiRangeInlineEditor.prototype.onParentShown = function () {
        MultiRangeInlineEditor.prototype.parentClass.onParentShown.apply(this, arguments);
        this._updateSelectedMarker(false);
    };

    /**
     * Refreshes the height of the inline editor and all child editors.
     * @override
     */
    MultiRangeInlineEditor.prototype.refresh = function () {
        MultiRangeInlineEditor.prototype.parentClass.refresh.apply(this, arguments);
        this.sizeInlineWidgetToContents();
        if (this.editor) {
            this.editor.refresh();
        }
    };

    /**
     * Returns the currently focused MultiRangeInlineEditor.
     * @return {MultiRangeInlineEditor}
     */
    function getFocusedMultiRangeInlineEditor() {
        var focusedWidget = EditorManager.getFocusedInlineWidget();
        if (focusedWidget instanceof MultiRangeInlineEditor) {
            return focusedWidget;
        } else {
            return null;
        }
    }

    /**
     * Previous Range command handler
     */
    function _previousRange() {
        var focusedMultiRangeInlineEditor = getFocusedMultiRangeInlineEditor();
        if (focusedMultiRangeInlineEditor) {
            focusedMultiRangeInlineEditor._selectPreviousRange();
        }
    }

    /**
     * Next Range command handler
     */
    function _nextRange() {
        var focusedMultiRangeInlineEditor = getFocusedMultiRangeInlineEditor();
        if (focusedMultiRangeInlineEditor) {
            focusedMultiRangeInlineEditor._selectNextRange();
        }
    }

    _prevMatchCmd = CommandManager.register(Strings.CMD_QUICK_EDIT_PREV_MATCH, Commands.QUICK_EDIT_PREV_MATCH, _previousRange);
    _prevMatchCmd.setEnabled(false);
    _nextMatchCmd = CommandManager.register(Strings.CMD_QUICK_EDIT_NEXT_MATCH, Commands.QUICK_EDIT_NEXT_MATCH, _nextRange);
    _nextMatchCmd.setEnabled(false);

    exports.MultiRangeInlineEditor = MultiRangeInlineEditor;
    exports.getFocusedMultiRangeInlineEditor = getFocusedMultiRangeInlineEditor;
});