export type FlipOptions = { host: HTMLElement; itemSelector: string; itemId: (item: HTMLElement) => string | null; }; type Rect = { left: number; top: number }; export type FlipOrigin = { itemId: string; rect: Rect }; function capture(options: FlipOptions): Map { const result = new Map(); options.host.querySelectorAll(options.itemSelector).forEach((item) => { const id = options.itemId(item); if (id) { const rect = item.getBoundingClientRect(); result.set(id, { left: rect.left, top: rect.top }); } }); return result; } function play(options: FlipOptions, before: Map): void { options.host.querySelectorAll(options.itemSelector).forEach((item) => { const id = options.itemId(item); const first = id ? before.get(id) : undefined; if (!first) return; const last = item.getBoundingClientRect(); const dx = first.left - last.left; const dy = first.top - last.top; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; item.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], { duration: 180, easing: "cubic-bezier(.2, 0, 0, 1)", }); }); } /** Watches a host for the DOM change caused by a semantic move, including a later SSE morph. */ export function installFlip(options: FlipOptions): { prepare: (origin?: FlipOrigin) => void; dispose: () => void } { let before: Map | null = null; let timer: ReturnType | null = null; let frame: number | null = null; const clearPending = (): void => { before = null; if (timer !== null) clearTimeout(timer); if (frame !== null) cancelAnimationFrame(frame); timer = null; frame = null; }; const finish = (): void => { if (!before) return; const snapshot = before; clearPending(); play(options, snapshot); }; const containsItem = (node: Node): boolean => node instanceof Element && (node.matches(options.itemSelector) || !!node.querySelector(options.itemSelector)); const observer = new MutationObserver((records) => { if ( !before || frame !== null || !records.some((record) => [...record.addedNodes, ...record.removedNodes].some(containsItem)) ) return; frame = requestAnimationFrame(finish); }); observer.observe(options.host, { childList: true, subtree: true }); return { prepare: (origin) => { clearPending(); before = capture(options); if (origin) before.set(origin.itemId, origin.rect); timer = setTimeout(clearPending, 2000); }, dispose: () => { observer.disconnect(); clearPending(); }, }; }