@daz4126/swifty 2.12.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1385 @@
1
+ /**
2
+ * @typedef {object} ConfigHead
3
+ *
4
+ * @property {'merge' | 'append' | 'morph' | 'none'} [style]
5
+ * @property {boolean} [block]
6
+ * @property {boolean} [ignore]
7
+ * @property {function(Element): boolean} [shouldPreserve]
8
+ * @property {function(Element): boolean} [shouldReAppend]
9
+ * @property {function(Element): boolean} [shouldRemove]
10
+ * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} ConfigCallbacks
15
+ *
16
+ * @property {function(Node): boolean} [beforeNodeAdded]
17
+ * @property {function(Node): void} [afterNodeAdded]
18
+ * @property {function(Element, Node): boolean} [beforeNodeMorphed]
19
+ * @property {function(Element, Node): void} [afterNodeMorphed]
20
+ * @property {function(Element): boolean} [beforeNodeRemoved]
21
+ * @property {function(Element): void} [afterNodeRemoved]
22
+ * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} Config
27
+ *
28
+ * @property {'outerHTML' | 'innerHTML'} [morphStyle]
29
+ * @property {boolean} [ignoreActive]
30
+ * @property {boolean} [ignoreActiveValue]
31
+ * @property {boolean} [restoreFocus]
32
+ * @property {ConfigCallbacks} [callbacks]
33
+ * @property {ConfigHead} [head]
34
+ */
35
+
36
+ /**
37
+ * @typedef {function} NoOp
38
+ *
39
+ * @returns {void}
40
+ */
41
+
42
+ /**
43
+ * @typedef {object} ConfigHeadInternal
44
+ *
45
+ * @property {'merge' | 'append' | 'morph' | 'none'} style
46
+ * @property {boolean} [block]
47
+ * @property {boolean} [ignore]
48
+ * @property {(function(Element): boolean) | NoOp} shouldPreserve
49
+ * @property {(function(Element): boolean) | NoOp} shouldReAppend
50
+ * @property {(function(Element): boolean) | NoOp} shouldRemove
51
+ * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} ConfigCallbacksInternal
56
+ *
57
+ * @property {(function(Node): boolean) | NoOp} beforeNodeAdded
58
+ * @property {(function(Node): void) | NoOp} afterNodeAdded
59
+ * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
60
+ * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
61
+ * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved
62
+ * @property {(function(Node): void) | NoOp} afterNodeRemoved
63
+ * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} ConfigInternal
68
+ *
69
+ * @property {'outerHTML' | 'innerHTML'} morphStyle
70
+ * @property {boolean} [ignoreActive]
71
+ * @property {boolean} [ignoreActiveValue]
72
+ * @property {boolean} [restoreFocus]
73
+ * @property {ConfigCallbacksInternal} callbacks
74
+ * @property {ConfigHeadInternal} head
75
+ */
76
+
77
+ /**
78
+ * @typedef {Object} IdSets
79
+ * @property {Set<string>} persistentIds
80
+ * @property {Map<Node, Set<string>>} idMap
81
+ */
82
+
83
+ /**
84
+ * @typedef {Function} Morph
85
+ *
86
+ * @param {Element | Document} oldNode
87
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
88
+ * @param {Config} [config]
89
+ * @returns {undefined | Node[]}
90
+ */
91
+
92
+ // base IIFE to define idiomorph
93
+ /**
94
+ *
95
+ * @type {{defaults: ConfigInternal, morph: Morph}}
96
+ */
97
+ var Idiomorph = (function () {
98
+ "use strict";
99
+
100
+ /**
101
+ * @typedef {object} MorphContext
102
+ *
103
+ * @property {Element} target
104
+ * @property {Element} newContent
105
+ * @property {ConfigInternal} config
106
+ * @property {ConfigInternal['morphStyle']} morphStyle
107
+ * @property {ConfigInternal['ignoreActive']} ignoreActive
108
+ * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
109
+ * @property {ConfigInternal['restoreFocus']} restoreFocus
110
+ * @property {Map<Node, Set<string>>} idMap
111
+ * @property {Set<string>} persistentIds
112
+ * @property {ConfigInternal['callbacks']} callbacks
113
+ * @property {ConfigInternal['head']} head
114
+ * @property {HTMLDivElement} pantry
115
+ * @property {Element[]} activeElementAndParents
116
+ */
117
+
118
+ //=============================================================================
119
+ // AND NOW IT BEGINS...
120
+ //=============================================================================
121
+
122
+ const noOp = () => {};
123
+ /**
124
+ * Default configuration values, updatable by users now
125
+ * @type {ConfigInternal}
126
+ */
127
+ const defaults = {
128
+ morphStyle: "outerHTML",
129
+ callbacks: {
130
+ beforeNodeAdded: noOp,
131
+ afterNodeAdded: noOp,
132
+ beforeNodeMorphed: noOp,
133
+ afterNodeMorphed: noOp,
134
+ beforeNodeRemoved: noOp,
135
+ afterNodeRemoved: noOp,
136
+ beforeAttributeUpdated: noOp,
137
+ },
138
+ head: {
139
+ style: "merge",
140
+ shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
141
+ shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
142
+ shouldRemove: noOp,
143
+ afterHeadMorphed: noOp,
144
+ },
145
+ restoreFocus: true,
146
+ };
147
+
148
+ /**
149
+ * Core idiomorph function for morphing one DOM tree to another
150
+ *
151
+ * @param {Element | Document} oldNode
152
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
153
+ * @param {Config} [config]
154
+ * @returns {Promise<Node[]> | Node[]}
155
+ */
156
+ function morph(oldNode, newContent, config = {}) {
157
+ oldNode = normalizeElement(oldNode);
158
+ const newNode = normalizeParent(newContent);
159
+ const ctx = createMorphContext(oldNode, newNode, config);
160
+
161
+ const morphedNodes = saveAndRestoreFocus(ctx, () => {
162
+ return withHeadBlocking(
163
+ ctx,
164
+ oldNode,
165
+ newNode,
166
+ /** @param {MorphContext} ctx */ (ctx) => {
167
+ if (ctx.morphStyle === "innerHTML") {
168
+ morphChildren(ctx, oldNode, newNode);
169
+ return Array.from(oldNode.childNodes);
170
+ } else {
171
+ return morphOuterHTML(ctx, oldNode, newNode);
172
+ }
173
+ },
174
+ );
175
+ });
176
+
177
+ ctx.pantry.remove();
178
+ return morphedNodes;
179
+ }
180
+
181
+ /**
182
+ * Morph just the outerHTML of the oldNode to the newContent
183
+ * We have to be careful because the oldNode could have siblings which need to be untouched
184
+ * @param {MorphContext} ctx
185
+ * @param {Element} oldNode
186
+ * @param {Element} newNode
187
+ * @returns {Node[]}
188
+ */
189
+ function morphOuterHTML(ctx, oldNode, newNode) {
190
+ const oldParent = normalizeParent(oldNode);
191
+ morphChildren(
192
+ ctx,
193
+ oldParent,
194
+ newNode,
195
+ // these two optional params are the secret sauce
196
+ oldNode, // start point for iteration
197
+ oldNode.nextSibling, // end point for iteration
198
+ );
199
+ // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
200
+ return Array.from(oldParent.childNodes);
201
+ }
202
+
203
+ /**
204
+ * @param {MorphContext} ctx
205
+ * @param {Function} fn
206
+ * @returns {Promise<Node[]> | Node[]}
207
+ */
208
+ function saveAndRestoreFocus(ctx, fn) {
209
+ if (!ctx.config.restoreFocus) return fn();
210
+ let activeElement =
211
+ /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
212
+ document.activeElement
213
+ );
214
+
215
+ // don't bother if the active element is not an input or textarea
216
+ if (
217
+ !(
218
+ activeElement instanceof HTMLInputElement ||
219
+ activeElement instanceof HTMLTextAreaElement
220
+ )
221
+ ) {
222
+ return fn();
223
+ }
224
+
225
+ const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
226
+
227
+ const results = fn();
228
+
229
+ if (
230
+ activeElementId &&
231
+ activeElementId !== document.activeElement?.getAttribute("id")
232
+ ) {
233
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
234
+ activeElement?.focus();
235
+ }
236
+ if (activeElement && !activeElement.selectionEnd && selectionEnd) {
237
+ activeElement.setSelectionRange(selectionStart, selectionEnd);
238
+ }
239
+
240
+ return results;
241
+ }
242
+
243
+ const morphChildren = (function () {
244
+ /**
245
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
246
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
247
+ * by using id sets, we are able to better match up with content deeper in the DOM.
248
+ *
249
+ * Basic algorithm:
250
+ * - for each node in the new content:
251
+ * - search self and siblings for an id set match, falling back to a soft match
252
+ * - if match found
253
+ * - remove any nodes up to the match:
254
+ * - pantry persistent nodes
255
+ * - delete the rest
256
+ * - morph the match
257
+ * - elsif no match found, and node is persistent
258
+ * - find its match by querying the old root (future) and pantry (past)
259
+ * - move it and its children here
260
+ * - morph it
261
+ * - else
262
+ * - create a new node from scratch as a last result
263
+ *
264
+ * @param {MorphContext} ctx the merge context
265
+ * @param {Element} oldParent the old content that we are merging the new content into
266
+ * @param {Element} newParent the parent element of the new content
267
+ * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
268
+ * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
269
+ */
270
+ function morphChildren(
271
+ ctx,
272
+ oldParent,
273
+ newParent,
274
+ insertionPoint = null,
275
+ endPoint = null,
276
+ ) {
277
+ // normalize
278
+ if (
279
+ oldParent instanceof HTMLTemplateElement &&
280
+ newParent instanceof HTMLTemplateElement
281
+ ) {
282
+ // @ts-ignore we can pretend the DocumentFragment is an Element
283
+ oldParent = oldParent.content;
284
+ // @ts-ignore ditto
285
+ newParent = newParent.content;
286
+ }
287
+ insertionPoint ||= oldParent.firstChild;
288
+
289
+ // run through all the new content
290
+ for (const newChild of newParent.childNodes) {
291
+ // once we reach the end of the old parent content skip to the end and insert the rest
292
+ if (insertionPoint && insertionPoint != endPoint) {
293
+ const bestMatch = findBestMatch(
294
+ ctx,
295
+ newChild,
296
+ insertionPoint,
297
+ endPoint,
298
+ );
299
+ if (bestMatch) {
300
+ // if the node to morph is not at the insertion point then remove/move up to it
301
+ if (bestMatch !== insertionPoint) {
302
+ removeNodesBetween(ctx, insertionPoint, bestMatch);
303
+ }
304
+ morphNode(bestMatch, newChild, ctx);
305
+ insertionPoint = bestMatch.nextSibling;
306
+ continue;
307
+ }
308
+ }
309
+
310
+ // if the matching node is elsewhere in the original content
311
+ if (newChild instanceof Element) {
312
+ // we can pretend the id is non-null because the next `.has` line will reject it if not
313
+ const newChildId = /** @type {String} */ (
314
+ newChild.getAttribute("id")
315
+ );
316
+ if (ctx.persistentIds.has(newChildId)) {
317
+ // move it and all its children here and morph
318
+ const movedChild = moveBeforeById(
319
+ oldParent,
320
+ newChildId,
321
+ insertionPoint,
322
+ ctx,
323
+ );
324
+ morphNode(movedChild, newChild, ctx);
325
+ insertionPoint = movedChild.nextSibling;
326
+ continue;
327
+ }
328
+ }
329
+
330
+ // last resort: insert the new node from scratch
331
+ const insertedNode = createNode(
332
+ oldParent,
333
+ newChild,
334
+ insertionPoint,
335
+ ctx,
336
+ );
337
+ // could be null if beforeNodeAdded prevented insertion
338
+ if (insertedNode) {
339
+ insertionPoint = insertedNode.nextSibling;
340
+ }
341
+ }
342
+
343
+ // remove any remaining old nodes that didn't match up with new content
344
+ while (insertionPoint && insertionPoint != endPoint) {
345
+ const tempNode = insertionPoint;
346
+ insertionPoint = insertionPoint.nextSibling;
347
+ removeNode(ctx, tempNode);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * This performs the action of inserting a new node while handling situations where the node contains
353
+ * elements with persistent ids and possible state info we can still preserve by moving in and then morphing
354
+ *
355
+ * @param {Element} oldParent
356
+ * @param {Node} newChild
357
+ * @param {Node|null} insertionPoint
358
+ * @param {MorphContext} ctx
359
+ * @returns {Node|null}
360
+ */
361
+ function createNode(oldParent, newChild, insertionPoint, ctx) {
362
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
363
+ if (ctx.idMap.has(newChild)) {
364
+ // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
365
+ const newEmptyChild = document.createElement(
366
+ /** @type {Element} */ (newChild).tagName,
367
+ );
368
+ oldParent.insertBefore(newEmptyChild, insertionPoint);
369
+ morphNode(newEmptyChild, newChild, ctx);
370
+ ctx.callbacks.afterNodeAdded(newEmptyChild);
371
+ return newEmptyChild;
372
+ } else {
373
+ // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
374
+ const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
375
+ oldParent.insertBefore(newClonedChild, insertionPoint);
376
+ ctx.callbacks.afterNodeAdded(newClonedChild);
377
+ return newClonedChild;
378
+ }
379
+ }
380
+
381
+ //=============================================================================
382
+ // Matching Functions
383
+ //=============================================================================
384
+ const findBestMatch = (function () {
385
+ /**
386
+ * Scans forward from the startPoint to the endPoint looking for a match
387
+ * for the node. It looks for an id set match first, then a soft match.
388
+ * We abort softmatching if we find two future soft matches, to reduce churn.
389
+ * @param {Node} node
390
+ * @param {MorphContext} ctx
391
+ * @param {Node | null} startPoint
392
+ * @param {Node | null} endPoint
393
+ * @returns {Node | null}
394
+ */
395
+ function findBestMatch(ctx, node, startPoint, endPoint) {
396
+ let softMatch = null;
397
+ let nextSibling = node.nextSibling;
398
+ let siblingSoftMatchCount = 0;
399
+
400
+ let cursor = startPoint;
401
+ while (cursor && cursor != endPoint) {
402
+ // soft matching is a prerequisite for id set matching
403
+ if (isSoftMatch(cursor, node)) {
404
+ if (isIdSetMatch(ctx, cursor, node)) {
405
+ return cursor; // found an id set match, we're done!
406
+ }
407
+
408
+ // we haven't yet saved a soft match fallback
409
+ if (softMatch === null) {
410
+ // the current soft match will hard match something else in the future, leave it
411
+ if (!ctx.idMap.has(cursor)) {
412
+ // save this as the fallback if we get through the loop without finding a hard match
413
+ softMatch = cursor;
414
+ }
415
+ }
416
+ }
417
+ if (
418
+ softMatch === null &&
419
+ nextSibling &&
420
+ isSoftMatch(cursor, nextSibling)
421
+ ) {
422
+ // The next new node has a soft match with this node, so
423
+ // increment the count of future soft matches
424
+ siblingSoftMatchCount++;
425
+ nextSibling = nextSibling.nextSibling;
426
+
427
+ // If there are two future soft matches, block soft matching for this node to allow
428
+ // future siblings to soft match. This is to reduce churn in the DOM when an element
429
+ // is prepended.
430
+ if (siblingSoftMatchCount >= 2) {
431
+ softMatch = undefined;
432
+ }
433
+ }
434
+
435
+ // if the current node contains active element, stop looking for better future matches,
436
+ // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
437
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
438
+ if (ctx.activeElementAndParents.includes(cursor)) break;
439
+
440
+ cursor = cursor.nextSibling;
441
+ }
442
+
443
+ return softMatch || null;
444
+ }
445
+
446
+ /**
447
+ *
448
+ * @param {MorphContext} ctx
449
+ * @param {Node} oldNode
450
+ * @param {Node} newNode
451
+ * @returns {boolean}
452
+ */
453
+ function isIdSetMatch(ctx, oldNode, newNode) {
454
+ let oldSet = ctx.idMap.get(oldNode);
455
+ let newSet = ctx.idMap.get(newNode);
456
+
457
+ if (!newSet || !oldSet) return false;
458
+
459
+ for (const id of oldSet) {
460
+ // a potential match is an id in the new and old nodes that
461
+ // has not already been merged into the DOM
462
+ // But the newNode content we call this on has not been
463
+ // merged yet and we don't allow duplicate IDs so it is simple
464
+ if (newSet.has(id)) {
465
+ return true;
466
+ }
467
+ }
468
+ return false;
469
+ }
470
+
471
+ /**
472
+ *
473
+ * @param {Node} oldNode
474
+ * @param {Node} newNode
475
+ * @returns {boolean}
476
+ */
477
+ function isSoftMatch(oldNode, newNode) {
478
+ // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
479
+ const oldElt = /** @type {Element} */ (oldNode);
480
+ const newElt = /** @type {Element} */ (newNode);
481
+
482
+ return (
483
+ oldElt.nodeType === newElt.nodeType &&
484
+ oldElt.tagName === newElt.tagName &&
485
+ // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
486
+ // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
487
+ // its not persistent, and new nodes can't have any hidden state.
488
+ // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
489
+ (!oldElt.getAttribute?.("id") ||
490
+ oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
491
+ );
492
+ }
493
+
494
+ return findBestMatch;
495
+ })();
496
+
497
+ //=============================================================================
498
+ // DOM Manipulation Functions
499
+ //=============================================================================
500
+
501
+ /**
502
+ * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
503
+ * - Persistent nodes will be moved to the pantry for later reuse
504
+ * - Other nodes will have their hooks called, and then are removed
505
+ * @param {MorphContext} ctx
506
+ * @param {Node} node
507
+ */
508
+ function removeNode(ctx, node) {
509
+ // are we going to id set match this later?
510
+ if (ctx.idMap.has(node)) {
511
+ // skip callbacks and move to pantry
512
+ moveBefore(ctx.pantry, node, null);
513
+ } else {
514
+ // remove for realsies
515
+ if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
516
+ node.parentNode?.removeChild(node);
517
+ ctx.callbacks.afterNodeRemoved(node);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Remove nodes between the start and end nodes
523
+ * @param {MorphContext} ctx
524
+ * @param {Node} startInclusive
525
+ * @param {Node} endExclusive
526
+ * @returns {Node|null}
527
+ */
528
+ function removeNodesBetween(ctx, startInclusive, endExclusive) {
529
+ /** @type {Node | null} */
530
+ let cursor = startInclusive;
531
+ // remove nodes until the endExclusive node
532
+ while (cursor && cursor !== endExclusive) {
533
+ let tempNode = /** @type {Node} */ (cursor);
534
+ cursor = cursor.nextSibling;
535
+ removeNode(ctx, tempNode);
536
+ }
537
+ return cursor;
538
+ }
539
+
540
+ /**
541
+ * Search for an element by id within the document and pantry, and move it using moveBefore.
542
+ *
543
+ * @param {Element} parentNode - The parent node to which the element will be moved.
544
+ * @param {string} id - The ID of the element to be moved.
545
+ * @param {Node | null} after - The reference node to insert the element before.
546
+ * If `null`, the element is appended as the last child.
547
+ * @param {MorphContext} ctx
548
+ * @returns {Element} The found element
549
+ */
550
+ function moveBeforeById(parentNode, id, after, ctx) {
551
+ const target =
552
+ /** @type {Element} - will always be found */
553
+ (
554
+ // ctx.target.id unsafe because of form input shadowing
555
+ // ctx.target could be a document fragment which doesn't have `getAttribute`
556
+ (ctx.target.getAttribute?.("id") === id && ctx.target) ||
557
+ ctx.target.querySelector(`[id="${id}"]`) ||
558
+ ctx.pantry.querySelector(`[id="${id}"]`)
559
+ );
560
+ removeElementFromAncestorsIdMaps(target, ctx);
561
+ moveBefore(parentNode, target, after);
562
+ return target;
563
+ }
564
+
565
+ /**
566
+ * Removes an element from its ancestors' id maps. This is needed when an element is moved from the
567
+ * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
568
+ * pantry rather than being deleted, preventing their removal hooks from being called.
569
+ *
570
+ * @param {Element} element - element to remove from its ancestors' id maps
571
+ * @param {MorphContext} ctx
572
+ */
573
+ function removeElementFromAncestorsIdMaps(element, ctx) {
574
+ // we know id is non-null String, because this function is only called on elements with ids
575
+ const id = /** @type {String} */ (element.getAttribute("id"));
576
+ /** @ts-ignore - safe to loop in this way **/
577
+ while ((element = element.parentNode)) {
578
+ let idSet = ctx.idMap.get(element);
579
+ if (idSet) {
580
+ idSet.delete(id);
581
+ if (!idSet.size) {
582
+ ctx.idMap.delete(element);
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Moves an element before another element within the same parent.
590
+ * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
591
+ * This is essentialy a forward-compat wrapper.
592
+ *
593
+ * @param {Element} parentNode - The parent node containing the after element.
594
+ * @param {Node} element - The element to be moved.
595
+ * @param {Node | null} after - The reference node to insert `element` before.
596
+ * If `null`, `element` is appended as the last child.
597
+ */
598
+ function moveBefore(parentNode, element, after) {
599
+ // @ts-ignore - use proposed moveBefore feature
600
+ if (parentNode.moveBefore) {
601
+ try {
602
+ // @ts-ignore - use proposed moveBefore feature
603
+ parentNode.moveBefore(element, after);
604
+ } catch (e) {
605
+ // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
606
+ parentNode.insertBefore(element, after);
607
+ }
608
+ } else {
609
+ parentNode.insertBefore(element, after);
610
+ }
611
+ }
612
+
613
+ return morphChildren;
614
+ })();
615
+
616
+ //=============================================================================
617
+ // Single Node Morphing Code
618
+ //=============================================================================
619
+ const morphNode = (function () {
620
+ /**
621
+ * @param {Node} oldNode root node to merge content into
622
+ * @param {Node} newContent new content to merge
623
+ * @param {MorphContext} ctx the merge context
624
+ * @returns {Node | null} the element that ended up in the DOM
625
+ */
626
+ function morphNode(oldNode, newContent, ctx) {
627
+ if (ctx.ignoreActive && oldNode === document.activeElement) {
628
+ // don't morph focused element
629
+ return null;
630
+ }
631
+
632
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
633
+ return oldNode;
634
+ }
635
+
636
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
637
+ // ignore the head element
638
+ } else if (
639
+ oldNode instanceof HTMLHeadElement &&
640
+ ctx.head.style !== "morph"
641
+ ) {
642
+ // ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above
643
+ handleHeadElement(
644
+ oldNode,
645
+ /** @type {HTMLHeadElement} */ (newContent),
646
+ ctx,
647
+ );
648
+ } else {
649
+ morphAttributes(oldNode, newContent, ctx);
650
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
651
+ // @ts-ignore newContent can be a node here because .firstChild will be null
652
+ morphChildren(ctx, oldNode, newContent);
653
+ }
654
+ }
655
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
656
+ return oldNode;
657
+ }
658
+
659
+ /**
660
+ * syncs the oldNode to the newNode, copying over all attributes and
661
+ * inner element state from the newNode to the oldNode
662
+ *
663
+ * @param {Node} oldNode the node to copy attributes & state to
664
+ * @param {Node} newNode the node to copy attributes & state from
665
+ * @param {MorphContext} ctx the merge context
666
+ */
667
+ function morphAttributes(oldNode, newNode, ctx) {
668
+ let type = newNode.nodeType;
669
+
670
+ // if is an element type, sync the attributes from the
671
+ // new node into the new node
672
+ if (type === 1 /* element type */) {
673
+ const oldElt = /** @type {Element} */ (oldNode);
674
+ const newElt = /** @type {Element} */ (newNode);
675
+
676
+ const oldAttributes = oldElt.attributes;
677
+ const newAttributes = newElt.attributes;
678
+ for (const newAttribute of newAttributes) {
679
+ if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
680
+ continue;
681
+ }
682
+ if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
683
+ oldElt.setAttribute(newAttribute.name, newAttribute.value);
684
+ }
685
+ }
686
+ // iterate backwards to avoid skipping over items when a delete occurs
687
+ for (let i = oldAttributes.length - 1; 0 <= i; i--) {
688
+ const oldAttribute = oldAttributes[i];
689
+
690
+ // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
691
+ // e.g. custom element attribute callbacks can remove other attributes
692
+ if (!oldAttribute) continue;
693
+
694
+ if (!newElt.hasAttribute(oldAttribute.name)) {
695
+ if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
696
+ continue;
697
+ }
698
+ oldElt.removeAttribute(oldAttribute.name);
699
+ }
700
+ }
701
+
702
+ if (!ignoreValueOfActiveElement(oldElt, ctx)) {
703
+ syncInputValue(oldElt, newElt, ctx);
704
+ }
705
+ }
706
+
707
+ // sync text nodes
708
+ if (type === 8 /* comment */ || type === 3 /* text */) {
709
+ if (oldNode.nodeValue !== newNode.nodeValue) {
710
+ oldNode.nodeValue = newNode.nodeValue;
711
+ }
712
+ }
713
+ }
714
+
715
+ /**
716
+ * NB: many bothans died to bring us information:
717
+ *
718
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
719
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
720
+ *
721
+ * @param {Element} oldElement the element to sync the input value to
722
+ * @param {Element} newElement the element to sync the input value from
723
+ * @param {MorphContext} ctx the merge context
724
+ */
725
+ function syncInputValue(oldElement, newElement, ctx) {
726
+ if (
727
+ oldElement instanceof HTMLInputElement &&
728
+ newElement instanceof HTMLInputElement &&
729
+ newElement.type !== "file"
730
+ ) {
731
+ let newValue = newElement.value;
732
+ let oldValue = oldElement.value;
733
+
734
+ // sync boolean attributes
735
+ syncBooleanAttribute(oldElement, newElement, "checked", ctx);
736
+ syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
737
+
738
+ if (!newElement.hasAttribute("value")) {
739
+ if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
740
+ oldElement.value = "";
741
+ oldElement.removeAttribute("value");
742
+ }
743
+ } else if (oldValue !== newValue) {
744
+ if (!ignoreAttribute("value", oldElement, "update", ctx)) {
745
+ oldElement.setAttribute("value", newValue);
746
+ oldElement.value = newValue;
747
+ }
748
+ }
749
+ // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
750
+ // did I break something?
751
+ } else if (
752
+ oldElement instanceof HTMLOptionElement &&
753
+ newElement instanceof HTMLOptionElement
754
+ ) {
755
+ syncBooleanAttribute(oldElement, newElement, "selected", ctx);
756
+ } else if (
757
+ oldElement instanceof HTMLTextAreaElement &&
758
+ newElement instanceof HTMLTextAreaElement
759
+ ) {
760
+ let newValue = newElement.value;
761
+ let oldValue = oldElement.value;
762
+ if (ignoreAttribute("value", oldElement, "update", ctx)) {
763
+ return;
764
+ }
765
+ if (newValue !== oldValue) {
766
+ oldElement.value = newValue;
767
+ }
768
+ if (
769
+ oldElement.firstChild &&
770
+ oldElement.firstChild.nodeValue !== newValue
771
+ ) {
772
+ oldElement.firstChild.nodeValue = newValue;
773
+ }
774
+ }
775
+ }
776
+
777
+ /**
778
+ * @param {Element} oldElement element to write the value to
779
+ * @param {Element} newElement element to read the value from
780
+ * @param {string} attributeName the attribute name
781
+ * @param {MorphContext} ctx the merge context
782
+ */
783
+ function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
784
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
785
+ const newLiveValue = newElement[attributeName],
786
+ // @ts-ignore ditto
787
+ oldLiveValue = oldElement[attributeName];
788
+ if (newLiveValue !== oldLiveValue) {
789
+ const ignoreUpdate = ignoreAttribute(
790
+ attributeName,
791
+ oldElement,
792
+ "update",
793
+ ctx,
794
+ );
795
+ if (!ignoreUpdate) {
796
+ // update attribute's associated DOM property
797
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
798
+ oldElement[attributeName] = newElement[attributeName];
799
+ }
800
+ if (newLiveValue) {
801
+ if (!ignoreUpdate) {
802
+ // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
803
+ // this is the correct way to set a boolean attribute to "true"
804
+ oldElement.setAttribute(attributeName, "");
805
+ }
806
+ } else {
807
+ if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
808
+ oldElement.removeAttribute(attributeName);
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ /**
815
+ * @param {string} attr the attribute to be mutated
816
+ * @param {Element} element the element that is going to be updated
817
+ * @param {"update" | "remove"} updateType
818
+ * @param {MorphContext} ctx the merge context
819
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
820
+ */
821
+ function ignoreAttribute(attr, element, updateType, ctx) {
822
+ if (
823
+ attr === "value" &&
824
+ ctx.ignoreActiveValue &&
825
+ element === document.activeElement
826
+ ) {
827
+ return true;
828
+ }
829
+ return (
830
+ ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
831
+ false
832
+ );
833
+ }
834
+
835
+ /**
836
+ * @param {Node} possibleActiveElement
837
+ * @param {MorphContext} ctx
838
+ * @returns {boolean}
839
+ */
840
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
841
+ return (
842
+ !!ctx.ignoreActiveValue &&
843
+ possibleActiveElement === document.activeElement &&
844
+ possibleActiveElement !== document.body
845
+ );
846
+ }
847
+
848
+ return morphNode;
849
+ })();
850
+
851
+ //=============================================================================
852
+ // Head Management Functions
853
+ //=============================================================================
854
+ /**
855
+ * @param {MorphContext} ctx
856
+ * @param {Element} oldNode
857
+ * @param {Element} newNode
858
+ * @param {function} callback
859
+ * @returns {Node[] | Promise<Node[]>}
860
+ */
861
+ function withHeadBlocking(ctx, oldNode, newNode, callback) {
862
+ if (ctx.head.block) {
863
+ const oldHead = oldNode.querySelector("head");
864
+ const newHead = newNode.querySelector("head");
865
+ if (oldHead && newHead) {
866
+ const promises = handleHeadElement(oldHead, newHead, ctx);
867
+ // when head promises resolve, proceed ignoring the head tag
868
+ return Promise.all(promises).then(() => {
869
+ const newCtx = Object.assign(ctx, {
870
+ head: {
871
+ block: false,
872
+ ignore: true,
873
+ },
874
+ });
875
+ return callback(newCtx);
876
+ });
877
+ }
878
+ }
879
+ // just proceed if we not head blocking
880
+ return callback(ctx);
881
+ }
882
+
883
+ /**
884
+ * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
885
+ *
886
+ * @param {Element} oldHead
887
+ * @param {Element} newHead
888
+ * @param {MorphContext} ctx
889
+ * @returns {Promise<void>[]}
890
+ */
891
+ function handleHeadElement(oldHead, newHead, ctx) {
892
+ let added = [];
893
+ let removed = [];
894
+ let preserved = [];
895
+ let nodesToAppend = [];
896
+
897
+ // put all new head elements into a Map, by their outerHTML
898
+ let srcToNewHeadNodes = new Map();
899
+ for (const newHeadChild of newHead.children) {
900
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
901
+ }
902
+
903
+ // for each elt in the current head
904
+ for (const currentHeadElt of oldHead.children) {
905
+ // If the current head element is in the map
906
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
907
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
908
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
909
+ if (inNewContent || isPreserved) {
910
+ if (isReAppended) {
911
+ // remove the current version and let the new version replace it and re-execute
912
+ removed.push(currentHeadElt);
913
+ } else {
914
+ // this element already exists and should not be re-appended, so remove it from
915
+ // the new content map, preserving it in the DOM
916
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
917
+ preserved.push(currentHeadElt);
918
+ }
919
+ } else {
920
+ if (ctx.head.style === "append") {
921
+ // we are appending and this existing element is not new content
922
+ // so if and only if it is marked for re-append do we do anything
923
+ if (isReAppended) {
924
+ removed.push(currentHeadElt);
925
+ nodesToAppend.push(currentHeadElt);
926
+ }
927
+ } else {
928
+ // if this is a merge, we remove this content since it is not in the new head
929
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
930
+ removed.push(currentHeadElt);
931
+ }
932
+ }
933
+ }
934
+ }
935
+
936
+ // Push the remaining new head elements in the Map into the
937
+ // nodes to append to the head tag
938
+ nodesToAppend.push(...srcToNewHeadNodes.values());
939
+
940
+ let promises = [];
941
+ for (const newNode of nodesToAppend) {
942
+ // TODO: This could theoretically be null, based on type
943
+ let newElt = /** @type {ChildNode} */ (
944
+ document.createRange().createContextualFragment(newNode.outerHTML)
945
+ .firstChild
946
+ );
947
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
948
+ if (
949
+ ("href" in newElt && newElt.href) ||
950
+ ("src" in newElt && newElt.src)
951
+ ) {
952
+ /** @type {(result?: any) => void} */ let resolve;
953
+ let promise = new Promise(function (_resolve) {
954
+ resolve = _resolve;
955
+ });
956
+ newElt.addEventListener("load", function () {
957
+ resolve();
958
+ });
959
+ promises.push(promise);
960
+ }
961
+ oldHead.appendChild(newElt);
962
+ ctx.callbacks.afterNodeAdded(newElt);
963
+ added.push(newElt);
964
+ }
965
+ }
966
+
967
+ // remove all removed elements, after we have appended the new elements to avoid
968
+ // additional network requests for things like style sheets
969
+ for (const removedElement of removed) {
970
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
971
+ oldHead.removeChild(removedElement);
972
+ ctx.callbacks.afterNodeRemoved(removedElement);
973
+ }
974
+ }
975
+
976
+ ctx.head.afterHeadMorphed(oldHead, {
977
+ added: added,
978
+ kept: preserved,
979
+ removed: removed,
980
+ });
981
+ return promises;
982
+ }
983
+
984
+ //=============================================================================
985
+ // Create Morph Context Functions
986
+ //=============================================================================
987
+ const createMorphContext = (function () {
988
+ /**
989
+ *
990
+ * @param {Element} oldNode
991
+ * @param {Element} newContent
992
+ * @param {Config} config
993
+ * @returns {MorphContext}
994
+ */
995
+ function createMorphContext(oldNode, newContent, config) {
996
+ const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
997
+
998
+ const mergedConfig = mergeDefaults(config);
999
+ const morphStyle = mergedConfig.morphStyle || "outerHTML";
1000
+ if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
1001
+ throw `Do not understand how to morph style ${morphStyle}`;
1002
+ }
1003
+
1004
+ return {
1005
+ target: oldNode,
1006
+ newContent: newContent,
1007
+ config: mergedConfig,
1008
+ morphStyle: morphStyle,
1009
+ ignoreActive: mergedConfig.ignoreActive,
1010
+ ignoreActiveValue: mergedConfig.ignoreActiveValue,
1011
+ restoreFocus: mergedConfig.restoreFocus,
1012
+ idMap: idMap,
1013
+ persistentIds: persistentIds,
1014
+ pantry: createPantry(),
1015
+ activeElementAndParents: createActiveElementAndParents(oldNode),
1016
+ callbacks: mergedConfig.callbacks,
1017
+ head: mergedConfig.head,
1018
+ };
1019
+ }
1020
+
1021
+ /**
1022
+ * Deep merges the config object and the Idiomorph.defaults object to
1023
+ * produce a final configuration object
1024
+ * @param {Config} config
1025
+ * @returns {ConfigInternal}
1026
+ */
1027
+ function mergeDefaults(config) {
1028
+ let finalConfig = Object.assign({}, defaults);
1029
+
1030
+ // copy top level stuff into final config
1031
+ Object.assign(finalConfig, config);
1032
+
1033
+ // copy callbacks into final config (do this to deep merge the callbacks)
1034
+ finalConfig.callbacks = Object.assign(
1035
+ {},
1036
+ defaults.callbacks,
1037
+ config.callbacks,
1038
+ );
1039
+
1040
+ // copy head config into final config (do this to deep merge the head)
1041
+ finalConfig.head = Object.assign({}, defaults.head, config.head);
1042
+
1043
+ return finalConfig;
1044
+ }
1045
+
1046
+ /**
1047
+ * @returns {HTMLDivElement}
1048
+ */
1049
+ function createPantry() {
1050
+ const pantry = document.createElement("div");
1051
+ pantry.hidden = true;
1052
+ document.body.insertAdjacentElement("afterend", pantry);
1053
+ return pantry;
1054
+ }
1055
+
1056
+ /**
1057
+ * @param {Element} oldNode
1058
+ * @returns {Element[]}
1059
+ */
1060
+ function createActiveElementAndParents(oldNode) {
1061
+ /** @type {Element[]} */
1062
+ let activeElementAndParents = [];
1063
+ let elt = document.activeElement;
1064
+ if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
1065
+ while (elt) {
1066
+ activeElementAndParents.push(elt);
1067
+ if (elt === oldNode) break;
1068
+ elt = elt.parentElement;
1069
+ }
1070
+ }
1071
+ return activeElementAndParents;
1072
+ }
1073
+
1074
+ /**
1075
+ * Returns all elements with an ID contained within the root element and its descendants
1076
+ *
1077
+ * @param {Element} root
1078
+ * @returns {Element[]}
1079
+ */
1080
+ function findIdElements(root) {
1081
+ let elements = Array.from(root.querySelectorAll("[id]"));
1082
+ // root could be a document fragment which doesn't have `getAttribute`
1083
+ if (root.getAttribute?.("id")) {
1084
+ elements.push(root);
1085
+ }
1086
+ return elements;
1087
+ }
1088
+
1089
+ /**
1090
+ * A bottom-up algorithm that populates a map of Element -> IdSet.
1091
+ * The idSet for a given element is the set of all IDs contained within its subtree.
1092
+ * As an optimzation, we filter these IDs through the given list of persistent IDs,
1093
+ * because we don't need to bother considering IDed elements that won't be in the new content.
1094
+ *
1095
+ * @param {Map<Node, Set<string>>} idMap
1096
+ * @param {Set<string>} persistentIds
1097
+ * @param {Element} root
1098
+ * @param {Element[]} elements
1099
+ */
1100
+ function populateIdMapWithTree(idMap, persistentIds, root, elements) {
1101
+ for (const elt of elements) {
1102
+ // we can pretend id is non-null String, because the .has line will reject it immediately if not
1103
+ const id = /** @type {String} */ (elt.getAttribute("id"));
1104
+ if (persistentIds.has(id)) {
1105
+ /** @type {Element|null} */
1106
+ let current = elt;
1107
+ // walk up the parent hierarchy of that element, adding the id
1108
+ // of element to the parent's id set
1109
+ while (current) {
1110
+ let idSet = idMap.get(current);
1111
+ // if the id set doesn't exist, create it and insert it in the map
1112
+ if (idSet == null) {
1113
+ idSet = new Set();
1114
+ idMap.set(current, idSet);
1115
+ }
1116
+ idSet.add(id);
1117
+
1118
+ if (current === root) break;
1119
+ current = current.parentElement;
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
1127
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
1128
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
1129
+ * to contribute to a parent nodes matching.
1130
+ *
1131
+ * @param {Element} oldContent the old content that will be morphed
1132
+ * @param {Element} newContent the new content to morph to
1133
+ * @returns {IdSets}
1134
+ */
1135
+ function createIdMaps(oldContent, newContent) {
1136
+ const oldIdElements = findIdElements(oldContent);
1137
+ const newIdElements = findIdElements(newContent);
1138
+
1139
+ const persistentIds = createPersistentIds(oldIdElements, newIdElements);
1140
+
1141
+ /** @type {Map<Node, Set<string>>} */
1142
+ let idMap = new Map();
1143
+ populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
1144
+
1145
+ /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
1146
+ const newRoot = newContent.__idiomorphRoot || newContent;
1147
+ populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
1148
+
1149
+ return { persistentIds, idMap };
1150
+ }
1151
+
1152
+ /**
1153
+ * This function computes the set of ids that persist between the two contents excluding duplicates
1154
+ *
1155
+ * @param {Element[]} oldIdElements
1156
+ * @param {Element[]} newIdElements
1157
+ * @returns {Set<string>}
1158
+ */
1159
+ function createPersistentIds(oldIdElements, newIdElements) {
1160
+ let duplicateIds = new Set();
1161
+
1162
+ /** @type {Map<string, string>} */
1163
+ let oldIdTagNameMap = new Map();
1164
+ for (const { id, tagName } of oldIdElements) {
1165
+ if (oldIdTagNameMap.has(id)) {
1166
+ duplicateIds.add(id);
1167
+ } else {
1168
+ oldIdTagNameMap.set(id, tagName);
1169
+ }
1170
+ }
1171
+
1172
+ let persistentIds = new Set();
1173
+ for (const { id, tagName } of newIdElements) {
1174
+ if (persistentIds.has(id)) {
1175
+ duplicateIds.add(id);
1176
+ } else if (oldIdTagNameMap.get(id) === tagName) {
1177
+ persistentIds.add(id);
1178
+ }
1179
+ // skip if tag types mismatch because its not possible to morph one tag into another
1180
+ }
1181
+
1182
+ for (const id of duplicateIds) {
1183
+ persistentIds.delete(id);
1184
+ }
1185
+ return persistentIds;
1186
+ }
1187
+
1188
+ return createMorphContext;
1189
+ })();
1190
+
1191
+ //=============================================================================
1192
+ // HTML Normalization Functions
1193
+ //=============================================================================
1194
+ const { normalizeElement, normalizeParent } = (function () {
1195
+ /** @type {WeakSet<Node>} */
1196
+ const generatedByIdiomorph = new WeakSet();
1197
+
1198
+ /**
1199
+ *
1200
+ * @param {Element | Document} content
1201
+ * @returns {Element}
1202
+ */
1203
+ function normalizeElement(content) {
1204
+ if (content instanceof Document) {
1205
+ return content.documentElement;
1206
+ } else {
1207
+ return content;
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ *
1213
+ * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
1214
+ * @returns {Element}
1215
+ */
1216
+ function normalizeParent(newContent) {
1217
+ if (newContent == null) {
1218
+ return document.createElement("div"); // dummy parent element
1219
+ } else if (typeof newContent === "string") {
1220
+ return normalizeParent(parseContent(newContent));
1221
+ } else if (
1222
+ generatedByIdiomorph.has(/** @type {Element} */ (newContent))
1223
+ ) {
1224
+ // the template tag created by idiomorph parsing can serve as a dummy parent
1225
+ return /** @type {Element} */ (newContent);
1226
+ } else if (newContent instanceof Node) {
1227
+ if (newContent.parentNode) {
1228
+ // we can't use the parent directly because newContent may have siblings
1229
+ // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
1230
+ // so instead we create a fake parent node that only sees a slice of its children.
1231
+ /** @type {Element} */
1232
+ return /** @type {any} */ (new SlicedParentNode(newContent));
1233
+ } else {
1234
+ // a single node is added as a child to a dummy parent
1235
+ const dummyParent = document.createElement("div");
1236
+ dummyParent.append(newContent);
1237
+ return dummyParent;
1238
+ }
1239
+ } else {
1240
+ // all nodes in the array or HTMLElement collection are consolidated under
1241
+ // a single dummy parent element
1242
+ const dummyParent = document.createElement("div");
1243
+ for (const elt of [...newContent]) {
1244
+ dummyParent.append(elt);
1245
+ }
1246
+ return dummyParent;
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * A fake duck-typed parent element to wrap a single node, without actually reparenting it.
1252
+ * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
1253
+ * or replaced with one or more elements during the morph. This class effectively allows us a window into
1254
+ * a slice of a node's children.
1255
+ * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
1256
+ */
1257
+ class SlicedParentNode {
1258
+ /** @param {Node} node */
1259
+ constructor(node) {
1260
+ this.originalNode = node;
1261
+ this.realParentNode = /** @type {Element} */ (node.parentNode);
1262
+ this.previousSibling = node.previousSibling;
1263
+ this.nextSibling = node.nextSibling;
1264
+ }
1265
+
1266
+ /** @returns {Node[]} */
1267
+ get childNodes() {
1268
+ // return slice of realParent's current childNodes, based on previousSibling and nextSibling
1269
+ const nodes = [];
1270
+ let cursor = this.previousSibling
1271
+ ? this.previousSibling.nextSibling
1272
+ : this.realParentNode.firstChild;
1273
+ while (cursor && cursor != this.nextSibling) {
1274
+ nodes.push(cursor);
1275
+ cursor = cursor.nextSibling;
1276
+ }
1277
+ return nodes;
1278
+ }
1279
+
1280
+ /**
1281
+ * @param {string} selector
1282
+ * @returns {Element[]}
1283
+ */
1284
+ querySelectorAll(selector) {
1285
+ return this.childNodes.reduce((results, node) => {
1286
+ if (node instanceof Element) {
1287
+ if (node.matches(selector)) results.push(node);
1288
+ const nodeList = node.querySelectorAll(selector);
1289
+ for (let i = 0; i < nodeList.length; i++) {
1290
+ results.push(nodeList[i]);
1291
+ }
1292
+ }
1293
+ return results;
1294
+ }, /** @type {Element[]} */ ([]));
1295
+ }
1296
+
1297
+ /**
1298
+ * @param {Node} node
1299
+ * @param {Node} referenceNode
1300
+ * @returns {Node}
1301
+ */
1302
+ insertBefore(node, referenceNode) {
1303
+ return this.realParentNode.insertBefore(node, referenceNode);
1304
+ }
1305
+
1306
+ /**
1307
+ * @param {Node} node
1308
+ * @param {Node} referenceNode
1309
+ * @returns {Node}
1310
+ */
1311
+ moveBefore(node, referenceNode) {
1312
+ // @ts-ignore - use new moveBefore feature
1313
+ return this.realParentNode.moveBefore(node, referenceNode);
1314
+ }
1315
+
1316
+ /**
1317
+ * for later use with populateIdMapWithTree to halt upwards iteration
1318
+ * @returns {Node}
1319
+ */
1320
+ get __idiomorphRoot() {
1321
+ return this.originalNode;
1322
+ }
1323
+ }
1324
+
1325
+ /**
1326
+ *
1327
+ * @param {string} newContent
1328
+ * @returns {Node | null | DocumentFragment}
1329
+ */
1330
+ function parseContent(newContent) {
1331
+ let parser = new DOMParser();
1332
+
1333
+ // remove svgs to avoid false-positive matches on head, etc.
1334
+ let contentWithSvgsRemoved = newContent.replace(
1335
+ /<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,
1336
+ "",
1337
+ );
1338
+
1339
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
1340
+ if (
1341
+ contentWithSvgsRemoved.match(/<\/html>/) ||
1342
+ contentWithSvgsRemoved.match(/<\/head>/) ||
1343
+ contentWithSvgsRemoved.match(/<\/body>/)
1344
+ ) {
1345
+ let content = parser.parseFromString(newContent, "text/html");
1346
+ // if it is a full HTML document, return the document itself as the parent container
1347
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
1348
+ generatedByIdiomorph.add(content);
1349
+ return content;
1350
+ } else {
1351
+ // otherwise return the html element as the parent container
1352
+ let htmlElement = content.firstChild;
1353
+ if (htmlElement) {
1354
+ generatedByIdiomorph.add(htmlElement);
1355
+ }
1356
+ return htmlElement;
1357
+ }
1358
+ } else {
1359
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
1360
+ // deal with touchy tags like tr, tbody, etc.
1361
+ let responseDoc = parser.parseFromString(
1362
+ "<body><template>" + newContent + "</template></body>",
1363
+ "text/html",
1364
+ );
1365
+ let content = /** @type {HTMLTemplateElement} */ (
1366
+ responseDoc.body.querySelector("template")
1367
+ ).content;
1368
+ generatedByIdiomorph.add(content);
1369
+ return content;
1370
+ }
1371
+ }
1372
+
1373
+ return { normalizeElement, normalizeParent };
1374
+ })();
1375
+
1376
+ //=============================================================================
1377
+ // This is what ends up becoming the Idiomorph global object
1378
+ //=============================================================================
1379
+ return {
1380
+ morph,
1381
+ defaults,
1382
+ };
1383
+ })();
1384
+
1385
+ export {Idiomorph};