| /** | |
| * @license | |
| * Copyright 2016 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {Subscription} from 'rxjs'; | |
| import {AbortStop, CursorMoveResult, Stop} from '../../../api/core'; | |
| import { | |
| DiffViewMode, | |
| GrDiffCursor as GrDiffCursorApi, | |
| LineNumber, | |
| LineSelectedEventDetail, | |
| } from '../../../api/diff'; | |
| import {ScrollMode, Side} from '../../../constants/constants'; | |
| import {toggleClass} from '../../../utils/dom-util'; | |
| import { | |
| GrCursorManager, | |
| isTargetable, | |
| } from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager'; | |
| import {GrDiffLineType} from '../gr-diff/gr-diff-line'; | |
| import {GrDiffGroupType} from '../gr-diff/gr-diff-group'; | |
| import {GrDiff} from '../gr-diff/gr-diff'; | |
| import {fire} from '../../../utils/event-util'; | |
| type GrDiffRowType = GrDiffLineType | GrDiffGroupType; | |
| const LEFT_SIDE_CLASS = 'target-side-left'; | |
| const RIGHT_SIDE_CLASS = 'target-side-right'; | |
| interface Address { | |
| leftSide: boolean; | |
| number: number; | |
| } | |
| /** | |
| * From <tr> diff row go up to <tbody> diff chunk. | |
| * | |
| * In Lit based diff there is a <gr-diff-row> element in between the two. | |
| */ | |
| export function fromRowToChunk( | |
| rowEl: HTMLElement | |
| ): HTMLTableSectionElement | undefined { | |
| const parent = rowEl.parentElement; | |
| if (!parent) return undefined; | |
| if (parent.tagName === 'TBODY') { | |
| return parent as HTMLTableSectionElement; | |
| } | |
| const grandParent = parent.parentElement; | |
| if (!grandParent) return undefined; | |
| if (grandParent.tagName === 'TBODY') { | |
| return grandParent as HTMLTableSectionElement; | |
| } | |
| return undefined; | |
| } | |
| /** A subset of the GrDiff API that the cursor is using. */ | |
| export interface GrDiffCursorable extends HTMLElement { | |
| isRangeSelected(): boolean; | |
| createRangeComment(): void; | |
| getCursorStops(): Stop[]; | |
| path?: string; | |
| } | |
| export class GrDiffCursor implements GrDiffCursorApi { | |
| private preventAutoScrollOnManualScroll = false; | |
| set side(side: Side) { | |
| if (this.sideInternal === side) { | |
| return; | |
| } | |
| if (this.sideInternal && this.diffRow) { | |
| this.fireCursorMoved( | |
| 'line-cursor-moved-out', | |
| this.diffRow, | |
| this.sideInternal | |
| ); | |
| } | |
| this.sideInternal = side; | |
| this.updateSideClass(); | |
| if (this.diffRow) { | |
| this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side); | |
| } | |
| } | |
| get side(): Side { | |
| return this.sideInternal; | |
| } | |
| private sideInternal = Side.RIGHT; | |
| set diffRow(diffRow: HTMLElement | undefined) { | |
| if (this.diffRowInternal) { | |
| this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS); | |
| this.fireCursorMoved( | |
| 'line-cursor-moved-out', | |
| this.diffRowInternal, | |
| this.side | |
| ); | |
| } | |
| this.diffRowInternal = diffRow; | |
| this.updateSideClass(); | |
| if (this.diffRow) { | |
| this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side); | |
| } | |
| } | |
| get diffRow(): HTMLElement | undefined { | |
| return this.diffRowInternal; | |
| } | |
| private diffRowInternal?: HTMLElement; | |
| private diffs: GrDiffCursorable[] = []; | |
| /** | |
| * 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. | |
| */ | |
| initialLineNumber: number | null = null; | |
| // visible for testing | |
| cursorManager = new GrCursorManager(); | |
| private targetSubscription?: Subscription; | |
| constructor() { | |
| this.cursorManager.cursorTargetClass = 'target-row'; | |
| this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE; | |
| this.cursorManager.focusOnMove = true; | |
| window.addEventListener('scroll', this._boundHandleWindowScroll); | |
| this.targetSubscription = this.cursorManager.target$.subscribe(target => { | |
| this.diffRow = target || undefined; | |
| }); | |
| } | |
| dispose() { | |
| this.cursorManager.unsetCursor(); | |
| if (this.targetSubscription) this.targetSubscription.unsubscribe(); | |
| 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): CursorMoveResult { | |
| const result = this.cursorManager.next({ | |
| filter: (row: HTMLElement) => this._isFirstRowOfChunk(row), | |
| getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0, | |
| clipToTop, | |
| }); | |
| this._fixSide(); | |
| return result; | |
| } | |
| moveToPreviousChunk(): CursorMoveResult { | |
| const result = this.cursorManager.previous({ | |
| filter: (row: HTMLElement) => this._isFirstRowOfChunk(row), | |
| }); | |
| this._fixSide(); | |
| return result; | |
| } | |
| moveToNextCommentThread(): CursorMoveResult { | |
| if (this.isAtEnd()) { | |
| return CursorMoveResult.CLIPPED; | |
| } | |
| 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: LineNumber, | |
| side: Side, | |
| path?: string, | |
| intentionalMove?: boolean | |
| ) { | |
| const row = this._findRowByNumberAndFile(number, side, path); | |
| if (row) { | |
| this.side = side; | |
| this.cursorManager.setCursor(row, undefined, intentionalMove); | |
| } | |
| } | |
| /** | |
| * 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 = this.diffRow.getRootNode() as ShadowRoot; | |
| 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 reInitAndUpdateStops() instead. | |
| */ | |
| reInitCursor() { | |
| this._updateStops(); | |
| if (!this.diffRow) { | |
| // does not scroll during init unless requested | |
| this.cursorManager.scrollMode = this.initialLineNumber | |
| ? ScrollMode.KEEP_VISIBLE | |
| : ScrollMode.NEVER; | |
| if (this.initialLineNumber) { | |
| this.moveToLineNumber(this.initialLineNumber, this.side); | |
| this.initialLineNumber = null; | |
| } else { | |
| this.moveToFirstChunk(); | |
| } | |
| } | |
| this.resetScrollMode(); | |
| } | |
| resetScrollMode() { | |
| this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE; | |
| } | |
| private _boundHandleWindowScroll = () => { | |
| if (this.preventAutoScrollOnManualScroll) { | |
| this.cursorManager.scrollMode = ScrollMode.NEVER; | |
| this.cursorManager.focusOnMove = false; | |
| this.preventAutoScrollOnManualScroll = false; | |
| } | |
| }; | |
| reInitAndUpdateStops() { | |
| this.resetScrollMode(); | |
| this._updateStops(); | |
| } | |
| 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.cursorManager.focusOnMove = true; | |
| this.preventAutoScrollOnManualScroll = false; | |
| }; | |
| private _boundHandleDiffLineSelected = ( | |
| e: CustomEvent<LineSelectedEventDetail> | |
| ) => { | |
| this.moveToLineNumber(e.detail.number, e.detail.side, e.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(): Address | null { | |
| 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. | |
| return this.getAddressFor(this.diffRow, this.side); | |
| } | |
| private getAddressFor(diffRow: HTMLElement, side: Side): Address | null { | |
| let cell; | |
| if (this._getViewMode() === DiffViewMode.UNIFIED) { | |
| cell = diffRow.querySelector('.lineNum.right'); | |
| if (!cell) { | |
| cell = diffRow.querySelector('.lineNum.left'); | |
| } | |
| } else { | |
| cell = diffRow.querySelector('.lineNum.' + 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 chunk = fromRowToChunk(row); | |
| if (!chunk) return false; | |
| const isInDeltaChunk = chunk.classList.contains('delta'); | |
| if (!isInDeltaChunk) return false; | |
| const firstRow = chunk.querySelector('tr:not(.moveControls)'); | |
| return firstRow === row; | |
| } | |
| _rowHasThread(row: HTMLElement): boolean { | |
| const slots = [ | |
| ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'), | |
| ]; | |
| return slots.some(slot => slot.assignedElements().length > 0); | |
| } | |
| /** | |
| * 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) | |
| ); | |
| } | |
| private fireCursorMoved( | |
| event: 'line-cursor-moved-out' | 'line-cursor-moved-in', | |
| row: HTMLElement, | |
| side: Side | |
| ) { | |
| const address = this.getAddressFor(row, side); | |
| if (address) { | |
| const {leftSide, number} = address; | |
| fire(row, event, { | |
| lineNum: number, | |
| side: leftSide ? Side.LEFT : Side.RIGHT, | |
| }); | |
| } | |
| } | |
| private updateSideClass() { | |
| if (!this.diffRow) { | |
| return; | |
| } | |
| toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT); | |
| toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT); | |
| } | |
| _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()), | |
| [] | |
| ); | |
| } | |
| replaceDiffs(diffs: GrDiffCursorable[]) { | |
| for (const diff of this.diffs) { | |
| this.removeEventListeners(diff); | |
| } | |
| this.diffs = []; | |
| for (const diff of diffs) { | |
| this.addEventListeners(diff); | |
| } | |
| this.diffs.push(...diffs); | |
| this._updateStops(); | |
| } | |
| unregisterDiff(diff: GrDiffCursorable) { | |
| // This can happen during destruction - just don't unregister then. | |
| if (!this.diffs) return; | |
| const i = this.diffs.indexOf(diff); | |
| if (i !== -1) { | |
| this.diffs.splice(i, 1); | |
| } | |
| } | |
| private removeEventListeners(diff: GrDiffCursorable) { | |
| diff.removeEventListener( | |
| 'loading-changed', | |
| this.boundHandleDiffLoadingChanged | |
| ); | |
| diff.removeEventListener('render-start', this._boundHandleDiffRenderStart); | |
| diff.removeEventListener( | |
| 'render-content', | |
| this._boundHandleDiffRenderContent | |
| ); | |
| diff.removeEventListener( | |
| 'line-selected', | |
| this._boundHandleDiffLineSelected | |
| ); | |
| } | |
| private addEventListeners(diff: GrDiffCursorable) { | |
| diff.addEventListener( | |
| 'loading-changed', | |
| this.boundHandleDiffLoadingChanged | |
| ); | |
| diff.addEventListener('render-start', this._boundHandleDiffRenderStart); | |
| diff.addEventListener('render-content', this._boundHandleDiffRenderContent); | |
| diff.addEventListener('line-selected', this._boundHandleDiffLineSelected); | |
| } | |
| _findRowByNumberAndFile( | |
| targetNumber: LineNumber, | |
| 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)); | |
| } | |
| } |