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