// 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'; var CharCode = { LESS_THAN: '<'.charCodeAt(0), GREATER_THAN: '>'.charCodeAt(0), AMPERSAND: '&'.charCodeAt(0), SEMICOLON: ';'.charCodeAt(0), }; var TAB_REGEX = /\t/g; Polymer({ is: 'gr-diff-side', /** * Fired when an expand context control is clicked. * * @event expand-context */ /** * Fired when a thread's height is changed. * * @event thread-height-change */ /** * Fired when a draft should be added. * * @event add-draft */ /** * Fired when a thread is removed. * * @event remove-thread */ properties: { canComment: { type: Boolean, value: false, }, content: { type: Array, notify: true, observer: '_contentChanged', }, prefs: { type: Object, value: function() { return {}; }, }, changeNum: String, patchNum: String, path: String, projectConfig: { type: Object, observer: '_projectConfigChanged', }, _lineFeedHTML: { type: String, value: '<span class="style-scope gr-diff-side br"></span>', readOnly: true, }, _highlightStartTag: { type: String, value: '<hl class="style-scope gr-diff-side">', readOnly: true, }, _highlightEndTag: { type: String, value: '</hl>', readOnly: true, }, _diffChunkLineNums: { type: Array, value: function() { return []; }, }, _commentThreadLineNums: { type: Array, value: function() { return []; }, }, _focusedLineNum: { type: Number, value: 1, }, }, listeners: { 'tap': '_tapHandler', }, observers: [ '_prefsChanged(prefs.*)', ], rowInserted: function(index) { this.renderLineIndexRange(index, index); this._updateDOMIndices(); this._updateJumpIndices(); }, rowRemoved: function(index) { var removedEls = Polymer.dom(this.root).querySelectorAll( '[data-index="' + index + '"]'); for (var i = 0; i < removedEls.length; i++) { removedEls[i].parentNode.removeChild(removedEls[i]); } this._updateDOMIndices(); this._updateJumpIndices(); }, rowUpdated: function(index) { var removedEls = Polymer.dom(this.root).querySelectorAll( '[data-index="' + index + '"]'); for (var i = 0; i < removedEls.length; i++) { removedEls[i].parentNode.removeChild(removedEls[i]); } this.renderLineIndexRange(index, index); }, scrollToLine: function(lineNum) { if (isNaN(lineNum) || lineNum < 1) { return; } var el = this.$$('.numbers .lineNum[data-line-num="' + lineNum + '"]'); if (!el) { return; } // Calculate where the line is relative to the window. var top = el.offsetTop; for (var offsetParent = el.offsetParent; offsetParent; offsetParent = offsetParent.offsetParent) { top += offsetParent.offsetTop; } // Scroll the element to the middle of the window. Dividing by a third // instead of half the inner height feels a bit better otherwise the // element appears to be below the center of the window even when it // isn't. window.scrollTo(0, top - (window.innerHeight / 3) - el.offsetHeight); }, scrollToNextDiffChunk: function() { this._scrollToNextChunkOrThread(this._diffChunkLineNums); }, scrollToPreviousDiffChunk: function() { this._scrollToPreviousChunkOrThread(this._diffChunkLineNums); }, scrollToNextCommentThread: function() { this._scrollToNextChunkOrThread(this._commentThreadLineNums); }, scrollToPreviousCommentThread: function() { this._scrollToPreviousChunkOrThread(this._commentThreadLineNums); }, renderLineIndexRange: function(startIndex, endIndex) { this._render(this.content, startIndex, endIndex); }, hideElementsWithIndex: function(index) { var els = Polymer.dom(this.root).querySelectorAll( '[data-index="' + index + '"]'); for (var i = 0; i < els.length; i++) { els[i].setAttribute('hidden', true); } }, getRowHeight: function(index) { var row = this.content[index]; // Filler elements should not be taken into account when determining // height calculations. if (row.type == 'FILLER') { return 0; } if (row.height != null) { return row.height; } var selector = '[data-index="' + index + '"]'; var els = Polymer.dom(this.root).querySelectorAll(selector); if (els.length != 2) { throw Error('Rows should only consist of two elements'); } return Math.max(els[0].offsetHeight, els[1].offsetHeight); }, getRowNaturalHeight: function(index) { var contentEl = this.$$('.content [data-index="' + index + '"]'); return contentEl.naturalHeight || contentEl.offsetHeight; }, setRowNaturalHeight: function(index) { var lineEl = this.$$('.numbers [data-index="' + index + '"]'); var contentEl = this.$$('.content [data-index="' + index + '"]'); contentEl.style.height = null; var height = contentEl.offsetHeight; lineEl.style.height = height + 'px'; this.content[index].height = height; return height; }, setRowHeight: function(index, height) { var selector = '[data-index="' + index + '"]'; var els = Polymer.dom(this.root).querySelectorAll(selector); for (var i = 0; i < els.length; i++) { els[i].style.height = height + 'px'; } this.content[index].height = height; }, _scrollToNextChunkOrThread: function(lineNums) { for (var i = 0; i < lineNums.length; i++) { if (lineNums[i] > this._focusedLineNum) { this._focusedLineNum = lineNums[i]; this.scrollToLine(this._focusedLineNum); return; } } }, _scrollToPreviousChunkOrThread: function(lineNums) { for (var i = lineNums.length - 1; i >= 0; i--) { if (this._focusedLineNum > lineNums[i]) { this._focusedLineNum = lineNums[i]; this.scrollToLine(this._focusedLineNum); return; } } }, _updateJumpIndices: function() { this._commentThreadLineNums = []; this._diffChunkLineNums = []; var inHighlight = false; for (var i = 0; i < this.content.length; i++) { switch (this.content[i].type) { case 'COMMENT_THREAD': this._commentThreadLineNums.push( this.content[i].comments[0].line); break; case 'CODE': // Only grab the first line of the highlighted chunk. if (!inHighlight && this.content[i].highlight) { this._diffChunkLineNums.push(this.content[i].lineNum); inHighlight = true; } else if (!this.content[i].highlight) { inHighlight = false; } break; } } }, _updateDOMIndices: function() { // There is no way to select elements with a data-index greater than a // given value. For now, just update all DOM elements. var lineEls = Polymer.dom(this.root).querySelectorAll( '.numbers [data-index]'); var contentEls = Polymer.dom(this.root).querySelectorAll( '.content [data-index]'); if (lineEls.length != contentEls.length) { throw Error( 'There must be the same number of line and content elements'); } var index = 0; for (var i = 0; i < this.content.length; i++) { if (this.content[i].hidden) { continue; } lineEls[index].setAttribute('data-index', i); contentEls[index].setAttribute('data-index', i); index++; } }, _prefsChanged: function(changeRecord) { var prefs = changeRecord.base; this.$.content.style.width = prefs.line_length + 'ch'; }, _projectConfigChanged: function(projectConfig) { var threadEls = Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread'); for (var i = 0; i < threadEls.length; i++) { threadEls[i].projectConfig = projectConfig; } }, _contentChanged: function(diff) { this._clearChildren(this.$.numbers); this._clearChildren(this.$.content); this._render(diff, 0, diff.length - 1); this._updateJumpIndices(); }, _computeContainerClass: function(canComment) { return 'container' + (canComment ? ' canComment' : ''); }, _tapHandler: function(e) { var lineEl = Polymer.dom(e).rootTarget; if (!this.canComment || !lineEl.classList.contains('lineNum')) { return; } e.preventDefault(); var index = parseInt(lineEl.getAttribute('data-index'), 10); var line = parseInt(lineEl.getAttribute('data-line-num'), 10); this.fire('add-draft', { index: index, line: line }, {bubbles: false}); }, _clearChildren: function(el) { while (el.firstChild) { el.removeChild(el.firstChild); } }, _handleContextControlClick: function(context, e) { e.preventDefault(); this.fire('expand-context', {context: context}, {bubbles: false}); }, _render: function(diff, startIndex, endIndex) { var beforeLineEl; var beforeContentEl; if (endIndex != diff.length - 1) { beforeLineEl = this.$$('.numbers [data-index="' + endIndex + '"]'); beforeContentEl = this.$$('.content [data-index="' + endIndex + '"]'); if (!beforeLineEl && !beforeContentEl) { // `endIndex` may be present within the model, but not in the DOM. // Insert it before its successive element. beforeLineEl = this.$$( '.numbers [data-index="' + (endIndex + 1) + '"]'); beforeContentEl = this.$$( '.content [data-index="' + (endIndex + 1) + '"]'); } } for (var i = startIndex; i <= endIndex; i++) { if (diff[i].hidden) { continue; } switch (diff[i].type) { case 'CODE': this._renderCode(diff[i], i, beforeLineEl, beforeContentEl); break; case 'FILLER': this._renderFiller(diff[i], i, beforeLineEl, beforeContentEl); break; case 'CONTEXT_CONTROL': this._renderContextControl(diff[i], i, beforeLineEl, beforeContentEl); break; case 'COMMENT_THREAD': this._renderCommentThread(diff[i], i, beforeLineEl, beforeContentEl); break; } } }, _handleCommentThreadHeightChange: function(e) { var threadEl = Polymer.dom(e).rootTarget; var index = parseInt(threadEl.getAttribute('data-index'), 10); this.content[index].height = e.detail.height; var lineEl = this.$$('.numbers [data-index="' + index + '"]'); lineEl.style.height = e.detail.height + 'px'; this.fire('thread-height-change', { index: index, height: e.detail.height, }, {bubbles: false}); }, _handleCommentThreadDiscard: function(e) { var threadEl = Polymer.dom(e).rootTarget; var index = parseInt(threadEl.getAttribute('data-index'), 10); this.fire('remove-thread', {index: index}, {bubbles: false}); }, _renderCommentThread: function(thread, index, beforeLineEl, beforeContentEl) { var lineEl = this._createElement('div', 'commentThread'); lineEl.classList.add('filler'); lineEl.setAttribute('data-index', index); var threadEl = document.createElement('gr-diff-comment-thread'); threadEl.addEventListener('height-change', this._handleCommentThreadHeightChange.bind(this)); threadEl.addEventListener('thread-discard', this._handleCommentThreadDiscard.bind(this)); threadEl.setAttribute('data-index', index); threadEl.changeNum = this.changeNum; threadEl.patchNum = thread.patchNum || this.patchNum; threadEl.path = this.path; threadEl.comments = thread.comments; threadEl.projectConfig = this.projectConfig; this.$.numbers.insertBefore(lineEl, beforeLineEl); this.$.content.insertBefore(threadEl, beforeContentEl); }, _renderContextControl: function(control, index, beforeLineEl, beforeContentEl) { var lineEl = this._createElement('div', 'contextControl'); lineEl.setAttribute('data-index', index); lineEl.textContent = '@@'; var contentEl = this._createElement('div', 'contextControl'); contentEl.setAttribute('data-index', index); var a = this._createElement('a'); a.href = '#'; a.textContent = 'Show ' + control.numLines + ' common ' + (control.numLines == 1 ? 'line' : 'lines') + '...'; a.addEventListener('click', this._handleContextControlClick.bind(this, control)); contentEl.appendChild(a); this.$.numbers.insertBefore(lineEl, beforeLineEl); this.$.content.insertBefore(contentEl, beforeContentEl); }, _renderFiller: function(filler, index, beforeLineEl, beforeContentEl) { var lineFillerEl = this._createElement('div', 'filler'); lineFillerEl.setAttribute('data-index', index); var fillerEl = this._createElement('div', 'filler'); fillerEl.setAttribute('data-index', index); var numLines = filler.numLines || 1; lineFillerEl.textContent = '\n'.repeat(numLines); for (var i = 0; i < numLines; i++) { var newlineEl = this._createElement('span', 'br'); fillerEl.appendChild(newlineEl); } this.$.numbers.insertBefore(lineFillerEl, beforeLineEl); this.$.content.insertBefore(fillerEl, beforeContentEl); }, _renderCode: function(code, index, beforeLineEl, beforeContentEl) { var lineNumEl = this._createElement('div', 'lineNum'); lineNumEl.setAttribute('data-line-num', code.lineNum); lineNumEl.setAttribute('data-index', index); var numLines = code.numLines || 1; lineNumEl.textContent = code.lineNum + '\n'.repeat(numLines); var contentEl = this._createElement('div', 'code'); contentEl.setAttribute('data-line-num', code.lineNum); contentEl.setAttribute('data-index', index); if (code.highlight) { contentEl.classList.add(code.intraline.length > 0 ? 'lightHighlight' : 'darkHighlight'); } var html = util.escapeHTML(code.content); if (code.highlight && code.intraline.length > 0) { html = this._addIntralineHighlights(code.content, html, code.intraline); } if (numLines > 1) { html = this._addNewLines(code.content, html, numLines); } html = this._addTabWrappers(code.content, html); // If the html is equivalent to the text then it didn't get highlighted // or escaped. Use textContent which is faster than innerHTML. if (code.content == html) { contentEl.textContent = code.content; } else { contentEl.innerHTML = html; } this.$.numbers.insertBefore(lineNumEl, beforeLineEl); this.$.content.insertBefore(contentEl, beforeContentEl); }, // Advance `index` by the appropriate number of characters that would // represent one source code character and return that index. For // example, for source code '<span>' the escaped html string is // '<span>'. Advancing from index 0 on the prior html string would // return 4, since < maps to one source code character ('<'). _advanceChar: function(html, index) { // Any tags don't count as characters while (index < html.length && html.charCodeAt(index) == CharCode.LESS_THAN) { while (index < html.length && html.charCodeAt(index) != CharCode.GREATER_THAN) { index++; } index++; // skip the ">" itself } // An HTML entity (e.g., <) counts as one character. if (index < html.length && html.charCodeAt(index) == CharCode.AMPERSAND) { while (index < html.length && html.charCodeAt(index) != CharCode.SEMICOLON) { index++; } } return index + 1; }, _addIntralineHighlights: function(content, html, highlights) { var startTag = this._highlightStartTag; var endTag = this._highlightEndTag; for (var i = 0; i < highlights.length; i++) { var hl = highlights[i]; var htmlStartIndex = 0; for (var j = 0; j < hl.startIndex; j++) { htmlStartIndex = this._advanceChar(html, htmlStartIndex); } var htmlEndIndex = 0; if (hl.endIndex != null) { for (var j = 0; j < hl.endIndex; j++) { htmlEndIndex = this._advanceChar(html, htmlEndIndex); } } else { // If endIndex isn't present, continue to the end of the line. htmlEndIndex = html.length; } // The start and end indices could be the same if a highlight is meant // to start at the end of a line and continue onto the next one. // Ignore it. if (htmlStartIndex != htmlEndIndex) { html = html.slice(0, htmlStartIndex) + startTag + html.slice(htmlStartIndex, htmlEndIndex) + endTag + html.slice(htmlEndIndex); } } return html; }, _addNewLines: function(content, html, numLines) { var htmlIndex = 0; var indices = []; var numChars = 0; for (var i = 0; i < content.length; i++) { if (numChars > 0 && numChars % this.prefs.line_length == 0) { indices.push(htmlIndex); } htmlIndex = this._advanceChar(html, htmlIndex); if (content[i] == '\t') { numChars += this.prefs.tab_size; } else { numChars++; } } var result = html; var linesLeft = numLines; // Since the result string is being altered in place, start from the end // of the string so that the insertion indices are not affected as the // result string changes. for (var i = indices.length - 1; i >= 0; i--) { result = result.slice(0, indices[i]) + this._lineFeedHTML + result.slice(indices[i]); linesLeft--; } // numLines is the total number of lines this code block should take up. // Fill in the remaining ones. for (var i = 0; i < linesLeft; i++) { result += this._lineFeedHTML; } return result; }, _addTabWrappers: function(content, html) { // TODO(andybons): CSS tab-size is not supported in IE. // Force this to be a number to prevent arbitrary injection. var tabSize = +this.prefs.tab_size; var htmlStr = '<span class="style-scope gr-diff-side tab ' + (this.prefs.show_tabs ? 'withIndicator" ' : '" ') + 'style="tab-size:' + tabSize + ';' + '-moz-tab-size:' + tabSize + ';">\t</span>'; return html.replace(TAB_REGEX, htmlStr); }, _createElement: function(tagName, className) { var el = document.createElement(tagName); // When Shady DOM is being used, these classes are added to account for // Polymer's polyfill behavior. In order to guarantee sufficient // specificity within the CSS rules, these are added to every element. // Since the Polymer DOM utility functions (which would do this // automatically) are not being used for performance reasons, this is // done manually. el.classList.add('style-scope', 'gr-diff-side'); if (!!className) { el.classList.add(className); } return el; }, }); })();