fix: two-pass start, depth cap, cssText restore on stop
This commit is contained in:
@@ -15,7 +15,7 @@ interface PhysicsBody {
|
|||||||
|
|
||||||
export class GravityEngine {
|
export class GravityEngine {
|
||||||
private bodies: PhysicsBody[] = [];
|
private bodies: PhysicsBody[] = [];
|
||||||
private dirtyElements: HTMLElement[] = [];
|
private dirtyElements: Array<{ el: HTMLElement; originalCssText: string }> = [];
|
||||||
private animationFrameId: number | null = null;
|
private animationFrameId: number | null = null;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
@@ -26,51 +26,65 @@ export class GravityEngine {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.dirtyElements = [];
|
this.dirtyElements = [];
|
||||||
|
|
||||||
|
const MAX_DEPTH = 3;
|
||||||
const containers = document.querySelectorAll(".gravity-body");
|
const containers = document.querySelectorAll(".gravity-body");
|
||||||
const leafElements: HTMLElement[] = [];
|
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(
|
const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
|
||||||
el.tagName.toUpperCase(),
|
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) {
|
if (el.offsetWidth > 0 && el.offsetHeight > 0) {
|
||||||
leafElements.push(el);
|
leafElements.push(el);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.style.width = `${el.offsetWidth}px`;
|
intermediates.push({ el, w: el.offsetWidth, h: el.offsetHeight });
|
||||||
el.style.height = `${el.offsetHeight}px`;
|
|
||||||
this.dirtyElements.push(el);
|
|
||||||
|
|
||||||
Array.from(el.children).forEach((child) =>
|
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) => {
|
containers.forEach((container) => {
|
||||||
const htmlContainer = container as HTMLElement;
|
const htmlContainer = container as HTMLElement;
|
||||||
htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
|
rootSizes.push({
|
||||||
htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
|
el: htmlContainer,
|
||||||
this.dirtyElements.push(htmlContainer);
|
w: htmlContainer.offsetWidth,
|
||||||
|
h: htmlContainer.offsetHeight,
|
||||||
|
});
|
||||||
Array.from(htmlContainer.children).forEach((child) =>
|
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) => ({
|
const snapshots = leafElements.map((el) => ({
|
||||||
el,
|
el,
|
||||||
rect: el.getBoundingClientRect(),
|
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.bodies = snapshots.map(({ el, rect }) => {
|
||||||
|
this.dirtyElements.push({ el, originalCssText: el.style.cssText });
|
||||||
el.style.width = `${rect.width}px`;
|
el.style.width = `${rect.width}px`;
|
||||||
el.style.height = `${rect.height}px`;
|
el.style.height = `${rect.height}px`;
|
||||||
el.style.margin = "0px";
|
el.style.margin = "0px";
|
||||||
@@ -78,7 +92,6 @@ export class GravityEngine {
|
|||||||
el.style.left = "0px";
|
el.style.left = "0px";
|
||||||
el.style.top = "0px";
|
el.style.top = "0px";
|
||||||
el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
|
el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
|
||||||
this.dirtyElements.push(el);
|
|
||||||
|
|
||||||
const body: PhysicsBody = {
|
const body: PhysicsBody = {
|
||||||
el,
|
el,
|
||||||
@@ -117,22 +130,16 @@ export class GravityEngine {
|
|||||||
body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`;
|
body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// After the transition completes, clear all inline styles from every
|
// After the transition completes, restore every element's original inline
|
||||||
// element we touched so the document flow is fully restored.
|
// 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];
|
const elementsToClear = [...this.dirtyElements];
|
||||||
this.bodies = [];
|
this.bodies = [];
|
||||||
this.dirtyElements = [];
|
this.dirtyElements = [];
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
elementsToClear.forEach((el) => {
|
elementsToClear.forEach(({ el, originalCssText }) => {
|
||||||
el.style.transform = "";
|
el.style.cssText = originalCssText;
|
||||||
el.style.transition = "";
|
|
||||||
el.style.position = "";
|
|
||||||
el.style.left = "";
|
|
||||||
el.style.top = "";
|
|
||||||
el.style.width = "";
|
|
||||||
el.style.height = "";
|
|
||||||
el.style.margin = "";
|
|
||||||
});
|
});
|
||||||
}, DURATION);
|
}, DURATION);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user