| /** | |
| * @license | |
| * Copyright 2016 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import '../../../styles/shared-styles'; | |
| import '../gr-selection-action-box/gr-selection-action-box'; | |
| import {getLength} 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 '../gr-selection-action-box/gr-selection-action-box'; | |
| import { | |
| getLineElByChild, | |
| getLineNumberByChild, | |
| getSideByLineEl, | |
| GrDiffCommentThread, | |
| } from '../gr-diff/gr-diff-utils'; | |
| import {debounce, DelayedTask} from '../../../utils/async-util'; | |
| import {assertIsDefined, queryAndAssert} from '../../../utils/common-util'; | |
| import {DiffModel} from '../gr-diff-model/gr-diff-model'; | |
| 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; | |
| diffModel: DiffModel; | |
| } | |
| /** | |
| * 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 toggleRangeElHighlight( | |
| thread: GrDiffCommentThread, | |
| highlightRange = false | |
| ) { | |
| const rootId = thread?.rootId; | |
| if (!rootId) return; | |
| if (!this.diffTable) return; | |
| const highlightClass = highlightRange ? 'range' : 'rangeHoverHighlight'; | |
| const selector = `.${highlightClass}.${strToClassName(rootId)}`; | |
| const rangeNodes = this.diffTable.querySelectorAll(selector); | |
| for (const rangeNode of rangeNodes) { | |
| rangeNode.classList.toggle('rangeHoverHighlight', highlightRange); | |
| } | |
| } | |
| private handleCommentThreadMouseenter = ( | |
| e: CustomEvent<GrDiffCommentThread> | |
| ) => { | |
| this.toggleRangeElHighlight(e.detail, /* highlightRange= */ true); | |
| }; | |
| private handleCommentThreadMouseleave = ( | |
| e: CustomEvent<GrDiffCommentThread> | |
| ) => { | |
| this.toggleRangeElHighlight(e.detail, /* 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 (typeof line !== 'number') return null; | |
| 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.createRangeComment(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 createRangeComment(side: Side, range: CommentRange) { | |
| assertIsDefined(this.diffBuilder, 'diffBuilder'); | |
| this.diffBuilder?.diffModel.createCommentOnRange(range, side); | |
| this.removeActionBox(); | |
| } | |
| private handleRangeCommentRequest = (e: Event) => { | |
| e.stopPropagation(); | |
| assertIsDefined(this.selectedRange, 'selectedRange'); | |
| const {side, range} = this.selectedRange; | |
| this.createRangeComment(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 getLength(node); | |
| } | |
| } | |
| } |