| /** |
| * @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 '../../../styles/shared-styles'; |
| import '../gr-selection-action-box/gr-selection-action-box'; |
| import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-diff-highlight_html'; |
| import {GrAnnotation} from './gr-annotation'; |
| import {normalize} from './gr-range-normalizer'; |
| import {strToClassName} from '../../../utils/dom-util'; |
| import {customElement, property} from '@polymer/decorators'; |
| import {Side} from '../../../constants/constants'; |
| import {CommentRange} from '../../../types/common'; |
| import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box'; |
| import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element'; |
| import {FILE} from '../gr-diff/gr-diff-line'; |
| import { |
| getLineElByChild, |
| getLineNumberByChild, |
| getRange, |
| getSide, |
| getSideByLineEl, |
| } from '../gr-diff/gr-diff-utils'; |
| import {debounce, DelayedTask} from '../../../utils/async-util'; |
| import {queryAndAssert} from '../../../utils/common-util'; |
| |
| 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; |
| } |
| |
| // TODO(TS): Replace by GrCommentThread once that is converted. |
| interface CommentThreadElement extends HTMLElement { |
| rootId: string; |
| } |
| |
| @customElement('gr-diff-highlight') |
| export class GrDiffHighlight extends PolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Array, notify: true}) |
| commentRanges: SidedRange[] = []; |
| |
| @property({type: Boolean}) |
| loggedIn?: boolean; |
| |
| @property({type: Object}) |
| _cachedDiffBuilder?: GrDiffBuilderElement; |
| |
| @property({type: Object, notify: true}) |
| selectedRange?: SidedRange; |
| |
| private selectionChangeTask?: DelayedTask; |
| |
| constructor() { |
| super(); |
| this.addEventListener('comment-thread-mouseleave', e => |
| this._handleCommentThreadMouseleave(e) |
| ); |
| this.addEventListener('comment-thread-mouseenter', e => |
| this._handleCommentThreadMouseenter(e) |
| ); |
| this.addEventListener('create-comment-requested', e => |
| this._handleRangeCommentRequest(e) |
| ); |
| } |
| |
| override disconnectedCallback() { |
| this.selectionChangeTask?.cancel(); |
| super.disconnectedCallback(); |
| } |
| |
| get diffBuilder() { |
| if (!this._cachedDiffBuilder) { |
| this._cachedDiffBuilder = this.querySelector( |
| 'gr-diff-builder' |
| ) as GrDiffBuilderElement; |
| } |
| return this._cachedDiffBuilder; |
| } |
| |
| /** |
| * 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 |
| ); |
| } |
| |
| _getThreadEl(e: Event): CommentThreadElement | null { |
| const path = (dom(e) as EventApi).path || []; |
| for (const pathEl of path) { |
| if ( |
| pathEl instanceof HTMLElement && |
| pathEl.classList.contains('comment-thread') |
| ) { |
| return pathEl as CommentThreadElement; |
| } |
| } |
| return null; |
| } |
| |
| _toggleRangeElHighlight( |
| threadEl: CommentThreadElement, |
| highlightRange = false |
| ) { |
| // We don't want to re-create the line just for highlighting the range which |
| // is creating annoying bugs: @see Issue 12934 |
| // As gr-ranged-comment-layer now does not notify the layer re-render and |
| // lack of access to the thread or the lineEl from the ranged-comment-layer, |
| // need to update range class for styles here. |
| let curNode: HTMLElement | null = threadEl.assignedSlot; |
| while (curNode) { |
| if (curNode.nodeName === 'TABLE') break; |
| curNode = curNode.parentElement; |
| } |
| if (curNode?.querySelectorAll) { |
| if (highlightRange) { |
| const rangeNodes = curNode.querySelectorAll( |
| `.range.${strToClassName(threadEl.rootId)}` |
| ); |
| rangeNodes.forEach(rangeNode => { |
| rangeNode.classList.add('rangeHoverHighlight'); |
| }); |
| const hintNode = threadEl.parentElement?.querySelector( |
| `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]` |
| ); |
| if (hintNode) { |
| hintNode.shadowRoot |
| ?.querySelectorAll('.rangeHighlight') |
| .forEach(highlightNode => |
| highlightNode.classList.add('rangeHoverHighlight') |
| ); |
| } |
| } else { |
| const rangeNodes = curNode.querySelectorAll( |
| `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}` |
| ); |
| rangeNodes.forEach(rangeNode => { |
| rangeNode.classList.remove('rangeHoverHighlight'); |
| }); |
| const hintNode = threadEl.parentElement?.querySelector( |
| `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]` |
| ); |
| if (hintNode) { |
| hintNode.shadowRoot |
| ?.querySelectorAll('.rangeHoverHighlight') |
| .forEach(highlightNode => |
| highlightNode.classList.remove('rangeHoverHighlight') |
| ); |
| } |
| } |
| } |
| } |
| |
| _handleCommentThreadMouseenter(e: Event) { |
| const threadEl = this._getThreadEl(e)!; |
| const index = this._indexForThreadEl(threadEl); |
| |
| if (index !== undefined) { |
| this.set(['commentRanges', index, 'hovering'], true); |
| } |
| |
| this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true); |
| } |
| |
| _handleCommentThreadMouseleave(e: Event) { |
| const threadEl = this._getThreadEl(e)!; |
| const index = this._indexForThreadEl(threadEl); |
| |
| if (index !== undefined) { |
| this.set(['commentRanges', index, 'hovering'], false); |
| } |
| |
| this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false); |
| } |
| |
| _indexForThreadEl(threadEl: HTMLElement) { |
| const side = getSide(threadEl); |
| const range = getRange(threadEl); |
| if (!side || !range) return undefined; |
| return this._indexOfCommentRange(side, range); |
| } |
| |
| _indexOfCommentRange(side: Side, range: CommentRange) { |
| function rangesEqual(a: CommentRange, b: CommentRange) { |
| if (!a && !b) { |
| return true; |
| } |
| if (!a || !b) { |
| return false; |
| } |
| return ( |
| a.start_line === b.start_line && |
| a.start_character === b.start_character && |
| a.end_line === b.end_line && |
| a.end_character === b.end_character |
| ); |
| } |
| |
| return this.commentRanges.findIndex( |
| commentRange => |
| commentRange.side === side && rangesEqual(commentRange.range, range) |
| ); |
| } |
| |
| /** |
| * 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). |
| */ |
| _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 |
| */ |
| _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 |
| */ |
| _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 |
| */ |
| _normalizeSelectionSide( |
| node: Node | null, |
| offset: number |
| ): NormalizedPosition | null { |
| let column; |
| if (!node || !this.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; |
| 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. |
| */ |
| _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); |
| } |
| |
| _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) |
| ); |
| } |
| |
| _handleSelection(selection: Selection | Range, isMouseUp: boolean) { |
| /* On Safari, the selection events may return a null range that should |
| be ignored */ |
| if (!selection) { |
| 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.shadowRoot!.querySelector( |
| 'gr-selection-action-box' |
| ) as GrSelectionActionBox | null; |
| if (!actionBox) { |
| actionBox = document.createElement('gr-selection-action-box'); |
| this.root!.insertBefore(actionBox, this.root!.firstElementChild); |
| } |
| 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(); |
| } |
| } |
| |
| _fireCreateRangeComment(side: Side, range: CommentRange) { |
| this.dispatchEvent( |
| new CustomEvent('create-range-comment', { |
| detail: {side, range}, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| this._removeActionBox(); |
| } |
| |
| _handleRangeCommentRequest(e: Event) { |
| e.stopPropagation(); |
| if (!this.selectedRange) { |
| throw Error('Selected Range is needed for new range comment!'); |
| } |
| const {side, range} = this.selectedRange; |
| this._fireCreateRangeComment(side, range); |
| } |
| |
| _removeActionBox() { |
| this.selectedRange = undefined; |
| const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box'); |
| if (actionBox) { |
| this.root!.removeChild(actionBox); |
| } |
| } |
| |
| _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. |
| */ |
| _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); |
| } |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-diff-highlight': GrDiffHighlight; |
| } |
| } |