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.

238 lines
6.7 KiB

import { clamp, debounce, normaliseAngleDifference } from "../../util/index.ts";
interface Vec2 {
x: number;
y: number;
}
export interface DraggableOpts {
initialRotation?: number;
onViewportExit?: () => void;
onViewportEnter?: () => void;
}
export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
// --- Initial Setup ---
const calculateInitialCenter = (): Vec2 => {
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 = opts.initialRotation ?? 0;
let dragging = false;
let offsetLocal: Vec2 = { x: 0, y: 0 };
let velocity: Vec2 = { 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;
const VIEWPORT_CHECK_INTERVAL_MS = 100;
// --- State ---
let lastMousePosition: Vec2 = { x: 0, y: 0 };
let activePointerId: number | null = null;
let animationFrameId: number | null = null;
let isOutsideViewport = false;
// --- Helpers ---
const checkViewportExit = debounce(() => {
// Don't check if we're dragging, user may still be able to move the card back into view
if (dragging) return;
const rect = card.getBoundingClientRect();
const outside =
rect.right < 0 ||
rect.bottom < 0 ||
rect.left > window.innerWidth ||
rect.top > window.innerHeight;
if (outside !== isOutsideViewport) {
isOutsideViewport = outside;
if (isOutsideViewport) opts.onViewportExit?.();
else opts.onViewportEnter?.();
}
}, VIEWPORT_CHECK_INTERVAL_MS);
// --- 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 };
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);
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(() => {
// 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)
`;
checkViewportExit();
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 = "";
};
}