// 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() { if (this.enabled) { this.listen(document, 'selectionchange', '_handleSelectionChange'); } else { this.unlisten(document, 'selectionchange', '_handleSelectionChange'); } 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); } }, _handleSelectionChange: function() { // 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); }, _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); }, /** * 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 * }} */ _normalizeSelectionSide: function(node, offset) { var column; if (!this.contains(node)) { return; } var lineEl = this.diffBuilder.getLineElByChild(node); if (!lineEl) { return; } var side = this.diffBuilder.getSideByLineEl(lineEl); if (!side) { return; } var line = this.diffBuilder.getLineNumberByChild(lineEl); if (!line) { return; } var content = this.diffBuilder.getContentByLineEl(lineEl); if (!content) { return; } if (!content.contains(node)) { node = content; column = 0; } else { var thread = content.querySelector('gr-diff-comment-thread'); if (thread && thread.contains(node)) { column = this._getLength(content); node = content; } else { column = this._convertOffsetToColumn(node, offset); } } return { node: node, side: side, line: line, column: column, }; }, _handleSelection: function() { var selection = window.getSelection(); if (selection.rangeCount != 1) { return; } var range = selection.getRangeAt(0); if (range.collapsed) { return; } var start = this._normalizeSelectionSide(range.startContainer, range.startOffset); if (!start) { return; } var end = this._normalizeSelectionSide(range.endContainer, range.endOffset); 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. var actionBox = document.createElement('gr-selection-action-box'); Polymer.dom(this.root).appendChild(actionBox); actionBox.range = { startLine: start.line, startChar: start.column, endLine: end.line, endChar: end.column, }; actionBox.side = start.side; if (start.line === end.line) { actionBox.placeAbove(range); } else if (start.node instanceof Text) { actionBox.placeAbove(start.node.splitText(start.column)); start.node.parentElement.normalize(); // Undo splitText from above. } else if (start.node.classList.contains('content') && start.node.firstChild) { actionBox.placeAbove(start.node.firstChild); } else { actionBox.placeAbove(start.node); } }, _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); }, _removeActionBoxDebounced: function() { this.debounce('removeActionBox', this._removeActionBox, 10); }, _removeActionBox: function() { var actionBox = this.$$('gr-selection-action-box'); if (actionBox) { Polymer.dom(this.root).removeChild(actionBox); } }, _convertOffsetToColumn: function(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 {!Node} startNode * @param {function(Node):boolean} callback * @param {Object=} opt_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' && node.tagName !== 'SPAN') { 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) { 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; if (node.tagName === 'HL') { hl = node; hl.classList.add(cssClass); } else { 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. */ _splitTextNode: 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 Node at offset. * If Node is Element, it's cloned and the node at offset is split too. * * @param {!Node} node * @param {number} offset * @return {!Node} Trailing Node. */ _splitNode: function(element, offset) { if (element instanceof Text) { return this._splitTextNode(element, offset); } var tail = element.cloneNode(false); element.parentElement.insertBefore(tail, element.nextSibling); // Skip nodes before offset. var node = element.firstChild; while (node && this._getLength(node) <= offset || this._getLength(node) === 0) { offset -= this._getLength(node); node = node.nextSibling; } if (this._getLength(node) > offset) { tail.appendChild(this._splitNode(node, offset)); } while (node.nextSibling) { tail.appendChild(node.nextSibling); } return tail; }, /** * Split Text Node and wrap it in hl with cssClass. * Wraps trailing part after split, tailing one if opt_firstPart is true. * * @param {!Node} 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._splitNode(node, offset); // Node points to first part of the Text, second one is sibling. } else { node = this._splitNode(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. var nodeLength = this._getLength(startNode); while (startNode && (nodeLength <= startOffset || nodeLength === 0)) { startOffset -= nodeLength; startNode = startNode.nextSibling; nodeLength = startNode && this._getLength(startNode); } if (!startNode) { return null; } // Split Text node. if (startNode instanceof Text) { startNode = this._splitAndWrapInHighlight(startNode, startOffset, cssClass); // Edge case: single line, text node wraps the highlight. if (isOneLine && this._getLength(startNode) > length) { var extra = this._splitTextNode(startNode.firstChild, length); startContent.insertBefore(extra, startNode.nextSibling); startContent.normalize(); } } else if (startNode.tagName == 'HL') { if (!startNode.classList.contains(cssClass)) { // Edge case: single line, <hl> wraps the highlight. // Should leave wrapping HL's content after the highlight. if (isOneLine && startOffset + length < this._getLength(startNode)) { this._splitNode(startNode, startOffset + length); } startNode = this._splitAndWrapInHighlight(startNode, startOffset, cssClass); } } 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. var nodeLength = this._getLength(endNode); while (endNode && (nodeLength < endOffset || nodeLength === 0)) { endOffset -= nodeLength; endNode = endNode.nextSibling; nodeLength = endNode && this._getLength(endNode); } if (!endNode) { return null; } 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, endOffset, cssClass, true); 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) { var growStartHl = function(node) { if (node instanceof Text || node.tagName === 'SPAN') { startNode.appendChild(node); } else if (node.tagName === 'HL') { this._traverseContentSiblings(node.firstChild, growStartHl); node.remove(); } return node == endNode; }.bind(this); this._traverseContentSiblings(startNode.nextSibling, growStartHl); startNode.normalize(); } if (!isOneLine && endNode) { var growEndHl = function(node) { if (node instanceof Text || node.tagName === 'SPAN') { endNode.insertBefore(node, endNode.firstChild); } else if (node.tagName === 'HL') { this._traverseContentSiblings(node.firstChild, growEndHl); node.remove(); } }.bind(this); // Prepend text up to line start to the ending highlight. this._traverseContentSiblings( endNode.previousSibling, growEndHl, {left: true}); endNode.normalize(); } }, /** * @param {string} cssClass * @param {number} startLine Range start code line number. * @param {number} startCol Range start column number. * @param {number} endLine Range end line number. * @param {number} endCol Range end column number. * @param {string=} opt_side Side selector (right or left). */ _applyRangedHighlight: function( cssClass, startLine, startCol, endLine, endCol, 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 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); }, }); })();