1
0
mirror of https://github.com/morris/vanilla-todo.git synced 2025-08-19 12:21:19 +02:00
Files
vanilla-todo/public/scripts/AppFlip.js
Morris Brodersen 2815a1eb4c clean up comments
2023-11-26 14:31:37 +01:00

197 lines
5.0 KiB
JavaScript

/**
* @param {HTMLElement} el
* @param {{
* initialDelay?: number;
* removeTimeout: number;
* selector: string;
* }} options
*/
export function AppFlip(el, options) {
let enabled = options.initialDelay === 0;
let first;
let level = 0;
// Enable animations only after an initial delay.
setTimeout(() => {
enabled = true;
}, options.initialDelay ?? 100);
// Take a snapshot before any HTML changes.
// Do this only for the first beforeFlip event in the current cycle.
el.addEventListener('beforeFlip', () => {
if (!enabled) return;
if (++level > 1) return;
first = snapshot();
});
// Take a snapshot after HTML changes, calculate and play animations.
// Do this only for the last flip event in the current cycle.
el.addEventListener('flip', () => {
if (!enabled) return;
if (--level > 0) return;
const last = snapshot();
const toRemove = invertForRemoval(first, last);
const toAnimate = invertForAnimation(first, last);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
remove(toRemove);
animate(toAnimate);
first = null;
});
});
});
// Build a snapshot of the current HTML's client rectangles,
// including original transforms and hierarchy.
function snapshot() {
const map = new Map();
el.querySelectorAll(options.selector).forEach((el) => {
const key = el.dataset.key ?? el;
// Parse original transform,
// i.e. strip inverse transform using "scale(1)" marker.
const transform = el.style.transform
? el.style.transform.replace(/^.*scale\(1\)/, '')
: '';
map.set(key, {
key,
el,
rect: el.getBoundingClientRect(),
ancestor: null,
transform,
});
});
resolveAncestors(map);
return map;
}
function resolveAncestors(map) {
map.forEach((entry) => {
let current = entry.el.parentNode;
while (current && current !== el) {
const ancestor = map.get(current.dataset.key ?? current);
if (ancestor) {
entry.ancestor = ancestor;
return;
}
current = current.parentNode;
}
});
}
// Reinsert removed elements at their original position.
function invertForRemoval(first, last) {
const toRemove = [];
first.forEach((entry) => {
if (entry.el.classList.contains('_noflip')) return;
if (!needsRemoval(entry)) return;
entry.el.style.position = 'fixed';
entry.el.style.left = `${entry.rect.left}px`;
entry.el.style.top = `${entry.rect.top}px`;
entry.el.style.width = `${entry.rect.right - entry.rect.left}px`;
entry.el.style.transition = 'none';
entry.el.style.transform = '';
el.appendChild(entry.el);
toRemove.push(entry);
});
return toRemove;
function needsRemoval(entry) {
if (entry.ancestor && needsRemoval(entry.ancestor)) {
return false;
}
return !last.has(entry.key);
}
}
// Set position of moved elements to their original position,
// or set opacity to zero for new elements to appear nicely.
function invertForAnimation(first, last) {
const toAnimate = [];
last.forEach((entry) => {
if (entry.el.classList.contains('_noflip')) return;
calculate(entry);
if (entry.appear) {
entry.el.style.transition = 'none';
entry.el.style.opacity = '0';
toAnimate.push(entry);
} else if (entry.deltaX !== 0 || entry.deltaY !== 0) {
// Set inverted transform with "scale(1)" marker, see above.
entry.el.style.transition = 'none';
entry.el.style.transform = `translate(${entry.deltaX}px, ${entry.deltaY}px) scale(1) ${entry.transform}`;
toAnimate.push(entry);
}
});
return toAnimate;
// Calculate inverse transform relative to any animated ancestors.
function calculate(entry) {
if (entry.calculated) return;
entry.calculated = true;
const b = first.get(entry.key);
if (b) {
entry.deltaX = b.rect.left - entry.rect.left;
entry.deltaY = b.rect.top - entry.rect.top;
if (entry.ancestor) {
calculate(entry.ancestor);
entry.deltaX -= entry.ancestor.deltaX;
entry.deltaY -= entry.ancestor.deltaY;
}
} else {
entry.appear = true;
entry.deltaX = 0;
entry.deltaY = 0;
}
}
}
// Play remove animations and remove elements after timeout.
function remove(entries) {
entries.forEach((entry) => {
entry.el.style.transition = '';
entry.el.style.opacity = '0';
});
setTimeout(() => {
entries.forEach((entry) => {
if (entry.el.parentNode) {
entry.el.parentNode.removeChild(entry.el);
}
});
}, options.removeTimeout);
}
// Play move/appear animations.
function animate(entries) {
entries.forEach((entry) => {
entry.el.style.transition = '';
entry.el.style.transform = entry.transform;
entry.el.style.opacity = '';
});
}
}