@matrixreq/htmldiff 1.6.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -8
- package/js/constants.d.ts +3 -0
- package/js/constants.js +6 -0
- package/js/core/atomicTags.d.ts +10 -0
- package/js/core/atomicTags.js +28 -0
- package/js/core/diff.d.ts +2 -0
- package/js/core/diff.js +15 -0
- package/js/core/matching.d.ts +27 -0
- package/js/core/matching.js +207 -0
- package/js/core/operations.d.ts +10 -0
- package/js/core/operations.js +76 -0
- package/js/core/rendering.d.ts +25 -0
- package/js/core/rendering.js +212 -0
- package/js/core/tokens.d.ts +11 -0
- package/js/core/tokens.js +256 -0
- package/js/htmldiff.d.ts +0 -15
- package/js/htmldiff.js +14 -1351
- package/js/tables/Cell.d.ts +24 -0
- package/js/tables/Cell.js +115 -0
- package/js/tables/ColumnAligner.d.ts +11 -0
- package/js/tables/ColumnAligner.js +83 -0
- package/js/tables/MergedCells.d.ts +11 -0
- package/js/tables/MergedCells.js +171 -0
- package/js/tables/Row.d.ts +22 -0
- package/js/tables/Row.js +49 -0
- package/js/tables/RowAligner.d.ts +12 -0
- package/js/tables/RowAligner.js +107 -0
- package/js/tables/SequenceAligner.d.ts +47 -0
- package/js/tables/SequenceAligner.js +125 -0
- package/js/tables/Table.d.ts +27 -0
- package/js/tables/Table.js +166 -0
- package/js/tables/TableAligner.d.ts +9 -0
- package/js/tables/TableAligner.js +69 -0
- package/js/tables/TableMerger.d.ts +16 -0
- package/js/tables/TableMerger.js +216 -0
- package/js/tables/TableRedlining.d.ts +10 -0
- package/js/tables/TableRedlining.js +65 -0
- package/js/tables/TableVersion.d.ts +32 -0
- package/js/tables/TableVersion.js +128 -0
- package/js/tables/constants.d.ts +6 -0
- package/js/tables/constants.js +8 -0
- package/js/tables/helpers.d.ts +4 -0
- package/js/tables/helpers.js +23 -0
- package/js/tables/html.d.ts +32 -0
- package/js/tables/html.js +114 -0
- package/js/tables/index.d.ts +2 -0
- package/js/tables/index.js +5 -0
- package/js/tables/similarity.d.ts +10 -0
- package/js/tables/similarity.js +107 -0
- package/package.json +21 -14
- package/test/calculate_operations.spec.js +0 -278
- package/test/config.js +0 -2
- package/test/diff.spec.js +0 -318
- package/test/edge_cases.spec.js +0 -41
- package/test/find_matching_blocks.spec.js +0 -168
- package/test/from_port_source.spec.js +0 -30
- package/test/html_to_tokens.spec.js +0 -249
- package/test/inner_diff.spec.js +0 -333
- package/test/mocha.opts +0 -3
- package/test/module.spec.js +0 -11
- package/test/pain_games.spec.js +0 -18
- package/test/render_operations.spec.js +0 -178
package/js/htmldiff.js
CHANGED
|
@@ -1,1351 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* 3. Render the list of operations by wrapping tokens with <ins> and <del> tags where
|
|
16
|
-
* appropriate with renderOperations.
|
|
17
|
-
*
|
|
18
|
-
* Example usage:
|
|
19
|
-
*
|
|
20
|
-
* var htmldiff = require('htmldiff.js');
|
|
21
|
-
*
|
|
22
|
-
* htmldiff('<p>this is some text</p>', '<p>this is some more text</p>')
|
|
23
|
-
* == '<p>this is some <ins>more </ins>text</p>'
|
|
24
|
-
*
|
|
25
|
-
* htmldiff('<p>this is some text</p>', '<p>this is some more text</p>', 'diff-class')
|
|
26
|
-
* == '<p>this is some <ins class="diff-class">more </ins>text</p>'
|
|
27
|
-
*/
|
|
28
|
-
(function(){
|
|
29
|
-
'use strict';
|
|
30
|
-
|
|
31
|
-
function isEndOfTag(char){
|
|
32
|
-
return char === '>';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isStartOfTag(char){
|
|
36
|
-
return char === '<';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function isWhitespace(char){
|
|
40
|
-
return /^\s+$/.test(char);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Determines if the given token is a tag.
|
|
45
|
-
*
|
|
46
|
-
* @param {string} token The token in question.
|
|
47
|
-
*
|
|
48
|
-
* @return {boolean|string} False if the token is not a tag, or the tag name otherwise.
|
|
49
|
-
*/
|
|
50
|
-
function isTag(token){
|
|
51
|
-
var match = token.match(/^\s*<([^!>][^>]*)>\s*$/);
|
|
52
|
-
return !!match && match[1].trim().split(' ')[0];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isntTag(token){
|
|
56
|
-
return !isTag(token);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isStartofHTMLComment(word){
|
|
60
|
-
return /^<!--/.test(word);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function isEndOfHTMLComment(word){
|
|
64
|
-
return /--\>$/.test(word);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Regular expression to check atomic tags.
|
|
69
|
-
* @see function diff.
|
|
70
|
-
*/
|
|
71
|
-
// Added head and style (for style tags inside the body)
|
|
72
|
-
// The tag name must be followed by a delimiter (not a \b word boundary): the tokenizer
|
|
73
|
-
// matches against partially read tags, and a word boundary would match at the end of an
|
|
74
|
-
// incomplete name, e.g. detecting '<abbr>' as the atomic tag 'a' while reading '<a'.
|
|
75
|
-
var defaultAtomicTagsRegExp = new RegExp('^<(iframe|object|math|svg|script|video|head|style|a)[\\s/>]');
|
|
76
|
-
var atomicTagsRegExp = defaultAtomicTagsRegExp;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Matches an element whose own opening tag carries data-htmldiff-id; captures tag name and
|
|
80
|
-
* attribute value. The skip before the attribute is quote aware, so it cannot run past '>'
|
|
81
|
-
* into a nested child. The leading \s prevents matching 'x-data-htmldiff-id'.
|
|
82
|
-
* @see isStartOfAtomicTag and createToken.
|
|
83
|
-
*/
|
|
84
|
-
const dataHtmlDiffIdRegExp =
|
|
85
|
-
/^<([a-z\-]+)(?:[^>"']|"[^"]*"|'[^']*')*\sdata-htmldiff-id=["']?((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>"']))*.)["']?/;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Opt-in marker for the recursive inner diff. When two matched atomic tokens (typically
|
|
89
|
-
* matched by data-htmldiff-id) have equal keys but different content, an element carrying
|
|
90
|
-
* this attribute gets its inner HTML diffed recursively instead of being rendered as is.
|
|
91
|
-
* The attribute must appear in the element's opening tag. Captures the attribute value;
|
|
92
|
-
* a bare attribute or any value other than "false" enables the opt-in.
|
|
93
|
-
* @see OPS.equal and renderInnerDiff.
|
|
94
|
-
*/
|
|
95
|
-
const dataHtmlDiffInnerDiffRegExp =
|
|
96
|
-
/^<[^>]*\sdata-htmldiff-inner-diff(?:\s*=\s*["']?([^"'\s/>]*)|(?=[\s/>]))/;
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Per-element override for the atomic tags used inside a recursive inner diff. The value
|
|
100
|
-
* is a comma separated tag name list, like the atomicTags parameter of the diff function;
|
|
101
|
-
* an empty value means no tag name is atomic. Without the attribute the default list
|
|
102
|
-
* without 'a' is used, so anchors have their text content diffed word by word while
|
|
103
|
-
* embedded content like svg stays atomic. The attribute is only consulted on elements
|
|
104
|
-
* that opted in via data-htmldiff-inner-diff.
|
|
105
|
-
*/
|
|
106
|
-
const dataHtmlDiffInnerDiffAtomicTagsRegExp =
|
|
107
|
-
/^<[^>]*\sdata-htmldiff-inner-diff-atomic-tags\s*=\s*["']([^"']*)["']/;
|
|
108
|
-
|
|
109
|
-
// Atomic tags used inside a recursive inner diff unless the element overrides them via
|
|
110
|
-
// data-htmldiff-inner-diff-atomic-tags: the default list without 'a'.
|
|
111
|
-
var defaultInnerDiffAtomicTagsRegExp =
|
|
112
|
-
new RegExp('^<(iframe|object|math|svg|script|video|head|style)[\\s/>]');
|
|
113
|
-
|
|
114
|
-
// Matches no tag at all: used when data-htmldiff-inner-diff-atomic-tags is empty.
|
|
115
|
-
const noAtomicTagsRegExp = /^<(?!)/;
|
|
116
|
-
|
|
117
|
-
// The number of currently active recursive inner diffs. Recursion is governed per
|
|
118
|
-
// element (each nesting level requires its own data-htmldiff-inner-diff attribute), so
|
|
119
|
-
// the depth is naturally bounded by the nesting of opted-in elements; the cap is only a
|
|
120
|
-
// backstop against pathologically deep documents.
|
|
121
|
-
var innerDiffDepth = 0;
|
|
122
|
-
const maxInnerDiffDepth = 10;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Builds the atomic tags regular expression from a comma separated tag name list.
|
|
126
|
-
*
|
|
127
|
-
* @param {string} atomicTags Comma separated list of tag names, e.g. 'head,script,style'.
|
|
128
|
-
*
|
|
129
|
-
* @return {RegExp} The regular expression matching the start of those tags.
|
|
130
|
-
*/
|
|
131
|
-
function buildAtomicTagsRegExp(atomicTags){
|
|
132
|
-
// Require a delimiter after the name (see defaultAtomicTagsRegExp on why not \b).
|
|
133
|
-
return new RegExp('^<(' + atomicTags.replace(/\s*/g, '').replace(/,/g, '|') + ')[\\s/>]');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Checks if the current word is the beginning of an atomic tag. An atomic tag is one whose
|
|
138
|
-
* child nodes should not be compared - the entire tag should be treated as one token. This
|
|
139
|
-
* is useful for tags where it does not make sense to insert <ins> and <del> tags.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} word The characters of the current token read so far.
|
|
142
|
-
*
|
|
143
|
-
* @return {string|null} The name of the atomic tag if the word will be an atomic tag,
|
|
144
|
-
* null otherwise
|
|
145
|
-
*/
|
|
146
|
-
function isStartOfAtomicTag(word){
|
|
147
|
-
var result = atomicTagsRegExp.exec(word) || dataHtmlDiffIdRegExp.exec(word);
|
|
148
|
-
return result && result[1];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Inspects the last tag in the given string its slice from the final '<'. A '>' before the slice's end
|
|
153
|
-
* means text follows e.g. "a > b" in a <script>, not a clean tag, so returns false.
|
|
154
|
-
* @return {boolean} True if word ends with an opening (non-self-closing) tag for the given
|
|
155
|
-
* tag name.
|
|
156
|
-
*/
|
|
157
|
-
function isOpeningTagOf(word, tag){
|
|
158
|
-
var tagText = word.substring(word.lastIndexOf('<'));
|
|
159
|
-
if (tagText.indexOf('>') !== tagText.length - 1) {
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
return new RegExp('^<' + tag + '(\\s|>)').test(tagText) && !/\/>$/.test(tagText);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Inspects the last tag in the given string its slice from the final '<'. A '>' before the slice's end
|
|
167
|
-
* means text follows e.g. "a > b" in a <script>, not a clean tag, so returns false.
|
|
168
|
-
* @return {boolean} True if word ends with a closing tag for the given tag name.
|
|
169
|
-
*/
|
|
170
|
-
function isClosingTagOf(word, tag){
|
|
171
|
-
var tagText = word.substring(word.lastIndexOf('<'));
|
|
172
|
-
if (tagText.indexOf('>') !== tagText.length - 1) {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
return new RegExp('^</' + tag + '(\\s|>)').test(tagText);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Checks if a tag is a void tag.
|
|
180
|
-
*
|
|
181
|
-
* @param {string} token The token to check.
|
|
182
|
-
*
|
|
183
|
-
* @return {boolean} True if the token is a void tag, false otherwise.
|
|
184
|
-
*/
|
|
185
|
-
function isVoidTag(token){
|
|
186
|
-
return /^\s*<[^>]+\/>\s*$/.test(token);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Checks if a tag name is an HTML void element. Void elements cannot have content and can skip a
|
|
191
|
-
* closing tag, so an atomic element with a void tag name ends with its opening tag -
|
|
192
|
-
* with or without the XML style '/>'.
|
|
193
|
-
*
|
|
194
|
-
* @param {string} tag The tag name to check.
|
|
195
|
-
*
|
|
196
|
-
* @return {boolean} True if the tag name is a void element.
|
|
197
|
-
*/
|
|
198
|
-
function isVoidTagName(tag){
|
|
199
|
-
return /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/
|
|
200
|
-
.test(tag);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Checks if a token can be wrapped inside a tag.
|
|
205
|
-
*
|
|
206
|
-
* @param {string} token The token to check.
|
|
207
|
-
*
|
|
208
|
-
* @return {boolean} True if the token can be wrapped inside a tag, false otherwise.
|
|
209
|
-
*/
|
|
210
|
-
function isWrappable(token){
|
|
211
|
-
var is_img = /^<img[\s>]/.test(token);
|
|
212
|
-
return is_img|| isntTag(token) || isStartOfAtomicTag(token) || isVoidTag(token);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Creates a token that holds a string and key representation. The key is used for diffing
|
|
217
|
-
* comparisons and the string is used to recompose the document after the diff is complete.
|
|
218
|
-
*
|
|
219
|
-
* @param {string} currentWord The section of the document to create a token for.
|
|
220
|
-
*
|
|
221
|
-
* @return {Object} A token object with a string and key property.
|
|
222
|
-
*/
|
|
223
|
-
function createToken(currentWord){
|
|
224
|
-
return {
|
|
225
|
-
string: currentWord,
|
|
226
|
-
key: getKeyForToken(currentWord)
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* A Match stores the information of a matching block. A matching block is a list of
|
|
232
|
-
* consecutive tokens that appear in both the before and after lists of tokens.
|
|
233
|
-
*
|
|
234
|
-
* @param {number} startInBefore The index of the first token in the list of before tokens.
|
|
235
|
-
* @param {number} startInAfter The index of the first token in the list of after tokens.
|
|
236
|
-
* @param {number} length The number of consecutive matching tokens in this block.
|
|
237
|
-
* @param {Segment} segment The segment where the match was found.
|
|
238
|
-
*/
|
|
239
|
-
function Match(startInBefore, startInAfter, length, segment){
|
|
240
|
-
this.segment = segment;
|
|
241
|
-
this.length = length;
|
|
242
|
-
|
|
243
|
-
this.startInBefore = startInBefore + segment.beforeIndex;
|
|
244
|
-
this.startInAfter = startInAfter + segment.afterIndex;
|
|
245
|
-
this.endInBefore = this.startInBefore + this.length - 1;
|
|
246
|
-
this.endInAfter = this.startInAfter + this.length - 1;
|
|
247
|
-
|
|
248
|
-
this.segmentStartInBefore = startInBefore;
|
|
249
|
-
this.segmentStartInAfter = startInAfter;
|
|
250
|
-
this.segmentEndInBefore = (this.segmentStartInBefore + this.length) - 1;
|
|
251
|
-
this.segmentEndInAfter = (this.segmentStartInAfter + this.length) - 1;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Tokenizes a string of HTML.
|
|
256
|
-
*
|
|
257
|
-
* @param {string} html The string to tokenize.
|
|
258
|
-
*
|
|
259
|
-
* @return {Array.<string>} The list of tokens.
|
|
260
|
-
*/
|
|
261
|
-
function htmlToTokens(html){
|
|
262
|
-
var mode = 'char';
|
|
263
|
-
var currentWord = '';
|
|
264
|
-
var currentAtomicTag = '';
|
|
265
|
-
var currentAtomicTagDepth = 0;
|
|
266
|
-
// The quote character of the attribute value currently being read, or null. Quoted
|
|
267
|
-
// attribute values may contain any character including '>', so no tag boundary or
|
|
268
|
-
// atomic tag detection applies inside them.
|
|
269
|
-
var currentQuote = null;
|
|
270
|
-
// True while reading the atomic tag's own opening tag. Quote tracking in atomic
|
|
271
|
-
// mode is limited to that region: quotes in the element's content (text
|
|
272
|
-
// apostrophes, comments, nested tags) must not affect how the token ends.
|
|
273
|
-
var inAtomicOpeningTag = false;
|
|
274
|
-
var words = [];
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Consumes a character belonging to a quoted attribute value, updating the quote
|
|
278
|
-
* state. Quoted values may contain any character including '>', so as long as a
|
|
279
|
-
* quote is open no tag boundary or atomic tag detection applies.
|
|
280
|
-
*
|
|
281
|
-
* @param {string} char The current character.
|
|
282
|
-
* @param {boolean} canOpenQuote Whether a quote may start at this position.
|
|
283
|
-
*
|
|
284
|
-
* @return {boolean} True if the character was consumed and no other handling
|
|
285
|
-
* applies to it.
|
|
286
|
-
*/
|
|
287
|
-
function consumeAttributeQuote(char, canOpenQuote){
|
|
288
|
-
if (currentQuote){
|
|
289
|
-
if (char === currentQuote){
|
|
290
|
-
currentQuote = null;
|
|
291
|
-
}
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
if (canOpenQuote && (char === '"' || char === "'")){
|
|
295
|
-
currentQuote = char;
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
return false;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
for (var i = 0; i < html.length; i++){
|
|
302
|
-
var char = html[i];
|
|
303
|
-
switch (mode){
|
|
304
|
-
case 'tag':
|
|
305
|
-
// Quote handling must come before the atomic tag detection:
|
|
306
|
-
// dataHtmlDiffIdRegExp can match right at the opening quote of the
|
|
307
|
-
// attribute value, which would enter atomic mode with the quote
|
|
308
|
-
// tracking out of sync.
|
|
309
|
-
if (consumeAttributeQuote(char, true)){
|
|
310
|
-
currentWord += char;
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
313
|
-
// The atomic tag regexps require a delimiter after the tag name, so the
|
|
314
|
-
// current character must be included in the check: without it a tag
|
|
315
|
-
// ending right at the name (e.g. '<script' + '>') would never match.
|
|
316
|
-
var atomicTag = isStartOfAtomicTag(currentWord + char);
|
|
317
|
-
if (atomicTag && isEndOfTag(char) &&
|
|
318
|
-
(isVoidTagName(atomicTag) || /\/$/.test(currentWord))){
|
|
319
|
-
// The atomic tag itself is void or self-closing: it has no content
|
|
320
|
-
// that could be swallowed, emit it as a complete token.
|
|
321
|
-
currentWord += '>';
|
|
322
|
-
words.push(createToken(currentWord));
|
|
323
|
-
currentWord = '';
|
|
324
|
-
mode = 'char';
|
|
325
|
-
} else if (atomicTag){
|
|
326
|
-
mode = 'atomic_tag';
|
|
327
|
-
currentAtomicTag = atomicTag;
|
|
328
|
-
// we are still inside the atomic tag's own opening tag unless this
|
|
329
|
-
// character just ended it
|
|
330
|
-
inAtomicOpeningTag = !isEndOfTag(char);
|
|
331
|
-
// skip standalone tags like <script> and <style>
|
|
332
|
-
currentAtomicTagDepth = isEndOfTag(char) ? 1 : 0;
|
|
333
|
-
currentWord += char;
|
|
334
|
-
} else if (isStartofHTMLComment(currentWord)){
|
|
335
|
-
mode = 'html_comment';
|
|
336
|
-
currentWord += char;
|
|
337
|
-
} else if (isEndOfTag(char)){
|
|
338
|
-
currentWord += '>';
|
|
339
|
-
words.push(createToken(currentWord));
|
|
340
|
-
currentWord = '';
|
|
341
|
-
if (isWhitespace(char)){
|
|
342
|
-
mode = 'whitespace';
|
|
343
|
-
} else {
|
|
344
|
-
mode = 'char';
|
|
345
|
-
}
|
|
346
|
-
} else {
|
|
347
|
-
currentWord += char;
|
|
348
|
-
}
|
|
349
|
-
break;
|
|
350
|
-
case 'atomic_tag':
|
|
351
|
-
currentWord += char;
|
|
352
|
-
// Quotes may only open inside the atomic tag's own opening tag, where
|
|
353
|
-
// attribute values may contain '>' or '/>' and must not affect the
|
|
354
|
-
// depth tracking below. Quote characters in the element's content
|
|
355
|
-
// (text apostrophes, comments) are not attribute quotes.
|
|
356
|
-
if (consumeAttributeQuote(char, inAtomicOpeningTag)){
|
|
357
|
-
break;
|
|
358
|
-
}
|
|
359
|
-
// track the same name nested tags depth;
|
|
360
|
-
// end the atomic token only when it returns to 0.
|
|
361
|
-
if (isEndOfTag(char)){
|
|
362
|
-
inAtomicOpeningTag = false;
|
|
363
|
-
if (isClosingTagOf(currentWord, currentAtomicTag)){
|
|
364
|
-
currentAtomicTagDepth--;
|
|
365
|
-
if (currentAtomicTagDepth <= 0){
|
|
366
|
-
words.push(createToken(currentWord));
|
|
367
|
-
currentWord = '';
|
|
368
|
-
currentAtomicTag = '';
|
|
369
|
-
currentAtomicTagDepth = 0;
|
|
370
|
-
mode = 'char';
|
|
371
|
-
}
|
|
372
|
-
} else if (currentAtomicTagDepth === 0 &&
|
|
373
|
-
(isVoidTagName(currentAtomicTag) || /\/>$/.test(currentWord))){
|
|
374
|
-
// At depth 0 this '>' can only end the atomic tag's own opening
|
|
375
|
-
// tag. When the element is void or self-closing it has no
|
|
376
|
-
// content: end the token so trailing content tokenizes normally.
|
|
377
|
-
words.push(createToken(currentWord));
|
|
378
|
-
currentWord = '';
|
|
379
|
-
currentAtomicTag = '';
|
|
380
|
-
mode = 'char';
|
|
381
|
-
} else if (isOpeningTagOf(currentWord, currentAtomicTag)){
|
|
382
|
-
currentAtomicTagDepth++;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
break;
|
|
386
|
-
case 'html_comment':
|
|
387
|
-
currentWord += char;
|
|
388
|
-
if (isEndOfHTMLComment(currentWord)){
|
|
389
|
-
currentWord = '';
|
|
390
|
-
mode = 'char';
|
|
391
|
-
}
|
|
392
|
-
break;
|
|
393
|
-
case 'char':
|
|
394
|
-
if (isStartOfTag(char)){
|
|
395
|
-
if (currentWord){
|
|
396
|
-
words.push(createToken(currentWord));
|
|
397
|
-
}
|
|
398
|
-
currentWord = '<';
|
|
399
|
-
mode = 'tag';
|
|
400
|
-
} else if (/\s/.test(char)){
|
|
401
|
-
if (currentWord){
|
|
402
|
-
words.push(createToken(currentWord));
|
|
403
|
-
}
|
|
404
|
-
currentWord = char;
|
|
405
|
-
mode = 'whitespace';
|
|
406
|
-
} else if (/[\w\d\#@]/.test(char)){
|
|
407
|
-
currentWord += char;
|
|
408
|
-
} else if (/&/.test(char)){
|
|
409
|
-
if (currentWord){
|
|
410
|
-
words.push(createToken(currentWord));
|
|
411
|
-
}
|
|
412
|
-
currentWord = char;
|
|
413
|
-
} else {
|
|
414
|
-
currentWord += char;
|
|
415
|
-
words.push(createToken(currentWord));
|
|
416
|
-
currentWord = '';
|
|
417
|
-
}
|
|
418
|
-
break;
|
|
419
|
-
case 'whitespace':
|
|
420
|
-
if (isStartOfTag(char)){
|
|
421
|
-
if (currentWord){
|
|
422
|
-
words.push(createToken(currentWord));
|
|
423
|
-
}
|
|
424
|
-
currentWord = '<';
|
|
425
|
-
mode = 'tag';
|
|
426
|
-
} else if (isWhitespace(char)){
|
|
427
|
-
currentWord += char;
|
|
428
|
-
} else {
|
|
429
|
-
if (currentWord){
|
|
430
|
-
words.push(createToken(currentWord));
|
|
431
|
-
}
|
|
432
|
-
currentWord = char;
|
|
433
|
-
mode = 'char';
|
|
434
|
-
}
|
|
435
|
-
break;
|
|
436
|
-
default:
|
|
437
|
-
throw new Error('Unknown mode ' + mode);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (currentWord){
|
|
441
|
-
words.push(createToken(currentWord));
|
|
442
|
-
}
|
|
443
|
-
return words;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Creates a key that should be used to match tokens. This is useful, for example, if we want
|
|
448
|
-
* to consider two open tag tokens as equal, even if they don't have the same attributes. We
|
|
449
|
-
* use a key instead of overwriting the token because we may want to render the original string
|
|
450
|
-
* without losing the attributes.
|
|
451
|
-
*
|
|
452
|
-
* @param {string} token The token to create the key for.
|
|
453
|
-
*
|
|
454
|
-
* @return {string} The identifying key that should be used to match before and after tokens.
|
|
455
|
-
*/
|
|
456
|
-
function getKeyForToken(token){
|
|
457
|
-
// If the token is an image element, grab it's src attribute to include in the key.
|
|
458
|
-
var img = /^<img.*src=['"]([^"']*)['"].*>$/.exec(token);
|
|
459
|
-
if (img) {
|
|
460
|
-
return '<img src="' + img[1] + '">';
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// If the token is an a element, grab it's data attribute to include in the key.
|
|
464
|
-
// Only when <a> is atomic: if it has been excluded from the atomic tags (as done in
|
|
465
|
-
// recursive inner diffs), the token is just the opening tag.
|
|
466
|
-
var a = /^<a.*href=['"]([^"']*)['"]/.exec(token);
|
|
467
|
-
if (a && atomicTagsRegExp.test('<a ')) {
|
|
468
|
-
return '<a href="' + a[1] + '"></a>';
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// If the token is an object element, grab it's data attribute to include in the key.
|
|
472
|
-
var object = /^<object.*data=['"]([^"']*)['"]/.exec(token);
|
|
473
|
-
if (object) {
|
|
474
|
-
return '<object src="' + object[1] + '"></object>';
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// If it's a video, math or svg element, the entire token should be compared except the
|
|
478
|
-
// data-uuid.
|
|
479
|
-
if(/^<(svg|math|video)[\s>]/.test(token)) {
|
|
480
|
-
var uuid = token.indexOf('data-uuid="');
|
|
481
|
-
if (uuid !== -1) {
|
|
482
|
-
var start = token.slice(0, uuid);
|
|
483
|
-
var end = token.slice(uuid + 44);
|
|
484
|
-
return start + end;
|
|
485
|
-
} else {
|
|
486
|
-
return token;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// If the token is an iframe element, grab it's src attribute to include in it's key.
|
|
491
|
-
var iframe = /^<iframe.*src=['"]([^"']*)['"].*>/.exec(token);
|
|
492
|
-
if (iframe) {
|
|
493
|
-
return '<iframe src="' + iframe[1] + '"></iframe>';
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// if the token has data-htmldiff-uuid use it as a key
|
|
497
|
-
const uuidTag = dataHtmlDiffIdRegExp.exec(token);
|
|
498
|
-
if (uuidTag) {
|
|
499
|
-
return uuidTag[2];
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// If the token is any other element, just grab the tag name.
|
|
503
|
-
var tagName = /<([^\s>]+)[\s>]/.exec(token);
|
|
504
|
-
if (tagName){
|
|
505
|
-
return '<' + (tagName[1].toLowerCase()) + '>';
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Otherwise, the token is text, collapse the whitespace
|
|
509
|
-
// (except new lines, see https://matrixreq.atlassian.net/browse/MATRIX-7880)
|
|
510
|
-
// potentially, this also causing the problems with prettified HTML (with "\n" between the tags),
|
|
511
|
-
// so it's required to "flatten" html before passing it to the diffing function
|
|
512
|
-
if (token) {
|
|
513
|
-
return token.replace(/([^\S\r\n]+| | )/g, ' ');
|
|
514
|
-
}
|
|
515
|
-
return token;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Creates a map from token key to an array of indices of locations of the matching token in
|
|
520
|
-
* the list of all tokens.
|
|
521
|
-
*
|
|
522
|
-
* @param {Array.<string>} tokens The list of tokens to be mapped.
|
|
523
|
-
*
|
|
524
|
-
* @return {Object} A mapping that can be used to search for tokens.
|
|
525
|
-
*/
|
|
526
|
-
function createMap(tokens){
|
|
527
|
-
return tokens.reduce(function(map, token, index){
|
|
528
|
-
if (map[token.key]){
|
|
529
|
-
map[token.key].push(index);
|
|
530
|
-
} else {
|
|
531
|
-
map[token.key] = [index];
|
|
532
|
-
}
|
|
533
|
-
return map;
|
|
534
|
-
}, Object.create(null));
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Compares two match objects to determine if the second match object comes before or after the
|
|
539
|
-
* first match object. Returns -1 if the m2 should come before m1. Returns 1 if m1 should come
|
|
540
|
-
* before m2. If the two matches criss-cross each other, a null is returned.
|
|
541
|
-
*
|
|
542
|
-
* @param {Match} m1 The first match object to compare.
|
|
543
|
-
* @param {Match} m2 The second match object to compare.
|
|
544
|
-
*
|
|
545
|
-
* @return {number} Returns -1 if the m2 should come before m1. Returns 1 if m1 should come
|
|
546
|
-
* before m2. If the two matches criss-cross each other, 0 is returned.
|
|
547
|
-
*/
|
|
548
|
-
function compareMatches(m1, m2){
|
|
549
|
-
if (m2.endInBefore < m1.startInBefore && m2.endInAfter < m1.startInAfter){
|
|
550
|
-
return -1;
|
|
551
|
-
} else if (m2.startInBefore > m1.endInBefore && m2.startInAfter > m1.endInAfter){
|
|
552
|
-
return 1;
|
|
553
|
-
} else {
|
|
554
|
-
return 0;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* A constructor for a binary search tree used to keep match objects in the proper order as
|
|
560
|
-
* they're found.
|
|
561
|
-
*
|
|
562
|
-
* @constructor
|
|
563
|
-
*/
|
|
564
|
-
function MatchBinarySearchTree(){
|
|
565
|
-
this._root = null;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
MatchBinarySearchTree.prototype = {
|
|
569
|
-
/**
|
|
570
|
-
* Adds matches to the binary search tree.
|
|
571
|
-
*
|
|
572
|
-
* @param {Match} value The match to add to the binary search tree.
|
|
573
|
-
*/
|
|
574
|
-
add: function (value){
|
|
575
|
-
// Create the node to hold the match value.
|
|
576
|
-
var node = {
|
|
577
|
-
value: value,
|
|
578
|
-
left: null,
|
|
579
|
-
right: null
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
var current = this._root;
|
|
583
|
-
if(current){
|
|
584
|
-
while (true){
|
|
585
|
-
// Determine if the match value should go to the left or right of the current
|
|
586
|
-
// node.
|
|
587
|
-
var position = compareMatches(current.value, value);
|
|
588
|
-
if (position === -1){
|
|
589
|
-
// The position of the match is to the left of this node.
|
|
590
|
-
if (current.left){
|
|
591
|
-
current = current.left;
|
|
592
|
-
} else {
|
|
593
|
-
current.left = node;
|
|
594
|
-
break;
|
|
595
|
-
}
|
|
596
|
-
} else if (position === 1){
|
|
597
|
-
// The position of the match is to the right of this node.
|
|
598
|
-
if (current.right){
|
|
599
|
-
current = current.right;
|
|
600
|
-
} else {
|
|
601
|
-
current.right = node;
|
|
602
|
-
break;
|
|
603
|
-
}
|
|
604
|
-
} else {
|
|
605
|
-
// If 0 was returned from compareMatches, that means the node cannot
|
|
606
|
-
// be inserted because it overlaps an existing node.
|
|
607
|
-
break;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
} else {
|
|
611
|
-
// If no nodes exist in the tree, make this the root node.
|
|
612
|
-
this._root = node;
|
|
613
|
-
}
|
|
614
|
-
},
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Converts the binary search tree into an array using an in-order traversal.
|
|
618
|
-
*
|
|
619
|
-
* @return {Array.<Match>} An array containing the matches in the binary search tree.
|
|
620
|
-
*/
|
|
621
|
-
toArray: function(){
|
|
622
|
-
function inOrder(node, nodes){
|
|
623
|
-
if (node){
|
|
624
|
-
inOrder(node.left, nodes);
|
|
625
|
-
nodes.push(node.value);
|
|
626
|
-
inOrder(node.right, nodes);
|
|
627
|
-
}
|
|
628
|
-
return nodes;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return inOrder(this._root, []);
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* Finds and returns the best match between the before and after arrays contained in the segment
|
|
638
|
-
* provided.
|
|
639
|
-
*
|
|
640
|
-
* @param {Segment} segment The segment in which to look for a match.
|
|
641
|
-
*
|
|
642
|
-
* @return {Match} The best match.
|
|
643
|
-
*/
|
|
644
|
-
function findBestMatch(segment){
|
|
645
|
-
var beforeTokens = segment.beforeTokens;
|
|
646
|
-
var afterMap = segment.afterMap;
|
|
647
|
-
var lastSpace = null;
|
|
648
|
-
var bestMatch = null;
|
|
649
|
-
|
|
650
|
-
// Iterate through the entirety of the beforeTokens to find the best match.
|
|
651
|
-
for (var beforeIndex = 0; beforeIndex < beforeTokens.length; beforeIndex++){
|
|
652
|
-
var lookBehind = false;
|
|
653
|
-
|
|
654
|
-
// If the current best match is longer than the remaining tokens, we can bail because we
|
|
655
|
-
// won't find a better match.
|
|
656
|
-
var remainingTokens = beforeTokens.length - beforeIndex;
|
|
657
|
-
if (bestMatch && remainingTokens < bestMatch.length){
|
|
658
|
-
break;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// If the current token is whitespace, make a note of it and move on. Trying to start a
|
|
662
|
-
// set of matches with whitespace is not efficient because it's too prevelant in most
|
|
663
|
-
// documents. Instead, if the next token yields a match, we'll see if the whitespace can
|
|
664
|
-
// be included in that match.
|
|
665
|
-
var beforeToken = beforeTokens[beforeIndex];
|
|
666
|
-
if (beforeToken.key === ' '){
|
|
667
|
-
lastSpace = beforeIndex;
|
|
668
|
-
continue;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Check to see if we just skipped a space, if so, we'll ask getFullMatch to look behind
|
|
672
|
-
// by one token to see if it can include the whitespace.
|
|
673
|
-
if (lastSpace === beforeIndex - 1){
|
|
674
|
-
lookBehind = true;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// If the current token is not found in the afterTokens, it won't match and we can move
|
|
678
|
-
// on.
|
|
679
|
-
var afterTokenLocations = afterMap[beforeToken.key];
|
|
680
|
-
if(!afterTokenLocations){
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// For each instance of the current token in afterTokens, let's see how big of a match
|
|
685
|
-
// we can build.
|
|
686
|
-
afterTokenLocations.forEach(function(afterIndex){
|
|
687
|
-
// getFullMatch will see how far the current token match will go in both
|
|
688
|
-
// beforeTokens and afterTokens.
|
|
689
|
-
var bestMatchLength = bestMatch ? bestMatch.length : 0;
|
|
690
|
-
var match = getFullMatch(
|
|
691
|
-
segment, beforeIndex, afterIndex, bestMatchLength, lookBehind);
|
|
692
|
-
|
|
693
|
-
// If we got a new best match, we'll save it aside.
|
|
694
|
-
if (match && match.length > bestMatchLength){
|
|
695
|
-
bestMatch = match;
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
return bestMatch;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* Takes the start of a match, and expands it in the beforeTokens and afterTokens of the
|
|
705
|
-
* current segment as far as it can go.
|
|
706
|
-
*
|
|
707
|
-
* @param {Segment} segment The segment object to search within when expanding the match.
|
|
708
|
-
* @param {number} beforeStart The offset within beforeTokens to start looking.
|
|
709
|
-
* @param {number} afterStart The offset within afterTokens to start looking.
|
|
710
|
-
* @param {number} minLength The minimum length match that must be found.
|
|
711
|
-
* @param {boolean} lookBehind If true, attempt to match a whitespace token just before the
|
|
712
|
-
* beforeStart and afterStart tokens.
|
|
713
|
-
*
|
|
714
|
-
* @return {Match} The full match.
|
|
715
|
-
*/
|
|
716
|
-
function getFullMatch(segment, beforeStart, afterStart, minLength, lookBehind){
|
|
717
|
-
var beforeTokens = segment.beforeTokens;
|
|
718
|
-
var afterTokens = segment.afterTokens;
|
|
719
|
-
|
|
720
|
-
// If we already have a match that goes to the end of the document, no need to keep looking.
|
|
721
|
-
var minBeforeIndex = beforeStart + minLength;
|
|
722
|
-
var minAfterIndex = afterStart + minLength;
|
|
723
|
-
if(minBeforeIndex >= beforeTokens.length || minAfterIndex >= afterTokens.length){
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// If a minLength was provided, we can do a quick check to see if the tokens after that
|
|
728
|
-
// length match. If not, we won't be beating the previous best match, and we can bail out
|
|
729
|
-
// early.
|
|
730
|
-
if (minLength){
|
|
731
|
-
var nextBeforeWord = beforeTokens[minBeforeIndex].key;
|
|
732
|
-
var nextAfterWord = afterTokens[minAfterIndex].key;
|
|
733
|
-
if (nextBeforeWord !== nextAfterWord){
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Extend the current match as far foward as it can go, without overflowing beforeTokens or
|
|
739
|
-
// afterTokens.
|
|
740
|
-
var searching = true;
|
|
741
|
-
var currentLength = 1;
|
|
742
|
-
var beforeIndex = beforeStart + currentLength;
|
|
743
|
-
var afterIndex = afterStart + currentLength;
|
|
744
|
-
|
|
745
|
-
while (searching && beforeIndex < beforeTokens.length && afterIndex < afterTokens.length){
|
|
746
|
-
var beforeWord = beforeTokens[beforeIndex].key;
|
|
747
|
-
var afterWord = afterTokens[afterIndex].key;
|
|
748
|
-
if (beforeWord === afterWord){
|
|
749
|
-
currentLength++;
|
|
750
|
-
beforeIndex = beforeStart + currentLength;
|
|
751
|
-
afterIndex = afterStart + currentLength;
|
|
752
|
-
} else {
|
|
753
|
-
searching = false;
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// If we've been asked to look behind, it's because both beforeTokens and afterTokens may
|
|
758
|
-
// have a whitespace token just behind the current match that was previously ignored. If so,
|
|
759
|
-
// we'll expand the current match to include it.
|
|
760
|
-
if (lookBehind && beforeStart > 0 && afterStart > 0){
|
|
761
|
-
var prevBeforeKey = beforeTokens[beforeStart - 1].key;
|
|
762
|
-
var prevAfterKey = afterTokens[afterStart - 1].key;
|
|
763
|
-
if (prevBeforeKey === ' ' && prevAfterKey === ' '){
|
|
764
|
-
beforeStart--;
|
|
765
|
-
afterStart--;
|
|
766
|
-
currentLength++;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
return new Match(beforeStart, afterStart, currentLength, segment);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Creates segment objects from the original document that can be used to restrict the area that
|
|
775
|
-
* findBestMatch and it's helper functions search to increase performance.
|
|
776
|
-
*
|
|
777
|
-
* @param {Array.<Token>} beforeTokens Tokens from the before document.
|
|
778
|
-
* @param {Array.<Token>} afterTokens Tokens from the after document.
|
|
779
|
-
* @param {number} beforeIndex The index within the before document where this segment begins.
|
|
780
|
-
* @param {number} afterIndex The index within the after document where this segment behinds.
|
|
781
|
-
*
|
|
782
|
-
* @return {Segment} The segment object.
|
|
783
|
-
*/
|
|
784
|
-
function createSegment(beforeTokens, afterTokens, beforeIndex, afterIndex){
|
|
785
|
-
return {
|
|
786
|
-
beforeTokens: beforeTokens,
|
|
787
|
-
afterTokens: afterTokens,
|
|
788
|
-
beforeMap: createMap(beforeTokens),
|
|
789
|
-
afterMap: createMap(afterTokens),
|
|
790
|
-
beforeIndex: beforeIndex,
|
|
791
|
-
afterIndex: afterIndex
|
|
792
|
-
};
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Finds all the matching blocks within the given segment in the before and after lists of
|
|
797
|
-
* tokens.
|
|
798
|
-
*
|
|
799
|
-
* @param {Segment} The segment that should be searched for matching blocks.
|
|
800
|
-
*
|
|
801
|
-
* @return {Array.<Match>} The list of matching blocks in this range.
|
|
802
|
-
*/
|
|
803
|
-
function findMatchingBlocks(segment){
|
|
804
|
-
// Create a binary search tree to hold the matches we find in order.
|
|
805
|
-
var matches = new MatchBinarySearchTree();
|
|
806
|
-
var match;
|
|
807
|
-
var segments = [segment];
|
|
808
|
-
|
|
809
|
-
// Each time the best match is found in a segment, zero, one or two new segments may be
|
|
810
|
-
// created from the parts of the original segment not included in the match. We will
|
|
811
|
-
// continue to iterate until all segments have been processed.
|
|
812
|
-
while(segments.length){
|
|
813
|
-
segment = segments.pop();
|
|
814
|
-
match = findBestMatch(segment);
|
|
815
|
-
|
|
816
|
-
if (match && match.length){
|
|
817
|
-
// If there's an unmatched area at the start of the segment, create a new segment
|
|
818
|
-
// from that area and throw it into the segments array to get processed.
|
|
819
|
-
if (match.segmentStartInBefore > 0 && match.segmentStartInAfter > 0){
|
|
820
|
-
var leftBeforeTokens = segment.beforeTokens.slice(
|
|
821
|
-
0, match.segmentStartInBefore);
|
|
822
|
-
var leftAfterTokens = segment.afterTokens.slice(0, match.segmentStartInAfter);
|
|
823
|
-
|
|
824
|
-
segments.push(createSegment(leftBeforeTokens, leftAfterTokens,
|
|
825
|
-
segment.beforeIndex, segment.afterIndex));
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// If there's an unmatched area at the end of the segment, create a new segment from that
|
|
829
|
-
// area and throw it into the segments array to get processed.
|
|
830
|
-
var rightBeforeTokens = segment.beforeTokens.slice(match.segmentEndInBefore + 1);
|
|
831
|
-
var rightAfterTokens = segment.afterTokens.slice(match.segmentEndInAfter + 1);
|
|
832
|
-
var rightBeforeIndex = segment.beforeIndex + match.segmentEndInBefore + 1;
|
|
833
|
-
var rightAfterIndex = segment.afterIndex + match.segmentEndInAfter + 1;
|
|
834
|
-
|
|
835
|
-
if (rightBeforeTokens.length && rightAfterTokens.length){
|
|
836
|
-
segments.push(createSegment(rightBeforeTokens, rightAfterTokens,
|
|
837
|
-
rightBeforeIndex, rightAfterIndex));
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
matches.add(match);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return matches.toArray();
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Gets a list of operations required to transform the before list of tokens into the
|
|
849
|
-
* after list of tokens. An operation describes whether a particular list of consecutive
|
|
850
|
-
* tokens are equal, replaced, inserted, or deleted.
|
|
851
|
-
*
|
|
852
|
-
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
853
|
-
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
854
|
-
*
|
|
855
|
-
* @return {Array.<Object>} The list of operations to transform the before list of
|
|
856
|
-
* tokens into the after list of tokens, where each operation has the following
|
|
857
|
-
* keys:
|
|
858
|
-
* - {string} action One of {'replace', 'insert', 'delete', 'equal'}.
|
|
859
|
-
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
860
|
-
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
861
|
-
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
862
|
-
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
863
|
-
*/
|
|
864
|
-
function calculateOperations(beforeTokens, afterTokens){
|
|
865
|
-
if (!beforeTokens) throw new Error('Missing beforeTokens');
|
|
866
|
-
if (!afterTokens) throw new Error('Missing afterTokens');
|
|
867
|
-
|
|
868
|
-
var positionInBefore = 0;
|
|
869
|
-
var positionInAfter = 0;
|
|
870
|
-
var operations = [];
|
|
871
|
-
var segment = createSegment(beforeTokens, afterTokens, 0, 0);
|
|
872
|
-
var matches = findMatchingBlocks(segment);
|
|
873
|
-
matches.push(new Match(beforeTokens.length, afterTokens.length, 0, segment));
|
|
874
|
-
|
|
875
|
-
for (var index = 0; index < matches.length; index++){
|
|
876
|
-
var match = matches[index];
|
|
877
|
-
var actionUpToMatchPositions = 'none';
|
|
878
|
-
if (positionInBefore === match.startInBefore){
|
|
879
|
-
if (positionInAfter !== match.startInAfter){
|
|
880
|
-
actionUpToMatchPositions = 'insert';
|
|
881
|
-
}
|
|
882
|
-
} else {
|
|
883
|
-
actionUpToMatchPositions = 'delete';
|
|
884
|
-
if (positionInAfter !== match.startInAfter){
|
|
885
|
-
actionUpToMatchPositions = 'replace';
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
if (actionUpToMatchPositions !== 'none'){
|
|
889
|
-
operations.push({
|
|
890
|
-
action: actionUpToMatchPositions,
|
|
891
|
-
startInBefore: positionInBefore,
|
|
892
|
-
endInBefore: (actionUpToMatchPositions !== 'insert' ?
|
|
893
|
-
match.startInBefore - 1 : null),
|
|
894
|
-
startInAfter: positionInAfter,
|
|
895
|
-
endInAfter: (actionUpToMatchPositions !== 'delete' ?
|
|
896
|
-
match.startInAfter - 1 : null)
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
if (match.length !== 0){
|
|
900
|
-
operations.push({
|
|
901
|
-
action: 'equal',
|
|
902
|
-
startInBefore: match.startInBefore,
|
|
903
|
-
endInBefore: match.endInBefore,
|
|
904
|
-
startInAfter: match.startInAfter,
|
|
905
|
-
endInAfter: match.endInAfter
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
positionInBefore = match.endInBefore + 1;
|
|
909
|
-
positionInAfter = match.endInAfter + 1;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
var postProcessed = [];
|
|
913
|
-
var lastOp = {action: 'none'};
|
|
914
|
-
|
|
915
|
-
function isSingleWhitespace(op){
|
|
916
|
-
if (op.action !== 'equal'){
|
|
917
|
-
return false;
|
|
918
|
-
}
|
|
919
|
-
if (op.endInBefore - op.startInBefore !== 0){
|
|
920
|
-
return false;
|
|
921
|
-
}
|
|
922
|
-
return /^\s$/.test(beforeTokens.slice(op.startInBefore, op.endInBefore + 1));
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
for (var i = 0; i < operations.length; i++){
|
|
926
|
-
var op = operations[i];
|
|
927
|
-
|
|
928
|
-
if ((isSingleWhitespace(op) && lastOp.action === 'replace') ||
|
|
929
|
-
(op.action === 'replace' && lastOp.action === 'replace')){
|
|
930
|
-
lastOp.endInBefore = op.endInBefore;
|
|
931
|
-
lastOp.endInAfter = op.endInAfter;
|
|
932
|
-
} else {
|
|
933
|
-
postProcessed.push(op);
|
|
934
|
-
lastOp = op;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
return postProcessed;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/**
|
|
941
|
-
* A TokenWrapper provides a utility for grouping segments of tokens based on whether they're
|
|
942
|
-
* wrappable or not. A tag is considered wrappable if it is closed within the given set of
|
|
943
|
-
* tokens. For example, given the following tokens:
|
|
944
|
-
*
|
|
945
|
-
* ['</b>', 'this', ' ', 'is', ' ', 'a', ' ', '<b>', 'test', '</b>', '!']
|
|
946
|
-
*
|
|
947
|
-
* The first '</b>' is not considered wrappable since the tag is not fully contained within the
|
|
948
|
-
* array of tokens. The '<b>', 'test', and '</b>' would be a part of the same wrappable segment
|
|
949
|
-
* since the entire bold tag is within the set of tokens.
|
|
950
|
-
*
|
|
951
|
-
* TokenWrapper has a method 'combine' which allows walking over the segments to wrap them in
|
|
952
|
-
* tags.
|
|
953
|
-
*/
|
|
954
|
-
function TokenWrapper(tokens){
|
|
955
|
-
this.tokens = tokens;
|
|
956
|
-
this.notes = tokens.reduce(function(data, token, index){
|
|
957
|
-
data.notes.push({
|
|
958
|
-
isWrappable: isWrappable(token),
|
|
959
|
-
insertedTag: false
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
var tag = !isVoidTag(token) && isTag(token);
|
|
963
|
-
var lastEntry = data.tagStack[data.tagStack.length - 1];
|
|
964
|
-
if (tag){
|
|
965
|
-
if (lastEntry && '/' + lastEntry.tag === tag){
|
|
966
|
-
data.notes[lastEntry.position].insertedTag = true;
|
|
967
|
-
data.tagStack.pop();
|
|
968
|
-
} else {
|
|
969
|
-
data.tagStack.push({
|
|
970
|
-
tag: tag,
|
|
971
|
-
position: index
|
|
972
|
-
});
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return data;
|
|
976
|
-
}, {notes: [], tagStack: []}).notes;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/**
|
|
980
|
-
* Wraps the contained tokens in tags based on output given by a map function. Each segment of
|
|
981
|
-
* tokens will be visited. A segment is a continuous run of either all wrappable
|
|
982
|
-
* tokens or unwrappable tokens. The given map function will be called with each segment of
|
|
983
|
-
* tokens and the resulting strings will be combined to form the wrapped HTML.
|
|
984
|
-
*
|
|
985
|
-
* @param {function(boolean, Array.<string>)} mapFn A function called with an array of tokens
|
|
986
|
-
* and whether those tokens are wrappable or not. The result should be a string.
|
|
987
|
-
*/
|
|
988
|
-
TokenWrapper.prototype.combine = function(mapFn, tagFn){
|
|
989
|
-
var notes = this.notes;
|
|
990
|
-
var tokens = this.tokens.slice();
|
|
991
|
-
var segments = tokens.reduce(function(data, token, index){
|
|
992
|
-
if (notes[index].insertedTag){
|
|
993
|
-
tokens[index] = tagFn(tokens[index]);
|
|
994
|
-
}
|
|
995
|
-
if (data.status === null){
|
|
996
|
-
data.status = notes[index].isWrappable;
|
|
997
|
-
}
|
|
998
|
-
var status = notes[index].isWrappable;
|
|
999
|
-
|
|
1000
|
-
// Handling atomic tags wrapping independently
|
|
1001
|
-
// each atomic tag is wrapped with their own ins/del tags
|
|
1002
|
-
var isAtomic = isStartOfAtomicTag(token);
|
|
1003
|
-
if (status !== data.status || (isAtomic && index > data.lastIndex) || data.lastWasAtomic){
|
|
1004
|
-
data.list.push({
|
|
1005
|
-
isWrappable: data.status,
|
|
1006
|
-
tokens: tokens.slice(data.lastIndex, index)
|
|
1007
|
-
});
|
|
1008
|
-
data.lastIndex = index;
|
|
1009
|
-
data.status = status;
|
|
1010
|
-
}
|
|
1011
|
-
// tracking if the last token was an atomic tag
|
|
1012
|
-
// if so then we break the segment and wrap them
|
|
1013
|
-
data.lastWasAtomic = isAtomic;
|
|
1014
|
-
if (index === tokens.length - 1){
|
|
1015
|
-
data.list.push({
|
|
1016
|
-
isWrappable: data.status,
|
|
1017
|
-
tokens: tokens.slice(data.lastIndex, index + 1)
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
return data;
|
|
1021
|
-
}, {list: [], status: null, lastIndex: 0, lastWasAtomic: false}).list;
|
|
1022
|
-
|
|
1023
|
-
return segments.map(mapFn).join('');
|
|
1024
|
-
};
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Wraps and concatenates a list of tokens with a tag. Does not wrap tag tokens,
|
|
1028
|
-
* unless they are wrappable (i.e. void and atomic tags).
|
|
1029
|
-
*
|
|
1030
|
-
* @param {sting} tag The tag name of the wrapper tags.
|
|
1031
|
-
* @param {Array.<string>} content The list of tokens to wrap.
|
|
1032
|
-
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
1033
|
-
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
1034
|
-
*/
|
|
1035
|
-
function wrap(tag, content, opIndex, dataPrefix, className){
|
|
1036
|
-
var wrapper = new TokenWrapper(content);
|
|
1037
|
-
dataPrefix = dataPrefix ? dataPrefix + '-' : '';
|
|
1038
|
-
var attrs = ' data-' + dataPrefix + 'operation-index="' + opIndex + '"';
|
|
1039
|
-
if (className){
|
|
1040
|
-
attrs += ' class="' + className + '"';
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
return wrapper.combine(function(segment){
|
|
1044
|
-
if (segment.isWrappable){
|
|
1045
|
-
var val = segment.tokens.join('');
|
|
1046
|
-
if (val.trim()){
|
|
1047
|
-
return '<' + tag + attrs + '>' + val + '</' + tag + '>';
|
|
1048
|
-
}
|
|
1049
|
-
} else {
|
|
1050
|
-
return segment.tokens.join('');
|
|
1051
|
-
}
|
|
1052
|
-
return '';
|
|
1053
|
-
}, function(openingTag){
|
|
1054
|
-
var dataAttrs = ' data-diff-node="' + tag + '"';
|
|
1055
|
-
dataAttrs += ' data-' + dataPrefix + 'operation-index="' + opIndex + '"';
|
|
1056
|
-
|
|
1057
|
-
return openingTag.replace(/>\s*$/, dataAttrs + '$&');
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/**
|
|
1062
|
-
* Checks whether a token is an atomic tag that opted into the recursive inner diff via
|
|
1063
|
-
* the data-htmldiff-inner-diff attribute. A bare attribute or any value other than
|
|
1064
|
-
* "false" counts as opted in. Opted-in elements nested inside other opted-in elements
|
|
1065
|
-
* are diffed recursively as well, up to the depth cap; beyond it, opted-in tokens are
|
|
1066
|
-
* rendered verbatim like any other atomic token.
|
|
1067
|
-
*
|
|
1068
|
-
* @param {string} tokenString The token string to check.
|
|
1069
|
-
*
|
|
1070
|
-
* @return {boolean} True if the token should get a recursive inner diff.
|
|
1071
|
-
*/
|
|
1072
|
-
function isInnerDiffToken(tokenString){
|
|
1073
|
-
if (innerDiffDepth >= maxInnerDiffDepth || !isStartOfAtomicTag(tokenString)){
|
|
1074
|
-
return false;
|
|
1075
|
-
}
|
|
1076
|
-
var attr = dataHtmlDiffInnerDiffRegExp.exec(tokenString);
|
|
1077
|
-
return !!attr && attr[1] !== 'false';
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
/**
|
|
1081
|
-
* Finds the index of the '>' that ends the opening tag at the start of the given token
|
|
1082
|
-
* string, skipping any '>' inside quoted attribute values (e.g. title="a > b").
|
|
1083
|
-
*
|
|
1084
|
-
* @param {string} tokenString The token string starting with an opening tag.
|
|
1085
|
-
*
|
|
1086
|
-
* @return {number} The index of the closing '>' of the opening tag, or -1 if there is
|
|
1087
|
-
* none (e.g. an unterminated tag or an unbalanced attribute quote).
|
|
1088
|
-
*/
|
|
1089
|
-
function findOpeningTagEnd(tokenString){
|
|
1090
|
-
var quote = null;
|
|
1091
|
-
for (var i = 0; i < tokenString.length; i++){
|
|
1092
|
-
var char = tokenString[i];
|
|
1093
|
-
// quote is closed
|
|
1094
|
-
if (char === quote){
|
|
1095
|
-
quote = null;
|
|
1096
|
-
continue;
|
|
1097
|
-
}
|
|
1098
|
-
// inside quote
|
|
1099
|
-
if (quote){
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
// quote start
|
|
1103
|
-
if (char === '"' || char === "'"){
|
|
1104
|
-
quote = char;
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
// not inside quote, check for tag end
|
|
1108
|
-
if (char === '>'){
|
|
1109
|
-
return i;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
return -1;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* Splits an atomic token string into its opening tag, inner HTML and closing tag.
|
|
1117
|
-
* A token consisting of a single tag (a void or self-closing element) has an empty
|
|
1118
|
-
* inner HTML and no closing tag.
|
|
1119
|
-
*
|
|
1120
|
-
* @param {string} tokenString The atomic token string, e.g. '<div a="b">content</div>'.
|
|
1121
|
-
*
|
|
1122
|
-
* @return {Object|null} An object with openingTag, innerHtml and closingTag properties,
|
|
1123
|
-
* or null if the token cannot be split (e.g. an unterminated tag).
|
|
1124
|
-
*/
|
|
1125
|
-
function splitAtomicTokenString(tokenString){
|
|
1126
|
-
var openingTagEnd = findOpeningTagEnd(tokenString);
|
|
1127
|
-
if (openingTagEnd === -1) {
|
|
1128
|
-
return null;
|
|
1129
|
-
}
|
|
1130
|
-
if (openingTagEnd === tokenString.length - 1) {
|
|
1131
|
-
// The token is a single tag (void or self-closing): the element has no content.
|
|
1132
|
-
return {
|
|
1133
|
-
openingTag: tokenString,
|
|
1134
|
-
innerHtml: '',
|
|
1135
|
-
closingTag: ''
|
|
1136
|
-
};
|
|
1137
|
-
}
|
|
1138
|
-
var closingTagStart = tokenString.lastIndexOf('<');
|
|
1139
|
-
if (closingTagStart <= openingTagEnd || tokenString[closingTagStart + 1] !== '/') {
|
|
1140
|
-
return null;
|
|
1141
|
-
}
|
|
1142
|
-
return {
|
|
1143
|
-
openingTag: tokenString.slice(0, openingTagEnd + 1),
|
|
1144
|
-
innerHtml: tokenString.slice(openingTagEnd + 1, closingTagStart),
|
|
1145
|
-
closingTag: tokenString.slice(closingTagStart)
|
|
1146
|
-
};
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/**
|
|
1150
|
-
* Renders the recursive inner diff of two matched atomic tokens with equal keys but
|
|
1151
|
-
* different content. The after version's opening and closing tags are emitted with the
|
|
1152
|
-
* diff of the two inner HTML fragments in between. Inside the recursion the default
|
|
1153
|
-
* atomic tags without 'a' are used, so link text is diffed word by word and href-only
|
|
1154
|
-
* changes do not produce any markup. The after version's
|
|
1155
|
-
* data-htmldiff-inner-diff-atomic-tags attribute overrides that list. Nested opted-in
|
|
1156
|
-
* elements are diffed recursively as well, up to a hardcoded depth cap.
|
|
1157
|
-
*
|
|
1158
|
-
* @param {string} beforeString The before version of the atomic token.
|
|
1159
|
-
* @param {string} afterString The after version of the atomic token.
|
|
1160
|
-
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
1161
|
-
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
1162
|
-
*
|
|
1163
|
-
* @return {string} The rendered element with inner differences wrapped in ins/del tags.
|
|
1164
|
-
*/
|
|
1165
|
-
function renderInnerDiff(beforeString, afterString, dataPrefix, className){
|
|
1166
|
-
var before = splitAtomicTokenString(beforeString);
|
|
1167
|
-
var after = splitAtomicTokenString(afterString);
|
|
1168
|
-
if (!before || !after){
|
|
1169
|
-
return afterString;
|
|
1170
|
-
}
|
|
1171
|
-
var atomicTagsOverride = dataHtmlDiffInnerDiffAtomicTagsRegExp.exec(afterString);
|
|
1172
|
-
var outerAtomicTagsRegExp = atomicTagsRegExp;
|
|
1173
|
-
innerDiffDepth++;
|
|
1174
|
-
// we need to clean up the innerDiffDepth update and atomicTagsRegExp restoration in case of an error, hence try/finally
|
|
1175
|
-
try {
|
|
1176
|
-
atomicTagsRegExp = defaultInnerDiffAtomicTagsRegExp;
|
|
1177
|
-
|
|
1178
|
-
if (atomicTagsOverride) {
|
|
1179
|
-
atomicTagsRegExp = atomicTagsOverride[1]
|
|
1180
|
-
? buildAtomicTagsRegExp(atomicTagsOverride[1])
|
|
1181
|
-
: noAtomicTagsRegExp;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
var innerDiff = diffCore(before.innerHtml, after.innerHtml, className, dataPrefix);
|
|
1185
|
-
} finally {
|
|
1186
|
-
innerDiffDepth--;
|
|
1187
|
-
atomicTagsRegExp = outerAtomicTagsRegExp;
|
|
1188
|
-
}
|
|
1189
|
-
return after.openingTag + innerDiff + after.closingTag;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
/**
|
|
1193
|
-
* OPS.equal/insert/delete/replace are functions that render an operation into
|
|
1194
|
-
* HTML content.
|
|
1195
|
-
*
|
|
1196
|
-
* @param {Object} op The operation that applies to a prticular list of tokens. Has the
|
|
1197
|
-
* following keys:
|
|
1198
|
-
* - {string} action One of ['replace', 'insert', 'delete', 'equal'].
|
|
1199
|
-
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
1200
|
-
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
1201
|
-
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
1202
|
-
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
1203
|
-
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
1204
|
-
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
1205
|
-
* @param {number} opIndex The index into the list of operations that identifies the change to
|
|
1206
|
-
* be rendered. This is used to mark wrapped HTML as part of the same operation.
|
|
1207
|
-
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
1208
|
-
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
1209
|
-
*
|
|
1210
|
-
* @return {string} The rendering of that operation.
|
|
1211
|
-
*/
|
|
1212
|
-
var OPS = {
|
|
1213
|
-
'equal': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
1214
|
-
// Tokens in an equal operation pair up one to one between before and after. Equal
|
|
1215
|
-
// keys do not guarantee equal strings (e.g. atomic tokens matched by
|
|
1216
|
-
// data-htmldiff-id): elements that opted in via data-htmldiff-inner-diff get a
|
|
1217
|
-
// recursive diff of their content, everything else renders the after version.
|
|
1218
|
-
var result = '';
|
|
1219
|
-
for (var i = 0; op.startInAfter + i <= op.endInAfter; i++){
|
|
1220
|
-
var afterToken = afterTokens[op.startInAfter + i];
|
|
1221
|
-
var beforeToken = beforeTokens[op.startInBefore + i];
|
|
1222
|
-
if (beforeToken && beforeToken.string !== afterToken.string &&
|
|
1223
|
-
isInnerDiffToken(afterToken.string)){
|
|
1224
|
-
result += renderInnerDiff(
|
|
1225
|
-
beforeToken.string, afterToken.string, dataPrefix, className);
|
|
1226
|
-
} else {
|
|
1227
|
-
result += afterToken.string;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
return result;
|
|
1231
|
-
},
|
|
1232
|
-
'insert': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
1233
|
-
var tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
|
|
1234
|
-
var val = tokens.map(function(token){
|
|
1235
|
-
return token.string;
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
const res = wrap('ins', val, opIndex, dataPrefix, className);
|
|
1239
|
-
|
|
1240
|
-
// handling inserted tags, see https://matrixreq.atlassian.net/browse/MATRIX-7876
|
|
1241
|
-
if (/^<[^.\/]+?>$/.exec(res)) {
|
|
1242
|
-
return `${res.slice(0, res.length - 1)} data-inserted="true">`;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
return res;
|
|
1246
|
-
},
|
|
1247
|
-
'delete': function(op, beforeTokens, afterTokens, opIndex, dataPrefix, className){
|
|
1248
|
-
var tokens = beforeTokens.slice(op.startInBefore, op.endInBefore + 1);
|
|
1249
|
-
var val = tokens.map(function(token){
|
|
1250
|
-
return token.string;
|
|
1251
|
-
});
|
|
1252
|
-
const res = wrap("del", val, opIndex, dataPrefix, className);
|
|
1253
|
-
|
|
1254
|
-
// handling cases like deleted </p><p>, see https://matrixreq.atlassian.net/browse/MATRIX-7688
|
|
1255
|
-
if (/^<\/.+?><.+?>$/.exec(res) && !res.includes("del")) {
|
|
1256
|
-
return `<del>${val.slice(1, val.length - 1).join("")}</del>`;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
return res;
|
|
1260
|
-
},
|
|
1261
|
-
'replace': function(){
|
|
1262
|
-
return OPS['delete'].apply(null, arguments) + OPS['insert'].apply(null, arguments);
|
|
1263
|
-
}
|
|
1264
|
-
};
|
|
1265
|
-
|
|
1266
|
-
/**
|
|
1267
|
-
* Renders a list of operations into HTML content. The result is the combined version
|
|
1268
|
-
* of the before and after tokens with the differences wrapped in tags.
|
|
1269
|
-
*
|
|
1270
|
-
* @param {Array.<string>} beforeTokens The before list of tokens.
|
|
1271
|
-
* @param {Array.<string>} afterTokens The after list of tokens.
|
|
1272
|
-
* @param {Array.<Object>} operations The list of operations to transform the before
|
|
1273
|
-
* list of tokens into the after list of tokens, where each operation has the
|
|
1274
|
-
* following keys:
|
|
1275
|
-
* - {string} action One of {'replace', 'insert', 'delete', 'equal'}.
|
|
1276
|
-
* - {number} startInBefore The beginning of the range in the list of before tokens.
|
|
1277
|
-
* - {number} endInBefore The end of the range in the list of before tokens.
|
|
1278
|
-
* - {number} startInAfter The beginning of the range in the list of after tokens.
|
|
1279
|
-
* - {number} endInAfter The end of the range in the list of after tokens.
|
|
1280
|
-
* @param {string} dataPrefix (Optional) The prefix to use in data attributes.
|
|
1281
|
-
* @param {string} className (Optional) The class name to include in the wrapper tag.
|
|
1282
|
-
*
|
|
1283
|
-
* @return {string} The rendering of the list of operations.
|
|
1284
|
-
*/
|
|
1285
|
-
function renderOperations(beforeTokens, afterTokens, operations, dataPrefix, className){
|
|
1286
|
-
return operations.reduce(function(rendering, op, index){
|
|
1287
|
-
return rendering + OPS[op.action](
|
|
1288
|
-
op, beforeTokens, afterTokens, index, dataPrefix, className);
|
|
1289
|
-
}, '');
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
/**
|
|
1293
|
-
* Compares two pieces of HTML content and returns the combined content with differences
|
|
1294
|
-
* wrapped in <ins> and <del> tags.
|
|
1295
|
-
*
|
|
1296
|
-
* @param {string} before The HTML content before the changes.
|
|
1297
|
-
* @param {string} after The HTML content after the changes.
|
|
1298
|
-
* @param {string} className (Optional) The class attribute to include in <ins> and <del> tags.
|
|
1299
|
-
* @param {string} dataPrefix (Optional) The data prefix to use for data attributes. The
|
|
1300
|
-
* operation index data attribute will be named `data-${dataPrefix-}operation-index`.
|
|
1301
|
-
* @param {string} atomicTags (Optional) Comma separated list of atomic tag names. The
|
|
1302
|
-
* list has to be in the form `tag1,tag2,...` e. g. `head,script,style`. If not used,
|
|
1303
|
-
* the default list `iframe,object,math,svg,script,video,head,style` will be used.
|
|
1304
|
-
*
|
|
1305
|
-
* @return {string} The combined HTML content with differences wrapped in <ins> and <del> tags.
|
|
1306
|
-
*/
|
|
1307
|
-
function diff(before, after, className, dataPrefix, atomicTags){
|
|
1308
|
-
// Enable user provided atomic tag list.
|
|
1309
|
-
atomicTags ?
|
|
1310
|
-
(atomicTagsRegExp = buildAtomicTagsRegExp(atomicTags))
|
|
1311
|
-
: (atomicTagsRegExp = defaultAtomicTagsRegExp);
|
|
1312
|
-
|
|
1313
|
-
return diffCore(before, after, className, dataPrefix);
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
/**
|
|
1317
|
-
* Runs the diff pipeline with whatever atomic tags regular expression is currently active.
|
|
1318
|
-
* Used by the diff function after resolving the atomicTags parameter and by
|
|
1319
|
-
* renderInnerDiff, which sets the regular expression itself.
|
|
1320
|
-
*
|
|
1321
|
-
* @see function diff for the parameter and return value descriptions.
|
|
1322
|
-
*/
|
|
1323
|
-
function diffCore(before, after, className, dataPrefix){
|
|
1324
|
-
if (before === after) return before;
|
|
1325
|
-
|
|
1326
|
-
before = htmlToTokens(before);
|
|
1327
|
-
after = htmlToTokens(after);
|
|
1328
|
-
var ops = calculateOperations(before, after);
|
|
1329
|
-
return renderOperations(before, after, ops, dataPrefix, className);
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
diff.htmlToTokens = htmlToTokens;
|
|
1333
|
-
diff.findMatchingBlocks = findMatchingBlocks;
|
|
1334
|
-
findMatchingBlocks.findBestMatch = findBestMatch;
|
|
1335
|
-
findMatchingBlocks.createMap = createMap;
|
|
1336
|
-
findMatchingBlocks.createToken = createToken;
|
|
1337
|
-
findMatchingBlocks.createSegment = createSegment;
|
|
1338
|
-
findMatchingBlocks.getKeyForToken = getKeyForToken;
|
|
1339
|
-
diff.calculateOperations = calculateOperations;
|
|
1340
|
-
diff.renderOperations = renderOperations;
|
|
1341
|
-
|
|
1342
|
-
if (typeof define === 'function'){
|
|
1343
|
-
define([], function(){
|
|
1344
|
-
return diff;
|
|
1345
|
-
});
|
|
1346
|
-
} else if (typeof module !== 'undefined' && module !== null){
|
|
1347
|
-
module.exports = diff;
|
|
1348
|
-
} else {
|
|
1349
|
-
this.htmldiff = diff;
|
|
1350
|
-
}
|
|
1351
|
-
}).call(this);
|
|
1
|
+
"use strict";
|
|
2
|
+
const atomicTags_1 = require("./core/atomicTags");
|
|
3
|
+
const diff_1 = require("./core/diff");
|
|
4
|
+
const tables_1 = require("./tables");
|
|
5
|
+
function diff(before, after, className, dataPrefix, atomicTags) {
|
|
6
|
+
if (before === after) {
|
|
7
|
+
return before;
|
|
8
|
+
}
|
|
9
|
+
(0, atomicTags_1.setAtomicTagsRegExp)(atomicTags ? (0, atomicTags_1.buildAtomicTagsRegExp)(atomicTags) : atomicTags_1.defaultAtomicTagsRegExp);
|
|
10
|
+
const tables = new tables_1.TableRedlining((oldContent, newContent) => (0, diff_1.diffCore)(oldContent, newContent, className, dataPrefix));
|
|
11
|
+
const prepared = tables.redline(before, after);
|
|
12
|
+
return (0, diff_1.diffCore)(prepared.before, prepared.after, className, dataPrefix);
|
|
13
|
+
}
|
|
14
|
+
module.exports = diff;
|