Apply range comments and highlights

Utility methods for applying comment range highlights to diff, with
tests including some of the corner cases.

Feature: Issue 3910
Change-Id: Id7de2dd4ff027ce96479a2d596e9414a0cadd6bf
This commit is contained in:
Viktar Donich
2016-06-22 10:32:00 -07:00
parent 9ea8b684b7
commit d9b1f53a6a
3 changed files with 556 additions and 9 deletions

View File

@@ -14,6 +14,9 @@
(function() {
'use strict';
// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
Polymer({
is: 'gr-diff-highlight',
@@ -44,13 +47,6 @@
return this._cachedDiffBuilder;
},
get diffElement() {
if (!this._diffElement) {
this._diffElement = Polymer.dom(this).querySelector('#diffTable');
}
return this._diffElement;
},
detached: function() {
this.enabled = false;
},
@@ -76,5 +72,307 @@
this._removeActionBox();
}
},
_removeActionBox: function() {
var actionBox = this.$$('gr-selection-action-box');
if (actionBox) {
Polymer.dom(this.root).removeChild(actionBox);
}
},
/**
* Traverse diff content from right to left, call callback for each node.
* Stops if callback returns true.
*
* @param {!Node} startNode
* @param {function(Node):boolean} callback
* @param {Object=} 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') {
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) {
// Only measure Text nodes and <hl>
if (node instanceof Text || node.tagName == 'HL') {
length += this._getLength(node);
}
node = node.nextSibling;
}
return length;
} else {
// DOM API for textConten.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 = 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.
*/
_splitText: 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 Text Node and wrap it in hl with cssClass.
* Wraps trailing part after split, tailing one if opt_firstPart is true.
*
* @param {!Text} 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._splitText(node, offset);
// Node points to first part of the Text, second one is sibling.
} else {
node = this._splitText(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.
while (startNode &&
this._getLength(startNode) <= startOffset ||
this._getLength(startNode) === 0) {
startOffset -= this._getLength(startNode);
startNode = startNode.nextSibling;
}
// Split Text node.
if (startNode instanceof Text) {
startNode =
this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
startContent.insertBefore(startNode, startNode.nextSibling);
// Edge case: single line, text node wraps the highlight.
if (isOneLine && this._getLength(startNode) > length) {
var extra = this._splitText(startNode.firstChild, length);
startContent.insertBefore(extra, startNode.nextSibling);
startContent.normalize();
}
} else if (startNode.tagName == 'HL') {
if (!startNode.classList.contains(cssClass)) {
var hl = startNode;
startNode = this._splitAndWrapInHighlight(
startNode.firstChild, startOffset, cssClass);
startContent.insertBefore(startNode, hl.nextSibling);
// Edge case: single line, <hl> wraps the highlight.
if (isOneLine && this._getLength(startNode) > length) {
var trailingHl = hl.cloneNode(false);
trailingHl.appendChild(
this._splitText(startNode.firstChild, length));
startContent.insertBefore(trailingHl, startNode.nextSibling);
}
if (hl.textContent.length === 0) {
hl.remove();
}
}
} 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.
while (endNode &&
this._getLength(endNode) < endOffset ||
this._getLength(endNode) === 0) {
endOffset -= this._getLength(endNode);
endNode = endNode.nextSibling;
}
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.firstChild, endOffset, cssClass, true);
endContent.insertBefore(endNode, hl);
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) {
this._traverseContentSiblings(startNode.nextSibling, function(node) {
startNode.textContent += node.textContent;
node.remove();
return node == endNode;
});
}
if (!isOneLine && endNode) {
// Prepend text up to line start to the ending highlight.
this._traverseContentSiblings(endNode.previousSibling, function(node) {
endNode.textContent = node.textContent + endNode.textContent;
node.remove();
}, {left: true});
}
},
/**
* @param {string} cssClass
* @param {number} startLine Range start code line number.
* @param {number} startCol Range start column number.
* @param {number} endCol Range end column number.
* @param {number} endOffset Range end within end content.
* @param {string=} opt_side Side selector (right or left).
*/
_applyRangedHighlight: function(
cssClass, startLine, startCol, endLine, endCol, opt_side) {
var side = 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 lineEl = this.diffBuilder.getLineElByChild(content);
var line = lineEl.getAttribute('data-value');
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);
}
},
});
})();

View File

