Properly bookend file-list hotkey scrolling

The addition of incremental file loading in the file-list introduced an
edge case in which the user could scroll off the file-list with hotkeys.
This change implements the gr-cursor-manager to control file-list
scrolling behavior.

In addition, some interesting edge cases with the cursor-manager were
uncovered during use -- these were also fixed.

Change-Id: I936d4368058212c98684bc2617be2d98bdf6e41d
This commit is contained in:
Kasper Nilsson
2016-11-21 14:11:11 -08:00
parent c04343eaad
commit 034f79b9f4
5 changed files with 66 additions and 73 deletions

View File

@@ -21,6 +21,7 @@ limitations under the License.
<link rel="import" href="../../diff/gr-diff/gr-diff.html"> <link rel="import" href="../../diff/gr-diff/gr-diff.html">
<link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html"> <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
<link rel="import" href="../../shared/gr-button/gr-button.html"> <link rel="import" href="../../shared/gr-button/gr-button.html">
<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html"> <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html"> <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
<link rel="import" href="../../shared/gr-select/gr-select.html"> <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -64,7 +65,7 @@ limitations under the License.
.row:not(.header):hover { .row:not(.header):hover {
background-color: #f5fafd; background-color: #f5fafd;
} }
.row[selected] { .row.selected {
background-color: #ebf5fb; background-color: #ebf5fb;
} }
.path { .path {
@@ -145,7 +146,7 @@ limitations under the License.
max-width: 25em; max-width: 25em;
} }
@media screen and (max-width: 50em) { @media screen and (max-width: 50em) {
.row[selected] { .row.selected {
background-color: transparent; background-color: transparent;
} }
.stats { .stats {
@@ -207,7 +208,7 @@ limitations under the License.
items="[[_shownFiles]]" items="[[_shownFiles]]"
as="file" as="file"
initial-count="[[_fileListIncrement]]"> initial-count="[[_fileListIncrement]]">
<div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]"> <div class="file-row row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
<div class="reviewed" hidden$="[[!_loggedIn]]" hidden> <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
<input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]" <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
data-path$="[[file.__path]]" on-change="_handleReviewedChange" data-path$="[[file.__path]]" on-change="_handleReviewedChange"
@@ -302,7 +303,11 @@ limitations under the License.
</gr-button> </gr-button>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface> <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
<gr-storage id="storage"></gr-storage> <gr-storage id="storage"></gr-storage>
<gr-diff-cursor id="cursor"></gr-diff-cursor> <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
<gr-cursor-manager
id="fileCursor"
scroll-behavior="keep-visible"
cursor-target-class="selected"></gr-cursor-manager>
</template> </template>
<script src="gr-file-list.js"></script> <script src="gr-file-list.js"></script>
</dom-module> </dom-module>

View File

@@ -35,10 +35,6 @@
drafts: Object, drafts: Object,
revisions: Object, revisions: Object,
projectConfig: Object, projectConfig: Object,
selectedIndex: {
type: Number,
notify: true,
},
keyEventTarget: { keyEventTarget: {
type: Object, type: Object,
value: function() { return document.body; }, value: function() { return document.body; },
@@ -253,7 +249,7 @@
this.set(['_shownFiles', i, '__expanded'], false); this.set(['_shownFiles', i, '__expanded'], false);
this.set(['_files', i, '__expanded'], false); this.set(['_files', i, '__expanded'], false);
} }
this.$.cursor.handleDiffUpdate(); this.$.diffCursor.handleDiffUpdate();
}, },
_computeCommentsString: function(comments, patchNum, path) { _computeCommentsString: function(comments, patchNum, path) {
@@ -326,7 +322,7 @@
if (!this._showInlineDiffs) { return; } if (!this._showInlineDiffs) { return; }
e.preventDefault(); e.preventDefault();
this.$.cursor.moveLeft(); this.$.diffCursor.moveLeft();
}, },
_handleShiftRightKey: function(e) { _handleShiftRightKey: function(e) {
@@ -334,20 +330,20 @@
if (!this._showInlineDiffs) { return; } if (!this._showInlineDiffs) { return; }
e.preventDefault(); e.preventDefault();
this.$.cursor.moveRight(); this.$.diffCursor.moveRight();
}, },
_handleIKey: function(e) { _handleIKey: function(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (this.shouldSuppressKeyboardShortcut(e)) { return; }
if (this.selectedIndex === undefined) { return; } if (this.$.fileCursor.index === -1) { return; }
e.preventDefault(); e.preventDefault();
var expanded = this._files[this.selectedIndex].__expanded; var expanded = this._files[this.$.fileCursor.index].__expanded;
// Until Polymer 2.0, manual management of reflection between _files // Until Polymer 2.0, manual management of reflection between _files
// and _shownFiles is necessary. // and _shownFiles is necessary.
this.set(['_shownFiles', this.selectedIndex, '__expanded'], this.set(['_shownFiles', this.$.fileCursor.index, '__expanded'],
!expanded); !expanded);
this.set(['_files', this.selectedIndex, '__expanded'], !expanded); this.set(['_files', this.$.fileCursor.index, '__expanded'], !expanded);
}, },
_handleCapitalIKey: function(e) { _handleCapitalIKey: function(e) {
@@ -359,14 +355,11 @@
_handleDownKey: function(e) { _handleDownKey: function(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; } if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault(); e.preventDefault();
if (this._showInlineDiffs) { if (this._showInlineDiffs) {
this.$.cursor.moveDown(); this.$.diffCursor.moveDown();
} else { } else {
this.selectedIndex = this.$.fileCursor.next();
Math.min(this._numFilesShown, this.selectedIndex + 1);
this._scrollToSelectedFile();
} }
}, },
@@ -375,10 +368,9 @@
e.preventDefault(); e.preventDefault();
if (this._showInlineDiffs) { if (this._showInlineDiffs) {
this.$.cursor.moveUp(); this.$.diffCursor.moveUp();
} else { } else {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.$.fileCursor.previous();
this._scrollToSelectedFile();
} }
}, },
@@ -425,9 +417,9 @@
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
this.$.cursor.moveToNextCommentThread(); this.$.diffCursor.moveToNextCommentThread();
} else { } else {
this.$.cursor.moveToNextChunk(); this.$.diffCursor.moveToNextChunk();
} }
}, },
@@ -437,9 +429,9 @@
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
this.$.cursor.moveToPreviousCommentThread(); this.$.diffCursor.moveToPreviousCommentThread();
} else { } else {
this.$.cursor.moveToPreviousChunk(); this.$.diffCursor.moveToPreviousChunk();
} }
}, },
@@ -461,43 +453,27 @@
}, },
_openCursorFile: function() { _openCursorFile: function() {
var diff = this.$.cursor.getTargetDiffElement(); var diff = this.$.diffCursor.getTargetDiffElement();
page.show(this._computeDiffURL(diff.changeNum, diff.patchRange, page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
diff.path)); diff.path));
}, },
_openSelectedFile: function(opt_index) { _openSelectedFile: function(opt_index) {
if (opt_index != null) { if (opt_index != null) {
this.selectedIndex = opt_index; this.$.fileCursor.setCursorAtIndex(opt_index);
} }
page.show(this._computeDiffURL(this.changeNum, this.patchRange, page.show(this._computeDiffURL(this.changeNum, this.patchRange,
this._files[this.selectedIndex].__path)); this._files[this.$.fileCursor.index].__path));
}, },
_addDraftAtTarget: function() { _addDraftAtTarget: function() {
var diff = this.$.cursor.getTargetDiffElement(); var diff = this.$.diffCursor.getTargetDiffElement();
var target = this.$.cursor.getTargetLineElement(); var target = this.$.diffCursor.getTargetLineElement();
if (diff && target) { if (diff && target) {
diff.addDraftAtLine(target); diff.addDraftAtLine(target);
} }
}, },
_scrollToSelectedFile: function() {
var el = this.$$('.row[selected]');
var top = 0;
for (var node = el; node; node = node.offsetParent) {
top += node.offsetTop;
}
// Don't scroll if it's already in view.
if (top > window.pageYOffset &&
top < window.pageYOffset + window.innerHeight - el.clientHeight) {
return;
}
window.scrollTo(0, top - document.body.clientHeight / 2);
},
_shouldHideChangeTotals: function(_patchChange) { _shouldHideChangeTotals: function(_patchChange) {
return _patchChange.inserted === 0 && _patchChange.deleted === 0; return _patchChange.inserted === 0 && _patchChange.deleted === 0;
}, },
@@ -576,8 +552,12 @@
var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff'); var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
// Overwrite the cursor's list of diffs: // Overwrite the cursor's list of diffs:
this.$.cursor.splice.apply(this.$.cursor, this.$.diffCursor.splice.apply(this.$.diffCursor,
['diffs', 0, this.$.cursor.diffs.length].concat(diffElements)); ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
var files = Polymer.dom(this.root).querySelectorAll('.file-row');
this.$.fileCursor.stops = files;
if (this.$.fileCursor.index === -1) { this.$.fileCursor.moveToStart(); }
}.bind(this), 1); }.bind(this), 1);
}, },

