blob: 518da3ec1da074ca48a30096c1cb0ca3bb160446 [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';
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
// '&lt;span&gt;'. Advancing from index 0 on the prior html string would
// return 4, since &lt; 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., &lt;) 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;
},
});
})();