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