| // 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('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.showActions = this.canComment; |
| 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; |
| }, |
| }); |
| })(); |