blob: 0714645bbf805040a265f9cbd18d4a41ecca2430 [file] [log] [blame]
/**
* @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 {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 '../gr-selection-action-box/gr-selection-action-box';
import {FILE} from '../gr-diff/gr-diff-line';
import {
getLineElByChild,
getLineNumberByChild,
getSideByLineEl,
GrDiffThreadElement,
} from '../gr-diff/gr-diff-utils';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {assertIsDefined, 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;
}
/**
* 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 | null;
}
/**
* 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;
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) {
this.diffTable?.dispatchEvent(
new CustomEvent('create-range-comment', {
detail: {side, range},
composed: true,
bubbles: true,
})
);
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>;
}
}