From cc284c27a923140121d3b1e06759329216ad0832 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 24 Apr 2026 13:43:48 +0200 Subject: [PATCH] fix: two-pass start, depth cap, cssText restore on stop --- lib/gravity-engine.ts | 65 ++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/lib/gravity-engine.ts b/lib/gravity-engine.ts index 6a5ecab..f0cc06a 100644 --- a/lib/gravity-engine.ts +++ b/lib/gravity-engine.ts @@ -15,7 +15,7 @@ interface PhysicsBody { export class GravityEngine { private bodies: PhysicsBody[] = []; - private dirtyElements: HTMLElement[] = []; + private dirtyElements: Array<{ el: HTMLElement; originalCssText: string }> = []; private animationFrameId: number | null = null; private isRunning = false; @@ -26,51 +26,65 @@ export class GravityEngine { this.isRunning = true; this.dirtyElements = []; + const MAX_DEPTH = 3; const containers = document.querySelectorAll(".gravity-body"); const leafElements: HTMLElement[] = []; + const intermediates: { el: HTMLElement; w: number; h: number }[] = []; + + // READ-ONLY traversal: classify all elements and snapshot sizes. + // No DOM writes here — one layout flush for the entire traversal. + const collectElements = (el: HTMLElement, depth: number) => { + // Skip purely decorative elements (e.g. background images). + if (el.classList.contains("pointer-events-none")) return; - // Phase 1 — Size-lock: freeze intermediate container dimensions - // so they don't collapse when children become position:fixed. - const extractLeaves = (el: HTMLElement) => { const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes( el.tagName.toUpperCase(), ); - if (isSolid || el.children.length === 0) { + // Treat as a leaf if solid, childless, or at the depth cap. + if (isSolid || el.children.length === 0 || depth >= MAX_DEPTH) { if (el.offsetWidth > 0 && el.offsetHeight > 0) { leafElements.push(el); } return; } - el.style.width = `${el.offsetWidth}px`; - el.style.height = `${el.offsetHeight}px`; - this.dirtyElements.push(el); - + intermediates.push({ el, w: el.offsetWidth, h: el.offsetHeight }); Array.from(el.children).forEach((child) => - extractLeaves(child as HTMLElement), + collectElements(child as HTMLElement, depth + 1), ); }; + const rootSizes: { el: HTMLElement; w: number; h: number }[] = []; containers.forEach((container) => { const htmlContainer = container as HTMLElement; - htmlContainer.style.width = `${htmlContainer.offsetWidth}px`; - htmlContainer.style.height = `${htmlContainer.offsetHeight}px`; - this.dirtyElements.push(htmlContainer); - + rootSizes.push({ + el: htmlContainer, + w: htmlContainer.offsetWidth, + h: htmlContainer.offsetHeight, + }); Array.from(htmlContainer.children).forEach((child) => - extractLeaves(child as HTMLElement), + collectElements(child as HTMLElement, 0), ); }); - // Phase 2 — Read: batch all getBoundingClientRect() before any writes. + // WRITE PASS 1: size-lock roots and intermediates so they don't collapse + // when their children become position:fixed. + [...rootSizes, ...intermediates].forEach(({ el, w, h }) => { + this.dirtyElements.push({ el, originalCssText: el.style.cssText }); + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }); + + // READ PASS 2: batch all getBoundingClientRect calls before any leaf writes. const snapshots = leafElements.map((el) => ({ el, rect: el.getBoundingClientRect(), })); - // Phase 3 — Write: apply fixed positioning using snapshotted rects. + // WRITE PASS 2: apply fixed positioning to leaves using snapshotted rects. this.bodies = snapshots.map(({ el, rect }) => { + this.dirtyElements.push({ el, originalCssText: el.style.cssText }); el.style.width = `${rect.width}px`; el.style.height = `${rect.height}px`; el.style.margin = "0px"; @@ -78,7 +92,6 @@ export class GravityEngine { el.style.left = "0px"; el.style.top = "0px"; el.style.transform = `translate(${rect.left}px, ${rect.top}px)`; - this.dirtyElements.push(el); const body: PhysicsBody = { el, @@ -117,22 +130,16 @@ export class GravityEngine { body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`; }); - // After the transition completes, clear all inline styles from every - // element we touched so the document flow is fully restored. + // After the transition completes, restore every element's original inline + // styles so document flow is fully recovered — including elements that had + // pre-existing inline styles we must not erase (e.g. Next.js fill images). const elementsToClear = [...this.dirtyElements]; this.bodies = []; this.dirtyElements = []; setTimeout(() => { - elementsToClear.forEach((el) => { - el.style.transform = ""; - el.style.transition = ""; - el.style.position = ""; - el.style.left = ""; - el.style.top = ""; - el.style.width = ""; - el.style.height = ""; - el.style.margin = ""; + elementsToClear.forEach(({ el, originalCssText }) => { + el.style.cssText = originalCssText; }); }, DURATION); }