Showing Diff inline

This commit is contained in:
Tobias Hößl 2016-11-06 14:58:22 +01:00
parent fb646df1fd
commit 2958a401ad
8 changed files with 414 additions and 42 deletions

View File

@ -399,6 +399,14 @@ img {
.motion-text li {
margin-left: 30px;
}
.motion-text ins {
color: green;
text-decoration: underline;
}
.motion-text del {
color: red;
text-decoration: line-through;
}
.motion-text.line-numbers-outside {
padding-left: 40px;
position: relative;

View File

@ -394,7 +394,9 @@ angular.module('OpenSlidesApp.core.pdf', [])
"h4": ["font-size:24"],
"h5": ["font-size:22"],
"h6": ["font-size:20"],
"a": ["color:blue", "text-decoration:underline"]
"a": ["color:blue", "text-decoration:underline"],
"del": ["color:red", "text-decoration:line-through"],
"ins": ["color:green", "text-decoration:underline"]
},
classStyles = {
"delete": ["color:red", "text-decoration:line-through"],
@ -541,6 +543,8 @@ angular.module('OpenSlidesApp.core.pdf', [])
case "u":
case "em":
case "i":
case "ins":
case "del":
parseChildren(alreadyConverted, element, currentParagraph, styles.concat(elementStyles[nodeName]), diff_mode);
break;
case "table":

View File

@ -280,7 +280,7 @@ angular.module('OpenSlidesApp.motions', [
text = '';
for (var i = 0; i < changes.length; i++) {
text += this.getTextBetweenChangeRecommendations(versionId, (i === 0 ? null : changes[i - 1]), changes[i], highlight);
text += changes[i].format(this, versionId, highlight);
text += changes[i].getDiff(this, versionId);
}
text += this.getTextRemainderAfterLastChangeRecommendation(versionId, changes);
break;
@ -584,37 +584,16 @@ angular.module('OpenSlidesApp.motions', [
saveStatus: function() {
this.DSSave();
},
format: function(motion, version, highlight) {
getDiff: function(motion, version, highlight) {
var lineLength = Config.get('motions_line_length').value,
html = lineNumberingService.insertLineNumbers(motion.getVersion(version).text, lineLength);
var data = diffService.extractRangeByLineNumbers(html, this.line_from, this.line_to),
oldText = data.outerContextStart + data.innerContextStart +
data.html + data.innerContextEnd + data.outerContextEnd,
oldTextWithBreaks = lineNumberingService.insertLineNumbersNode(oldText, lineLength, highlight, this.line_from),
newTextWithBreaks = lineNumberingService.insertLineNumbersNode(this.text, lineLength, null, this.line_from);
data.html + data.innerContextEnd + data.outerContextEnd;
for (var i = 0; i < oldTextWithBreaks.childNodes.length; i++) {
diffService.addCSSClass(oldTextWithBreaks.childNodes[i], 'delete');
}
for (i = 0; i < newTextWithBreaks.childNodes.length; i++) {
diffService.addCSSClass(newTextWithBreaks.childNodes[i], 'insert');
}
var mergedFragment = document.createDocumentFragment(),
el;
while (oldTextWithBreaks.firstChild) {
el = oldTextWithBreaks.firstChild;
oldTextWithBreaks.removeChild(el);
mergedFragment.appendChild(el);
}
while (newTextWithBreaks.firstChild) {
el = newTextWithBreaks.firstChild;
newTextWithBreaks.removeChild(el);
mergedFragment.appendChild(el);
}
return diffService._serializeDom(mergedFragment);
var diff = diffService.diff(oldText, this.text, lineLength, this.line_from);
return lineNumberingService.insertLineNumbers(diff, lineLength, highlight, null, this.line_from);
},
getType: function(original_full_html) {
var lineLength = Config.get('motions_line_length').value,

View File

@ -524,22 +524,49 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return out;
};
/**
*
* @param {string} html
* @returns {string}
* @private
*/
this._normalizeHtmlForDiff = function (html) {
// Convert all HTML tags to uppercase, strip trailing whitespaces
html = html.replace(/<[^>]+>/g, function (tag) {
return tag.toUpperCase();
});
var entities = {
'&nbsp;': ' ',
'&ndash;': '-',
'&auml;': 'ä',
'&ouml;': 'ö',
'&uuml;': 'ü',
'&Auml;': 'Ä',
'&Ouml;': 'Ö',
'&Uuml;': 'Ü',
'&szlig;': 'ß'
};
html = html.replace(/\s+<\/P>/gi, '</P>').replace(/\s+<\/DIV>/gi, '</DIV>').replace(/\s+<\/LI>/gi, '</LI>');
html = html.replace(/\s+<LI>/gi, '<LI>').replace(/<\/LI>\s+/gi, '</LI>');
html = html.replace(/\u00A0/g, ' ');
html = html.replace(/\u2013/g, '-');
for (var ent in entities) {
html = html.replace(new RegExp(ent, 'g'), entities[ent]);
}
return html;
};
/**
* @param {string} htmlOld
* @param {string} htmlNew
* @returns {number}
*/
this.detectReplacementType = function (htmlOld, htmlNew) {
// Convert all HTML tags to uppercase, strip trailing whitespaces
var normalizeHtml = function(html) {
html = html.replace(/<[^>]+>/g, function(tag) { return tag.toUpperCase(); });
html = html.replace(/\s+<\/P>/gi, '</P>').replace(/\s+<\/DIV>/gi, '</DIV>').replace(/\s+<\/LI>/gi, '</LI>');
html = html.replace(/\s+<LI>/gi, '<LI>').replace(/<\/LI>\s+/gi, '</LI>');
html = html.replace(/&nbsp;/gi, ' ').replace(/\u00A0/g, ' '); // non-breaking spaces
return html;
};
htmlOld = normalizeHtml(htmlOld);
htmlNew = normalizeHtml(htmlNew);
htmlOld = this._normalizeHtmlForDiff(htmlOld);
htmlNew = this._normalizeHtmlForDiff(htmlNew);
if (htmlOld == htmlNew) {
return this.TYPE_REPLACEMENT;
@ -609,7 +636,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
if (classes.indexOf(className) == -1) {
classes.push(className);
}
node.setAttribute('class', classes);
node.setAttribute('class', classes.join(' '));
};
this.addDiffMarkup = function (originalHTML, newHTML, fromLine, toLine, diffFormatterCb) {
@ -651,6 +678,274 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return this._serializeDom(mergedFragment, true);
};
/**
* Adapted from http://ejohn.org/projects/javascript-diff-algorithm/
* by John Resig, MIT License
* @param {array} oldArr
* @param {array} newArr
* @returns {object}
*/
this._diff = function (oldArr, newArr) {
var ns = {},
os = {},
i;
for (i = 0; i < newArr.length; i++) {
if (ns[newArr[i]] === undefined)
ns[newArr[i]] = {rows: [], o: null};
ns[newArr[i]].rows.push(i);
}
for (i = 0; i < oldArr.length; i++) {
if (os[oldArr[i]] === undefined)
os[oldArr[i]] = {rows: [], n: null};
os[oldArr[i]].rows.push(i);
}
for (i in ns) {
if (ns[i].rows.length == 1 && typeof(os[i]) != "undefined" && os[i].rows.length == 1) {
newArr[ns[i].rows[0]] = {text: newArr[ns[i].rows[0]], row: os[i].rows[0]};
oldArr[os[i].rows[0]] = {text: oldArr[os[i].rows[0]], row: ns[i].rows[0]};
}
}
for (i = 0; i < newArr.length - 1; i++) {
if (newArr[i].text !== null && newArr[i + 1].text === undefined && newArr[i].row + 1 < oldArr.length &&
oldArr[newArr[i].row + 1].text === undefined && newArr[i + 1] == oldArr[newArr[i].row + 1]) {
newArr[i + 1] = {text: newArr[i + 1], row: newArr[i].row + 1};
oldArr[newArr[i].row + 1] = {text: oldArr[newArr[i].row + 1], row: i + 1};
}
}
for (i = newArr.length - 1; i > 0; i--) {
if (newArr[i].text !== null && newArr[i - 1].text === undefined && newArr[i].row > 0 &&
oldArr[newArr[i].row - 1].text === undefined && newArr[i - 1] == oldArr[newArr[i].row - 1]) {
newArr[i - 1] = {text: newArr[i - 1], row: newArr[i].row - 1};
oldArr[newArr[i].row - 1] = {text: oldArr[newArr[i].row - 1], row: i - 1};
}
}
return {o: oldArr, n: newArr};
};
this._tokenizeHtml = function (str) {
var splitArrayEntries = function (arr, by, prepend) {
var newArr = [];
for (var i = 0; i < arr.length; i++) {
if (arr[i][0] == '<' && (by == " " || by == "\n")) {
// Don't split HTML tags
newArr.push(arr[i]);
continue;
}
var parts = arr[i].split(by);
if (parts.length == 1) {
newArr.push(arr[i]);
} else {
var j;
if (prepend) {
if (parts[0] !== '') {
newArr.push(parts[0]);
}
for (j = 1; j < parts.length; j++) {
newArr.push(by + parts[j]);
}
} else {
for (j = 0; j < parts.length - 1; j++) {
newArr.push(parts[j] + by);
}
if (parts[parts.length - 1] !== '') {
newArr.push(parts[parts.length - 1]);
}
}
}
}
return newArr;
};
var arr = splitArrayEntries([str], '<', true);
arr = splitArrayEntries(arr, '>', false);
arr = splitArrayEntries(arr, " ", false);
arr = splitArrayEntries(arr, "\n", false);
return arr;
};
this._outputcharcode = function (pre, str) {
var arr = [];
for (var i = 0; i < str.length; i++) {
arr.push(str.charCodeAt(i));
}
console.log(str, pre, arr);
};
/**
* @param {string} oldStr
* @param {string} newStr
* @returns {string}
*/
this._diffString = function (oldStr, newStr) {
oldStr = this._normalizeHtmlForDiff(oldStr.replace(/\s+$/, '').replace(/^\s+/, ''));
newStr = this._normalizeHtmlForDiff(newStr.replace(/\s+$/, '').replace(/^\s+/, ''));
var out = this._diff(this._tokenizeHtml(oldStr), this._tokenizeHtml(newStr));
var str = "";
var i;
if (out.n.length === 0) {
for (i = 0; i < out.o.length; i++) {
//this._outputcharcode('del', out.o[i]);
str += '<del>' + out.o[i] + "</del>";
}
} else {
if (out.n[0].text === undefined) {
for (var k = 0; k < out.o.length && out.o[k].text === undefined; k++) {
//this._outputcharcode('del', out.o[k]);
str += '<del>' + out.o[k] + "</del>";
}
}
for (i = 0; i < out.n.length; i++) {
if (out.n[i].text === undefined) {
//this._outputcharcode('ins', out.n[i]);
str += '<ins>' + out.n[i] + "</ins>";
} else {
var pre = "";
for (var j = out.n[i].row + 1; j < out.o.length && out.o[j].text === undefined; j++) {
//this._outputcharcode('del', out.o[j]);
pre += '<del>' + out.o[j] + "</del>";
}
str += out.n[i].text + pre;
}
}
}
return str.replace(/^\s+/g, '').replace(/\s+$/g, '').replace(/ {2,}/g, ' ');
};
/**
*
* @param {string} html
* @returns {boolean}
* @private
*/
this._diffDetectBrokenDiffHtml = function(html) {
var match = html.match(/<(ins|del)><[^>]*><\/(ins|del)>/gi);
return (match !== null && match.length > 0);
};
this._diffParagraphs = function(oldText, newText, lineLength, firstLineNumber) {
var oldTextWithBreaks, newTextWithBreaks;
if (lineLength !== undefined) {
oldTextWithBreaks = lineNumberingService.insertLineNumbersNode(oldText, lineLength, null, firstLineNumber);
newTextWithBreaks = lineNumberingService.insertLineNumbersNode(newText, lineLength, null, firstLineNumber);
} else {
oldTextWithBreaks = document.createElement('div');
oldTextWithBreaks.innerHTML = oldText;
newTextWithBreaks = document.createElement('div');
newTextWithBreaks.innerHTML = newText;
}
for (var i = 0; i < oldTextWithBreaks.childNodes.length; i++) {
this.addCSSClass(oldTextWithBreaks.childNodes[i], 'delete');
}
for (i = 0; i < newTextWithBreaks.childNodes.length; i++) {
this.addCSSClass(newTextWithBreaks.childNodes[i], 'insert');
}
var mergedFragment = document.createDocumentFragment(),
el;
while (oldTextWithBreaks.firstChild) {
el = oldTextWithBreaks.firstChild;
oldTextWithBreaks.removeChild(el);
mergedFragment.appendChild(el);
}
while (newTextWithBreaks.firstChild) {
el = newTextWithBreaks.firstChild;
newTextWithBreaks.removeChild(el);
mergedFragment.appendChild(el);
}
return this._serializeDom(mergedFragment);
};
/**
* This function calculates the diff between two strings and tries to fix problems with the resulting HTML.
* If lineLength and firstLineNumber is given, line numbers will be returned es well
*
* @param {number} lineLength
* @param {number} firstLineNumber
* @param {string} htmlOld
* @param {string} htmlNew
* @returns {string}
*/
this.diff = function (htmlOld, htmlNew, lineLength, firstLineNumber) {
var cacheKey = lineLength + ' ' + firstLineNumber + ' ' +
lineNumberingService.djb2hash(htmlOld) + lineNumberingService.djb2hash(htmlNew),
cached = diffCache.get(cacheKey);
if (!angular.isUndefined(cached)) {
return cached;
}
var str = this._diffString(htmlOld, htmlNew),
diffUnnormalized = str.replace(/^\s+/g, '').replace(/\s+$/g, '').replace(/ {2,}/g, ' ')
.replace(/<\/ins><ins>/gi, '').replace(/<\/del><del>/gi, '');
diffUnnormalized = diffUnnormalized.replace(/<del>([a-z0-9,_-]* ?)<\/del><ins>([a-z0-9,_-]* ?)<\/ins>/gi, function (found, oldText, newText) {
var foundDiff = false, commonStart = '', commonEnd = '',
remainderOld = oldText, remainderNew = newText;
while (remainderOld.length > 0 && remainderNew.length > 0 && !foundDiff) {
if (remainderOld[0] == remainderNew[0]) {
commonStart += remainderOld[0];
remainderOld = remainderOld.substr(1);
remainderNew = remainderNew.substr(1);
} else {
foundDiff = true;
}
}
foundDiff = false;
while (remainderOld.length > 0 && remainderNew.length > 0 && !foundDiff) {
if (remainderOld[remainderOld.length - 1] == remainderNew[remainderNew.length - 1]) {
commonEnd = remainderOld[remainderOld.length - 1] + commonEnd;
remainderNew = remainderNew.substr(0, remainderNew.length - 1);
remainderOld = remainderOld.substr(0, remainderOld.length - 1);
} else {
foundDiff = true;
}
}
var out = commonStart;
if (remainderOld !== '') {
out += '<del>' + remainderOld + '</del>';
}
if (remainderNew !== '') {
out += '<ins>' + remainderNew + '</ins>';
}
out += commonEnd;
return out;
});
var diff;
if (this._diffDetectBrokenDiffHtml(diffUnnormalized)) {
diff = this._diffParagraphs(htmlOld, htmlNew, lineLength, firstLineNumber);
} else {
var node = document.createElement('div');
node.innerHTML = diffUnnormalized;
diff = node.innerHTML;
if (lineLength !== undefined && firstLineNumber !== undefined) {
node = lineNumberingService.insertLineNumbersNode(diff, lineLength, null, firstLineNumber);
diff = node.innerHTML;
}
}
diffCache.put(cacheKey, diff);
return diff;
};
}
]);

View File

@ -9,6 +9,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
*
* Only the following inline elements are supported:
* - 'SPAN', 'A', 'EM', 'S', 'B', 'I', 'STRONG', 'U', 'BIG', 'SMALL', 'SUB', 'SUP', 'TT'
* - 'INS' and 'DEL' are supported, but line numbering does not affect the content of 'INS'-elements
*
* Only other inline elements are allowed within inline elements.
* No constructs like <a...><div></div></a> are allowed. CSS-attributes like 'display: block' are ignored.
@ -37,11 +38,15 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
this._isInlineElement = function (node) {
var inlineElements = [
'SPAN', 'A', 'EM', 'S', 'B', 'I', 'STRONG', 'U', 'BIG', 'SMALL', 'SUB', 'SUP', 'TT'
'SPAN', 'A', 'EM', 'S', 'B', 'I', 'STRONG', 'U', 'BIG', 'SMALL', 'SUB', 'SUP', 'TT', 'INS', 'DEL'
];
return (inlineElements.indexOf(node.nodeName) > -1);
};
this._isIgnoredByLineNumbering = function (node) {
return (node.nodeName == 'INS');
};
this._isOsLineBreakNode = function (node) {
var isLineBreak = false;
if (node && node.nodeType === ELEMENT_NODE && node.nodeName == 'BR' && node.hasAttribute('class')) {
@ -304,7 +309,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
} else if (oldChildren[i].nodeType == ELEMENT_NODE) {
var firstword = this._lengthOfFirstInlineWord(oldChildren[i]),
overlength = ((this._currentInlineOffset + firstword) > length && this._currentInlineOffset > 0);
if (overlength && this._isInlineElement(oldChildren[i])) {
if (overlength && this._isInlineElement(oldChildren[i]) && !this._isIgnoredByLineNumbering(oldChildren[i])) {
this._currentInlineOffset = 0;
node.appendChild(this._createLineBreak());
node.appendChild(this._createLineNumber());
@ -327,7 +332,9 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
if (node.nodeType !== ELEMENT_NODE) {
throw 'This method may only be called for ELEMENT-nodes: ' + node.nodeValue;
}
if (this._isInlineElement(node)) {
if (this._isIgnoredByLineNumbering(node)) {
return node;
} else if (this._isInlineElement(node)) {
return this._insertLineNumbersToInlineNode(node, length, highlight);
} else {
var newLength = this._calcBlockNodeLength(node, length);
@ -357,6 +364,13 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
return root.innerHTML;
};
/**
*
* @param {string} html
* @param {number} lineLength
* @param {number} highlight - optional
* @param {number} firstLine
*/
this.insertLineNumbersNode = function (html, lineLength, highlight, firstLine) {
var root = document.createElement('div');
root.innerHTML = html;

View File

@ -36,7 +36,7 @@
</div>
<div class="motion-text motion-text-diff line-numbers-{{ lineNumberMode }}"
ng-bind-html="change.format(motion, version, highlight) | trusted"></div>
ng-bind-html="change.getDiff(motion, version, highlight) | trusted"></div>
</div>
</div>

View File

@ -287,4 +287,53 @@ describe('linenumbering', function () {
expect(calculatedType).toBe(diffService.TYPE_REPLACEMENT);
});
});
describe('the core diff algorithm', function() {
it('acts as documented by the official documentation', function () {
var before = "The red brown fox jumped over the rolling log.",
after = "The brown spotted fox leaped over the rolling log.";
var diff = diffService.diff(before, after);
expect(diff).toBe('The <del>red </del>brown <ins>spotted </ins>fox <del>jum</del><ins>lea</ins>ped over the rolling log.');
});
it('ignores changing cases in HTML tags', function () {
var before = "The <strong>brown</strong> spotted fox jumped over the rolling log.",
after = "The <STRONG>brown</STRONG> spotted fox leaped over the rolling log.";
var diff = diffService.diff(before, after);
expect(diff).toBe('The <strong>brown</strong> spotted fox <del>jum</del><ins>lea</ins>ped over the rolling log.');
});
it('merges multiple inserts and deletes', function () {
var before = "Test1 Test2 Test3 Test4 Test5 Test9",
after = "Test1 Test6 Test7 Test8 Test9";
var diff = diffService.diff(before, after);
expect(diff).toBe('Test1 <del>Test2 Test3 Test4 Test5 </del><ins>Test6 Test7 Test8 </ins>Test9');
});
it('detects insertions and deletions in a word (1)', function () {
var before = "Test1 Test2 Test3 Test4 Test5 Test6 Test7",
after = "Test1 Test Test3 Test4addon Test5 Test6 Test7";
var diff = diffService.diff(before, after);
expect(diff).toBe('Test1 Test<del>2</del> Test3 Test4<ins>addon</ins> Test5 Test6 Test7');
});
it('detects insertions and deletions in a word (2)', function () {
var before = "Test Test",
after = "Test Testappend";
var diff = diffService.diff(before, after);
expect(diff).toBe('Test Test<ins>append</ins>');
});
it('cannot handle changing CSS-classes', function () {
var before = "<p class='p1'>Test1 Test2</p>",
after = "<p class='p2'>Test1 Test2</p>";
var diff = diffService.diff(before, after);
expect(diff).toBe("<P class=\"p1 delete\">Test1 Test2</P><P class=\"p2 insert\">Test1 Test2</P>");
});
});
});

View File

@ -126,6 +126,13 @@ describe('linenumbering', function () {
expect(outHtml).toBe(noMarkup(1) + '<span>Lorem ' + brMarkup(2) + '<strong>ipsum ' + brMarkup(3) + 'dolor' + brMarkup(4) + 'sit</strong> ' + brMarkup(5) + 'amet</span>');
expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml);
});
it('counts within DEL nodes', function () {
var inHtml = "1234 <del>1234</del> 1234 1234";
var outHtml = lineNumberingService.insertLineNumbers(inHtml, 10);
expect(outHtml).toBe(noMarkup(1) + '1234 <del>1234</del> ' + brMarkup(2) + '1234 1234');
expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml);
});
});
@ -228,4 +235,20 @@ describe('linenumbering', function () {
expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml);
});
});
describe('line numbering in regard to the inline diff', function() {
it('does not count within INS nodes', function () {
var inHtml = "1234 <ins>1234</ins> 1234 1234";
var outHtml = lineNumberingService.insertLineNumbers(inHtml, 10);
expect(outHtml).toBe(noMarkup(1) + '1234 <ins>1234</ins> 1234 ' + brMarkup(2) + '1234');
expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml);
});
it('does not create a new line for a trailing INS', function () {
var inHtml = "<p>et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur<ins>dsfsdf23</ins></p>";
var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80);
expect(outHtml).toBe('<p>' + noMarkup(1) + 'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' + brMarkup(2) + 'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur<ins>dsfsdf23</ins></p>');
expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml);
});
});
});