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.
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 } };
}
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;
});
Remove trailing "px" from a style size value.
function _parseStyleSize($target, styleName) {
return parseInt($target.css(styleName), 10);
}
Previous Range command handler
function _previousRange() {
var focusedMultiRangeInlineEditor = getFocusedMultiRangeInlineEditor();
if (focusedMultiRangeInlineEditor) {
focusedMultiRangeInlineEditor._selectPreviousRange();
}
}
Returns the currently focused MultiRangeInlineEditor.
function getFocusedMultiRangeInlineEditor() {
var focusedWidget = EditorManager.getFocusedInlineWidget();
if (focusedWidget instanceof MultiRangeInlineEditor) {
return focusedWidget;
} else {
return null;
}
}
Stores one search result: its source file, line range, etc. plus the DOM node representing it in the results list.
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());
}
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;
MultiRangeInlineEditor.prototype._$headers = null;
Map from fullPath to true if collapsed. May not agree with preferences, in cases where multiple inline editors make concurrent changes.
MultiRangeInlineEditor.prototype._collapsedFiles = null;
MultiRangeInlineEditor.prototype._messageCB = null;
MultiRangeInlineEditor.prototype._labelCB = null;
MultiRangeInlineEditor.prototype._fileComparator = null;
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;
};
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;
};
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
});
}
};
MultiRangeInlineEditor.prototype._getRanges = function () {
return this._ranges;
};
MultiRangeInlineEditor.prototype._getSelectedRange = function () {
return this._selectedRangeIndex >= 0 ? this._ranges[this._selectedRangeIndex] : null;
};
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);
}
}
}
};
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);
};
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();
};
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();
};
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);
};
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);
}
};
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);
};
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");
};
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.
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);
}
}
};
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));
};
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();
};
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");
};
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.
MultiRangeInlineEditor.prototype.refresh = function () {
MultiRangeInlineEditor.prototype.parentClass.refresh.apply(this, arguments);
this.sizeInlineWidgetToContents();
if (this.editor) {
this.editor.refresh();
}
};
Specify the range that is shown in the editor.
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();
};
Sizes the inline widget height to be the maximum between the range list height and the editor height
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);
}
};