View File

@@ -279,7 +279,7 @@ limitations under the License.
basePatchNum: 'PARENT', basePatchNum: 'PARENT',
patchNum: '2', patchNum: '2',
}; };
element.selectedIndex = 0; element.$.fileCursor.setCursorAtIndex(0);
}); });
test('toggle left diff via shortcut', function() { test('toggle left diff via shortcut', function() {
@@ -298,24 +298,26 @@ limitations under the License.
test('keyboard shortcuts', function() { test('keyboard shortcuts', function() {
flushAsynchronousOperations(); flushAsynchronousOperations();
var elementItems = Polymer.dom(element.root).querySelectorAll(
'.row:not(.header)'); var items = Polymer.dom(element.root).querySelectorAll('.file-row');
assert.equal(elementItems.length, 5); element.$.fileCursor.stops = items;
assert.isTrue(elementItems[0].hasAttribute('selected')); element.$.fileCursor.setCursorAtIndex(0);
assert.isFalse(elementItems[1].hasAttribute('selected')); assert.equal(items.length, 3);
assert.isFalse(elementItems[2].hasAttribute('selected')); assert.isTrue(items[0].classList.contains('selected'));
assert.isFalse(items[1].classList.contains('selected'));
assert.isFalse(items[2].classList.contains('selected'));
MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j'); MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
assert.equal(element.selectedIndex, 1); assert.equal(element.$.fileCursor.index, 1);
MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j'); MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
var showStub = sandbox.stub(page, 'show'); var showStub = sandbox.stub(page, 'show');
assert.equal(element.selectedIndex, 2); assert.equal(element.$.fileCursor.index, 2);
MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter'); MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'), assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
'Should navigate to /c/42/2/myfile.txt'); 'Should navigate to /c/42/2/myfile.txt');
MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k'); MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
assert.equal(element.selectedIndex, 1); assert.equal(element.$.fileCursor.index, 1);
MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o'); MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'), assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
'Should navigate to /c/42/2/file_added_in_rev2.txt'); 'Should navigate to /c/42/2/file_added_in_rev2.txt');
@@ -323,20 +325,20 @@ limitations under the License.
MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k'); MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k'); MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k'); MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
assert.equal(element.selectedIndex, 0); assert.equal(element.$.fileCursor.index, 0);
showStub.restore();
}); });
test('i key shows/hides selected inline diff', function() { test('i key shows/hides selected inline diff', function() {
element.selectedIndex = 0; flushAsynchronousOperations();
element.$.fileCursor.stops = element.diffs;
element.$.fileCursor.setCursorAtIndex(0);
MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i'); MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
flushAsynchronousOperations(); flushAsynchronousOperations();
assert.isFalse(element.diffs[0].hasAttribute('hidden')); assert.isFalse(element.diffs[0].hasAttribute('hidden'));
MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i'); MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
flushAsynchronousOperations(); flushAsynchronousOperations();
assert.isTrue(element.diffs[0].hasAttribute('hidden')); assert.isTrue(element.diffs[0].hasAttribute('hidden'));
element.selectedIndex = 1; element.$.fileCursor.setCursorAtIndex(1);
MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i'); MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
flushAsynchronousOperations(); flushAsynchronousOperations();
assert.isFalse(element.diffs[1].hasAttribute('hidden')); assert.isFalse(element.diffs[1].hasAttribute('hidden'));
@@ -416,7 +418,7 @@ limitations under the License.
basePatchNum: 'PARENT', basePatchNum: 'PARENT',
patchNum: '2', patchNum: '2',
}; };
element.selectedIndex = 0; element.$.fileCursor.setCursorAtIndex(0);
flushAsynchronousOperations(); flushAsynchronousOperations();
var fileRows = var fileRows =
@@ -499,7 +501,7 @@ limitations under the License.
basePatchNum: 'PARENT', basePatchNum: 'PARENT',
patchNum: '2', patchNum: '2',
}; };
element.selectedIndex = 0; element.$.fileCursor.setCursorAtIndex(0);
flushAsynchronousOperations(); flushAsynchronousOperations();
var fileRows = var fileRows =
Polymer.dom(element.root).querySelectorAll('.row:not(.header)'); Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
@@ -537,7 +539,7 @@ limitations under the License.
basePatchNum: 'PARENT', basePatchNum: 'PARENT',
patchNum: '2', patchNum: '2',
}; };
element.selectedIndex = 0; element.$.fileCursor.setCursorAtIndex(0);
flushAsynchronousOperations(); flushAsynchronousOperations();
var diffDisplay = element.diffs[0]; var diffDisplay = element.diffs[0];
element._userPrefs = {diff_view: 'SIDE_BY_SIDE'}; element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
@@ -580,7 +582,7 @@ limitations under the License.
patchNum: '2', patchNum: '2',
}; };
var computeSpy = sandbox.spy(element, '_fileListActionsVisible'); var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
element.selectedIndex = 0; element.$.fileCursor.setCursorAtIndex(0);
element._numFilesShown = 1; element._numFilesShown = 1;
flush(function() { flush(function() {
assert.isTrue(computeSpy.lastCall.returnValue); assert.isTrue(computeSpy.lastCall.returnValue);

View File

@@ -21,7 +21,7 @@ limitations under the License.
<template> <template>
<gr-cursor-manager <gr-cursor-manager
id="cursorManager" id="cursorManager"
scroll="[[_scrollBehavior]]" scroll-behavior="[[_scrollBehavior]]"
cursor-target-class="target-row" cursor-target-class="target-row"
target="{{diffRow}}"></gr-cursor-manager> target="{{diffRow}}"></gr-cursor-manager>
</template> </template>

View File

@@ -59,7 +59,7 @@
* 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
* the viewport. * the viewport.
*/ */
scroll: { scrollBehavior: {
type: String, type: String,
value: ScrollBehavior.NEVER, value: ScrollBehavior.NEVER,
}, },
@@ -108,6 +108,10 @@
} }
}, },
setCursorAtIndex: function(index) {
this.setCursor(this.stops[index]);
},
/** /**
* Move the cursor forward or backward by delta. Noop if moving past either * Move the cursor forward or backward by delta. Noop if moving past either
* end of the stop list. * end of the stop list.
@@ -194,7 +198,9 @@
}, },
_scrollToTarget: function() { _scrollToTarget: function() {
if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; } if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
return;
}
// Calculate where the element is relative to the window. // Calculate where the element is relative to the window.
var top = this.target.offsetTop; var top = this.target.offsetTop;
@@ -204,7 +210,7 @@
top += offsetParent.offsetTop; top += offsetParent.offsetTop;
} }
if (this.scroll === ScrollBehavior.KEEP_VISIBLE && if (this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
top > window.pageYOffset && top > window.pageYOffset &&
top < window.pageYOffset + window.innerHeight) { return; } top < window.pageYOffset + window.innerHeight) { return; }