Skip to main content

assets/js/relayout.js

/**
 * Inferred mid-life animations.
 *
 * Walks every tracked element, compares its last-known layout with where/
 * what it is now, and animates any delta:
 *
 *   position changed -> FLIP (First-Last-Invert-Play): the element is now at
 *     its new layout position, but we animate `translate(dx, dy) → 0` so it
 *     visually starts from its old position and slides to the new one.
 *
 *   size changed -> interpolate width/height from old to new; hold
 *     `overflow: hidden` throughout so content doesn't spill mid-grow.
 *
 * Per-element opt-outs via spec.disable: ["position"] / ["size"].
 */

import { TRACKED, readSpec } from "./state.js";
import { measure } from "./measure.js";
import { buildKeyframes } from "./keyframes.js";

export function relayout() {
  const changed = [];

  for (const el of TRACKED) {
    /**
     * Drop nodes that have been detached from the document (post-exit DOM
     * removal). Cheap O(1) `isConnected` check beats the alternative of a
     * global MutationObserver tracking removals.
     */
    if (!el.isConnected) {
      TRACKED.delete(el);
      continue;
    }
    /**
     * Skip elements whose enter/exit is currently mutating their size —
     * the WAAPI animation transiently clamps offsetHeight, which would
     * look like a real layout change and trigger a competing FLIP.
     */
    if (el.__shiftTransitioning) continue;

    const spec = readSpec(el);
    if (!spec) continue;

    const prev = el.__shiftLayout;
    const next = measure(el);
    /**
     * `measure` returns null when the element is currently hidden (under
     * `display: none`). Keep the cache as-is in that case — we have no
     * better value to write, and a future relayout pass after the element
     * becomes visible will seed it correctly.
     */
    if (!next) continue;
    el.__shiftLayout = next;
    /**
     * Refresh the cached DOM slot too — this is the element's "last stable
     * position", which is what we want to restore to if it later exits.
     * The value set at enter time goes stale as other siblings come and go.
     */
    if (el.parentElement) {
      el.__shiftSiblingIndex = [...el.parentElement.children].indexOf(el);
    }
    if (!prev) continue;

    const disabled = spec.disable || [];
    const dx = prev.left - next.left;
    const dy = prev.top - next.top;
    const dw = next.width - prev.width;
    const dh = next.height - prev.height;

    /**
     * Local position delta — within the immediate parent. This is the gate
     * for FLIP. If a sibling here was removed, local delta is real and we
     * animate. If something far up the page got shorter and pushed every
     * subsequent element along, local delta is zero (only body-relative
     * coordinates moved) — we skip, so unrelated lists below the change
     * don't all animate too.
     *
     * When the parent itself changed, local position is incomparable — the
     * element jumped to a different layout context. Always FLIP in that
     * case, regardless of local delta (an element that becomes the first
     * child of its new parent has localTop = 0 in both old and new, but is
     * still a real move that we want to animate).
     */
    const localDx = (prev.localLeft ?? prev.left) - (next.localLeft ?? next.left);
    const localDy = (prev.localTop ?? prev.top) - (next.localTop ?? next.top);
    const parentChanged = prev.parent !== next.parent;

    const animatePos =
      !disabled.includes("position") &&
      (parentChanged || Math.abs(localDx) > 1 || Math.abs(localDy) > 1);
    const animateSize =
      !disabled.includes("size") && (Math.abs(dw) > 1 || Math.abs(dh) > 1);

    if (animatePos || animateSize) {
      changed.push({
        el,
        dx,
        dy,
        prevW: prev.width,
        prevH: prev.height,
        newW: next.width,
        newH: next.height,
        animatePos,
        animateSize,
        spec,
      });
    }
  }

  for (const item of changed) {
    if (item.el.__shiftFlip) item.el.__shiftFlip.cancel();

    const from = {};
    const to = {};

    if (item.animatePos) {
      from.x = item.dx;
      from.y = item.dy;
      to.x = 0;
      to.y = 0;
    }

    if (item.animateSize) {
      from.width = item.prevW;
      from.height = item.prevH;
      to.width = item.newW;
      to.height = item.newH;
      /**
       * Discrete value held in every keyframe — keeps content from spilling
       * while the box is partway through its new dimensions.
       */
      from.overflow = "hidden";
      to.overflow = "hidden";
    }

    const { keyframes, options } = buildKeyframes(
      from,
      to,
      item.spec.transition,
      "layout",
    );
    const anim = item.el.animate(keyframes, options);
    /**
     * When the animation completes naturally, cancel it so the element
     * reverts to its natural DOM dimensions (which equal the end state —
     * no visual jump, but the WAAPI animation is removed instead of being
     * left to "fill" forever, which would freeze the element at the
     * animated end value).
     */
    anim.addEventListener("finish", () => anim.cancel());
    item.el.__shiftFlip = anim;
  }
}