blob: db092e604837769882d1287003596bd087b10361 [file] [log] [blame]
// 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';
// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
var RANGE_HIGHLIGHT = 'range';
var HOVER_HIGHLIGHT = 'rangeHighlight';
Polymer({
is: 'gr-diff-highlight',
properties: {
comments: Object,
enabled: {
type: Boolean,
observer: '_enabledChanged',
},
loggedIn: Boolean,
_cachedDiffBuilder: Object,
_enabledListeners: {
type: Object,
value: function() {
return {
'comment-discard': '_handleCommentDiscard',
'comment-mouse-out': '_handleCommentMouseOut',
'comment-mouse-over': '_handleCommentMouseOver',
'create-comment': '_createComment',
'render': '_handleRender',
'show-context': '_handleShowContext',
'thread-discard': '_handleThreadDiscard',
};
},
},
},
get diffBuilder() {
if (!this._cachedDiffBuilder) {
this._cachedDiffBuilder =
Polymer.dom(this).querySelector('gr-diff-builder');
}
return this._cachedDiffBuilder;
},
detached: function() {
this.enabled = false;
},
_enabledChanged: function() {
for (var eventName in this._enabledListeners) {
var methodName = this._enabledListeners[eventName];
if (this.enabled) {
this.listen(this, eventName, methodName);
} else {
this.unlisten(this, eventName, methodName);
}
}
},
isRangeSelected: function() {
return !!this.$$('gr-selection-action-box');
},
_handleThreadDiscard: function(e) {
var comment = e.detail.lastComment;
// Comment Element was removed from DOM already.
if (comment.range) {
this._renderCommentRange(comment, e.target);
}
},
_handleCommentDiscard: function(e) {
var comment = e.detail.comment;
if (comment.range) {
this._renderCommentRange(comment, e.target);
}
},
_handleRender: function() {
this._applyAllHighlights();
},
_handleShowContext: function() {
// TODO (viktard): Re-render expanded sections only.
this._applyAllHighlights();
},
_handleCommentMouseOver: function(e) {
var comment = e.detail.comment;
var range = comment.range;
if (!range) {
return;
}
var lineEl = this.diffBuilder.getLineElByChild(e.target);
var side = this.diffBuilder.getSideByLineEl(lineEl);
this._applyRangedHighlight(
HOVER_HIGHLIGHT, range.start_line, range.start_character,
range.end_line, range.end_character, side);
},
_handleCommentMouseOut: function(e) {
var comment = e.detail.comment;
var range = comment.range;
if (!range) {
return;
}
var lineEl = this.diffBuilder.getLineElByChild(e.target);
var side = this.diffBuilder.getSideByLineEl(lineEl);
var contentEls = this.diffBuilder.getContentsByLineRange(
range.start_line, range.end_line, side);
contentEls.forEach(function(content) {
Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
function(el) {
el.classList.remove(HOVER_HIGHLIGHT);
el.classList.add(RANGE_HIGHLIGHT);
});
}, this);
},
_renderCommentRange: function(comment, el) {
var lineEl = this.diffBuilder.getLineElByChild(el);
if (!lineEl) {
return;
}
var side = this.diffBuilder.getSideByLineEl(lineEl);
this._rerenderByLines(
comment.range.start_line, comment.range.end_line, side);
},
_createComment: function(e) {
this._removeActionBox();
var side = e.detail.side;
var range = e.detail.range;
if (!range) {
return;
}
var lineEl = this.diffBuilder.getLineElByChild(e.target);
var side = this.diffBuilder.getSideByLineEl(lineEl);
var contentEls = this.diffBuilder.getContentsByLineRange(
range.start_line, range.end_line, side);
contentEls.forEach(function(content) {
Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
function(el) {
el.classList.remove(HOVER_HIGHLIGHT);
el.classList.add(RANGE_HIGHLIGHT);
});
}, this);
},
_renderCommentRange: function(comment, el) {
var lineEl = this.diffBuilder.getLineElByChild(el);
if (!lineEl) {
return;
}
var side = this.diffBuilder.getSideByLineEl(lineEl);
this._rerenderByLines(
comment.range.start_line, comment.range.end_line, side);
},
_createComment: function(e) {
this._removeActionBox();
var side = e.detail.side;
var range = e.detail.range;
if (!range) {
return;
}
this._applyRangedHighlight(
RANGE_HIGHLIGHT, range.startLine, range.startChar,
range.endLine, range.endChar, side);
},
_removeActionBox: function() {
var actionBox = this.$$('gr-selection-action-box');
if (actionBox) {
Polymer.dom(this.root).removeChild(actionBox);
}
},
/**
* Traverse diff content from right to left, call callback for each node.
* Stops if callback returns true.
*
* @param {!Node} startNode
* @param {function(Node):boolean} callback
* @param {Object=} flags If flags.left is true, traverse left.
*/
_traverseContentSiblings: function(startNode, callback, opt_flags) {
var travelLeft = opt_flags && opt_flags.left;
var node = startNode;
while (node) {
if (node instanceof Element && node.tagName !== 'HL') {
break;
}
var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
if (callback(node)) {
break;
}
node = nextNode;
}
},
/**
* Get length of a node. Traverses diff content siblings if required.
*
* @param {!Node} node
* @return {number}
*/
_getLength: function(node) {
if (node instanceof Element && node.classList.contains('content')) {
node = node.firstChild;
var length = 0;
while (node) {
// Only measure Text nodes and <hl>
if (node instanceof Text || node.tagName == 'HL') {
length += this._getLength(node);
}
node = node.nextSibling;
}
return length;
} else {
// DOM API for textContent.length is broken for Unicode:
// https://mathiasbynens.be/notes/javascript-unicode
return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
}
},
/**
* Wraps node in hl tag with cssClass, replacing the node in DOM.
*
* @return {!Element} Wrapped node.
*/
_wrapInHighlight: function(node, cssClass) {
var hl = document.createElement('hl');
hl.className = cssClass;
Polymer.dom(node.parentElement).replaceChild(hl, node);
hl.appendChild(node);
return hl;
},
/**
* Node.prototype.splitText Unicode-valid alternative.
*
* @param {!Text} node
* @param {number} offset
* @return {!Text} Trailing Text Node.
*/
_splitText: function(node, offset) {
if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
// DOM Api for splitText() is broken for Unicode:
// https://mathiasbynens.be/notes/javascript-unicode
// TODO (viktard): Polyfill Array.from for IE10.
var head = Array.from(node.textContent);
var tail = head.splice(offset);
var parent = node.parentElement;
var headNode = document.createTextNode(head.join(''));
parent.replaceChild(headNode, node);
var tailNode = document.createTextNode(tail.join(''));
parent.insertBefore(tailNode, headNode.nextSibling);
return tailNode;
} else {
return node.splitText(offset);
}
},
/**
* Split Text Node and wrap it in hl with cssClass.
* Wraps trailing part after split, tailing one if opt_firstPart is true.
*
* @param {!Text} node
* @param {number} offset
* @param {string} cssClass
* @param {boolean=} opt_firstPart
*/
_splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
if (this._getLength(node) === offset || offset === 0) {
return this._wrapInHighlight(node, cssClass);
} else {
if (opt_firstPart) {
this._splitText(node, offset);
// Node points to first part of the Text, second one is sibling.
} else {
node = this._splitText(node, offset);
}
return this._wrapInHighlight(node, cssClass);
}
},
/**
* Creates hl tag with cssClass for starting side of range highlight.
*
* @param {!Element} startContent Range start diff content aka td.content.
* @param {!Element} endContent Range end diff content aka td.content.
* @param {number} startOffset Range start within start content.
* @param {number} endOffset Range end within end content.
* @param {string} cssClass
* @return {!Element} Range start node.
*/
_normalizeStart: function(
startContent, endContent, startOffset, endOffset, cssClass) {
var isOneLine = startContent === endContent;
var startNode = startContent.firstChild;
var length = endOffset - startOffset;
if (!startNode) {
return startNode;
}
// Skip nodes before startOffset.
while (startNode &&
this._getLength(startNode) <= startOffset ||
this._getLength(startNode) === 0) {
startOffset -= this._getLength(startNode);
startNode = startNode.nextSibling;
}
// Split Text node.
if (startNode instanceof Text) {
startNode =
this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
startContent.insertBefore(startNode, startNode.nextSibling);
// Edge case: single line, text node wraps the highlight.
if (isOneLine && this._getLength(startNode) > length) {
var extra = this._splitText(startNode.firstChild, length);
startContent.insertBefore(extra, startNode.nextSibling);
startContent.normalize();
}
} else if (startNode.tagName == 'HL') {
if (!startNode.classList.contains(cssClass)) {
var hl = startNode;
startNode = this._splitAndWrapInHighlight(
startNode.firstChild, startOffset, cssClass);
startContent.insertBefore(startNode, hl.nextSibling);
// Edge case: single line, <hl> wraps the highlight.
if (isOneLine && this._getLength(startNode) > length) {
var trailingHl = hl.cloneNode(false);
trailingHl.appendChild(
this._splitText(startNode.firstChild, length));
startContent.insertBefore(trailingHl, startNode.nextSibling);
}
if (hl.textContent.length === 0) {
hl.remove();
}
}
} else {
startNode = null;
}
return startNode;
},
/**
* Creates hl tag with cssClass for ending side of range highlight.
*
* @param {!Element} startContent Range start diff content aka td.content.
* @param {!Element} endContent Range end diff content aka td.content.
* @param {number} startOffset Range start within start content.
* @param {number} endOffset Range end within end content.
* @param {string} cssClass
* @return {!Element} Range start node.
*/
_normalizeEnd: function(
startContent, endContent, startOffset, endOffset, cssClass) {
var endNode = endContent.firstChild;
if (!endNode) {
return endNode;
}
// Find the node where endOffset points at.
while (endNode &&
this._getLength(endNode) < endOffset ||
this._getLength(endNode) === 0) {
endOffset -= this._getLength(endNode);
endNode = endNode.nextSibling;
}
if (endNode instanceof Text) {
endNode =
this._splitAndWrapInHighlight(endNode, endOffset, cssClass, true);
} else if (endNode.tagName == 'HL') {
if (!endNode.classList.contains(cssClass)) {
// Split text inside HL.
var hl = endNode;
endNode = this._splitAndWrapInHighlight(
endNode.firstChild, endOffset, cssClass, true);
endContent.insertBefore(endNode, hl);
if (hl.textContent.length === 0) {
hl.remove();
}
}
} else {
endNode = null;
}
return endNode;
},
/**
* Applies highlight to first and last lines in range.
*
* @param {!Element} startContent Range start diff content aka td.content.
* @param {!Element} endContent Range end diff content aka td.content.
* @param {number} startOffset Range start within start content.
* @param {number} endOffset Range end within end content.
* @param {string} cssClass
*/
_highlightSides: function(
startContent, endContent, startOffset, endOffset, cssClass) {
var isOneLine = startContent === endContent;
var startNode = this._normalizeStart(
startContent, endContent, startOffset, endOffset, cssClass);
var endNode = this._normalizeEnd(
startContent, endContent, startOffset, endOffset, cssClass);
// Grow starting highlight until endNode or end of line.
if (startNode && startNode != endNode) {
this._traverseContentSiblings(startNode.nextSibling, function(node) {
startNode.textContent += node.textContent;
node.remove();
return node == endNode;
});
}
if (!isOneLine && endNode) {
// Prepend text up to line start to the ending highlight.
this._traverseContentSiblings(endNode.previousSibling, function(node) {
endNode.textContent = node.textContent + endNode.textContent;
node.remove();
}, {left: true});
}
},
/**
* @param {string} cssClass
* @param {number} startLine Range start code line number.
* @param {number} startCol Range start column number.
* @param {number} endCol Range end column number.
* @param {number} endOffset Range end within end content.
* @param {string=} opt_side Side selector (right or left).
*/
_applyRangedHighlight: function(
cssClass, startLine, startCol, endLine, endCol, opt_side) {
var side = opt_side;
var startEl = this.diffBuilder.getContentByLine(startLine, opt_side);
var endEl = this.diffBuilder.getContentByLine(endLine, opt_side);
this._highlightSides(startEl, endEl, startCol, endCol, cssClass);
if (endLine - startLine > 1) {
// There is at least one line in between.
var contents = this.diffBuilder.getContentsByLineRange(
startLine + 1, endLine - 1, opt_side);
// Wrap contents in highlight.
contents.forEach(function(content) {
if (content.textContent.length === 0) {
return;
}
var lineEl = this.diffBuilder.getLineElByChild(content);
var line = lineEl.getAttribute('data-value');
var threadEl =
this.diffBuilder.getCommentThreadByContentEl(content);
if (threadEl) {
threadEl.remove();
}
var text = document.createTextNode(content.textContent);
while (content.firstChild) {
content.removeChild(content.firstChild);
}
content.appendChild(text);
if (threadEl) {
content.appendChild(threadEl);
}
this._wrapInHighlight(text, cssClass);
}, this);
}
},
_applyAllHighlights: function() {
var rangedLeft =
this.comments.left.filter(function(item) { return !!item.range; });
var rangedRight =
this.comments.right.filter(function(item) { return !!item.range; });
rangedLeft.forEach(function(item) {
var range = item.range;
this._applyRangedHighlight(
RANGE_HIGHLIGHT, range.start_line, range.start_character,
range.end_line, range.end_character, 'left');
}, this);
rangedRight.forEach(function(item) {
var range = item.range;
this._applyRangedHighlight(
RANGE_HIGHLIGHT, range.start_line, range.start_character,
range.end_line, range.end_character, 'right');
}, this);
},
_rerenderByLines: function(startLine, endLine, opt_side) {
this.async(function() {
this.diffBuilder.renderLineRange(startLine, endLine, opt_side);
}, 1);
},
});
})();