-
-
Notifications
You must be signed in to change notification settings - Fork 195
Expand file tree
/
Copy pathHTMLDOMDiff.js
More file actions
625 lines (551 loc) · 24.4 KB
/
HTMLDOMDiff.js
File metadata and controls
625 lines (551 loc) · 24.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
/*
* GNU AGPL-3.0 License
*
* Copyright (c) 2021 - present core.ai . All rights reserved.
* Original work Copyright (c) 2013 - 2021 Adobe Systems Incorporated. All rights reserved.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
* for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
*
*/
// @INCLUDE_IN_API_DOCS
/*unittests: HTML Instrumentation*/
define(function (require, exports, module) {
/**
* @private
*
* Determines the changes made to attributes and generates edits for those changes.
*
* @param {SimpleNode} oldNode node from old tree
* @param {SimpleNode} newNode node from new tree
* @return {Array.<Object>} list of edits to mutate attributes from the old node to the new
*/
function generateAttributeEdits(oldNode, newNode) {
// shallow copy the old attributes object so that we can modify it
var oldAttributes = $.extend({}, oldNode.attributes),
newAttributes = newNode.attributes,
edits = [];
Object.keys(newAttributes).forEach(function (attributeName) {
if (oldAttributes[attributeName] !== newAttributes[attributeName]) {
var type = oldAttributes.hasOwnProperty(attributeName) ? "attrChange" : "attrAdd";
edits.push({
type: type,
tagID: oldNode.tagID,
attribute: attributeName,
value: newAttributes[attributeName]
});
}
delete oldAttributes[attributeName];
});
Object.keys(oldAttributes).forEach(function (attributeName) {
edits.push({
type: "attrDelete",
tagID: oldNode.tagID,
attribute: attributeName
});
});
return edits;
}
/**
* @private
*
* Retrieve the parent tag ID of a SimpleDOM node.
*
* @param {Object} node SimpleDOM node for which to look up parent ID
* @return {int?} ID or null if there is no parent
*/
function getParentID(node) {
return node.parent && node.parent.tagID;
}
/**
* @private
*
* When the main loop (see below) determines that something has changed with
* an element's immediate children, it calls this function to create edit
* operations for those changes.
*
* This adds to the edit list in place and does not return anything.
*
* @param {?Object} oldParent SimpleDOM node for the previous state of this element, null/undefined if the element is new
* @param {Object} newParent SimpleDOM node for the current state of the element
*/
var generateChildEdits = function (oldParent, oldNodeMap, newParent, newNodeMap) {
/*jslint continue: true */
var newIndex = 0,
oldIndex = 0,
newChildren = newParent.children,
oldChildren = oldParent ? oldParent.children : [],
newChild,
oldChild,
newEdits = [],
newEdit,
textAfterID,
edits = [],
moves = [],
newElements = [];
/**
* We initially put new edit objects into the `newEdits` array so that we
* can fix them up with proper positioning information. This function is
* responsible for doing that fixup.
*
* The `beforeID` that appears in many edits tells the browser to make the
* change before the element with the given ID. In other words, an
* elementInsert with a `beforeID` of 32 would result in something like
* `parentElement.insertBefore(newChildElement, _queryBracketsID(32))`
*
* Many new edits are captured in the `newEdits` array so that a suitable
* `beforeID` can be added to them before they are added to the main edits
* list. This function sets the `beforeID` on any pending edits and adds
* them to the main list.
*
* If this item is not being deleted, then it will be used as the `afterID`
* for text edits that follow.
* @private
* @param {int} beforeID ID to set on the pending edits
* @param {boolean} isBeingDeleted true if the given item is being deleted. If so,
* we can't use it as the `afterID` for future edits--whatever previous item
* was set as the `textAfterID` is still okay.
*/
var finalizeNewEdits = function (beforeID, isBeingDeleted) {
newEdits.forEach(function (edit) {
// elementDeletes don't need any positioning information
if (edit.type !== "elementDelete") {
edit.beforeID = beforeID;
}
});
edits.push.apply(edits, newEdits);
newEdits = [];
// If the item we made this set of edits relative to
// is being deleted, we can't use it as the afterID for future
// edits. It's okay to just keep the previous afterID, since
// this node will no longer be in the tree by the time we get
// to any future edit that needs an afterID.
if (!isBeingDeleted) {
textAfterID = beforeID;
}
};
/**
* If the current element was not in the old DOM, then we will create
* an elementInsert edit for it.
*
* If the element was in the old DOM, this will return false and the
* main loop will either spot this element later in the child list
* or the element has been moved.
* @return {boolean} true if an elementInsert was created
*/
var addElementInsert = function () {
if (!oldNodeMap[newChild.tagID]) {
newEdit = {
type: "elementInsert",
tag: newChild.tag,
tagID: newChild.tagID,
parentID: newChild.parent.tagID,
attributes: newChild.attributes
};
newEdits.push(newEdit);
// This newly inserted node needs to have edits generated for its
// children, so we add it to the queue.
newElements.push(newChild);
// A textInsert edit that follows this elementInsert should use
// this element's ID.
textAfterID = newChild.tagID;
// new element means we need to move on to compare the next
// of the current tree with the one from the old tree that we
// just compared
newIndex++;
return true;
}
return false;
};
/**
* If the old element that we're looking at does not appear in the new
* DOM, that means it was deleted and we'll create an elementDelete edit.
*
* If the element is in the new DOM, then this will return false and
* the main loop with either spot this node later on or the element
* has been moved.
* @return {boolean} true if elementDelete was generated
*/
var addElementDelete = function () {
if (!newNodeMap[oldChild.tagID]) {
// We can finalize existing edits relative to this node *before* it's
// deleted.
finalizeNewEdits(oldChild.tagID, true);
newEdit = {
type: "elementDelete",
tagID: oldChild.tagID
};
newEdits.push(newEdit);
// deleted element means we need to move on to compare the next
// of the old tree with the one from the current tree that we
// just compared
oldIndex++;
return true;
}
return false;
};
/**
* Adds a textInsert edit for a newly created text node.
*/
var addTextInsert = function () {
newEdit = {
type: "textInsert",
content: newChild.content,
parentID: newChild.parent.tagID
};
// text changes will generally have afterID and beforeID, but we make
// special note if it's the first child.
if (textAfterID) {
newEdit.afterID = textAfterID;
} else {
newEdit.firstChild = true;
}
newEdits.push(newEdit);
// The text node is in the new tree, so we move to the next new tree item
newIndex++;
};
/**
* Finds the previous child of the new tree.
* @private
* @return {?Object} previous child or null if there wasn't one
*/
var prevNode = function () {
if (newIndex > 0) {
return newParent.children[newIndex - 1];
}
return null;
};
/**
* Adds a textDelete edit for text node that is not in the new tree.
* Note that we actually create a textReplace rather than a textDelete
* if the previous node in current tree was a text node. We do this because
* text nodes are not individually addressable and a delete event would
* end up clearing out both that previous text node that we want to keep
* and this text node that we want to eliminate. Instead, we just log
* a textReplace which will result in the deletion of this node and
* the maintaining of the old content.
*/
var addTextDelete = function () {
var prev = prevNode();
if (prev && !prev.children) {
newEdit = {
type: "textReplace",
content: prev.content
};
} else {
newEdit = {
type: "textDelete"
};
}
// When elements are deleted or moved from the old set of children, you
// can end up with multiple text nodes in a row. A single textReplace edit
// will take care of those (and will contain all of the right content since
// the text nodes between elements in the new DOM are merged together).
// The check below looks to see if we're already in the process of adding
// a textReplace edit following the same element.
var previousEdit = newEdits.length > 0 && newEdits[newEdits.length - 1];
if (previousEdit && previousEdit.type === "textReplace" &&
previousEdit.afterID === textAfterID) {
oldIndex++;
return;
}
newEdit.parentID = oldChild.parent.tagID;
// If there was only one child previously, we just pass along
// textDelete/textReplace with the parentID and the browser will
// clear all of the children
if (oldChild.parent.children.length === 1) {
newEdits.push(newEdit);
} else {
if (textAfterID) {
newEdit.afterID = textAfterID;
}
newEdits.push(newEdit);
}
// This text appeared in the old tree but not the new one, so we
// increment the old children counter.
oldIndex++;
};
/**
* Adds an elementMove edit if the parent has changed between the old and new trees.
* These are fairly infrequent and generally occur if you make a change across
* tag boundaries.
* @return {boolean} true if an elementMove was generated
*/
var addElementMove = function () {
// This check looks a little strange, but it suits what we're trying
// to do: as we're walking through the children, a child node that has moved
// from one parent to another will be found but would look like some kind
// of insert. The check that we're doing here is looking up the current
// child's ID in the *old* map and seeing if this child used to have a
// different parent.
var possiblyMovedElement = oldNodeMap[newChild.tagID];
if (possiblyMovedElement &&
newParent.tagID !== getParentID(possiblyMovedElement)) {
newEdit = {
type: "elementMove",
tagID: newChild.tagID,
parentID: newChild.parent.tagID
};
moves.push(newEdit.tagID);
newEdits.push(newEdit);
// this element in the new tree was a move to this spot, so we can move
// on to the next child in the new tree.
newIndex++;
return true;
}
return false;
};
/**
* Looks to see if the element in the old tree has moved by checking its
* current and former parents.
* @return {boolean} true if the element has moved
*/
var hasMoved = function (oldChild) {
var oldChildInNewTree = newNodeMap[oldChild.tagID];
return oldChild.children && oldChildInNewTree && getParentID(oldChild) !== getParentID(oldChildInNewTree);
};
// Loop through the current and old children, comparing them one by one.
while (newIndex < newChildren.length && oldIndex < oldChildren.length) {
newChild = newChildren[newIndex];
// Check to see if the currentChild has been reparented from somewhere
// else in the old tree
if (newChild.children && addElementMove()) {
continue;
}
oldChild = oldChildren[oldIndex];
// Check to see if the oldChild has been moved to another parent.
// If it has, we deal with it on the other side (see above)
if (hasMoved(oldChild)) {
oldIndex++;
continue;
}
if (newChild.isElement() || oldChild.isElement()) {
if (newChild.isElement() && oldChild.isText()) {
addTextDelete();
// If this element is new, add it and move to the next child
// in the current tree. Otherwise, we'll compare this same
// current element with the next old element on the next pass
// through the loop.
addElementInsert();
} else if (oldChild.isElement() && newChild.isText()) {
// If the old child has *not* been deleted, we assume that we've
// inserted some text and will still encounter the old node
if (!addElementDelete()) {
addTextInsert();
}
// both children are elements
} else {
if (newChild.tagID !== oldChild.tagID) {
// First, check to see if we're deleting an element.
// If we are, get rid of that element and restart our comparison
// logic with the same element from the new tree and the next one
// from the old tree.
if (!addElementDelete()) {
// Since we're not deleting and these elements don't match up, we
// must have a new element. Add an elementInsert (and log a problem
// if no insert works.)
if (!addElementInsert()) {
console.error("HTML Instrumentation: This should not happen. Two elements have different tag IDs and there was no insert/delete. This generally means there was a reordering of elements.");
newIndex++;
oldIndex++;
}
}
// There has been no change in the tag we're looking at.
} else {
// Since this element hasn't moved, it is a suitable "beforeID"
// for the edits we've logged.
finalizeNewEdits(oldChild.tagID, false);
newIndex++;
oldIndex++;
}
}
// We know we're comparing two texts. Just match up their signatures.
} else {
if (newChild.textSignature !== oldChild.textSignature) {
newEdit = {
type: "textReplace",
content: newChild.content,
parentID: newChild.parent.tagID
};
if (textAfterID) {
newEdit.afterID = textAfterID;
}
newEdits.push(newEdit);
}
// Either we've done a text replace or both sides matched. In either
// case we're ready to move forward among both the old and new children.
newIndex++;
oldIndex++;
}
}
// At this point, we've used up all of the children in at least one of the
// two sets of children.
/*
* Take care of any remaining children in the old tree.
*/
while (oldIndex < oldChildren.length) {
oldChild = oldChildren[oldIndex];
// Check for an element that has moved
if (hasMoved(oldChild)) {
// This element has moved, so we skip it on this side (the move
// is handled on the new tree side).
oldIndex++;
// is this an element? if so, delete it
} else if (oldChild.isElement()) {
if (!addElementDelete()) {
console.error("HTML Instrumentation: failed to add elementDelete for remaining element in the original DOM. This should not happen.", oldChild);
oldIndex++;
}
// must be text. delete that.
} else {
addTextDelete();
}
}
/*
* Take care of the remaining children in the new tree.
*/
while (newIndex < newChildren.length) {
newChild = newChildren[newIndex];
// Is this an element?
if (newChild.isElement()) {
// Look to see if the element has moved here.
if (!addElementMove()) {
// Not a move, so we insert this element.
if (!addElementInsert()) {
console.error("HTML Instrumentation: failed to add elementInsert for remaining element in the updated DOM. This should not happen.");
newIndex++;
}
}
// not a new element, so it must be new text.
} else {
addTextInsert();
}
}
/*
* Finalize remaining edits. For inserts and moves, we can set the `lastChild`
* flag and the browser can simply use `appendChild` to add these items.
*/
newEdits.forEach(function (edit) {
if (edit.type === "textInsert" || edit.type === "elementInsert" || edit.type === "elementMove") {
edit.lastChild = true;
delete edit.firstChild;
delete edit.afterID;
}
});
edits.push.apply(edits, newEdits);
return {
edits: edits,
moves: moves,
newElements: newElements
};
};
/**
* Generate a list of edits that will mutate oldNode to look like newNode.
* Currently, there are the following possible edit operations:
*
* - elementInsert
* - elementDelete
* - elementMove
* - textInsert
* - textDelete
* - textReplace
* - attrDelete
* - attrChange
* - attrAdd
* - rememberNodes (a special instruction that reflects the need to hang on to moved nodes)
* @param {Object} oldNode - SimpleDOM node with the original content.
* @param {Object} newNode - SimpleDOM node with the new content.
* @return {Array<Object>} - List of edit operations.
*/
function domdiff(oldNode, newNode) {
var queue = [],
edits = [],
moves = [],
newElement,
oldElement,
oldNodeMap = oldNode ? oldNode.nodeMap : {},
newNodeMap = newNode.nodeMap;
/**
* Adds elements to the queue for generateChildEdits.
* Only elements (and not text nodes) are added. New nodes (ones that aren't in the
* old nodeMap), are not added here because they will be added when generateChildEdits
* creates the elementInsert edit.
*/
var queuePush = function (node) {
if (node.children && oldNodeMap[node.tagID]) {
queue.push(node);
}
};
/**
* Aggregates the child edits in the proper data structures.
* @param {Object} delta edits, moves and newElements to add
*/
var addEdits = function (delta) {
edits.push.apply(edits, delta.edits);
moves.push.apply(moves, delta.moves);
queue.push.apply(queue, delta.newElements);
};
// Start at the root of the current tree.
queue.push(newNode);
do {
newElement = queue.pop();
oldElement = oldNodeMap[newElement.tagID];
// Do we need to compare elements?
if (oldElement) {
// Are attributes different?
if (newElement.attributeSignature !== oldElement.attributeSignature) {
// generate attribute edits
edits.push.apply(edits, generateAttributeEdits(oldElement, newElement));
}
// Has there been a change to this node's immediate children?
if (newElement.childSignature !== oldElement.childSignature) {
addEdits(generateChildEdits(oldElement, oldNodeMap, newElement, newNodeMap));
}
// If there's a change farther down in the tree, add the children to the queue.
// If not, we can skip that whole subtree.
if (newElement.subtreeSignature !== oldElement.subtreeSignature) {
newElement.children.forEach(queuePush);
}
// This is a new element, so go straight to generating child edits (which will
// create the appropriate Insert edits).
} else {
// If this is the root (html) tag, we need to manufacture an insert for it here,
// because it isn't the child of any other node. The browser-side code doesn't
// care about parentage/positioning in this case, and will handle just setting the
// ID on the existing implied HTML tag in the browser without actually creating it.
if (!newElement.parent) {
edits.push({
type: "elementInsert",
tag: newElement.tag,
tagID: newElement.tagID,
parentID: null,
attributes: newElement.attributes
});
}
addEdits(generateChildEdits(null, oldNodeMap, newElement, newNodeMap));
}
} while (queue.length);
// Special handling for moves: add edits to the beginning of the list so that
// moved nodes are set aside to ensure that they remain available at the time of their
// move.
if (moves.length > 0) {
edits.unshift({
type: "rememberNodes",
tagIDs: moves
});
}
return edits;
}
// Public API
exports.domdiff = domdiff;
});