|  | /** | 
|  | * @license | 
|  | * Copyright 2016 Google LLC | 
|  | * SPDX-License-Identifier: Apache-2.0 | 
|  | */ | 
|  | import '../../../styles/shared-styles'; | 
|  | import '../../diff/gr-selection-action-box/gr-selection-action-box'; | 
|  | import {GrAnnotation} from './gr-annotation'; | 
|  | import {normalize} from './gr-range-normalizer'; | 
|  | import {strToClassName} from '../../../utils/dom-util'; | 
|  | import {Side} from '../../../constants/constants'; | 
|  | import {CommentRange} from '../../../types/common'; | 
|  | import {GrSelectionActionBox} from '../../diff/gr-selection-action-box/gr-selection-action-box'; | 
|  | import { | 
|  | getLineElByChild, | 
|  | getLineNumberByChild, | 
|  | getSideByLineEl, | 
|  | GrDiffThreadElement, | 
|  | } from '../../diff/gr-diff/gr-diff-utils'; | 
|  | import {debounce, DelayedTask} from '../../../utils/async-util'; | 
|  | import { | 
|  | assert, | 
|  | assertIsDefined, | 
|  | queryAndAssert, | 
|  | } from '../../../utils/common-util'; | 
|  | import {fire} from '../../../utils/event-util'; | 
|  | import {FILE, LOST} from '../../../api/diff'; | 
|  |  | 
|  | interface SidedRange { | 
|  | side: Side; | 
|  | range: CommentRange; | 
|  | } | 
|  |  | 
|  | interface NormalizedPosition { | 
|  | node: Node | null; | 
|  | side: Side; | 
|  | line: number; | 
|  | column: number; | 
|  | } | 
|  |  | 
|  | interface NormalizedRange { | 
|  | start: NormalizedPosition | null; | 
|  | end: NormalizedPosition | null; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * The methods that we actually want to call on the builder. We don't want a | 
|  | * fully blown dependency on GrDiffBuilderElement. | 
|  | */ | 
|  | export interface DiffBuilderInterface { | 
|  | getContentTdByLineEl(lineEl?: Element): Element | undefined; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Handles showing, positioning and interacting with <gr-selection-action-box>. | 
|  | * | 
|  | * Toggles a css class for highlighting comment ranges when the mouse leaves or | 
|  | * enters a comment thread element. | 
|  | */ | 
|  | export class GrDiffHighlight { | 
|  | selectedRange?: SidedRange; | 
|  |  | 
|  | private diffBuilder?: DiffBuilderInterface; | 
|  |  | 
|  | private diffTable?: HTMLElement; | 
|  |  | 
|  | private selectionChangeTask?: DelayedTask; | 
|  |  | 
|  | init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) { | 
|  | this.cleanup(); | 
|  |  | 
|  | this.diffTable = diffTable; | 
|  | this.diffBuilder = diffBuilder; | 
|  |  | 
|  | diffTable.addEventListener( | 
|  | 'comment-thread-mouseleave', | 
|  | this.handleCommentThreadMouseleave | 
|  | ); | 
|  | diffTable.addEventListener( | 
|  | 'comment-thread-mouseenter', | 
|  | this.handleCommentThreadMouseenter | 
|  | ); | 
|  | diffTable.addEventListener( | 
|  | 'create-comment-requested', | 
|  | this.handleRangeCommentRequest | 
|  | ); | 
|  | } | 
|  |  | 
|  | cleanup() { | 
|  | this.selectionChangeTask?.cancel(); | 
|  | if (this.diffTable) { | 
|  | this.diffTable.removeEventListener( | 
|  | 'comment-thread-mouseleave', | 
|  | this.handleCommentThreadMouseleave | 
|  | ); | 
|  | this.diffTable.removeEventListener( | 
|  | 'comment-thread-mouseenter', | 
|  | this.handleCommentThreadMouseenter | 
|  | ); | 
|  | this.diffTable.removeEventListener( | 
|  | 'create-comment-requested', | 
|  | this.handleRangeCommentRequest | 
|  | ); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Determines side/line/range for a DOM selection and shows a tooltip. | 
|  | * | 
|  | * With native shadow DOM, gr-diff-highlight cannot access a selection that | 
|  | * references the DOM elements making up the diff because they are in the | 
|  | * shadow DOM the gr-diff element. For this reason, we listen to the | 
|  | * selectionchange event and retrieve the selection in gr-diff, and then | 
|  | * call this method to process the Selection. | 
|  | * | 
|  | * @param selection A DOM Selection living in the shadow DOM of | 
|  | * the diff element. | 
|  | * @param isMouseUp If true, this is called due to a mouseup | 
|  | * event, in which case we might want to immediately create a comment, | 
|  | * because isMouseUp === true combined with an existing selection must | 
|  | * mean that this is the end of a double-click. | 
|  | */ | 
|  | handleSelectionChange( | 
|  | selection: Selection | Range | null, | 
|  | isMouseUp: boolean | 
|  | ) { | 
|  | if (selection === null) return; | 
|  | // Debounce is not just nice for waiting until the selection has settled, | 
|  | // it is also vital for being able to click on the action box before it is | 
|  | // removed. | 
|  | // If you wait longer than 50 ms, then you don't properly catch a very | 
|  | // quick 'c' press after the selection change. If you wait less than 10 | 
|  | // ms, then you will have about 50 handleSelection() calls when doing a | 
|  | // simple drag for select. | 
|  | this.selectionChangeTask = debounce( | 
|  | this.selectionChangeTask, | 
|  | () => this.handleSelection(selection, isMouseUp), | 
|  | 10 | 
|  | ); | 
|  | } | 
|  |  | 
|  | private getThreadEl(e: Event): GrDiffThreadElement | null { | 
|  | for (const pathEl of e.composedPath()) { | 
|  | if ( | 
|  | pathEl instanceof HTMLElement && | 
|  | pathEl.classList.contains('comment-thread') | 
|  | ) { | 
|  | return pathEl as GrDiffThreadElement; | 
|  | } | 
|  | } | 
|  | return null; | 
|  | } | 
|  |  | 
|  | private toggleRangeElHighlight( | 
|  | threadEl: GrDiffThreadElement | null, | 
|  | highlightRange = false | 
|  | ) { | 
|  | const rootId = threadEl?.rootId; | 
|  | if (!rootId) return; | 
|  | if (!this.diffTable) return; | 
|  | if (highlightRange) { | 
|  | const selector = `.range.${strToClassName(rootId)}`; | 
|  | const rangeNodes = this.diffTable.querySelectorAll(selector); | 
|  | rangeNodes.forEach(rangeNode => { | 
|  | rangeNode.classList.add('rangeHoverHighlight'); | 
|  | }); | 
|  | const hintNode = this.diffTable.querySelector( | 
|  | `gr-ranged-comment-hint[threadElRootId="${rootId}"]` | 
|  | ); | 
|  | hintNode?.shadowRoot | 
|  | ?.querySelectorAll('.rangeHighlight') | 
|  | .forEach(highlightNode => | 
|  | highlightNode.classList.add('rangeHoverHighlight') | 
|  | ); | 
|  | } else { | 
|  | const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`; | 
|  | const rangeNodes = this.diffTable.querySelectorAll(selector); | 
|  | rangeNodes.forEach(rangeNode => { | 
|  | rangeNode.classList.remove('rangeHoverHighlight'); | 
|  | }); | 
|  | const hintNode = this.diffTable.querySelector( | 
|  | `gr-ranged-comment-hint[threadElRootId="${rootId}"]` | 
|  | ); | 
|  | hintNode?.shadowRoot | 
|  | ?.querySelectorAll('.rangeHoverHighlight') | 
|  | .forEach(highlightNode => | 
|  | highlightNode.classList.remove('rangeHoverHighlight') | 
|  | ); | 
|  | } | 
|  | } | 
|  |  | 
|  | private handleCommentThreadMouseenter = (e: Event) => { | 
|  | const threadEl = this.getThreadEl(e); | 
|  | this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true); | 
|  | }; | 
|  |  | 
|  | private handleCommentThreadMouseleave = (e: Event) => { | 
|  | const threadEl = this.getThreadEl(e); | 
|  | this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false); | 
|  | }; | 
|  |  | 
|  | /** | 
|  | * Get current normalized selection. | 
|  | * Merges multiple ranges, accounts for triple click, accounts for | 
|  | * syntax highligh, convert native DOM Range objects to Gerrit concepts | 
|  | * (line, side, etc). | 
|  | */ | 
|  | private getNormalizedRange(selection: Selection | Range) { | 
|  | /* On Safari the ShadowRoot.getSelection() isn't there and the only thing | 
|  | we can get is a single Range */ | 
|  | if (selection instanceof Range) { | 
|  | return this.normalizeRange(selection); | 
|  | } | 
|  | const rangeCount = selection.rangeCount; | 
|  | if (rangeCount === 0) { | 
|  | return null; | 
|  | } else if (rangeCount === 1) { | 
|  | return this.normalizeRange(selection.getRangeAt(0)); | 
|  | } else { | 
|  | const startRange = this.normalizeRange(selection.getRangeAt(0)); | 
|  | const endRange = this.normalizeRange( | 
|  | selection.getRangeAt(rangeCount - 1) | 
|  | ); | 
|  | return { | 
|  | start: startRange.start, | 
|  | end: endRange.end, | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Normalize a specific DOM Range. | 
|  | * | 
|  | * @return fixed normalized range | 
|  | */ | 
|  | private normalizeRange(domRange: Range): NormalizedRange { | 
|  | const range = normalize(domRange); | 
|  | return this.fixTripleClickSelection( | 
|  | { | 
|  | start: this.normalizeSelectionSide( | 
|  | range.startContainer, | 
|  | range.startOffset | 
|  | ), | 
|  | end: this.normalizeSelectionSide(range.endContainer, range.endOffset), | 
|  | }, | 
|  | domRange | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Adjust triple click selection for the whole line. | 
|  | * A triple click always results in: | 
|  | * - start.column == end.column == 0 | 
|  | * - end.line == start.line + 1 | 
|  | * | 
|  | * @param range Normalized range, ie column/line numbers | 
|  | * @param domRange DOM Range object | 
|  | * @return fixed normalized range | 
|  | */ | 
|  | private fixTripleClickSelection(range: NormalizedRange, domRange: Range) { | 
|  | if (!range.start) { | 
|  | // Selection outside of current diff. | 
|  | return range; | 
|  | } | 
|  | const start = range.start; | 
|  | const end = range.end; | 
|  | // Happens when triple click in side-by-side mode with other side empty. | 
|  | const endsAtOtherEmptySide = | 
|  | !end && | 
|  | domRange.endOffset === 0 && | 
|  | domRange.endContainer instanceof HTMLElement && | 
|  | domRange.endContainer.nodeName === 'TD' && | 
|  | (domRange.endContainer.classList.contains('left') || | 
|  | domRange.endContainer.classList.contains('right')); | 
|  | const endsAtBeginningOfNextLine = | 
|  | end && | 
|  | start.column === 0 && | 
|  | end.column === 0 && | 
|  | end.line === start.line + 1; | 
|  | const content = domRange.cloneContents().querySelector('.contentText'); | 
|  | const lineLength = (content && this.getLength(content)) || 0; | 
|  | if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) { | 
|  | // Move the selection to the end of the previous line. | 
|  | range.end = { | 
|  | node: start.node, | 
|  | column: lineLength, | 
|  | side: start.side, | 
|  | line: start.line, | 
|  | }; | 
|  | } | 
|  | return range; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Convert DOM Range selection to concrete numbers (line, column, side). | 
|  | * Moves range end if it's not inside td.content. | 
|  | * Returns null if selection end is not valid (outside of diff). | 
|  | * | 
|  | * @param node td.content child | 
|  | * @param offset offset within node | 
|  | */ | 
|  | private normalizeSelectionSide( | 
|  | node: Node | null, | 
|  | offset: number | 
|  | ): NormalizedPosition | null { | 
|  | let column; | 
|  | if (!this.diffTable) return null; | 
|  | if (!this.diffBuilder) return null; | 
|  | if (!node || !this.diffTable.contains(node)) return null; | 
|  | const lineEl = getLineElByChild(node); | 
|  | if (!lineEl) return null; | 
|  | const side = getSideByLineEl(lineEl); | 
|  | if (!side) return null; | 
|  | const line = getLineNumberByChild(lineEl); | 
|  | if (!line || line === FILE || line === LOST) return null; | 
|  | assert(typeof line === 'number', 'line must be a number'); | 
|  | const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl); | 
|  | if (!contentTd) return null; | 
|  | const contentText = contentTd.querySelector('.contentText'); | 
|  | if (!contentTd.contains(node)) { | 
|  | node = contentText; | 
|  | column = 0; | 
|  | } else { | 
|  | const thread = contentTd.querySelector('.comment-thread'); | 
|  | if (thread?.contains(node)) { | 
|  | column = this.getLength(contentText); | 
|  | node = contentText; | 
|  | } else { | 
|  | column = this.convertOffsetToColumn(node, offset); | 
|  | } | 
|  | } | 
|  |  | 
|  | return { | 
|  | node, | 
|  | side, | 
|  | line, | 
|  | column, | 
|  | }; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * The only line in which add a comment tooltip is cut off is the first | 
|  | * line. Even if there is a collapsed section, The first visible line is | 
|  | * in the position where the second line would have been, if not for the | 
|  | * collapsed section, so don't need to worry about this case for | 
|  | * positioning the tooltip. | 
|  | */ | 
|  | // visible for testing | 
|  | positionActionBox( | 
|  | actionBox: GrSelectionActionBox, | 
|  | startLine: number, | 
|  | range: Text | Element | Range | 
|  | ) { | 
|  | if (startLine > 1) { | 
|  | actionBox.positionBelow = false; | 
|  | actionBox.placeAbove(range); | 
|  | return; | 
|  | } | 
|  | actionBox.positionBelow = true; | 
|  | actionBox.placeBelow(range); | 
|  | } | 
|  |  | 
|  | private isRangeValid(range: NormalizedRange | null) { | 
|  | if (!range || !range.start || !range.start.node || !range.end) { | 
|  | return false; | 
|  | } | 
|  | const start = range.start; | 
|  | const end = range.end; | 
|  | return !( | 
|  | start.side !== end.side || | 
|  | end.line < start.line || | 
|  | (start.line === end.line && start.column === end.column) | 
|  | ); | 
|  | } | 
|  |  | 
|  | // visible for testing | 
|  | handleSelection(selection: Selection | Range, isMouseUp: boolean) { | 
|  | /* On Safari, the selection events may return a null range that should | 
|  | be ignored */ | 
|  | if (!selection) return; | 
|  | if (!this.diffTable) return; | 
|  |  | 
|  | const normalizedRange = this.getNormalizedRange(selection); | 
|  | if (!this.isRangeValid(normalizedRange)) { | 
|  | this.removeActionBox(); | 
|  | return; | 
|  | } | 
|  | /* On Safari the ShadowRoot.getSelection() isn't there and the only thing | 
|  | we can get is a single Range */ | 
|  | const domRange = | 
|  | selection instanceof Range ? selection : selection.getRangeAt(0); | 
|  | const start = normalizedRange!.start!; | 
|  | const end = normalizedRange!.end!; | 
|  |  | 
|  | // TODO (viktard): Drop empty first and last lines from selection. | 
|  |  | 
|  | // If the selection is from the end of one line to the start of the next | 
|  | // line, then this must have been a double-click, or you have started | 
|  | // dragging. Showing the action box is bad in the former case and not very | 
|  | // useful in the latter, so never do that. | 
|  | // If this was a mouse-up event, we create a comment immediately if | 
|  | // the selection is from the end of a line to the start of the next line. | 
|  | // In a perfect world we would only do this for double-click, but it is | 
|  | // extremely rare that a user would drag from the end of one line to the | 
|  | // start of the next and release the mouse, so we don't bother. | 
|  | // TODO(brohlfs): This does not work, if the double-click is before a new | 
|  | // diff chunk (start will be equal to end), and neither before an "expand | 
|  | // the diff context" block (end line will match the first line of the new | 
|  | // section and thus be greater than start line + 1). | 
|  | if (start.line === end.line - 1 && end.column === 0) { | 
|  | // Rather than trying to find the line contents (for comparing | 
|  | // start.column with the content length), we just check if the selection | 
|  | // is empty to see that it's at the end of a line. | 
|  | const content = domRange.cloneContents().querySelector('.contentText'); | 
|  | if (isMouseUp && this.getLength(content) === 0) { | 
|  | this.fireCreateRangeComment(start.side, { | 
|  | start_line: start.line, | 
|  | start_character: 0, | 
|  | end_line: start.line, | 
|  | end_character: start.column, | 
|  | }); | 
|  | } | 
|  | return; | 
|  | } | 
|  |  | 
|  | let actionBox = this.diffTable.querySelector('gr-selection-action-box'); | 
|  | if (!actionBox) { | 
|  | actionBox = document.createElement('gr-selection-action-box'); | 
|  | this.diffTable.appendChild(actionBox); | 
|  | } | 
|  | this.selectedRange = { | 
|  | range: { | 
|  | start_line: start.line, | 
|  | start_character: start.column, | 
|  | end_line: end.line, | 
|  | end_character: end.column, | 
|  | }, | 
|  | side: start.side, | 
|  | }; | 
|  | if (start.line === end.line) { | 
|  | this.positionActionBox(actionBox, start.line, domRange); | 
|  | } else if (start.node instanceof Text) { | 
|  | if (start.column) { | 
|  | this.positionActionBox( | 
|  | actionBox, | 
|  | start.line, | 
|  | start.node.splitText(start.column) | 
|  | ); | 
|  | } | 
|  | start.node.parentElement!.normalize(); // Undo splitText from above. | 
|  | } else if ( | 
|  | start.node instanceof HTMLElement && | 
|  | start.node.classList.contains('content') && | 
|  | (start.node.firstChild instanceof Element || | 
|  | start.node.firstChild instanceof Text) | 
|  | ) { | 
|  | this.positionActionBox(actionBox, start.line, start.node.firstChild); | 
|  | } else if (start.node instanceof Element || start.node instanceof Text) { | 
|  | this.positionActionBox(actionBox, start.line, start.node); | 
|  | } else { | 
|  | console.warn('Failed to position comment action box.'); | 
|  | this.removeActionBox(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private fireCreateRangeComment(side: Side, range: CommentRange) { | 
|  | if (this.diffTable) { | 
|  | fire(this.diffTable, 'create-range-comment', {side, range}); | 
|  | } | 
|  | this.removeActionBox(); | 
|  | } | 
|  |  | 
|  | private handleRangeCommentRequest = (e: Event) => { | 
|  | e.stopPropagation(); | 
|  | assertIsDefined(this.selectedRange, 'selectedRange'); | 
|  | const {side, range} = this.selectedRange; | 
|  | this.fireCreateRangeComment(side, range); | 
|  | }; | 
|  |  | 
|  | // visible for testing | 
|  | removeActionBox() { | 
|  | this.selectedRange = undefined; | 
|  | const actionBox = this.diffTable?.querySelector('gr-selection-action-box'); | 
|  | if (actionBox) actionBox.remove(); | 
|  | } | 
|  |  | 
|  | private convertOffsetToColumn(el: Node, offset: number) { | 
|  | if (el instanceof Element && el.classList.contains('content')) { | 
|  | return offset; | 
|  | } | 
|  | while ( | 
|  | el.previousSibling || | 
|  | !el.parentElement?.classList.contains('content') | 
|  | ) { | 
|  | if (el.previousSibling) { | 
|  | el = el.previousSibling; | 
|  | offset += this.getLength(el); | 
|  | } else { | 
|  | el = el.parentElement!; | 
|  | } | 
|  | } | 
|  | return offset; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Get length of a node. If the node is a content node, then only give the | 
|  | * length of its .contentText child. | 
|  | * | 
|  | * @param node this is sometimes passed as null. | 
|  | */ | 
|  | // visible for testing | 
|  | getLength(node: Node | null): number { | 
|  | if (node === null) return 0; | 
|  | if (node instanceof Element && node.classList.contains('content')) { | 
|  | return this.getLength(queryAndAssert(node, '.contentText')); | 
|  | } else { | 
|  | return GrAnnotation.getLength(node); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | export interface CreateRangeCommentEventDetail { | 
|  | side: Side; | 
|  | range: CommentRange; | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementEventMap { | 
|  | 'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>; | 
|  | } | 
|  | } |