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
Better minification with # private properties
joel.drapper.me
4 months ago
e3df16d4
baf62c5d
+62
-62
1 changed file
expand all
collapse all
unified
split
src
morphlex.ts
+62
-62
src/morphlex.ts
···
101
}
102
103
class Morph {
104
-
private readonly idMap: IdMap = new WeakMap()
105
-
private readonly options: Options
106
107
constructor(options: Options = {}) {
108
-
this.options = options
109
}
110
111
// Find longest increasing subsequence to minimize moves during reordering
112
// Returns the indices in the sequence that form the LIS
113
-
private longestIncreasingSubsequence(sequence: Array<number>): Array<number> {
114
const n = sequence.length
115
if (n === 0) return []
116
···
167
168
morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void {
169
if (isParentNode(from)) {
170
-
this.mapIdSets(from)
171
}
172
173
if (to instanceof NodeList) {
174
-
this.mapIdSetsForEach(to)
175
-
this.morphOneToMany(from, to)
176
} else if (isParentNode(to)) {
177
-
this.mapIdSets(to)
178
-
this.morphOneToOne(from, to)
179
}
180
}
181
182
-
private morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void {
183
const length = to.length
184
185
if (length === 0) {
186
-
this.removeNode(from)
187
} else if (length === 1) {
188
-
this.morphOneToOne(from, to[0]!)
189
} else if (length > 1) {
190
const newNodes = Array.from(to)
191
-
this.morphOneToOne(from, newNodes.shift()!)
192
const insertionPoint = from.nextSibling
193
const parent = from.parentNode || document
194
195
for (const newNode of newNodes) {
196
-
if (this.options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
197
moveBefore(parent, newNode, insertionPoint)
198
-
this.options.afterNodeAdded?.(newNode)
199
}
200
}
201
}
202
}
203
204
-
private morphOneToOne(from: ChildNode, to: ChildNode): void {
205
// Fast path: if nodes are exactly the same object, skip morphing
206
if (from === to) return
207
if (from.isEqualNode?.(to)) return
208
209
-
if (!(this.options.beforeNodeVisited?.(from, to) ?? true)) return
210
211
const pair: PairOfNodes<ChildNode> = [from, to]
212
213
if (isElementPair(pair)) {
214
if (isMatchingElementPair(pair)) {
215
-
this.morphMatchingElements(pair)
216
} else {
217
-
this.morphNonMatchingElements(pair)
218
}
219
} else {
220
-
this.morphOtherNode(pair)
221
}
222
223
-
this.options.afterNodeVisited?.(from, to)
224
}
225
226
-
private morphMatchingElements(pair: PairOfMatchingElements<Element>): void {
227
const [from, to] = pair
228
229
if (from.hasAttributes() || to.hasAttributes()) {
230
-
this.visitAttributes(pair)
231
}
232
233
if (isTextAreaElement(from) && isTextAreaElement(to)) {
234
-
this.visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>)
235
} else if (from.hasChildNodes() || to.hasChildNodes()) {
236
this.visitChildNodes(pair)
237
}
238
}
239
240
-
private morphNonMatchingElements([from, to]: PairOfNodes<Element>): void {
241
-
this.replaceNode(from, to)
242
}
243
244
-
private morphOtherNode([from, to]: PairOfNodes<ChildNode>): void {
245
if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) {
246
from.nodeValue = to.nodeValue
247
} else {
248
-
this.replaceNode(from, to)
249
}
250
}
251
252
-
private visitAttributes([from, to]: PairOfMatchingElements<Element>): void {
253
if (from.hasAttribute("morphlex-dirty")) {
254
from.removeAttribute("morphlex-dirty")
255
}
···
258
for (const { name, value } of to.attributes) {
259
if (name === "value") {
260
if (isInputElement(from) && from.value !== value) {
261
-
if (!this.options.preserveModified || from.value === from.defaultValue) {
262
from.value = value
263
}
264
}
···
266
267
if (name === "selected") {
268
if (isOptionElement(from) && !from.selected) {
269
-
if (!this.options.preserveModified || from.selected === from.defaultSelected) {
270
from.selected = true
271
}
272
}
···
274
275
if (name === "checked") {
276
if (isInputElement(from) && !from.checked) {
277
-
if (!this.options.preserveModified || from.checked === from.defaultChecked) {
278
from.checked = true
279
}
280
}
···
282
283
const oldValue = from.getAttribute(name)
284
285
-
if (oldValue !== value && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) {
286
from.setAttribute(name, value)
287
-
this.options.afterAttributeUpdated?.(from, name, oldValue)
288
}
289
}
290
···
297
if (!to.hasAttribute(name)) {
298
if (name === "selected") {
299
if (isOptionElement(from) && from.selected) {
300
-
if (!this.options.preserveModified || from.selected === from.defaultSelected) {
301
from.selected = false
302
}
303
}
···
305
306
if (name === "checked") {
307
if (isInputElement(from) && from.checked) {
308
-
if (!this.options.preserveModified || from.checked === from.defaultChecked) {
309
from.checked = false
310
}
311
}
312
}
313
314
-
if (this.options.beforeAttributeUpdated?.(from, name, null) ?? true) {
315
from.removeAttribute(name)
316
-
this.options.afterAttributeUpdated?.(from, name, value)
317
}
318
}
319
}
320
}
321
322
-
private visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void {
323
const newTextContent = to.textContent || ""
324
const isModified = from.value !== from.defaultValue
325
···
328
from.textContent = newTextContent
329
}
330
331
-
if (this.options.preserveModified && isModified) return
332
333
from.value = from.defaultValue
334
}
335
336
visitChildNodes([from, to]: PairOfMatchingElements<Element>): void {
337
-
if (!(this.options.beforeChildrenVisited?.(from) ?? true)) return
338
const parent = from
339
340
const fromChildNodes = from.childNodes
···
388
const element = toChildNodes[i]!
389
if (!isElement(element)) continue
390
391
-
const idSet = this.idMap.get(element)
392
if (!idSet) continue
393
394
candidateLoop: for (const candidate of candidateElements) {
395
if (isElement(candidate)) {
396
-
const candidateIdSet = this.idMap.get(candidate)
397
if (candidateIdSet) {
398
for (const id of idSet) {
399
if (candidateIdSet.has(id)) {
···
500
}
501
502
// Find LIS - these nodes don't need to move
503
-
const lisIndices = this.longestIncreasingSubsequence(sequence)
504
const shouldNotMove = new Set<number>()
505
for (const idx of lisIndices) {
506
shouldNotMove.add(sequence[idx]!)
···
517
if (!shouldNotMove.has(matchIndex)) {
518
moveBefore(parent, match, insertionPoint)
519
}
520
-
this.morphOneToOne(match, node)
521
insertionPoint = match.nextSibling
522
// Skip over any nodes that will be removed to avoid unnecessary moves
523
while (
···
527
insertionPoint = insertionPoint.nextSibling
528
}
529
} else {
530
-
if (this.options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
531
moveBefore(parent, node, insertionPoint)
532
-
this.options.afterNodeAdded?.(node)
533
insertionPoint = node.nextSibling
534
// Skip over any nodes that will be removed to avoid unnecessary moves
535
while (
···
544
545
// Remove any remaining unmatched candidates
546
for (const candidate of candidateNodes) {
547
-
this.removeNode(candidate)
548
}
549
550
for (const candidate of candidateElements) {
551
-
this.removeNode(candidate)
552
}
553
554
-
this.options.afterChildrenVisited?.(from)
555
}
556
557
-
private replaceNode(node: ChildNode, newNode: ChildNode): void {
558
const parent = node.parentNode || document
559
const insertionPoint = node
560
-
if (this.options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
561
moveBefore(parent, newNode, insertionPoint)
562
-
this.options.afterNodeAdded?.(newNode)
563
-
this.removeNode(node)
564
}
565
}
566
567
-
private removeNode(node: ChildNode): void {
568
-
if (this.options.beforeNodeRemoved?.(node) ?? true) {
569
node.remove()
570
-
this.options.afterNodeRemoved?.(node)
571
}
572
}
573
574
-
private mapIdSetsForEach(nodeList: NodeList): void {
575
for (const childNode of nodeList) {
576
if (isParentNode(childNode)) {
577
-
this.mapIdSets(childNode)
578
}
579
}
580
}
581
582
// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
583
-
private mapIdSets(node: ParentNode): void {
584
for (const elementWithId of node.querySelectorAll("[id]")) {
585
const id = elementWithId.id
586
···
589
let currentElement: Element | null = elementWithId
590
591
while (currentElement) {
592
-
const idSet: IdSet | undefined = this.idMap.get(currentElement)
593
if (idSet) idSet.add(id)
594
-
else this.idMap.set(currentElement, new Set([id]))
595
if (currentElement === node) break
596
currentElement = currentElement.parentElement
597
}
···
101
}
102
103
class Morph {
104
+
readonly #idMap: IdMap = new WeakMap()
105
+
readonly #options: Options
106
107
constructor(options: Options = {}) {
108
+
this.#options = options
109
}
110
111
// Find longest increasing subsequence to minimize moves during reordering
112
// Returns the indices in the sequence that form the LIS
113
+
#longestIncreasingSubsequence(sequence: Array<number>): Array<number> {
114
const n = sequence.length
115
if (n === 0) return []
116
···
167
168
morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void {
169
if (isParentNode(from)) {
170
+
this.#mapIdSets(from)
171
}
172
173
if (to instanceof NodeList) {
174
+
this.#mapIdSetsForEach(to)
175
+
this.#morphOneToMany(from, to)
176
} else if (isParentNode(to)) {
177
+
this.#mapIdSets(to)
178
+
this.#morphOneToOne(from, to)
179
}
180
}
181
182
+
#morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void {
183
const length = to.length
184
185
if (length === 0) {
186
+
this.#removeNode(from)
187
} else if (length === 1) {
188
+
this.#morphOneToOne(from, to[0]!)
189
} else if (length > 1) {
190
const newNodes = Array.from(to)
191
+
this.#morphOneToOne(from, newNodes.shift()!)
192
const insertionPoint = from.nextSibling
193
const parent = from.parentNode || document
194
195
for (const newNode of newNodes) {
196
+
if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
197
moveBefore(parent, newNode, insertionPoint)
198
+
this.#options.afterNodeAdded?.(newNode)
199
}
200
}
201
}
202
}
203
204
+
#morphOneToOne(from: ChildNode, to: ChildNode): void {
205
// Fast path: if nodes are exactly the same object, skip morphing
206
if (from === to) return
207
if (from.isEqualNode?.(to)) return
208
209
+
if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
210
211
const pair: PairOfNodes<ChildNode> = [from, to]
212
213
if (isElementPair(pair)) {
214
if (isMatchingElementPair(pair)) {
215
+
this.#morphMatchingElements(pair)
216
} else {
217
+
this.#morphNonMatchingElements(pair)
218
}
219
} else {
220
+
this.#morphOtherNode(pair)
221
}
222
223
+
this.#options.afterNodeVisited?.(from, to)
224
}
225
226
+
#morphMatchingElements(pair: PairOfMatchingElements<Element>): void {
227
const [from, to] = pair
228
229
if (from.hasAttributes() || to.hasAttributes()) {
230
+
this.#visitAttributes(pair)
231
}
232
233
if (isTextAreaElement(from) && isTextAreaElement(to)) {
234
+
this.#visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>)
235
} else if (from.hasChildNodes() || to.hasChildNodes()) {
236
this.visitChildNodes(pair)
237
}
238
}
239
240
+
#morphNonMatchingElements([from, to]: PairOfNodes<Element>): void {
241
+
this.#replaceNode(from, to)
242
}
243
244
+
#morphOtherNode([from, to]: PairOfNodes<ChildNode>): void {
245
if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) {
246
from.nodeValue = to.nodeValue
247
} else {
248
+
this.#replaceNode(from, to)
249
}
250
}
251
252
+
#visitAttributes([from, to]: PairOfMatchingElements<Element>): void {
253
if (from.hasAttribute("morphlex-dirty")) {
254
from.removeAttribute("morphlex-dirty")
255
}
···
258
for (const { name, value } of to.attributes) {
259
if (name === "value") {
260
if (isInputElement(from) && from.value !== value) {
261
+
if (!this.#options.preserveModified || from.value === from.defaultValue) {
262
from.value = value
263
}
264
}
···
266
267
if (name === "selected") {
268
if (isOptionElement(from) && !from.selected) {
269
+
if (!this.#options.preserveModified || from.selected === from.defaultSelected) {
270
from.selected = true
271
}
272
}
···
274
275
if (name === "checked") {
276
if (isInputElement(from) && !from.checked) {
277
+
if (!this.#options.preserveModified || from.checked === from.defaultChecked) {
278
from.checked = true
279
}
280
}
···
282
283
const oldValue = from.getAttribute(name)
284
285
+
if (oldValue !== value && (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true)) {
286
from.setAttribute(name, value)
287
+
this.#options.afterAttributeUpdated?.(from, name, oldValue)
288
}
289
}
290
···
297
if (!to.hasAttribute(name)) {
298
if (name === "selected") {
299
if (isOptionElement(from) && from.selected) {
300
+
if (!this.#options.preserveModified || from.selected === from.defaultSelected) {
301
from.selected = false
302
}
303
}
···
305
306
if (name === "checked") {
307
if (isInputElement(from) && from.checked) {
308
+
if (!this.#options.preserveModified || from.checked === from.defaultChecked) {
309
from.checked = false
310
}
311
}
312
}
313
314
+
if (this.#options.beforeAttributeUpdated?.(from, name, null) ?? true) {
315
from.removeAttribute(name)
316
+
this.#options.afterAttributeUpdated?.(from, name, value)
317
}
318
}
319
}
320
}
321
322
+
#visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void {
323
const newTextContent = to.textContent || ""
324
const isModified = from.value !== from.defaultValue
325
···
328
from.textContent = newTextContent
329
}
330
331
+
if (this.#options.preserveModified && isModified) return
332
333
from.value = from.defaultValue
334
}
335
336
visitChildNodes([from, to]: PairOfMatchingElements<Element>): void {
337
+
if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return
338
const parent = from
339
340
const fromChildNodes = from.childNodes
···
388
const element = toChildNodes[i]!
389
if (!isElement(element)) continue
390
391
+
const idSet = this.#idMap.get(element)
392
if (!idSet) continue
393
394
candidateLoop: for (const candidate of candidateElements) {
395
if (isElement(candidate)) {
396
+
const candidateIdSet = this.#idMap.get(candidate)
397
if (candidateIdSet) {
398
for (const id of idSet) {
399
if (candidateIdSet.has(id)) {
···
500
}
501
502
// Find LIS - these nodes don't need to move
503
+
const lisIndices = this.#longestIncreasingSubsequence(sequence)
504
const shouldNotMove = new Set<number>()
505
for (const idx of lisIndices) {
506
shouldNotMove.add(sequence[idx]!)
···
517
if (!shouldNotMove.has(matchIndex)) {
518
moveBefore(parent, match, insertionPoint)
519
}
520
+
this.#morphOneToOne(match, node)
521
insertionPoint = match.nextSibling
522
// Skip over any nodes that will be removed to avoid unnecessary moves
523
while (
···
527
insertionPoint = insertionPoint.nextSibling
528
}
529
} else {
530
+
if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
531
moveBefore(parent, node, insertionPoint)
532
+
this.#options.afterNodeAdded?.(node)
533
insertionPoint = node.nextSibling
534
// Skip over any nodes that will be removed to avoid unnecessary moves
535
while (
···
544
545
// Remove any remaining unmatched candidates
546
for (const candidate of candidateNodes) {
547
+
this.#removeNode(candidate)
548
}
549
550
for (const candidate of candidateElements) {
551
+
this.#removeNode(candidate)
552
}
553
554
+
this.#options.afterChildrenVisited?.(from)
555
}
556
557
+
#replaceNode(node: ChildNode, newNode: ChildNode): void {
558
const parent = node.parentNode || document
559
const insertionPoint = node
560
+
if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
561
moveBefore(parent, newNode, insertionPoint)
562
+
this.#options.afterNodeAdded?.(newNode)
563
+
this.#removeNode(node)
564
}
565
}
566
567
+
#removeNode(node: ChildNode): void {
568
+
if (this.#options.beforeNodeRemoved?.(node) ?? true) {
569
node.remove()
570
+
this.#options.afterNodeRemoved?.(node)
571
}
572
}
573
574
+
#mapIdSetsForEach(nodeList: NodeList): void {
575
for (const childNode of nodeList) {
576
if (isParentNode(childNode)) {
577
+
this.#mapIdSets(childNode)
578
}
579
}
580
}
581
582
// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
583
+
#mapIdSets(node: ParentNode): void {
584
for (const elementWithId of node.querySelectorAll("[id]")) {
585
const id = elementWithId.id
586
···
589
let currentElement: Element | null = elementWithId
590
591
while (currentElement) {
592
+
const idSet: IdSet | undefined = this.#idMap.get(currentElement)
593
if (idSet) idSet.add(id)
594
+
else this.#idMap.set(currentElement, new Set([id]))
595
if (currentElement === node) break
596
currentElement = currentElement.parentElement
597
}