type ElementMoveItem = { target: Element; childNodes: Element[]; }; class ElementTransferMap { keyToElementMap: (Element | null)[] = []; items: any = []; private key(target: undefined | Element): number { // TODO: Maybe here is a binary tree better? Elements are comparable. const targetNullable = !target ? null : target; // Important: indexOf(undefined) will just give the current count of the array, so map it to null before. const index = this.keyToElementMap.indexOf(targetNullable); // Important: push(undefined) will always result in push(null) return index >= 0 ? index : this.keyToElementMap.push(targetNullable) - 1; } private get(target: undefined | Element): undefined | Element[] { const key = this.key(target); return this.items[key]; } private set(target: undefined | Element, items: undefined | Element[]) { const key = this.key(target); if (items) { this.items[key] = items; } else { delete this.items[key]; } } move(target: undefined | Element, movable: Element) { // If siblingKey is undefined then those elements are appended // to the first added item. const targetGroup = this.get(target) || []; this.set(target, targetGroup); const movableGroup = this.get(movable); if (movableGroup) { // We have already moved elements into the movable. // Now we need to move its contents to the target. targetGroup.push(movable, ...movableGroup); // Remove the existing group for our movable from the items. this.set(movable, undefined); } else { // This movable does not represent a group, // so we add the single item directly to the target. targetGroup.push(movable); } } moveUndefinedTo(target: Element) { // If there are a lot of changes then the virtual dom will // remove our target before the really removed ones. // The items that need to be visible will be added right back. // So we end up for a short time with an empty container. // The first item that will be added back is our container. const movableGroup = this.get(undefined); if (movableGroup) { const targetGroup = this.get(target) || []; this.set(target, targetGroup); targetGroup.push(...movableGroup); this.set(undefined, undefined); } } buildMoveItems(): ElementMoveItem[] { const moveItems: ElementMoveItem[] = []; const entries: IterableIterator<[number, Element[]]> = this .items.entries(); for (const [key, movables] of entries) { if (!movables) { continue; } const target = this.keyToElementMap[key]; if (!target) { // Should not happen, because there is always an element in our list. debugger; continue; } const childNodes: Element[] = []; movables.forEach(function (movable) { movable.childNodes.forEach(function (childNode) { if (childNode instanceof Element) { childNodes.push(childNode); } }); }); moveItems.push({ target: target, childNodes: childNodes, }); } return moveItems; } } customElements.define( "animate-sibling-removal-behavior", class AnimateSiblingRemovalBehavior extends HTMLElement { static readonly activateMutationLogging = false; parent: HTMLDivElement | null; observer: MutationObserver | null; constructor() { super(); } executeMove(queueItem: ElementMoveItem) { const siblingChildContainer = queueItem.target; const childNodes = queueItem.childNodes; childNodes.forEach((childNode) => { if (AnimateSiblingRemovalBehavior.activateMutationLogging) { console.log( `Attempt to move ${childNode.id} to ${(siblingChildContainer .childNodes[0] as Element)?.id}`, ); } siblingChildContainer.appendChild(childNode); }); childNodes.forEach(function (childNode) { if (!(childNode instanceof HTMLElement)) { return; } const childNodeContent = childNode.childNodes.item(1); if (!(childNodeContent instanceof HTMLElement)) { return; } if (childNodeContent["remove-animation-applied"]) { return; } const duration = 700; childNode.animate( [{}, { marginLeft: "0px" }], { duration: duration, easing: "ease-in-out", fill: "forwards", }, ); childNodeContent["remove-animation-applied"] = true; childNodeContent.animate( [{}, { transform: "scale(0.0, 1.0)", transformOrigin: "right center", opacity: "0%", width: "0px", padding: "0px", }], { duration: duration, easing: "ease-in-out", fill: "forwards" }, ).onfinish = (function () { childNode.remove(); }); }); } private static logMutations(mutations: MutationRecord[]) { console.log(`Mutations: ${mutations.length}`); mutations.forEach((mutation, mutationIndex) => { function n(node: Node | undefined | null) { if (!node || !(node instanceof Element)) { return "undefined"; } return (node.childNodes.item(0) as Element)?.id ?? (node.tagName === "DIV" ? "appendix" : "behavior"); } if (mutation.addedNodes.length > 0) { const addedNodesIds = mutation.addedNodes.forEach( (addedNode, addedNodeIndex) => { console.log( "Added[", mutationIndex, addedNodeIndex, "]:", n(mutation.previousSibling), "<", n(addedNode), ">", n(mutation.nextSibling), ); }, ); } if (mutation.removedNodes.length > 0) { const removedNodesIds = mutation.removedNodes.forEach( (removedNode, removedNodeIndex) => { console.log( "Removed[", mutationIndex, removedNodeIndex, "]:", n(mutation.previousSibling), "<", n(removedNode), ">", n(mutation.nextSibling), ); }, ); } }); } handleMutations(mutations: MutationRecord[]) { if (AnimateSiblingRemovalBehavior.activateMutationLogging) { AnimateSiblingRemovalBehavior.logMutations(mutations); } const moveTargets = new ElementTransferMap(); mutations.forEach((mutation, mutationIndex) => { if ( mutation.type === "childList" && mutation.target === this.parent ) { const nextSibling = mutation.nextSibling as Element; mutation.removedNodes.forEach((removedNode) => { if ( !!removedNode.parentNode || !(removedNode instanceof Element) ) { return; } moveTargets.move(nextSibling, removedNode); }); if (mutation.addedNodes.length > 0) { // If the virtual dom clears the container first and then // adds the targets back, we need to add them to the first entry. const newTarget = mutation.addedNodes.item(0); if (newTarget) { if (newTarget instanceof Element) { moveTargets.moveUndefinedTo(newTarget); } } } } }); // Do all planned movements const queueItems = moveTargets.buildMoveItems(); queueItems.forEach((queueItem) => this.executeMove(queueItem)); } connectParent(parent: HTMLDivElement) { this.disconnectParent(); if (!parent || parent.tagName !== "DIV") { return; } this.parent = parent; // Register ObservationObserver var observer = new MutationObserver(this.handleMutations.bind(this)); var config = { attributes: false, childList: true, characterData: false }; observer.observe(parent, config); this.observer = observer; } disconnectParent() { const parent = this.parent; if (!parent || parent.tagName !== "VIDEO") { return; } this.parent = null; // Unregister ObservationObserver const observer = this.observer; if (!observer) { return; } observer.disconnect(); this.observer = null; } connectedCallback() { const parentElement = this.parentElement; if (parentElement instanceof HTMLDivElement) { this.connectParent(parentElement); } } disconnectedCallback() { this.disconnectParent(); } }, );