227 lines
6.1 KiB
TypeScript
227 lines
6.1 KiB
TypeScript
interface PhysicsBody {
|
|
el: HTMLElement;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
width: number;
|
|
height: number;
|
|
originX: number;
|
|
originY: number;
|
|
isDragging: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
}
|
|
|
|
export class GravityEngine {
|
|
private bodies: PhysicsBody[] = [];
|
|
private dirtyElements: HTMLElement[] = [];
|
|
private animationFrameId: number | null = null;
|
|
private isRunning = false;
|
|
|
|
INITIAL_FORCE = 10;
|
|
|
|
start() {
|
|
if (this.isRunning) return;
|
|
this.isRunning = true;
|
|
this.dirtyElements = [];
|
|
|
|
const containers = document.querySelectorAll(".gravity-body");
|
|
const leafElements: HTMLElement[] = [];
|
|
|
|
// 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) {
|
|
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);
|
|
|
|
Array.from(el.children).forEach((child) =>
|
|
extractLeaves(child as HTMLElement),
|
|
);
|
|
};
|
|
|
|
containers.forEach((container) => {
|
|
const htmlContainer = container as HTMLElement;
|
|
htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
|
|
htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
|
|
this.dirtyElements.push(htmlContainer);
|
|
|
|
Array.from(htmlContainer.children).forEach((child) =>
|
|
extractLeaves(child as HTMLElement),
|
|
);
|
|
});
|
|
|
|
// Phase 2 — Read: batch all getBoundingClientRect() before any writes.
|
|
const snapshots = leafElements.map((el) => ({
|
|
el,
|
|
rect: el.getBoundingClientRect(),
|
|
}));
|
|
|
|
// Phase 3 — Write: apply fixed positioning using snapshotted rects.
|
|
this.bodies = snapshots.map(({ el, rect }) => {
|
|
el.style.width = `${rect.width}px`;
|
|
el.style.height = `${rect.height}px`;
|
|
el.style.margin = "0px";
|
|
el.style.position = "fixed";
|
|
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,
|
|
x: rect.left,
|
|
y: rect.top,
|
|
vx: (Math.random() - 0.5) * 8,
|
|
vy: (Math.random() - 0.5) * 5,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
originX: rect.left,
|
|
originY: rect.top,
|
|
isDragging: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
};
|
|
|
|
this.attachMouseEvents(body);
|
|
return body;
|
|
});
|
|
|
|
this.tick();
|
|
}
|
|
|
|
stop() {
|
|
this.isRunning = false;
|
|
if (this.animationFrameId) {
|
|
cancelAnimationFrame(this.animationFrameId);
|
|
this.animationFrameId = null;
|
|
}
|
|
|
|
const DURATION = 600;
|
|
|
|
// Glide each body back to its origin position.
|
|
this.bodies.forEach((body) => {
|
|
body.el.style.transition = `transform ${DURATION}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
|
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.
|
|
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 = "";
|
|
});
|
|
}, DURATION);
|
|
}
|
|
|
|
private attachMouseEvents(body: PhysicsBody) {
|
|
// Prevent default drag behaviors that interfere with physics
|
|
body.el.ondragstart = () => false;
|
|
|
|
body.el.addEventListener("pointerdown", (e) => {
|
|
body.isDragging = true;
|
|
body.startX = e.clientX;
|
|
body.startY = e.clientY;
|
|
body.vx = 0;
|
|
body.vy = 0;
|
|
body.el.setPointerCapture(e.pointerId);
|
|
});
|
|
|
|
body.el.addEventListener("pointermove", (e) => {
|
|
if (!body.isDragging) return;
|
|
|
|
// Calculate velocity based on mouse movement for the "throw"
|
|
body.vx = e.movementX * 0.5;
|
|
body.vy = e.movementY * 0.5;
|
|
|
|
body.x += e.movementX;
|
|
body.y += e.movementY;
|
|
});
|
|
|
|
body.el.addEventListener("pointerup", (e) => {
|
|
body.isDragging = false;
|
|
body.el.releasePointerCapture(e.pointerId);
|
|
|
|
// The Drag vs. Click Resolver
|
|
const deltaX = Math.abs(e.clientX - body.startX);
|
|
const deltaY = Math.abs(e.clientY - body.startY);
|
|
|
|
// If the user moved the mouse less than 5px, treat it as a click
|
|
if (deltaX > 5 || deltaY > 5) {
|
|
// It was a drag. Prevent links from firing.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
// Catch clicks at the capture phase to stop them if we were dragging
|
|
body.el.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
const deltaX = Math.abs(e.clientX - body.startX);
|
|
const deltaY = Math.abs(e.clientY - body.startY);
|
|
if (deltaX > 5 || deltaY > 5) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
|
|
private tick = () => {
|
|
if (!this.isRunning) return;
|
|
|
|
const gravity = 0.5;
|
|
const bounce = -0.7;
|
|
const floorY = window.innerHeight;
|
|
|
|
this.bodies.forEach((body) => {
|
|
if (body.isDragging) {
|
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
|
return;
|
|
}
|
|
|
|
body.vy += gravity;
|
|
body.x += body.vx;
|
|
body.y += body.vy;
|
|
|
|
body.vx *= 0.99;
|
|
body.vy *= 0.99;
|
|
|
|
if (body.y + body.height > floorY) {
|
|
body.y = floorY - body.height;
|
|
body.vy *= bounce;
|
|
body.vx *= 0.9;
|
|
}
|
|
|
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
|
});
|
|
|
|
this.animationFrameId = requestAnimationFrame(this.tick);
|
|
};
|
|
}
|