| /** |
| * @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'; |
| import { |
| AbortStop, |
| CursorMoveResult, |
| GrCursorManager, |
| Stop, |
| isTargetable, |
| } from '../../shared/gr-cursor-manager/gr-cursor-manager'; |
| import {afterNextRender} from '@polymer/polymer/lib/utils/render-status'; |
| import {dom} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-diff-cursor_html'; |
| import {DiffViewMode} from '../../../api/diff'; |
| import {ScrollMode, Side} from '../../../constants/constants'; |
| import {customElement, property, observe} from '@polymer/decorators'; |
| import {GrDiffLineType} from '../gr-diff/gr-diff-line'; |
| import {PolymerSpliceChange} from '@polymer/polymer/interfaces'; |
| import {PolymerDomWrapper} from '../../../types/types'; |
| import {GrDiffGroupType} from '../gr-diff/gr-diff-group'; |
| import {GrDiff} from '../gr-diff/gr-diff'; |
| import {fireAlert, fireEvent} from '../../../utils/event-util'; |
| |
| type GrDiffRowType = GrDiffLineType | GrDiffGroupType; |
| |
| const LEFT_SIDE_CLASS = 'target-side-left'; |
| const RIGHT_SIDE_CLASS = 'target-side-right'; |
| |
| // Time in which pressing n key again after the toast navigates to next file |
| const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000; |
| |
| export interface GrDiffCursor { |
| $: { |
| cursorManager: GrCursorManager; |
| }; |
| } |
| |
| @customElement('gr-diff-cursor') |
| export class GrDiffCursor extends GestureEventListeners( |
| LegacyElementMixin(PolymerElement) |
| ) { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| private preventAutoScrollOnManualScroll = false; |
| |
| private lastDisplayedNavigateToNextFileToast: number | null = null; |
| |
| @property({type: String}) |
| side = Side.RIGHT; |
| |
| @property({type: Object, notify: true, observer: '_rowChanged'}) |
| diffRow?: HTMLElement; |
| |
| @property({type: Object}) |
| diffs: GrDiff[] = []; |
| |
| /** |
| * 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. |
| */ |
| @property({type: Number}) |
| initialLineNumber: number | null = 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. |
| */ |
| @property({type: String}) |
| _scrollMode = ScrollMode.KEEP_VISIBLE; |
| |
| @property({type: Boolean}) |
| _focusOnMove = true; |
| |
| @property({type: Boolean}) |
| _listeningForScroll = false; |
| |
| /** @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); |
| } |
| |
| // Don't remove - used by clients embedding gr-diff outside of Gerrit. |
| isAtStart() { |
| return this.$.cursorManager.isAtStart(); |
| } |
| |
| // Don't remove - used by clients embedding gr-diff outside of Gerrit. |
| isAtEnd() { |
| return this.$.cursorManager.isAtEnd(); |
| } |
| |
| moveLeft() { |
| this.side = Side.LEFT; |
| if (this._isTargetBlank()) { |
| this.moveUp(); |
| } |
| } |
| |
| moveRight() { |
| this.side = Side.RIGHT; |
| if (this._isTargetBlank()) { |
| this.moveUp(); |
| } |
| } |
| |
| moveDown() { |
| if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) { |
| return this.$.cursorManager.next({ |
| filter: (row: Element) => this._rowHasSide(row), |
| }); |
| } else { |
| return this.$.cursorManager.next(); |
| } |
| } |
| |
| moveUp() { |
| if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) { |
| return this.$.cursorManager.previous({ |
| filter: (row: Element) => this._rowHasSide(row), |
| }); |
| } else { |
| return this.$.cursorManager.previous(); |
| } |
| } |
| |
| moveToVisibleArea() { |
| if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) { |
| this.$.cursorManager.moveToVisibleArea((row: Element) => |
| this._rowHasSide(row) |
| ); |
| } else { |
| this.$.cursorManager.moveToVisibleArea(); |
| } |
| } |
| |
| moveToNextChunk( |
| clipToTop?: boolean, |
| navigateToNextFile?: boolean |
| ): CursorMoveResult { |
| const result = this.$.cursorManager.next({ |
| filter: (row: HTMLElement) => this._isFirstRowOfChunk(row), |
| getTargetHeight: target => |
| (target?.parentNode as HTMLElement)?.scrollHeight || 0, |
| clipToTop, |
| }); |
| /* |
| * If user presses n on the last diff chunk, show a toast informing user |
| * that pressing n again will navigate them to next unreviewed file. |
| * If click happens within the time limit, then navigate to next file |
| */ |
| if ( |
| navigateToNextFile && |
| result === CursorMoveResult.CLIPPED && |
| this.isAtEnd() |
| ) { |
| if ( |
| this.lastDisplayedNavigateToNextFileToast && |
| Date.now() - this.lastDisplayedNavigateToNextFileToast <= |
| NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS |
| ) { |
| // reset for next file |
| this.lastDisplayedNavigateToNextFileToast = null; |
| fireEvent(this, 'navigate-to-next-unreviewed-file'); |
| } else { |
| this.lastDisplayedNavigateToNextFileToast = Date.now(); |
| fireAlert(this, 'Press n again to navigate to next unreviewed file'); |
| } |
| } |
| |
| this._fixSide(); |
| return result; |
| } |
| |
| moveToPreviousChunk(): CursorMoveResult { |
| const result = this.$.cursorManager.previous({ |
| filter: (row: HTMLElement) => this._isFirstRowOfChunk(row), |
| }); |
| this._fixSide(); |
| return result; |
| } |
| |
| moveToNextCommentThread(): CursorMoveResult | undefined { |
| if (this.isAtEnd()) { |
| fireEvent(this, 'navigate-to-next-file-with-comments'); |
| return; |
| } |
| const result = this.$.cursorManager.next({ |
| filter: (row: HTMLElement) => this._rowHasThread(row), |
| }); |
| this._fixSide(); |
| return result; |
| } |
| |
| moveToPreviousCommentThread(): CursorMoveResult { |
| const result = this.$.cursorManager.previous({ |
| filter: (row: HTMLElement) => this._rowHasThread(row), |
| }); |
| this._fixSide(); |
| return result; |
| } |
| |
| moveToLineNumber(number: number, side: Side, path?: string) { |
| const row = this._findRowByNumberAndFile(number, side, path); |
| if (row) { |
| this.side = side; |
| this.$.cursorManager.setCursor(row); |
| } |
| } |
| |
| /** |
| * Get the line number element targeted by the cursor row and side. |
| */ |
| getTargetLineElement(): HTMLElement | null { |
| let lineElSelector = '.lineNum'; |
| |
| if (!this.diffRow) { |
| return null; |
| } |
| |
| if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) { |
| lineElSelector += this.side === Side.LEFT ? '.left' : '.right'; |
| } |
| |
| return this.diffRow.querySelector(lineElSelector); |
| } |
| |
| getTargetDiffElement(): GrDiff | null { |
| if (!this.diffRow) return null; |
| |
| const hostOwner = (dom(this.diffRow) as PolymerDomWrapper).getOwnerRoot(); |
| if (hostOwner?.host?.tagName === 'GR-DIFF') { |
| return hostOwner.host as GrDiff; |
| } |
| return null; |
| } |
| |
| moveToFirstChunk() { |
| this.$.cursorManager.moveToStart(); |
| if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) { |
| this.moveToNextChunk(true); |
| } else { |
| this._fixSide(); |
| } |
| } |
| |
| moveToLastChunk() { |
| this.$.cursorManager.moveToEnd(); |
| if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) { |
| this.moveToPreviousChunk(); |
| } else { |
| this._fixSide(); |
| } |
| } |
| |
| /** |
| * 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 |
| this._scrollMode = this.initialLineNumber |
| ? ScrollMode.KEEP_VISIBLE |
| : ScrollMode.NEVER; |
| if (this.initialLineNumber) { |
| this.moveToLineNumber(this.initialLineNumber, this.side); |
| this.initialLineNumber = null; |
| } else { |
| this.moveToFirstChunk(); |
| } |
| } |
| this.reInit(); |
| } |
| |
| reInit() { |
| this._scrollMode = ScrollMode.KEEP_VISIBLE; |
| } |
| |
| private _boundHandleWindowScroll = () => { |
| if (this.preventAutoScrollOnManualScroll) { |
| this._scrollMode = ScrollMode.NEVER; |
| this._focusOnMove = false; |
| this.preventAutoScrollOnManualScroll = false; |
| } |
| }; |
| |
| reInitAndUpdateStops() { |
| this.reInit(); |
| this._updateStops(); |
| } |
| |
| handleDiffUpdate() { |
| this._updateStops(); |
| this.reInitCursor(); |
| } |
| |
| private boundHandleDiffLoadingChanged = () => { |
| this._updateStops(); |
| }; |
| |
| private _boundHandleDiffRenderStart = () => { |
| this.preventAutoScrollOnManualScroll = true; |
| }; |
| |
| private _boundHandleDiffRenderContent = () => { |
| this._updateStops(); |
| // When done rendering, turn focus on move and automatic scrolling back on |
| this._focusOnMove = true; |
| this.preventAutoScrollOnManualScroll = false; |
| }; |
| |
| private _boundHandleDiffLineSelected = (event: Event) => { |
| const customEvent = event as CustomEvent; |
| this.moveToLineNumber( |
| customEvent.detail.number, |
| customEvent.detail.side, |
| customEvent.detail.path |
| ); |
| }; |
| |
| createCommentInPlace() { |
| const diffWithRangeSelected = this.diffs.find(diff => |
| diff.isRangeSelected() |
| ); |
| if (diffWithRangeSelected) { |
| diffWithRangeSelected.createRangeComment(); |
| } else { |
| const line = this.getTargetLineElement(); |
| const diff = this.getTargetDiffElement(); |
| if (diff && line) { |
| diff.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. |
| * |
| */ |
| 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: Number(number), |
| }; |
| } |
| |
| _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: Element) { |
| const selector = |
| (this.side === Side.LEFT ? '.left' : '.right') + ' + .content'; |
| return !!row.querySelector(selector); |
| } |
| |
| _isFirstRowOfChunk(row: HTMLElement) { |
| const parentClassList = (row.parentNode as HTMLElement).classList; |
| const isInChunk = |
| parentClassList.contains('section') && parentClassList.contains('delta'); |
| const previousRow = row.previousSibling as HTMLElement; |
| const firstContentRow = |
| !previousRow || previousRow.classList.contains('moveControls'); |
| return isInChunk && firstContentRow; |
| } |
| |
| _rowHasThread(row: HTMLElement): boolean { |
| 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 === Side.LEFT ? Side.RIGHT : Side.LEFT; |
| } |
| } |
| |
| _isTargetBlank() { |
| if (!this.diffRow) { |
| return false; |
| } |
| |
| const actions = this._getActionsForRow(); |
| return ( |
| (this.side === Side.LEFT && !actions.left) || |
| (this.side === Side.RIGHT && !actions.right) |
| ); |
| } |
| |
| _rowChanged(_: HTMLElement, oldRow: HTMLElement) { |
| if (oldRow) { |
| oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS); |
| } |
| this._updateSideClass(); |
| } |
| |
| @observe('side') |
| _updateSideClass() { |
| if (!this.diffRow) { |
| return; |
| } |
| this.toggleClass(LEFT_SIDE_CLASS, this.side === Side.LEFT, this.diffRow); |
| this.toggleClass(RIGHT_SIDE_CLASS, this.side === Side.RIGHT, this.diffRow); |
| } |
| |
| _isActionType(type: GrDiffRowType) { |
| return ( |
| type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL |
| ); |
| } |
| |
| _getActionsForRow() { |
| const actions = {left: false, right: false}; |
| if (this.diffRow) { |
| actions.left = this._isActionType( |
| this.diffRow.getAttribute('left-type') as GrDiffRowType |
| ); |
| actions.right = this._isActionType( |
| this.diffRow.getAttribute('right-type') as GrDiffRowType |
| ); |
| } |
| return actions; |
| } |
| |
| _updateStops() { |
| this.$.cursorManager.stops = this.diffs.reduce( |
| (stops: Stop[], diff) => stops.concat(diff.getCursorStops()), |
| [] |
| ); |
| } |
| |
| /** |
| * Setup and tear down on-render listeners for any diffs that are added or |
| * removed from the cursor. |
| */ |
| @observe('diffs.splices') |
| _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) { |
| if (!changeRecord) { |
| return; |
| } |
| |
| this._updateStops(); |
| |
| let splice; |
| let i; |
| for ( |
| let spliceIdx = 0; |
| changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length; |
| spliceIdx++ |
| ) { |
| splice = changeRecord.indexSplices[spliceIdx]; |
| |
| // Removals must come before additions, because the gr-diff instances |
| // might be the same. |
| for (i = 0; i < splice?.removed.length; i++) { |
| splice.removed[i].removeEventListener( |
| 'loading-changed', |
| this.boundHandleDiffLoadingChanged |
| ); |
| splice.removed[i].removeEventListener( |
| 'render-start', |
| this._boundHandleDiffRenderStart |
| ); |
| splice.removed[i].removeEventListener( |
| 'render-content', |
| this._boundHandleDiffRenderContent |
| ); |
| splice.removed[i].removeEventListener( |
| 'line-selected', |
| this._boundHandleDiffLineSelected |
| ); |
| } |
| |
| for (i = splice.index; i < splice.index + splice.addedCount; i++) { |
| this.diffs[i].addEventListener( |
| 'loading-changed', |
| this.boundHandleDiffLoadingChanged |
| ); |
| this.diffs[i].addEventListener( |
| 'render-start', |
| this._boundHandleDiffRenderStart |
| ); |
| this.diffs[i].addEventListener( |
| 'render-content', |
| this._boundHandleDiffRenderContent |
| ); |
| this.diffs[i].addEventListener( |
| 'line-selected', |
| this._boundHandleDiffLineSelected |
| ); |
| } |
| } |
| } |
| |
| _findRowByNumberAndFile( |
| targetNumber: number, |
| side: Side, |
| path?: string |
| ): HTMLElement | undefined { |
| let stops: Array<HTMLElement | AbortStop>; |
| if (path) { |
| const diff = this.diffs.filter(diff => diff.path === path)[0]; |
| stops = diff.getCursorStops(); |
| } else { |
| stops = this.$.cursorManager.stops; |
| } |
| // Sadly needed for type narrowing to understand that the result is always |
| // targetable. |
| const targetableStops: HTMLElement[] = stops.filter(isTargetable); |
| const selector = `.lineNum.${side}[data-value="${targetNumber}"]`; |
| return targetableStops.find(stop => stop.querySelector(selector)); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-diff-cursor': GrDiffCursor; |
| } |
| } |