@@ -26,18 +26,72 @@ limitations under the License.
<test-fixture id="basic">
<template>
<gr-diff-highlight></gr-diff-highlight>
<gr-diff-highlight>
<table id="diffTable">
<tbody class="section both">
<tr class="diff-row side-by-side" left-type="both" right-type="both">
<td class="left lineNum" data-value="138"></td>
<td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
<td class="right lineNum" data-value="119"></td>
<td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
</tr>
</tbody>
<tbody class="section delta">
<tr class="diff-row side-by-side" left-type="remove" right-type="add">
<td class="left lineNum" data-value="140"></td>
<!-- Next tag is formatted to eliminate zero-length text nodes. -->
<td class="content remove lightHighlight">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a udiam, <hl>quid</hl> sit, quod <hl>Epicurum</hl><gr-diff-comment-thread>
[Yet another random diff thread content here]
</gr-diff-comment-thread></td>
<td class="right lineNum" data-value="121"></td>
<td class="content add lightHighlight">
nacti ,
<hl>,</hl>
sumus otiosum, audiam, sit, quod
</td>
</tr>
</tbody>
<tbody class="section both">
<tr class="diff-row side-by-side" left-type="both" right-type="both">
<td class="left lineNum" data-value="149"></td>
<td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
<td class="right lineNum" data-value="130"></td>
<td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
</tr>
</tbody>
</table>
</gr-diff-highlight>
</template>
</test-fixture>
<test-fixture id="highlighted">
<template>
<div>
<hl class="rangeHighlight">foo</hl>
bar
<hl class="rangeHighlight">baz</hl>
</div>
</template>
</test-fixture>
<script>
suite('gr-diff-highlight', function() {
var element;
var sandbox;
setup(function() {
sandbox = sinon.sandbox.create();
element = fixture('basic');
});
teardown(function() {
sandbox.restore();
});
test('handles down only when enabled ', function() {
sinon.stub(element, '_handleDown');
MockInteractions.down(element);
@@ -47,5 +101,196 @@ limitations under the License.
assert.isTrue(element._handleDown.called);
element._handleDown.restore();
});
test('apply multiline highlight', function() {
var diff = element.querySelector('#diffTable');
var startContent =
diff.querySelector('.left.lineNum[data-value="138"] ~ .content');
var endContent =
diff.querySelector('.left.lineNum[data-value="149"] ~ .content');
var betweenContent =
diff.querySelector('.left.lineNum[data-value="140"] ~ .content');
var commentThread =
diff.querySelector('gr-diff-comment-thread');
var builder = {
getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
getContentByLine: sandbox.stub().returns({}),
getContentsByLineRange: sandbox.stub().returns([betweenContent]),
getLineElByChild: sandbox.stub().returns(
{getAttribute: sandbox.stub()}),
};
element._cachedDiffBuilder = builder;
element.enabled = true;
builder.getContentByLine.withArgs(138, 'left').returns(
startContent);
builder.getContentByLine.withArgs(149, 'left').returns(
endContent);
element._applyRangedHighlight('some', 138, 4, 149, 8, 'left');
assert.instanceOf(startContent.childNodes[0], Text);
assert.equal(startContent.childNodes[0].textContent, '[14]');
assert.instanceOf(startContent.childNodes[1], Element);
assert.equal(startContent.childNodes[1].textContent,
' Nam cum ad me in Cumanum salutandi causa uterque venisset,');
assert.equal(startContent.childNodes[1].tagName, 'HL');
assert.equal(startContent.childNodes[1].className, 'some');
assert.instanceOf(endContent.childNodes[0], Element);
assert.equal(endContent.childNodes[0].textContent, 'nam et c');
assert.equal(endContent.childNodes[0].tagName, 'HL');
assert.equal(endContent.childNodes[0].className, 'some');
assert.instanceOf(endContent.childNodes[1], Text);
assert.equal(endContent.childNodes[1].textContent,
'omplectitur verbis, quod vult, et dicit plane, quod intellegam;');
assert.instanceOf(betweenContent.firstChild, Element);
assert.equal(betweenContent.firstChild.tagName, 'HL');
assert.equal(betweenContent.firstChild.className, 'some');
assert.equal(betweenContent.childNodes.length, 2);
assert.equal(betweenContent.firstChild.childNodes.length, 1);
assert.equal(betweenContent.firstChild.textContent,
'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
'quid sit, quod Epicurum');
assert.isNull(diff.querySelector('.right + .content .some'),
'Highlight should be applied only to the left side content.');
assert.strictEqual(betweenContent.querySelector('gr-diff-comment-thread'),
commentThread, 'Comment threads should be preserved.');
});
suite('single line ranges', function() {
var diff;
var content;
var commentThread;
var builder;
setup(function() {
diff = element.querySelector('#diffTable');
content =
diff.querySelector('.left.lineNum[data-value="140"] ~ .content');
commentThread = diff.querySelector('gr-diff-comment-thread');
builder = {
getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
getContentByLine: sandbox.stub().returns(content),
getContentsByLineRange: sandbox.stub().returns([]),
getLineElByChild: sandbox.stub().returns(
{getAttribute: sandbox.stub()}),
};
element._cachedDiffBuilder = builder;
element.enabled = true;
});
test('whole line range', function() {
element._applyRangedHighlight('some', 140, 0, 140, 81, 'left');
assert.instanceOf(content.firstChild, Element);
assert.equal(content.firstChild.tagName, 'HL');
assert.equal(content.firstChild.className, 'some');
assert.equal(content.childNodes.length, 2);
assert.equal(content.firstChild.childNodes.length, 1);
assert.equal(content.firstChild.textContent,
'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
'quid sit, quod Epicurum');
});
test('merging multiple other hls', function() {
element._applyRangedHighlight('some', 140, 1, 140, 80, 'left');
assert.instanceOf(content.firstChild, Text);
assert.equal(content.childNodes.length, 4);
var hl = content.querySelector('hl.some');
assert.strictEqual(content.firstChild, hl.previousSibling);
assert.equal(hl.childNodes.length, 1);
assert.equal(hl.textContent,
'a💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
'quid sit, quod Epicuru');
});
test('hl inside Text node', function() {
// Before: na💢ti
// After: n<hl class="some">a💢t</hl>i
element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
});
test('hl ending over different hl', function() {
// Before: na💢ti <hl>te, inquit</hl>,
// After: na💢<hl class="some">ti te</hl><hl class="foo">, inquit</hl>,
element._applyRangedHighlight('some', 140, 3, 140, 8, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">ti te</hl>');
assert.equal(hl.nextSibling.outerHTML,
'<hl class="foo">, inquit</hl>');
});
test('hl starting inside different hl', function() {
// Before: na💢ti <hl>te, inquit</hl>, sumus
// After: na💢ti <hl class="foo">te, in</hl><hl class="some">quit, ...
element._applyRangedHighlight('some', 140, 12, 140, 21, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">quit, sum</hl>');
assert.equal(
hl.previousSibling.outerHTML, '<hl class="foo">te, in</hl>');
});
test('hl inside different hl', function() {
// Before: na💢ti <hl class="foo">te, inquit</hl>, sumus
// After: <hl class="foo">t</hl><hl="some">e, i</hl><hl class="foo">n..
element._applyRangedHighlight('some', 140, 7, 140, 12, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">e, in</hl>');
assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">t</hl>');
assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">quit</hl>');
});
test('hl starts and ends in different hls', function() {
element._applyRangedHighlight('some', 140, 8, 140, 27, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">, inquit, sumus ali</hl>');
assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">te</hl>');
assert.equal(hl.nextSibling.outerHTML, '<hl class="bar">quando</hl>');
});
test('hl over different hl', function() {
element._applyRangedHighlight('some', 140, 2, 140, 21, 'left');
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">💢ti te, inquit, sum</hl>');
assert.notOk(content.querySelector('.foo'));
});
test('hl starting and ending in boundaries', function() {
element._applyRangedHighlight('some', 140, 6, 140, 33, 'left');
var hl = content.querySelector('hl.some');
assert.equal(
hl.outerHTML, '<hl class="some">te, inquit, sumus aliquando</hl>');
assert.notOk(content.querySelector('.foo'));
assert.notOk(content.querySelector('.bar'));
});
test('overlapping hls', function() {
element._applyRangedHighlight('some', 140, 1, 140, 3, 'left');
element._applyRangedHighlight('some', 140, 2, 140, 4, 'left');
assert.equal(content.querySelectorAll('hl.some').length, 1);
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
});
test('growing hl left including another hl', function() {
element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
element._applyRangedHighlight('some', 140, 3, 140, 10, 'left');
assert.equal(content.querySelectorAll('hl.some').length, 1);
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">a💢ti te, </hl>');
assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">inquit</hl>');
});
test('growing hl right to start of line', function() {
element._applyRangedHighlight('some', 140, 2, 140, 5, 'left');
element._applyRangedHighlight('some', 140, 0, 140, 3, 'left');
assert.equal(content.querySelectorAll('hl.some').length, 1);
var hl = content.querySelector('hl.some');
assert.equal(hl.outerHTML, '<hl class="some">na💢ti</hl>');
assert.strictEqual(content.firstChild, hl);
});
});
});
</script>