
Unify all calls to threadEl.getAttribute() in gr-diff-utils. Change-Id: Iea805a8885d2fa222329ec841fd931d4b5369010
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
/**
|
|
* @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 {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-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 {getRange, getSide} from '../gr-diff/gr-diff-utils';
|
|
|
|
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 GestureEventListeners(
|
|
LegacyElementMixin(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;
|
|
|
|
/** @override */
|
|
created() {
|
|
super.created();
|
|
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)
|
|
);
|
|
}
|
|
|
|
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 | 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.debounce(
|
|
'selectionChange',
|
|
() => 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('rangeHighlight');
|
|
rangeNode.classList.remove('range');
|
|
});
|
|
} else {
|
|
const rangeNodes = curNode.querySelectorAll(
|
|
`.rangeHighlight.${strToClassName(threadEl.rootId)}`
|
|
);
|
|
rangeNodes.forEach(rangeNode => {
|
|
rangeNode.classList.remove('rangeHighlight');
|
|
rangeNode.classList.add('range');
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_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) {
|
|
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 = this.diffBuilder.getLineElByChild(node);
|
|
if (!lineEl) return null;
|
|
const side = this.diffBuilder.getSideByLineEl(lineEl);
|
|
if (!side) return null;
|
|
const line = this.diffBuilder.getLineNumberByChild(lineEl);
|
|
if (!line || line === FILE) 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.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, isMouseUp: boolean) {
|
|
const normalizedRange = this._getNormalizedRange(selection);
|
|
if (!this._isRangeValid(normalizedRange)) {
|
|
this._removeActionBox();
|
|
return;
|
|
}
|
|
const domRange = 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(node.querySelector('.contentText')!);
|
|
} else {
|
|
return GrAnnotation.getLength(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'gr-diff-highlight': GrDiffHighlight;
|
|
}
|
|
}
|