Files
gerrit/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
Ole Rehmsen a970d4e834 Imperative named Slots
The idea is to instead of imperatively appending slotted comments to
threadGroupEls (which results in them being removed from the light DOM
of the gr-diff element and thus causing all kinds of tracking
difficulties), to slot them into a named slot in the appropriate line.

Change-Id: I17e99b236240baab581f8c266bed3d400a302408
2018-11-19 20:59:57 +01:00

413 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: {
/** @type {!Array<!Gerrit.HoveredRange>} */
commentRanges: {
type: Array,
notify: true,
},
loggedIn: Boolean,
/**
* querySelector can return null, so needs to be nullable.
*
* @type {?HTMLElement}
* */
_cachedDiffBuilder: Object,
isAttached: Boolean,
},
listeners: {
'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
'create-range-comment': '_createRangeComment',
},
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);
},
_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).
* @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 = {
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);
}
},
});
})();