| // 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); |
| }, |
| }); |
| })(); |