mirror of https://github.com/mkrhere/pw2
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
6.7 KiB
244 lines
6.7 KiB
2 months ago
|
interface Vec2 {
|
||
|
x: number;
|
||
|
y: number;
|
||
|
}
|
||
|
|
||
|
// --- Debounce Utility ---
|
||
|
function debounce<T extends (...args: any[]) => void>(
|
||
|
func: T,
|
||
|
wait: number,
|
||
|
): T {
|
||
|
let timeoutId: number | null = null;
|
||
|
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||
|
if (timeoutId !== null) {
|
||
|
clearTimeout(timeoutId);
|
||
|
}
|
||
|
timeoutId = window.setTimeout(() => {
|
||
|
func.apply(this, args);
|
||
|
timeoutId = null; // Clear after execution
|
||
|
}, wait);
|
||
|
} as T;
|
||
|
}
|
||
|
|
||
|
export function makeDraggable(card: HTMLElement) {
|
||
|
// --- Initial Setup ---
|
||
|
const calculateInitialCenter = () => {
|
||
|
const rect = card.getBoundingClientRect();
|
||
|
return {
|
||
|
x: rect.left + rect.width / 2,
|
||
|
y: rect.top + rect.height / 2,
|
||
|
};
|
||
|
};
|
||
|
|
||
|
// Use mutable objects to allow updates in resize handler
|
||
|
let initialCenter = calculateInitialCenter();
|
||
|
let center = { ...initialCenter };
|
||
|
|
||
|
let rotation = 0;
|
||
|
let dragging = false;
|
||
|
let offsetLocal = { x: 0, y: 0 };
|
||
|
|
||
|
let velocity = { x: 0, y: 0 };
|
||
|
let angularVelocity = 0;
|
||
|
|
||
|
// --- Constants ---
|
||
|
const dampingFactor = 0.7;
|
||
|
const springFactor = 0.2;
|
||
|
const maxAngularVelocity = 0.95;
|
||
|
const momentumDampening = 0.98;
|
||
|
const RESIZE_DEBOUNCE_MS = 100; // Debounce time for resize
|
||
|
|
||
|
// --- State ---
|
||
|
let lastMousePosition = { x: 0, y: 0 };
|
||
|
let activePointerId: number | null = null;
|
||
|
let animationFrameId: number | null = null;
|
||
|
|
||
|
// --- Helpers ---
|
||
|
function clamp(value: number, min: number, max: number) {
|
||
|
return Math.min(Math.max(value, min), max);
|
||
|
}
|
||
|
|
||
|
function normaliseAngleDifference(delta: number): number {
|
||
|
// Bring into range (-2PI, 2PI)
|
||
|
delta = delta % (2 * Math.PI);
|
||
|
if (delta > Math.PI) delta -= 2 * Math.PI;
|
||
|
else if (delta <= -Math.PI) delta += 2 * Math.PI;
|
||
|
return delta;
|
||
|
}
|
||
|
|
||
|
// --- Event Handlers ---
|
||
|
const down = (e: PointerEvent) => {
|
||
|
if (activePointerId !== null) return;
|
||
|
|
||
|
dragging = true;
|
||
|
activePointerId = e.pointerId;
|
||
|
card.style.cursor = "grabbing";
|
||
|
card.setPointerCapture(e.pointerId);
|
||
|
velocity = { x: 0, y: 0 };
|
||
|
|
||
|
const dx = e.pageX - center.x;
|
||
|
const dy = e.pageY - center.y;
|
||
|
const cos = Math.cos(-rotation);
|
||
|
const sin = Math.sin(-rotation);
|
||
|
offsetLocal = {
|
||
|
x: dx * cos - dy * sin,
|
||
|
y: dx * sin + dy * cos,
|
||
|
};
|
||
|
|
||
|
lastMousePosition = { x: e.pageX, y: e.pageY };
|
||
|
// Stop momentum on new grab
|
||
|
angularVelocity = 0;
|
||
|
};
|
||
|
|
||
|
const move = (e: PointerEvent) => {
|
||
|
if (!dragging || e.pointerId !== activePointerId) return;
|
||
|
|
||
|
const mx = e.pageX;
|
||
|
const my = e.pageY;
|
||
|
|
||
|
velocity = {
|
||
|
x: mx - lastMousePosition.x,
|
||
|
y: my - lastMousePosition.y,
|
||
|
};
|
||
|
|
||
|
lastMousePosition = { x: mx, y: my };
|
||
|
|
||
|
const px = offsetLocal.x;
|
||
|
const py = offsetLocal.y;
|
||
|
|
||
|
const targetRotation =
|
||
|
Math.atan2(my - center.y, mx - center.x) - Math.atan2(py, px);
|
||
|
|
||
|
// Apply spring physics to rotation
|
||
|
const shortestAngleDifference = normaliseAngleDifference(
|
||
|
targetRotation - rotation,
|
||
|
);
|
||
|
angularVelocity += shortestAngleDifference * springFactor;
|
||
|
angularVelocity *= dampingFactor;
|
||
|
angularVelocity = clamp(
|
||
|
angularVelocity,
|
||
|
-maxAngularVelocity,
|
||
|
maxAngularVelocity,
|
||
|
);
|
||
|
|
||
|
rotation += angularVelocity;
|
||
|
|
||
|
const cos = Math.cos(rotation);
|
||
|
const sin = Math.sin(rotation);
|
||
|
|
||
|
const rx = px * cos - py * sin;
|
||
|
const ry = px * sin + py * cos;
|
||
|
|
||
|
center = {
|
||
|
x: mx - rx,
|
||
|
y: my - ry,
|
||
|
};
|
||
|
};
|
||
|
|
||
|
const up = (e: PointerEvent) => {
|
||
|
if (e.pointerId === activePointerId) {
|
||
|
dragging = false;
|
||
|
activePointerId = null;
|
||
|
card.style.cursor = "grab";
|
||
|
// Momentum is handled in the render loop
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Debounced Resize Handler using the Reset-Reflow-Recalculate-Reapply strategy
|
||
|
const handleResize = debounce(() => {
|
||
|
console.log("handle resize triggered");
|
||
|
// 1. Store current visual state relative to the *old* initialCenter
|
||
|
const currentDeltaX = center.x - initialCenter.x;
|
||
|
const currentDeltaY = center.y - initialCenter.y;
|
||
|
const currentRotation = rotation; // Rotation doesn't depend on initialCenter
|
||
|
|
||
|
// 2. Temporarily remove the transform
|
||
|
card.style.transition = "none"; // Disable transitions during adjustment
|
||
|
card.style.transform = "none";
|
||
|
|
||
|
// 3. Force browser reflow to get the *untouched* layout position
|
||
|
// Reading offsetWidth is a common way to trigger this synchronously.
|
||
|
void card.offsetWidth;
|
||
|
|
||
|
// 4. Recalculate initialCenter based on the *new*, untouched layout
|
||
|
const newInitialCenter = calculateInitialCenter();
|
||
|
|
||
|
// 5. Update state variables
|
||
|
initialCenter = newInitialCenter;
|
||
|
// Adjust 'center' to maintain the same visual delta relative to the *new* initialCenter
|
||
|
center.x = initialCenter.x + currentDeltaX;
|
||
|
center.y = initialCenter.y + currentDeltaY;
|
||
|
// rotation, velocity, angularVelocity remain unchanged
|
||
|
|
||
|
// 6. Reapply the transform immediately before the next paint
|
||
|
// Use the *stored* delta and rotation to put it back visually where it was
|
||
|
card.style.transform = `translate(${currentDeltaX}px, ${currentDeltaY}px) rotate(${currentRotation}rad)`;
|
||
|
|
||
|
// 7. Re-enable transitions if they were used
|
||
|
card.style.transition = ""; // Or restore previous transition style if needed
|
||
|
|
||
|
// The render loop will continue from this adjusted state.
|
||
|
}, RESIZE_DEBOUNCE_MS); // Apply debouncing
|
||
|
|
||
|
// --- Render Loop ---
|
||
|
function render() {
|
||
|
if (!dragging) {
|
||
|
if (Math.abs(angularVelocity) > 0.01) {
|
||
|
rotation += angularVelocity;
|
||
|
angularVelocity *= momentumDampening;
|
||
|
} else {
|
||
|
angularVelocity = 0;
|
||
|
}
|
||
|
|
||
|
const speed = Math.sqrt(
|
||
|
velocity.x * velocity.x + velocity.y * velocity.y,
|
||
|
);
|
||
|
|
||
|
if (speed > 0.01) {
|
||
|
center.x += velocity.x * 0.4;
|
||
|
center.y += velocity.y * 0.4;
|
||
|
velocity.x *= momentumDampening;
|
||
|
velocity.y *= momentumDampening;
|
||
|
} else {
|
||
|
velocity = { x: 0, y: 0 };
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const deltaX = center.x - initialCenter.x;
|
||
|
const deltaY = center.y - initialCenter.y;
|
||
|
|
||
|
card.style.transform = `
|
||
|
translate(${deltaX}px, ${deltaY}px)
|
||
|
rotate(${rotation}rad)
|
||
|
`;
|
||
|
|
||
|
animationFrameId = requestAnimationFrame(render);
|
||
|
}
|
||
|
|
||
|
card.style.cursor = "grab";
|
||
|
card.style.touchAction = "none";
|
||
|
card.style.userSelect = "none";
|
||
|
|
||
|
card.style.transform = `translate(0px, 0px) rotate(${rotation}rad)`;
|
||
|
|
||
|
card.addEventListener("pointerdown", down, { passive: true });
|
||
|
window.addEventListener("pointermove", move, { passive: true });
|
||
|
window.addEventListener("pointerup", up, { passive: true });
|
||
|
window.addEventListener("pointercancel", up, { passive: true });
|
||
|
window.addEventListener("resize", handleResize);
|
||
|
|
||
|
render();
|
||
|
|
||
|
return function cleanup() {
|
||
|
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
|
||
|
card.removeEventListener("pointerdown", down);
|
||
|
window.removeEventListener("pointermove", move);
|
||
|
window.removeEventListener("pointerup", up);
|
||
|
window.removeEventListener("pointercancel", up);
|
||
|
window.removeEventListener("resize", handleResize);
|
||
|
card.style.cursor = "";
|
||
|
card.style.touchAction = "";
|
||
|
card.style.userSelect = "";
|
||
|
};
|
||
|
}
|