
This assumes that any empty selection of just one line that ends at the start of a line is an after-end-of-line selection. We capture it on mouseup to avoid comments appearing while dragging. Known issues: Occasionally, when the comment is created on an empty line, discarding the comment can trigger another mouseup that creates a new comment. Occasionally the selection of a line hangs around and double-clicking after the end of the line doesn't do anything until the selection is cleared. Change-Id: Ifeea4f25e2ee4d4643766e4bdff31a74fedca68f
439 lines
14 KiB
JavaScript
439 lines
14 KiB
JavaScript
/**
|
|
* @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.
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
Polymer({
|
|
is: 'gr-diff-highlight',
|
|
|
|
properties: {
|
|
/** @type {!Array<!Gerrit.HoveredRange>} */
|
|
commentRanges: {
|
|
type: Array,
|
|
notify: true,
|
|
},
|
|
loggedIn: Boolean,
|
|
/**
|
|
* querySelector can return null, so needs to be nullable.
|
|
*
|
|
* @type {?HTMLElement}
|
|
* */
|
|
_cachedDiffBuilder: Object,
|
|
},
|
|
|
|
listeners: {
|
|
'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
|
|
'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
|
|
'create-range-comment': '_createRangeComment',
|
|
},
|
|
|
|
get diffBuilder() {
|
|
if (!this._cachedDiffBuilder) {
|
|
this._cachedDiffBuilder =
|
|
Polymer.dom(this).querySelector('gr-diff-builder');
|
|
}
|
|
return this._cachedDiffBuilder;
|
|
},
|
|
|
|
|
|
isRangeSelected() {
|
|
return !!this.$$('gr-selection-action-box');
|
|
},
|
|
|
|
/**
|
|
* 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} selection A DOM Selection living in the shadow DOM of
|
|
* the diff element.
|
|
* @param {boolean} isMouseUp If true, this is called due to a mouseup
|
|
* event, in which case we might want to immediately create a comment.
|
|
*/
|
|
handleSelectionChange(selection, isMouseUp) {
|
|
// Can't use up or down events to handle selection started and/or ended in
|
|
// in comment threads or outside of diff.
|
|
// Debounce removeActionBox to give it a chance to react to click/tap.
|
|
this._removeActionBoxDebounced();
|
|
this.debounce(
|
|
'selectionChange', () => this._handleSelection(selection, isMouseUp),
|
|
200);
|
|
},
|
|
|
|
_getThreadEl(e) {
|
|
const path = Polymer.dom(e).path || [];
|
|
for (const pathEl of path) {
|
|
if (pathEl.classList.contains('comment-thread')) return pathEl;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
_handleCommentThreadMouseenter(e) {
|
|
const threadEl = this._getThreadEl(e);
|
|
const index = this._indexForThreadEl(threadEl);
|
|
|
|
if (index !== undefined) {
|
|
this.set(['commentRanges', index, 'hovering'], true);
|
|
}
|
|
},
|
|
|
|
_handleCommentThreadMouseleave(e) {
|
|
const threadEl = this._getThreadEl(e);
|
|
const index = this._indexForThreadEl(threadEl);
|
|
|
|
if (index !== undefined) {
|
|
this.set(['commentRanges', index, 'hovering'], false);
|
|
}
|
|
},
|
|
|
|
_indexForThreadEl(threadEl) {
|
|
const side = threadEl.getAttribute('comment-side');
|
|
const range = JSON.parse(threadEl.getAttribute('range'));
|
|
|
|
if (!range) return undefined;
|
|
|
|
return this._indexOfCommentRange(side, range);
|
|
},
|
|
|
|
_indexOfCommentRange(side, range) {
|
|
function rangesEqual(a, b) {
|
|
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).
|
|
* @param {Selection} selection
|
|
* @return {({
|
|
* start: {
|
|
* node: Node,
|
|
* side: string,
|
|
* line: Number,
|
|
* column: Number
|
|
* },
|
|
* end: {
|
|
* node: Node,
|
|
* side: string,
|
|
* line: Number,
|
|
* column: Number
|
|
* }
|
|
* })|null|!Object}
|
|
*/
|
|
_getNormalizedRange(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 {!Object} fixed normalized range
|
|
*/
|
|
_normalizeRange(domRange) {
|
|
const range = GrRangeNormalizer.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 {!Object} range Normalized range, ie column/line numbers
|
|
* @param {!Range} domRange DOM Range object
|
|
* @return {!Object} fixed normalized range
|
|
*/
|
|
_fixTripleClickSelection(range, domRange) {
|
|
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.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} node td.content child
|
|
* @param {number} offset offset within node
|
|
* @return {({
|
|
* node: Node,
|
|
* side: string,
|
|
* line: Number,
|
|
* column: Number
|
|
* }|undefined)}
|
|
*/
|
|
_normalizeSelectionSide(node, offset) {
|
|
let column;
|
|
if (!this.contains(node)) {
|
|
return;
|
|
}
|
|
const lineEl = this.diffBuilder.getLineElByChild(node);
|
|
if (!lineEl) {
|
|
return;
|
|
}
|
|
const side = this.diffBuilder.getSideByLineEl(lineEl);
|
|
if (!side) {
|
|
return;
|
|
}
|
|
const line = this.diffBuilder.getLineNumberByChild(lineEl);
|
|
if (!line) {
|
|
return;
|
|
}
|
|
const contentText = this.diffBuilder.getContentByLineEl(lineEl);
|
|
if (!contentText) {
|
|
return;
|
|
}
|
|
const contentTd = contentText.parentElement;
|
|
if (!contentTd.contains(node)) {
|
|
node = contentText;
|
|
column = 0;
|
|
} else {
|
|
const thread = contentTd.querySelector('.comment-thread');
|
|
if (thread && 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, startLine, range) {
|
|
if (startLine > 1) {
|
|
actionBox.placeAbove(range);
|
|
return;
|
|
}
|
|
actionBox.positionBelow = true;
|
|
actionBox.placeBelow(range);
|
|
},
|
|
|
|
_handleSelection(selection, isMouseUp) {
|
|
const normalizedRange = this._getNormalizedRange(selection);
|
|
if (!normalizedRange) {
|
|
return;
|
|
}
|
|
const domRange = selection.getRangeAt(0);
|
|
/** @type {?} */
|
|
const start = normalizedRange.start;
|
|
if (!start) {
|
|
return;
|
|
}
|
|
const end = normalizedRange.end;
|
|
if (!end) {
|
|
return;
|
|
}
|
|
if (start.side !== end.side ||
|
|
end.line < start.line ||
|
|
(start.line === end.line && start.column === end.column)) {
|
|
return;
|
|
}
|
|
|
|
// TODO (viktard): Drop empty first and last lines from selection.
|
|
|
|
// 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.
|
|
// Rather than trying to find the line contents, we just check if the
|
|
// selection is empty to see that it's at the end of a line.
|
|
// In this case, we select the entire start line.
|
|
if (isMouseUp && start.line === end.line - 1 && end.column === 0) {
|
|
const content = domRange.cloneContents().querySelector('.contentText');
|
|
if (this._getLength(content) === 0) {
|
|
this.fire('create-range-comment', {side: start.side, range: {
|
|
start_line: start.line,
|
|
start_character: 0,
|
|
end_line: start.line,
|
|
end_character: start.column,
|
|
}});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const actionBox = document.createElement('gr-selection-action-box');
|
|
const root = Polymer.dom(this.root);
|
|
root.insertBefore(actionBox, root.firstElementChild);
|
|
actionBox.range = {
|
|
start_line: start.line,
|
|
start_character: start.column,
|
|
end_line: end.line,
|
|
end_character: end.column,
|
|
};
|
|
actionBox.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.classList.contains('content') &&
|
|
start.node.firstChild) {
|
|
this._positionActionBox(actionBox, start.line, start.node.firstChild);
|
|
} else {
|
|
this._positionActionBox(actionBox, start.line, start.node);
|
|
}
|
|
},
|
|
|
|
_createRangeComment(e) {
|
|
this._removeActionBox();
|
|
},
|
|
|
|
_removeActionBoxDebounced() {
|
|
this.debounce('removeActionBox', this._removeActionBox, 10);
|
|
},
|
|
|
|
_removeActionBox() {
|
|
const actionBox = this.$$('gr-selection-action-box');
|
|
if (actionBox) {
|
|
Polymer.dom(this.root).removeChild(actionBox);
|
|
}
|
|
},
|
|
|
|
_convertOffsetToColumn(el, offset) {
|
|
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;
|
|
},
|
|
|
|
/**
|
|
* Traverse Element from right to left, call callback for each node.
|
|
* Stops if callback returns true.
|
|
*
|
|
* @param {!Element} startNode
|
|
* @param {function(Node):boolean} callback
|
|
* @param {Object=} opt_flags If flags.left is true, traverse left.
|
|
*/
|
|
_traverseContentSiblings(startNode, callback, opt_flags) {
|
|
const travelLeft = opt_flags && opt_flags.left;
|
|
let node = startNode;
|
|
while (node) {
|
|
if (node instanceof Element &&
|
|
node.tagName !== 'HL' &&
|
|
node.tagName !== 'SPAN') {
|
|
break;
|
|
}
|
|
const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
|
|
if (callback(node)) {
|
|
break;
|
|
}
|
|
node = nextNode;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get length of a node. If the node is a content node, then only give the
|
|
* length of its .contentText child.
|
|
*
|
|
* @param {?Element} node this is sometimes passed as null.
|
|
* @return {number}
|
|
*/
|
|
_getLength(node) {
|
|
if (node instanceof Element && node.classList.contains('content')) {
|
|
return this._getLength(node.querySelector('.contentText'));
|
|
} else {
|
|
return GrAnnotation.getLength(node);
|
|
}
|
|
},
|
|
});
|
|
})();
|