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