Files
gerrit/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
Dhruv Srivastava 9c853fc1fa Prevent cursor from scrolling if multiple files are being rendered
After clicking Expand All, Gerrit begins to render the diffs and if
the user starts scrolling, the 'never' scroll behaviour is supposed to
prevent the scrolling of the user to the top.

We prevent any auto scrolling happening now after Expand All is
clicked.
This however introduces a regression in which the first chunk is not
focused on a small screen but we are fine in not supporting this use
case as of now.

Rename ScrollBehaviour to ScrollModes as Behaviours in Gerrit are
similar to mixins.

Move ScrollModes into constants.js

Change-Id: Ic904c01c5bf93934c8c9b215e35e4b1d95f4498d
2020-05-05 16:38:52 +00:00

540 lines
15 KiB
JavaScript

/**
* @license
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-diff-cursor_html.js';
import {ScrollModes} from '../../../constants/constants.js';
const DiffSides = {
LEFT: 'left',
RIGHT: 'right',
};
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
const LEFT_SIDE_CLASS = 'target-side-left';
const RIGHT_SIDE_CLASS = 'target-side-right';
/** @extends PolymerElement */
class GrDiffCursor extends GestureEventListeners(
LegacyElementMixin(PolymerElement)) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-diff-cursor'; }
static get properties() {
return {
/**
* Either DiffSides.LEFT or DiffSides.RIGHT.
*/
side: {
type: String,
value: DiffSides.RIGHT,
},
/** @type {!HTMLElement|undefined} */
diffRow: {
type: Object,
notify: true,
observer: '_rowChanged',
},
/**
* The diff views to cursor through and listen to.
*/
diffs: {
type: Array,
value() { return []; },
},
/**
* If set, the cursor will attempt to move to the line number (instead of
* the first chunk) the next time the diff renders. It is set back to null
* when used. It should be only used if you want the line to be focused
* after initialization of the component and page should scroll
* to that position. This parameter should be set at most for one gr-diff
* element in the page.
*
* @type {?number}
*/
initialLineNumber: {
type: Number,
value: null,
},
/**
* The scroll behavior for the cursor. Values are 'never' and
* 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
* the viewport.
*/
_scrollMode: {
type: String,
value: ScrollModes.KEEP_VISIBLE,
},
_focusOnMove: {
type: Boolean,
value: true,
},
_listeningForScroll: Boolean,
/**
* gr-diff-view has gr-fixed-panel on top. The panel can
* intersect a main element and partially hides a content of
* the main element. To correctly calculates visibility of an
* element, the cursor must know how much height occuped by a fixed
* panel.
* The scrollTopMargin defines margin occuped by fixed panel.
*/
scrollTopMargin: {
type: Number,
value: 0,
},
};
}
static get observers() {
return [
'_updateSideClass(side)',
'_diffsChanged(diffs.splices)',
];
}
constructor() {
super();
this._boundHandleWindowScroll = () => this._handleWindowScroll();
this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
this._boundHandleDiffLineSelected = e => this._handleDiffLineSelected(e);
}
/** @override */
ready() {
super.ready();
afterNextRender(this, () => {
/*
This represents the diff cursor is ready for interaction coming from
client components. It is more then Polymer "ready" lifecycle, as no
"ready" events are automatically fired by Polymer, it means
the cursor is completely interactable - in this case attached and
painted on the page. We name it "ready" instead of "rendered" as the
long-term goal is to make gr-diff-cursor a javascript class - not a DOM
element with an actual lifecycle. This will be triggered only once
per element.
*/
this.dispatchEvent(new CustomEvent('ready', {
composed: true, bubbles: false,
}));
});
}
/** @override */
connectedCallback() {
super.connectedCallback();
// Catch when users are scrolling as the view loads.
window.addEventListener('scroll', this._boundHandleWindowScroll);
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('scroll', this._boundHandleWindowScroll);
}
moveLeft() {
this.side = DiffSides.LEFT;
if (this._isTargetBlank()) {
this.moveUp();
}
}
moveRight() {
this.side = DiffSides.RIGHT;
if (this._isTargetBlank()) {
this.moveUp();
}
}
moveDown() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
this.$.cursorManager.next(this._rowHasSide.bind(this));
} else {
this.$.cursorManager.next();
}
}
moveUp() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
this.$.cursorManager.previous(this._rowHasSide.bind(this));
} else {
this.$.cursorManager.previous();
}
}
moveToVisibleArea() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
this.$.cursorManager.moveToVisibleArea(
this._rowHasSide.bind(this));
} else {
this.$.cursorManager.moveToVisibleArea();
}
}
moveToNextChunk(opt_clipToTop) {
this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
target => target.parentNode.scrollHeight, opt_clipToTop);
this._fixSide();
}
moveToPreviousChunk() {
this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
this._fixSide();
}
moveToNextCommentThread() {
this.$.cursorManager.next(this._rowHasThread.bind(this));
this._fixSide();
}
moveToPreviousCommentThread() {
this.$.cursorManager.previous(this._rowHasThread.bind(this));
this._fixSide();
}
/**
* @param {number} number
* @param {string} side
* @param {string=} opt_path
*/
moveToLineNumber(number, side, opt_path) {
const row = this._findRowByNumberAndFile(number, side, opt_path);
if (row) {
this.side = side;
this.$.cursorManager.setCursor(row);
}
}
/**
* Get the line number element targeted by the cursor row and side.
*
* @return {?Element|undefined}
*/
getTargetLineElement() {
let lineElSelector = '.lineNum';
if (!this.diffRow) {
return;
}
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
}
return this.diffRow.querySelector(lineElSelector);
}
getTargetDiffElement() {
if (!this.diffRow) return null;
const hostOwner = dom( (this.diffRow))
.getOwnerRoot();
if (hostOwner && hostOwner.host &&
hostOwner.host.tagName === 'GR-DIFF') {
return hostOwner.host;
}
return null;
}
moveToFirstChunk() {
this.$.cursorManager.moveToStart();
this.moveToNextChunk(true);
}
moveToLastChunk() {
this.$.cursorManager.moveToEnd();
this.moveToPreviousChunk();
}
/**
* Move the cursor either to initialLineNumber or the first chunk and
* reset scroll behavior.
*
* This may grab the focus from the app.
*
* If you do not want to move the cursor or grab focus, and just want to
* reset the scroll behavior, use reInit() instead.
*/
reInitCursor() {
if (!this.diffRow) {
// does not scroll during init unless requested
const scrollingBehaviorForInit = this.initialLineNumber ?
ScrollModes.KEEP_VISIBLE :
ScrollModes.NEVER;
this._scrollMode = scrollingBehaviorForInit;
if (this.initialLineNumber) {
this.moveToLineNumber(this.initialLineNumber, this.side);
this.initialLineNumber = null;
} else {
this.moveToFirstChunk();
}
}
this.reInit();
}
reInit() {
this._scrollMode = ScrollModes.KEEP_VISIBLE;
}
_handleWindowScroll() {
if (this._preventAutoScrollOnManualScroll) {
this._scrollMode = ScrollModes.NEVER;
this._focusOnMove = false;
this._preventAutoScrollOnManualScroll = false;
}
}
reInitAndUpdateStops() {
this.reInit();
this._updateStops();
}
handleDiffUpdate() {
this._updateStops();
this.reInitCursor();
}
_handleDiffRenderStart() {
this._preventAutoScrollOnManualScroll = true;
}
_handleDiffRenderContent() {
this._updateStops();
// When done rendering, turn focus on move and automatic scrolling back on
this._focusOnMove = true;
this._preventAutoScrollOnManualScroll = false;
}
_handleDiffLineSelected(event) {
this.moveToLineNumber(
event.detail.number, event.detail.side, event.detail.path);
}
createCommentInPlace() {
const diffWithRangeSelected = this.diffs
.find(diff => diff.isRangeSelected());
if (diffWithRangeSelected) {
diffWithRangeSelected.createRangeComment();
} else {
const line = this.getTargetLineElement();
if (line) {
this.getTargetDiffElement().addDraftAtLine(line);
}
}
}
/**
* Get an object describing the location of the cursor. Such as
* {leftSide: false, number: 123} for line 123 of the revision, or
* {leftSide: true, number: 321} for line 321 of the base patch.
* Returns null if an address is not available.
*
* @return {?Object}
*/
getAddress() {
if (!this.diffRow) { return null; }
// Get the line-number cell targeted by the cursor. If the mode is unified
// then prefer the revision cell if available.
let cell;
if (this._getViewMode() === DiffViewMode.UNIFIED) {
cell = this.diffRow.querySelector('.lineNum.right');
if (!cell) {
cell = this.diffRow.querySelector('.lineNum.left');
}
} else {
cell = this.diffRow.querySelector('.lineNum.' + this.side);
}
if (!cell) { return null; }
const number = cell.getAttribute('data-value');
if (!number || number === 'FILE') { return null; }
return {
leftSide: cell.matches('.left'),
number: parseInt(number, 10),
};
}
_getViewMode() {
if (!this.diffRow) {
return null;
}
if (this.diffRow.classList.contains('side-by-side')) {
return DiffViewMode.SIDE_BY_SIDE;
} else {
return DiffViewMode.UNIFIED;
}
}
_rowHasSide(row) {
const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
' + .content';
return !!row.querySelector(selector);
}
_isFirstRowOfChunk(row) {
const parentClassList = row.parentNode.classList;
return parentClassList.contains('section') &&
parentClassList.contains('delta') &&
!row.previousSibling;
}
_rowHasThread(row) {
return row.querySelector('.thread-group');
}
/**
* If we jumped to a row where there is no content on the current side then
* switch to the alternate side.
*/
_fixSide() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
this._isTargetBlank()) {
this.side = this.side === DiffSides.LEFT ?
DiffSides.RIGHT : DiffSides.LEFT;
}
}
_isTargetBlank() {
if (!this.diffRow) {
return false;
}
const actions = this._getActionsForRow();
return (this.side === DiffSides.LEFT && !actions.left) ||
(this.side === DiffSides.RIGHT && !actions.right);
}
_rowChanged(newRow, oldRow) {
if (oldRow) {
oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
}
this._updateSideClass();
}
_updateSideClass() {
if (!this.diffRow) {
return;
}
this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
this.diffRow);
this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
this.diffRow);
}
_isActionType(type) {
return type !== 'blank' && type !== 'contextControl';
}
_getActionsForRow() {
const actions = {left: false, right: false};
if (this.diffRow) {
actions.left = this._isActionType(
this.diffRow.getAttribute('left-type'));
actions.right = this._isActionType(
this.diffRow.getAttribute('right-type'));
}
return actions;
}
_getStops() {
return this.diffs.reduce(
(stops, diff) => stops.concat(diff.getCursorStops()), []);
}
_updateStops() {
this.$.cursorManager.stops = this._getStops();
}
/**
* Setup and tear down on-render listeners for any diffs that are added or
* removed from the cursor.
*
* @private
*/
_diffsChanged(changeRecord) {
if (!changeRecord) { return; }
this._updateStops();
let splice;
let i;
for (let spliceIdx = 0;
changeRecord.indexSplices &&
spliceIdx < changeRecord.indexSplices.length;
spliceIdx++) {
splice = changeRecord.indexSplices[spliceIdx];
for (i = splice.index; i < splice.index + splice.addedCount; i++) {
this.diffs[i].addEventListener(
'render-start', this._boundHandleDiffRenderStart);
this.diffs[i].addEventListener(
'render-content', this._boundHandleDiffRenderContent);
this.diffs[i].addEventListener(
'line-selected', this._boundHandleDiffLineSelected);
}
for (i = 0; i < splice.removed && splice.removed.length; i++) {
splice.removed[i].removeEventListener(
'render-start', this._boundHandleDiffRenderStart);
splice.removed[i].removeEventListener(
'render-content', this._boundHandleDiffRenderContent);
splice.removed[i].removeEventListener(
'line-selected', this._boundHandleDiffLineSelected);
}
}
}
_findRowByNumberAndFile(targetNumber, side, opt_path) {
let stops;
if (opt_path) {
const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
stops = diff.getCursorStops();
} else {
stops = this.$.cursorManager.stops;
}
let selector;
for (let i = 0; i < stops.length; i++) {
selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
if (stops[i].querySelector(selector)) {
return stops[i];
}
}
}
}
customElements.define(GrDiffCursor.is, GrDiffCursor);