
Triple click in side-by-side mode with other side empty results in DOM selection ending on the empty div line number tag. Normalizing such selection results in null element for the end container. This is handled on ad-hoc basis by validating where DOM selection landed and is handled properly. Previous fix (https://gerrit-review.googlesource.com/c/gerrit/+/193470) was only addressing the case when the other diff side is populated. Bug: Issue 8431 Change-Id: I17964db98c217d30dd266e01d0a03a11ed9c42b9
392 lines
12 KiB
JavaScript
392 lines
12 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: {
|
|
comments: Object,
|
|
loggedIn: Boolean,
|
|
/**
|
|
* querySelector can return null, so needs to be nullable.
|
|
*
|
|
* @type {?HTMLElement}
|
|
* */
|
|
_cachedDiffBuilder: Object,
|
|
isAttached: Boolean,
|
|
},
|
|
|
|
listeners: {
|
|
'comment-mouse-out': '_handleCommentMouseOut',
|
|
'comment-mouse-over': '_handleCommentMouseOver',
|
|
'create-comment': '_createComment',
|
|
},
|
|
|
|
observers: [
|
|
'_enableSelectionObserver(loggedIn, isAttached)',
|
|
],
|
|
|
|
get diffBuilder() {
|
|
if (!this._cachedDiffBuilder) {
|
|
this._cachedDiffBuilder =
|
|
Polymer.dom(this).querySelector('gr-diff-builder');
|
|
}
|
|
return this._cachedDiffBuilder;
|
|
},
|
|
|
|
_enableSelectionObserver(loggedIn, isAttached) {
|
|
if (loggedIn && isAttached) {
|
|
this.listen(document, 'selectionchange', '_handleSelectionChange');
|
|
} else {
|
|
this.unlisten(document, 'selectionchange', '_handleSelectionChange');
|
|
}
|
|
},
|
|
|
|
isRangeSelected() {
|
|
return !!this.$$('gr-selection-action-box');
|
|
},
|
|
|
|
_handleSelectionChange() {
|
|
// 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, 200);
|
|
},
|
|
|
|
_handleCommentMouseOver(e) {
|
|
const comment = e.detail.comment;
|
|
if (!comment.range) { return; }
|
|
const lineEl = this.diffBuilder.getLineElByChild(e.target);
|
|
const side = this.diffBuilder.getSideByLineEl(lineEl);
|
|
const index = this._indexOfComment(side, comment);
|
|
if (index !== undefined) {
|
|
this.set(['comments', side, index, '__hovering'], true);
|
|
}
|
|
},
|
|
|
|
_handleCommentMouseOut(e) {
|
|
const comment = e.detail.comment;
|
|
if (!comment.range) { return; }
|
|
const lineEl = this.diffBuilder.getLineElByChild(e.target);
|
|
const side = this.diffBuilder.getSideByLineEl(lineEl);
|
|
const index = this._indexOfComment(side, comment);
|
|
if (index !== undefined) {
|
|
this.set(['comments', side, index, '__hovering'], false);
|
|
}
|
|
},
|
|
|
|
_indexOfComment(side, comment) {
|
|
const idProp = comment.id ? 'id' : '__draftID';
|
|
for (let i = 0; i < this.comments[side].length; i++) {
|
|
if (comment[idProp] &&
|
|
this.comments[side][i][idProp] === comment[idProp]) {
|
|
return i;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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).
|
|
* @return {({
|
|
* start: {
|
|
* node: Node,
|
|
* side: string,
|
|
* line: Number,
|
|
* column: Number
|
|
* },
|
|
* end: {
|
|
* node: Node,
|
|
* side: string,
|
|
* line: Number,
|
|
* column: Number
|
|
* }
|
|
* })|null|!Object}
|
|
*/
|
|
_getNormalizedRange() {
|
|
const selection = window.getSelection();
|
|
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('gr-diff-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() {
|
|
const normalizedRange = this._getNormalizedRange();
|
|
if (!normalizedRange) {
|
|
return;
|
|
}
|
|
const domRange = window.getSelection().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.
|
|
|
|
const actionBox = document.createElement('gr-selection-action-box');
|
|
const root = Polymer.dom(this.root);
|
|
root.insertBefore(actionBox, root.firstElementChild);
|
|
actionBox.range = {
|
|
startLine: start.line,
|
|
startChar: start.column,
|
|
endLine: end.line,
|
|
endChar: 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);
|
|
}
|
|
},
|
|
|
|
_createComment(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);
|
|
}
|
|
},
|
|
});
|
|
})();
|