blob: 0157c7377bad290961efaedb7fc3c709b86492a0 [file] [log] [blame]
<!--
Copyright (C) 2015 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.
-->
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="gr-diff-comment-thread.html">
<dom-module id="gr-diff-side">
<template>
<style>
:host,
.container {
display: flex;
}
.lineNum:before,
.code:before {
/* To ensure the height is non-zero in these elements, a
zero-width space is set as its content. The character
itself doesn't matter. Just that there is something
there. */
content: '\200B';
}
.lineNum {
background-color: #eee;
color: #666;
padding: 0 .75em;
text-align: right;
}
.canComment .lineNum {
cursor: pointer;
text-decoration: underline;
}
.canComment .lineNum:hover {
background-color: #ccc;
}
.code {
white-space: pre;
}
.lightHighlight {
background-color: var(--light-highlight-color);
}
hl,
.darkHighlight {
background-color: var(--dark-highlight-color);
}
.br:after {
/* Line feed */
content: '\A';
}
.tab {
display: inline-block;
}
.tab.withIndicator:before {
color: #C62828;
/* >> character */
content: '\00BB';
}
.numbers .filler {
background-color: #eee;
}
.contextControl {
background-color: #fef;
}
.contextControl a:link,
.contextControl a:visited {
display: block;
text-decoration: none;
}
.numbers .contextControl {
padding: 0 .75em;
text-align: right;
}
.content .contextControl {
text-align: center;
}
</style>
<div class$="[[_computeContainerClass(canComment)]]">
<div class="numbers" id="numbers"></div>
<div class="content" id="content"></div>
</div>
</template>
<script>
(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,
},
},
listeners: {
'tap': '_tapHandler',
},
observers: [ '_prefsChanged(prefs.*)' ],
rowInserted: function(index) {
this.renderLineIndexRange(index, index);
this._updateDOMIndices();
},
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();
},
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);
},
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;
},
_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);
},
_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;
},
});
})();
</script>
</dom-module>