tangled
alpha
login
or
join now
yippee.fun
/
morphlex
0
fork
atom
Precise DOM morphing
morphing
typescript
dom
0
fork
atom
overview
issues
pulls
pipelines
Use for-loops
joel.drapper.me
2 years ago
28c2d66b
116efbfe
+373
-319
2 changed files
expand all
collapse all
unified
split
dist
morphlex.js
src
morphlex.ts
+358
-315
dist/morphlex.js
···
1
1
export function morph(node, reference, options = {}) {
2
2
-
if (typeof reference === "string") {
3
3
-
const template = document.createElement("template");
4
4
-
template.innerHTML = reference.trim();
5
5
-
reference = template.content.firstChild;
6
6
-
if (!reference) {
7
7
-
throw new Error("The provided string did not contain any nodes.");
8
8
-
}
9
9
-
}
10
10
-
if (isElement(node)) {
11
11
-
node.ariaBusy = "true";
12
12
-
new Morph(options).morph([node, reference]);
13
13
-
node.ariaBusy = null;
14
14
-
} else {
15
15
-
new Morph(options).morph([node, reference]);
16
16
-
}
2
2
+
if (typeof reference === "string") {
3
3
+
const template = document.createElement("template");
4
4
+
template.innerHTML = reference.trim();
5
5
+
reference = template.content.firstChild;
6
6
+
if (!reference) {
7
7
+
throw new Error("The provided string did not contain any nodes.");
8
8
+
}
9
9
+
}
10
10
+
if (isElement(node)) {
11
11
+
node.ariaBusy = "true";
12
12
+
new Morph(options).morph([node, reference]);
13
13
+
node.ariaBusy = null;
14
14
+
}
15
15
+
else {
16
16
+
new Morph(options).morph([node, reference]);
17
17
+
}
17
18
}
18
19
class Morph {
19
19
-
#idMap;
20
20
-
#sensivityMap;
21
21
-
#ignoreActiveValue;
22
22
-
#preserveModifiedValues;
23
23
-
#beforeNodeMorphed;
24
24
-
#afterNodeMorphed;
25
25
-
#beforeNodeAdded;
26
26
-
#afterNodeAdded;
27
27
-
#beforeNodeRemoved;
28
28
-
#afterNodeRemoved;
29
29
-
#beforeAttributeUpdated;
30
30
-
#afterAttributeUpdated;
31
31
-
#beforePropertyUpdated;
32
32
-
#afterPropertyUpdated;
33
33
-
constructor(options = {}) {
34
34
-
this.#idMap = new WeakMap();
35
35
-
this.#sensivityMap = new WeakMap();
36
36
-
this.#ignoreActiveValue = options.ignoreActiveValue || false;
37
37
-
this.#preserveModifiedValues = options.preserveModifiedValues || false;
38
38
-
this.#beforeNodeMorphed = options.beforeNodeMorphed;
39
39
-
this.#afterNodeMorphed = options.afterNodeMorphed;
40
40
-
this.#beforeNodeAdded = options.beforeNodeAdded;
41
41
-
this.#afterNodeAdded = options.afterNodeAdded;
42
42
-
this.#beforeNodeRemoved = options.beforeNodeRemoved;
43
43
-
this.#afterNodeRemoved = options.afterNodeRemoved;
44
44
-
this.#beforeAttributeUpdated = options.beforeAttributeUpdated;
45
45
-
this.#afterAttributeUpdated = options.afterAttributeUpdated;
46
46
-
this.#beforePropertyUpdated = options.beforePropertyUpdated;
47
47
-
this.#afterPropertyUpdated = options.afterPropertyUpdated;
48
48
-
Object.freeze(this);
49
49
-
}
50
50
-
morph(pair) {
51
51
-
if (isParentNodePair(pair)) this.#buildMaps(pair);
52
52
-
this.#morphNode(pair);
53
53
-
}
54
54
-
morphInner(pair) {
55
55
-
this.#buildMaps(pair);
56
56
-
if (isMatchingElementPair(pair)) {
57
57
-
this.#morphMatchingElementContent(pair);
58
58
-
} else {
59
59
-
throw new Error("You can only do an inner morph with matching elements.");
60
60
-
}
61
61
-
}
62
62
-
#buildMaps([node, reference]) {
63
63
-
this.#mapIdSets(node);
64
64
-
this.#mapIdSets(reference);
65
65
-
this.#mapSensivity(node);
66
66
-
Object.freeze(this.#idMap);
67
67
-
Object.freeze(this.#sensivityMap);
68
68
-
}
69
69
-
#mapSensivity(node) {
70
70
-
const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video");
71
71
-
for (const sensitiveElement of sensitiveElements) {
72
72
-
let sensivity = 0;
73
73
-
if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) {
74
74
-
sensivity++;
75
75
-
if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity++;
76
76
-
if (sensitiveElement === document.activeElement) sensivity++;
77
77
-
} else {
78
78
-
sensivity += 3;
79
79
-
if (isMedia(sensitiveElement) && !sensitiveElement.ended) {
80
80
-
if (!sensitiveElement.paused) sensivity++;
81
81
-
if (sensitiveElement.currentTime > 0) sensivity++;
82
82
-
}
83
83
-
}
84
84
-
let current = sensitiveElement;
85
85
-
while (current) {
86
86
-
this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity);
87
87
-
if (current === node) break;
88
88
-
current = current.parentElement;
89
89
-
}
90
90
-
}
91
91
-
}
92
92
-
// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
93
93
-
#mapIdSets(node) {
94
94
-
const elementsWithIds = node.querySelectorAll("[id]");
95
95
-
for (const elementWithId of elementsWithIds) {
96
96
-
const id = elementWithId.id;
97
97
-
// Ignore empty IDs
98
98
-
if (id === "") continue;
99
99
-
let current = elementWithId;
100
100
-
while (current) {
101
101
-
const idSet = this.#idMap.get(current);
102
102
-
idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id]));
103
103
-
if (current === node) break;
104
104
-
current = current.parentElement;
105
105
-
}
106
106
-
}
107
107
-
}
108
108
-
// This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`.
109
109
-
#morphNode(pair) {
110
110
-
if (isMatchingElementPair(pair)) this.#morphMatchingElementNode(pair);
111
111
-
else this.#morphOtherNode(pair);
112
112
-
}
113
113
-
#morphMatchingElementNode(pair) {
114
114
-
const [node, reference] = pair;
115
115
-
if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return;
116
116
-
if (node.hasAttributes() || reference.hasAttributes()) this.#morphAttributes(pair);
117
117
-
// TODO: Should use a branded pair here.
118
118
-
this.#morphMatchingElementContent(pair);
119
119
-
this.#afterNodeMorphed?.(node, writableNode(reference));
120
120
-
}
121
121
-
#morphOtherNode([node, reference]) {
122
122
-
if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return;
123
123
-
if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) {
124
124
-
// Handle text nodes, comments, and CDATA sections.
125
125
-
this.#updateProperty(node, "nodeValue", reference.nodeValue);
126
126
-
} else this.#replaceNode(node, reference.cloneNode(true));
127
127
-
this.#afterNodeMorphed?.(node, writableNode(reference));
128
128
-
}
129
129
-
#morphMatchingElementContent(pair) {
130
130
-
const [node, reference] = pair;
131
131
-
if (isHead(node)) {
132
132
-
// We can pass the reference as a head here becuase we know it's the same as the node.
133
133
-
this.#morphHeadContents(pair);
134
134
-
} else if (node.hasChildNodes() || reference.hasChildNodes()) this.#morphChildNodes(pair);
135
135
-
}
136
136
-
#morphHeadContents([node, reference]) {
137
137
-
const refChildNodesMap = new Map();
138
138
-
// Generate a map of the reference head element’s child nodes, keyed by their outerHTML.
139
139
-
for (const child of reference.children) refChildNodesMap.set(child.outerHTML, child);
140
140
-
for (const child of node.children) {
141
141
-
const key = child.outerHTML;
142
142
-
const refChild = refChildNodesMap.get(key);
143
143
-
// If the child is in the reference map already, we don’t need to add it later.
144
144
-
// If it’s not in the map, we need to remove it from the node.
145
145
-
refChild ? refChildNodesMap.delete(key) : this.#removeNode(child);
146
146
-
}
147
147
-
// Any remaining nodes in the map should be appended to the head.
148
148
-
for (const refChild of refChildNodesMap.values()) this.#appendChild(node, refChild.cloneNode(true));
149
149
-
}
150
150
-
#morphAttributes([element, reference]) {
151
151
-
// Remove any excess attributes from the element that aren’t present in the reference.
152
152
-
for (const { name, value } of element.attributes) {
153
153
-
if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) {
154
154
-
element.removeAttribute(name);
155
155
-
this.#afterAttributeUpdated?.(element, name, value);
156
156
-
}
157
157
-
}
158
158
-
// Copy attributes from the reference to the element, if they don’t already match.
159
159
-
for (const { name, value } of reference.attributes) {
160
160
-
const previousValue = element.getAttribute(name);
161
161
-
if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) {
162
162
-
element.setAttribute(name, value);
163
163
-
this.#afterAttributeUpdated?.(element, name, previousValue);
164
164
-
}
165
165
-
}
166
166
-
// For certain types of elements, we need to do some extra work to ensure
167
167
-
// the element’s state matches the reference elements’ state.
168
168
-
if (isInput(element) && isInput(reference)) {
169
169
-
this.#updateProperty(element, "checked", reference.checked);
170
170
-
this.#updateProperty(element, "disabled", reference.disabled);
171
171
-
this.#updateProperty(element, "indeterminate", reference.indeterminate);
172
172
-
if (
173
173
-
element.type !== "file" &&
174
174
-
!(this.#ignoreActiveValue && document.activeElement === element) &&
175
175
-
!(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)
176
176
-
) {
177
177
-
this.#updateProperty(element, "value", reference.value);
178
178
-
}
179
179
-
} else if (isOption(element) && isOption(reference)) {
180
180
-
this.#updateProperty(element, "selected", reference.selected);
181
181
-
} else if (
182
182
-
isTextArea(element) &&
183
183
-
isTextArea(reference) &&
184
184
-
!(this.#ignoreActiveValue && document.activeElement === element) &&
185
185
-
!(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)
186
186
-
) {
187
187
-
this.#updateProperty(element, "value", reference.value);
188
188
-
const text = element.firstElementChild;
189
189
-
if (text) this.#updateProperty(text, "textContent", reference.value);
190
190
-
}
191
191
-
}
192
192
-
// Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match.
193
193
-
#morphChildNodes(pair) {
194
194
-
const [element, reference] = pair;
195
195
-
const childNodes = element.childNodes;
196
196
-
const refChildNodes = reference.childNodes;
197
197
-
for (let i = 0; i < refChildNodes.length; i++) {
198
198
-
const child = childNodes[i];
199
199
-
const refChild = refChildNodes[i];
200
200
-
if (child && refChild) {
201
201
-
const pair = [child, refChild];
202
202
-
if (isMatchingElementPair(pair)) {
203
203
-
if (isHead(pair[0])) {
204
204
-
this.#morphHeadContents(pair);
205
205
-
} else {
206
206
-
this.#morphChildElement(pair, element);
207
207
-
}
208
208
-
} else this.#morphOtherNode(pair);
209
209
-
} else if (refChild) {
210
210
-
this.#appendChild(element, refChild.cloneNode(true));
211
211
-
} else if (child) {
212
212
-
this.#removeNode(child);
213
213
-
}
214
214
-
}
215
215
-
// Clean up any excess nodes that may be left over
216
216
-
while (childNodes.length > refChildNodes.length) {
217
217
-
const child = element.lastChild;
218
218
-
if (child) this.#removeNode(child);
219
219
-
}
220
220
-
}
221
221
-
#morphChildElement([child, reference], parent) {
222
222
-
if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true)) return;
223
223
-
const refIdSet = this.#idMap.get(reference);
224
224
-
// Generate the array in advance of the loop
225
225
-
const refSetArray = refIdSet ? [...refIdSet] : [];
226
226
-
let currentNode = child;
227
227
-
let nextMatchByTagName = null;
228
228
-
// Try find a match by idSet, while also looking out for the next best match by tagName.
229
229
-
while (currentNode) {
230
230
-
if (isElement(currentNode)) {
231
231
-
const id = currentNode.id;
232
232
-
if (!nextMatchByTagName && currentNode.localName === reference.localName) {
233
233
-
nextMatchByTagName = currentNode;
234
234
-
}
235
235
-
if (id !== "") {
236
236
-
if (id === reference.id) {
237
237
-
this.#insertBefore(parent, currentNode, child);
238
238
-
return this.#morphNode([currentNode, reference]);
239
239
-
} else {
240
240
-
const currentIdSet = this.#idMap.get(currentNode);
241
241
-
if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) {
242
242
-
this.#insertBefore(parent, currentNode, child);
243
243
-
return this.#morphNode([currentNode, reference]);
244
244
-
}
245
245
-
}
246
246
-
}
247
247
-
}
248
248
-
currentNode = currentNode.nextSibling;
249
249
-
}
250
250
-
if (nextMatchByTagName) {
251
251
-
this.#insertBefore(parent, nextMatchByTagName, child);
252
252
-
this.#morphNode([nextMatchByTagName, reference]);
253
253
-
} else {
254
254
-
const newNode = reference.cloneNode(true);
255
255
-
if (this.#beforeNodeAdded?.(newNode) ?? true) {
256
256
-
this.#insertBefore(parent, newNode, child);
257
257
-
this.#afterNodeAdded?.(newNode);
258
258
-
}
259
259
-
}
260
260
-
this.#afterNodeMorphed?.(child, writableNode(reference));
261
261
-
}
262
262
-
#updateProperty(node, propertyName, newValue) {
263
263
-
const previousValue = node[propertyName];
264
264
-
if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) {
265
265
-
node[propertyName] = newValue;
266
266
-
this.#afterPropertyUpdated?.(node, propertyName, previousValue);
267
267
-
}
268
268
-
}
269
269
-
#replaceNode(node, newNode) {
270
270
-
if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) {
271
271
-
node.replaceWith(newNode);
272
272
-
this.#afterNodeAdded?.(newNode);
273
273
-
this.#afterNodeRemoved?.(node);
274
274
-
}
275
275
-
}
276
276
-
#insertBefore(parent, node, insertionPoint) {
277
277
-
if (node === insertionPoint) return;
278
278
-
if (isElement(node)) {
279
279
-
const sensitivity = this.#sensivityMap.get(node) ?? 0;
280
280
-
if (sensitivity > 0) {
281
281
-
let previousNode = node.previousSibling;
282
282
-
while (previousNode) {
283
283
-
const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0;
284
284
-
if (previousNodeSensitivity < sensitivity) {
285
285
-
parent.insertBefore(previousNode, node.nextSibling);
286
286
-
if (previousNode === insertionPoint) return;
287
287
-
previousNode = node.previousSibling;
288
288
-
} else break;
289
289
-
}
290
290
-
}
291
291
-
}
292
292
-
parent.insertBefore(node, insertionPoint);
293
293
-
}
294
294
-
#appendChild(node, newNode) {
295
295
-
if (this.#beforeNodeAdded?.(newNode) ?? true) {
296
296
-
node.appendChild(newNode);
297
297
-
this.#afterNodeAdded?.(newNode);
298
298
-
}
299
299
-
}
300
300
-
#removeNode(node) {
301
301
-
if (this.#beforeNodeRemoved?.(node) ?? true) {
302
302
-
node.remove();
303
303
-
this.#afterNodeRemoved?.(node);
304
304
-
}
305
305
-
}
20
20
+
#idMap;
21
21
+
#sensivityMap;
22
22
+
#ignoreActiveValue;
23
23
+
#preserveModifiedValues;
24
24
+
#beforeNodeMorphed;
25
25
+
#afterNodeMorphed;
26
26
+
#beforeNodeAdded;
27
27
+
#afterNodeAdded;
28
28
+
#beforeNodeRemoved;
29
29
+
#afterNodeRemoved;
30
30
+
#beforeAttributeUpdated;
31
31
+
#afterAttributeUpdated;
32
32
+
#beforePropertyUpdated;
33
33
+
#afterPropertyUpdated;
34
34
+
constructor(options = {}) {
35
35
+
this.#idMap = new WeakMap();
36
36
+
this.#sensivityMap = new WeakMap();
37
37
+
this.#ignoreActiveValue = options.ignoreActiveValue || false;
38
38
+
this.#preserveModifiedValues = options.preserveModifiedValues || false;
39
39
+
this.#beforeNodeMorphed = options.beforeNodeMorphed;
40
40
+
this.#afterNodeMorphed = options.afterNodeMorphed;
41
41
+
this.#beforeNodeAdded = options.beforeNodeAdded;
42
42
+
this.#afterNodeAdded = options.afterNodeAdded;
43
43
+
this.#beforeNodeRemoved = options.beforeNodeRemoved;
44
44
+
this.#afterNodeRemoved = options.afterNodeRemoved;
45
45
+
this.#beforeAttributeUpdated = options.beforeAttributeUpdated;
46
46
+
this.#afterAttributeUpdated = options.afterAttributeUpdated;
47
47
+
this.#beforePropertyUpdated = options.beforePropertyUpdated;
48
48
+
this.#afterPropertyUpdated = options.afterPropertyUpdated;
49
49
+
Object.freeze(this);
50
50
+
}
51
51
+
morph(pair) {
52
52
+
if (isParentNodePair(pair))
53
53
+
this.#buildMaps(pair);
54
54
+
this.#morphNode(pair);
55
55
+
}
56
56
+
morphInner(pair) {
57
57
+
this.#buildMaps(pair);
58
58
+
if (isMatchingElementPair(pair)) {
59
59
+
this.#morphMatchingElementContent(pair);
60
60
+
}
61
61
+
else {
62
62
+
throw new Error("You can only do an inner morph with matching elements.");
63
63
+
}
64
64
+
}
65
65
+
#buildMaps([node, reference]) {
66
66
+
this.#mapIdSets(node);
67
67
+
this.#mapIdSets(reference);
68
68
+
this.#mapSensivity(node);
69
69
+
Object.freeze(this.#idMap);
70
70
+
Object.freeze(this.#sensivityMap);
71
71
+
}
72
72
+
#mapSensivity(node) {
73
73
+
const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video");
74
74
+
const sensitiveElementsLength = sensitiveElements.length;
75
75
+
for (let i = 0; i < sensitiveElementsLength; i++) {
76
76
+
const sensitiveElement = sensitiveElements[i];
77
77
+
let sensivity = 0;
78
78
+
if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) {
79
79
+
sensivity++;
80
80
+
if (sensitiveElement.value !== sensitiveElement.defaultValue)
81
81
+
sensivity++;
82
82
+
if (sensitiveElement === document.activeElement)
83
83
+
sensivity++;
84
84
+
}
85
85
+
else {
86
86
+
sensivity += 3;
87
87
+
if (isMedia(sensitiveElement) && !sensitiveElement.ended) {
88
88
+
if (!sensitiveElement.paused)
89
89
+
sensivity++;
90
90
+
if (sensitiveElement.currentTime > 0)
91
91
+
sensivity++;
92
92
+
}
93
93
+
}
94
94
+
let current = sensitiveElement;
95
95
+
while (current) {
96
96
+
this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity);
97
97
+
if (current === node)
98
98
+
break;
99
99
+
current = current.parentElement;
100
100
+
}
101
101
+
}
102
102
+
}
103
103
+
// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
104
104
+
#mapIdSets(node) {
105
105
+
const elementsWithIds = node.querySelectorAll("[id]");
106
106
+
const elementsWithIdsLength = elementsWithIds.length;
107
107
+
for (let i = 0; i < elementsWithIdsLength; i++) {
108
108
+
const elementWithId = elementsWithIds[i];
109
109
+
const id = elementWithId.id;
110
110
+
// Ignore empty IDs
111
111
+
if (id === "")
112
112
+
continue;
113
113
+
let current = elementWithId;
114
114
+
while (current) {
115
115
+
const idSet = this.#idMap.get(current);
116
116
+
idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id]));
117
117
+
if (current === node)
118
118
+
break;
119
119
+
current = current.parentElement;
120
120
+
}
121
121
+
}
122
122
+
}
123
123
+
// This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`.
124
124
+
#morphNode(pair) {
125
125
+
if (isMatchingElementPair(pair))
126
126
+
this.#morphMatchingElementNode(pair);
127
127
+
else
128
128
+
this.#morphOtherNode(pair);
129
129
+
}
130
130
+
#morphMatchingElementNode(pair) {
131
131
+
const [node, reference] = pair;
132
132
+
if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true))
133
133
+
return;
134
134
+
if (node.hasAttributes() || reference.hasAttributes())
135
135
+
this.#morphAttributes(pair);
136
136
+
// TODO: Should use a branded pair here.
137
137
+
this.#morphMatchingElementContent(pair);
138
138
+
this.#afterNodeMorphed?.(node, writableNode(reference));
139
139
+
}
140
140
+
#morphOtherNode([node, reference]) {
141
141
+
if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true))
142
142
+
return;
143
143
+
if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) {
144
144
+
// Handle text nodes, comments, and CDATA sections.
145
145
+
this.#updateProperty(node, "nodeValue", reference.nodeValue);
146
146
+
}
147
147
+
else
148
148
+
this.#replaceNode(node, reference.cloneNode(true));
149
149
+
this.#afterNodeMorphed?.(node, writableNode(reference));
150
150
+
}
151
151
+
#morphMatchingElementContent(pair) {
152
152
+
const [node, reference] = pair;
153
153
+
if (isHead(node)) {
154
154
+
// We can pass the reference as a head here becuase we know it's the same as the node.
155
155
+
this.#morphHeadContents(pair);
156
156
+
}
157
157
+
else if (node.hasChildNodes() || reference.hasChildNodes())
158
158
+
this.#morphChildNodes(pair);
159
159
+
}
160
160
+
#morphHeadContents([node, reference]) {
161
161
+
const refChildNodesMap = new Map();
162
162
+
// Generate a map of the reference head element’s child nodes, keyed by their outerHTML.
163
163
+
const referenceChildrenLength = reference.children.length;
164
164
+
for (let i = 0; i < referenceChildrenLength; i++) {
165
165
+
const child = reference.children[i];
166
166
+
refChildNodesMap.set(child.outerHTML, child);
167
167
+
}
168
168
+
const nodeChildrenLength = node.children.length;
169
169
+
for (let i = 0; i < nodeChildrenLength; i++) {
170
170
+
const child = node.children[i];
171
171
+
const key = child.outerHTML;
172
172
+
const refChild = refChildNodesMap.get(key);
173
173
+
// If the child is in the reference map already, we don’t need to add it later.
174
174
+
// If it’s not in the map, we need to remove it from the node.
175
175
+
refChild ? refChildNodesMap.delete(key) : this.#removeNode(child);
176
176
+
}
177
177
+
// Any remaining nodes in the map should be appended to the head.
178
178
+
for (const refChild of refChildNodesMap.values())
179
179
+
this.#appendChild(node, refChild.cloneNode(true));
180
180
+
}
181
181
+
#morphAttributes([element, reference]) {
182
182
+
// Remove any excess attributes from the element that aren’t present in the reference.
183
183
+
for (const { name, value } of element.attributes) {
184
184
+
if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) {
185
185
+
element.removeAttribute(name);
186
186
+
this.#afterAttributeUpdated?.(element, name, value);
187
187
+
}
188
188
+
}
189
189
+
// Copy attributes from the reference to the element, if they don’t already match.
190
190
+
for (const { name, value } of reference.attributes) {
191
191
+
const previousValue = element.getAttribute(name);
192
192
+
if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) {
193
193
+
element.setAttribute(name, value);
194
194
+
this.#afterAttributeUpdated?.(element, name, previousValue);
195
195
+
}
196
196
+
}
197
197
+
// For certain types of elements, we need to do some extra work to ensure
198
198
+
// the element’s state matches the reference elements’ state.
199
199
+
if (isInput(element) && isInput(reference)) {
200
200
+
this.#updateProperty(element, "checked", reference.checked);
201
201
+
this.#updateProperty(element, "disabled", reference.disabled);
202
202
+
this.#updateProperty(element, "indeterminate", reference.indeterminate);
203
203
+
if (element.type !== "file" &&
204
204
+
!(this.#ignoreActiveValue && document.activeElement === element) &&
205
205
+
!(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) {
206
206
+
this.#updateProperty(element, "value", reference.value);
207
207
+
}
208
208
+
}
209
209
+
else if (isOption(element) && isOption(reference)) {
210
210
+
this.#updateProperty(element, "selected", reference.selected);
211
211
+
}
212
212
+
else if (isTextArea(element) &&
213
213
+
isTextArea(reference) &&
214
214
+
!(this.#ignoreActiveValue && document.activeElement === element) &&
215
215
+
!(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) {
216
216
+
this.#updateProperty(element, "value", reference.value);
217
217
+
const text = element.firstElementChild;
218
218
+
if (text)
219
219
+
this.#updateProperty(text, "textContent", reference.value);
220
220
+
}
221
221
+
}
222
222
+
// Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match.
223
223
+
#morphChildNodes(pair) {
224
224
+
const [element, reference] = pair;
225
225
+
const childNodes = element.childNodes;
226
226
+
const refChildNodes = reference.childNodes;
227
227
+
for (let i = 0; i < refChildNodes.length; i++) {
228
228
+
const child = childNodes[i];
229
229
+
const refChild = refChildNodes[i];
230
230
+
if (child && refChild) {
231
231
+
const pair = [child, refChild];
232
232
+
if (isMatchingElementPair(pair)) {
233
233
+
if (isHead(pair[0])) {
234
234
+
this.#morphHeadContents(pair);
235
235
+
}
236
236
+
else {
237
237
+
this.#morphChildElement(pair, element);
238
238
+
}
239
239
+
}
240
240
+
else
241
241
+
this.#morphOtherNode(pair);
242
242
+
}
243
243
+
else if (refChild) {
244
244
+
this.#appendChild(element, refChild.cloneNode(true));
245
245
+
}
246
246
+
else if (child) {
247
247
+
this.#removeNode(child);
248
248
+
}
249
249
+
}
250
250
+
// Clean up any excess nodes that may be left over
251
251
+
while (childNodes.length > refChildNodes.length) {
252
252
+
const child = element.lastChild;
253
253
+
if (child)
254
254
+
this.#removeNode(child);
255
255
+
}
256
256
+
}
257
257
+
#morphChildElement([child, reference], parent) {
258
258
+
if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true))
259
259
+
return;
260
260
+
const refIdSet = this.#idMap.get(reference);
261
261
+
// Generate the array in advance of the loop
262
262
+
const refSetArray = refIdSet ? [...refIdSet] : [];
263
263
+
let currentNode = child;
264
264
+
let nextMatchByTagName = null;
265
265
+
// Try find a match by idSet, while also looking out for the next best match by tagName.
266
266
+
while (currentNode) {
267
267
+
if (isElement(currentNode)) {
268
268
+
const id = currentNode.id;
269
269
+
if (!nextMatchByTagName && currentNode.localName === reference.localName) {
270
270
+
nextMatchByTagName = currentNode;
271
271
+
}
272
272
+
if (id !== "") {
273
273
+
if (id === reference.id) {
274
274
+
this.#insertBefore(parent, currentNode, child);
275
275
+
return this.#morphNode([currentNode, reference]);
276
276
+
}
277
277
+
else {
278
278
+
const currentIdSet = this.#idMap.get(currentNode);
279
279
+
if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) {
280
280
+
this.#insertBefore(parent, currentNode, child);
281
281
+
return this.#morphNode([currentNode, reference]);
282
282
+
}
283
283
+
}
284
284
+
}
285
285
+
}
286
286
+
currentNode = currentNode.nextSibling;
287
287
+
}
288
288
+
if (nextMatchByTagName) {
289
289
+
this.#insertBefore(parent, nextMatchByTagName, child);
290
290
+
this.#morphNode([nextMatchByTagName, reference]);
291
291
+
}
292
292
+
else {
293
293
+
const newNode = reference.cloneNode(true);
294
294
+
if (this.#beforeNodeAdded?.(newNode) ?? true) {
295
295
+
this.#insertBefore(parent, newNode, child);
296
296
+
this.#afterNodeAdded?.(newNode);
297
297
+
}
298
298
+
}
299
299
+
this.#afterNodeMorphed?.(child, writableNode(reference));
300
300
+
}
301
301
+
#updateProperty(node, propertyName, newValue) {
302
302
+
const previousValue = node[propertyName];
303
303
+
if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) {
304
304
+
node[propertyName] = newValue;
305
305
+
this.#afterPropertyUpdated?.(node, propertyName, previousValue);
306
306
+
}
307
307
+
}
308
308
+
#replaceNode(node, newNode) {
309
309
+
if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) {
310
310
+
node.replaceWith(newNode);
311
311
+
this.#afterNodeAdded?.(newNode);
312
312
+
this.#afterNodeRemoved?.(node);
313
313
+
}
314
314
+
}
315
315
+
#insertBefore(parent, node, insertionPoint) {
316
316
+
if (node === insertionPoint)
317
317
+
return;
318
318
+
if (isElement(node)) {
319
319
+
const sensitivity = this.#sensivityMap.get(node) ?? 0;
320
320
+
if (sensitivity > 0) {
321
321
+
let previousNode = node.previousSibling;
322
322
+
while (previousNode) {
323
323
+
const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0;
324
324
+
if (previousNodeSensitivity < sensitivity) {
325
325
+
parent.insertBefore(previousNode, node.nextSibling);
326
326
+
if (previousNode === insertionPoint)
327
327
+
return;
328
328
+
previousNode = node.previousSibling;
329
329
+
}
330
330
+
else
331
331
+
break;
332
332
+
}
333
333
+
}
334
334
+
}
335
335
+
parent.insertBefore(node, insertionPoint);
336
336
+
}
337
337
+
#appendChild(node, newNode) {
338
338
+
if (this.#beforeNodeAdded?.(newNode) ?? true) {
339
339
+
node.appendChild(newNode);
340
340
+
this.#afterNodeAdded?.(newNode);
341
341
+
}
342
342
+
}
343
343
+
#removeNode(node) {
344
344
+
if (this.#beforeNodeRemoved?.(node) ?? true) {
345
345
+
node.remove();
346
346
+
this.#afterNodeRemoved?.(node);
347
347
+
}
348
348
+
}
306
349
}
307
350
// We cannot use `instanceof` when nodes might be from different documents,
308
351
// so we use type guards instead. This keeps TypeScript happy, while doing
309
352
// the necessary checks at runtime.
310
353
function writableNode(node) {
311
311
-
return node;
354
354
+
return node;
312
355
}
313
356
function isMatchingElementPair(pair) {
314
314
-
const [a, b] = pair;
315
315
-
return isElement(a) && isElement(b) && a.localName === b.localName;
357
357
+
const [a, b] = pair;
358
358
+
return isElement(a) && isElement(b) && a.localName === b.localName;
316
359
}
317
360
function isParentNodePair(pair) {
318
318
-
const [a, b] = pair;
319
319
-
return isParentNode(a) && isParentNode(b);
361
361
+
const [a, b] = pair;
362
362
+
return isParentNode(a) && isParentNode(b);
320
363
}
321
364
function isElement(node) {
322
322
-
return node.nodeType === 1;
365
365
+
return node.nodeType === 1;
323
366
}
324
367
function isMedia(element) {
325
325
-
return element.localName === "video" || element.localName === "audio";
368
368
+
return element.localName === "video" || element.localName === "audio";
326
369
}
327
370
function isInput(element) {
328
328
-
return element.localName === "input";
371
371
+
return element.localName === "input";
329
372
}
330
373
function isOption(element) {
331
331
-
return element.localName === "option";
374
374
+
return element.localName === "option";
332
375
}
333
376
function isTextArea(element) {
334
334
-
return element.localName === "textarea";
377
377
+
return element.localName === "textarea";
335
378
}
336
379
function isHead(element) {
337
337
-
return element.localName === "head";
380
380
+
return element.localName === "head";
338
381
}
339
382
const parentNodeTypes = new Set([1, 9, 11]);
340
383
function isParentNode(node) {
341
341
-
return parentNodeTypes.has(node.nodeType);
384
384
+
return parentNodeTypes.has(node.nodeType);
342
385
}
343
343
-
//# sourceMappingURL=morphlex.js.map
386
386
+
//# sourceMappingURL=morphlex.js.map
+15
-4
src/morphlex.ts
···
131
131
132
132
#mapSensivity(node: ReadonlyNode<ParentNode>): void {
133
133
const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video");
134
134
-
for (const sensitiveElement of sensitiveElements) {
134
134
+
135
135
+
const sensitiveElementsLength = sensitiveElements.length;
136
136
+
for (let i = 0; i < sensitiveElementsLength; i++) {
137
137
+
const sensitiveElement = sensitiveElements[i];
135
138
let sensivity = 0;
136
139
137
140
if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) {
···
161
164
#mapIdSets(node: ReadonlyNode<ParentNode>): void {
162
165
const elementsWithIds = node.querySelectorAll("[id]");
163
166
164
164
-
for (const elementWithId of elementsWithIds) {
167
167
+
const elementsWithIdsLength = elementsWithIds.length;
168
168
+
for (let i = 0; i < elementsWithIdsLength; i++) {
169
169
+
const elementWithId = elementsWithIds[i];
165
170
const id = elementWithId.id;
166
171
167
172
// Ignore empty IDs
···
221
226
const refChildNodesMap: Map<string, ReadonlyNode<Element>> = new Map();
222
227
223
228
// Generate a map of the reference head element’s child nodes, keyed by their outerHTML.
224
224
-
for (const child of reference.children) refChildNodesMap.set(child.outerHTML, child);
229
229
+
const referenceChildrenLength = reference.children.length;
230
230
+
for (let i = 0; i < referenceChildrenLength; i++) {
231
231
+
const child = reference.children[i];
232
232
+
refChildNodesMap.set(child.outerHTML, child);
233
233
+
}
225
234
226
226
-
for (const child of node.children) {
235
235
+
const nodeChildrenLength = node.children.length;
236
236
+
for (let i = 0; i < nodeChildrenLength; i++) {
237
237
+
const child = node.children[i];
227
238
const key = child.outerHTML;
228
239
const refChild = refChildNodesMap.get(key);
229
240