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