import { tick } from "svelte"; import { MediaQuery } from "svelte/reactivity"; import { settings } from "./settings.svelte.js"; const activationTimers = new WeakMap(); const entryAnimations = new WeakMap(); const imageLoadingClass = "image-loading"; export const prefersReducedMotion = new MediaQuery( "(prefers-reduced-motion: reduce)", ); export const entryMotion = { duration: 150, stagger: 20, maxDelay: 100, } as const; export const subtleMoveMotion = { duration: 80, easing: "cubic-bezier(0.25, 1, 0.5, 1)", } as const; export function animateEntryRows(rows: Iterable) { let index = 0; for (const row of rows) { if (entryAnimations.has(row)) { index++; continue; } const animation = row.animate( [ { opacity: 0, translate: "0 -0.25rem" }, { opacity: 1, translate: "0" }, ], { duration: entryMotion.duration, delay: entryMotion.maxDelay * (1 - Math.exp((-index * entryMotion.stagger) / entryMotion.maxDelay)), easing: "ease-out", fill: "backwards", }, ); entryAnimations.set(row, animation); animation.onfinish = animation.oncancel = () => { if (entryAnimations.get(row) === animation) entryAnimations.delete(row); }; index++; } } export const motionEnabled = () => settings.motion && !prefersReducedMotion.current; export function fadeImage(image: HTMLImageElement, fadeIfComplete = false) { if (!motionEnabled() || (image.complete && !fadeIfComplete)) return; image.classList.add(imageLoadingClass); let fadeAnimation: Animation | undefined; const reveal = (fade = false) => { image.classList.remove(imageLoadingClass); if (!fade || !motionEnabled() || !image.naturalWidth) return; fadeAnimation?.cancel(); const nextAnimation = image.animate( [{ opacity: 0 }, { opacity: getComputedStyle(image).opacity }], { duration: entryMotion.duration, easing: "ease-out", }, ); fadeAnimation = nextAnimation; nextAnimation.onfinish = nextAnimation.oncancel = () => { if (fadeAnimation === nextAnimation) fadeAnimation = undefined; }; }; const handleLoad = () => reveal(true); const handleError = () => reveal(); if (image.complete) { handleLoad(); } else { image.addEventListener("load", handleLoad); image.addEventListener("error", handleError); } return () => { fadeAnimation?.cancel(); image.classList.remove(imageLoadingClass); image.removeEventListener("load", handleLoad); image.removeEventListener("error", handleError); }; } export function animateActivation(target: string | HTMLElement | null) { const element = typeof target === "string" ? document.querySelector(target) : target; if (!element || !motionEnabled()) return; const previousTimer = activationTimers.get(element); if (previousTimer !== undefined) clearTimeout(previousTimer); element.classList.remove("is-activated"); void element.offsetWidth; element.classList.add("is-activated"); const duration = getComputedStyle(element).animationDuration; const timer = window.setTimeout( () => { if (activationTimers.get(element) !== timer) return; activationTimers.delete(element); element.classList.remove("is-activated"); }, parseFloat(duration) * (duration.endsWith("ms") ? 1 : 1000), ); activationTimers.set(element, timer); } const rejectionAnimations = new WeakMap(); export function animateButtonRejection(button: HTMLButtonElement | null) { if (!button || !motionEnabled()) return; rejectionAnimations.get(button)?.cancel(); const animation = button.animate( [ { translate: "0" }, { translate: "0.25rem 0" }, { translate: "-0.25rem 0" }, { translate: "0.125rem 0" }, { translate: "-0.125rem 0" }, { translate: "0" }, ], { duration: 250, easing: "ease-out" }, ); rejectionAnimations.set(button, animation); animation.onfinish = animation.oncancel = () => { if (rejectionAnimations.get(button) === animation) rejectionAnimations.delete(button); }; } const layoutAnimations = new WeakMap(); export const animateTreeEntries = ( container: HTMLElement | undefined, keys?: readonly string[], includeLabels = false, ) => { if (!container || !motionEnabled()) return; void tick().then(() => requestAnimationFrame(() => { if (!motionEnabled()) return; const rows = [ ...container.querySelectorAll("[data-tree-key]"), ].filter( (row) => !keys || keys.some( (key) => row.dataset.treeKey?.startsWith(`${key}/`) || (includeLabels && row.dataset.treeKey === key), ), ); animateEntryRows(rows); }), ); }; export const captureTreeItemPositions = ( container: HTMLElement | undefined, ) => { if (!container || !motionEnabled()) return; return new Map( [...container.querySelectorAll("[data-tree-key]")].map( (row) => [row, row.getBoundingClientRect().top], ), ); }; const animateTreeItemPositions = ( positions: Map | undefined, ) => { if (!positions) return; void tick().then(() => requestAnimationFrame(() => { if (!motionEnabled()) return; for (const [row, previousTop] of positions) { if (!row.isConnected) continue; const offset = Math.max( -20, Math.min(20, previousTop - row.getBoundingClientRect().top), ); if (!offset) continue; layoutAnimations.get(row)?.cancel(); const animation = row.animate( [{ translate: `0 ${offset}px` }, { translate: "0" }], subtleMoveMotion, ); layoutAnimations.set(row, animation); animation.onfinish = animation.oncancel = () => { if (layoutAnimations.get(row) === animation) layoutAnimations.delete(row); }; } }), ); }; export const animateTreeExpansion = ( container: HTMLElement | undefined, key: string, positions: Map | undefined, ) => { animateTreeEntries(container, [key]); animateTreeItemPositions(positions); };