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