Browse Source

feat: attempt 6 at creating standalone draaggable (finally works!)

master
Muthu Kumar 1 month ago
parent
commit
82e3cc00ac
Failed to extract signature
  1. 243
      src/draggable.attempts/6/Draggable.ts
  2. 40
      src/draggable.attempts/6/Draggable2.tsx
  3. 72
      src/draggable.attempts/6/index.html
  4. 15
      src/draggable.attempts/6/vite.config.ts

243
src/draggable.attempts/6/Draggable.ts

@ -0,0 +1,243 @@
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 = "";
};
}

40
src/draggable.attempts/6/Draggable2.tsx

@ -0,0 +1,40 @@
import React, { forwardRef, useEffect, useRef } from "react";
import { makeDraggable } from "./Draggable.ts";
import { composeRefs } from "../../util/index.ts";
import { css, cx } from "@emotion/css";
export type DraggableProps = React.ComponentPropsWithRef<any> & {
as?: React.ElementType;
children: React.ReactNode;
};
export const Draggable = forwardRef<HTMLElement, DraggableProps>(
(
{ as: Comp = "div", children, className, ...props }: DraggableProps,
ref,
) => {
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!cardRef.current) return;
return makeDraggable(cardRef.current);
}, []);
return (
<Comp
className={cx(
className,
"draggable",
css`
cursor: grab;
`,
)}
ref={composeRefs(cardRef, ref)}
{...props}>
{children}
</Comp>
);
},
);
Draggable.displayName = "Draggable";

72
src/draggable.attempts/6/index.html

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100vw;
height: 100vh;
background: #111;
overflow: hidden;
}
#card {
width: 24rem;
height: 16rem;
background: linear-gradient(135deg, #ff9a9e, #fad0c4);
border-radius: 20px;
position: absolute;
left: 200px;
/* top: 200px; */
bottom: 0;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3);
transform-origin: center center;
cursor: grab;
/* touch-action: none; */
}
.container {
display: flex;
width: 100%;
height: 100%;
padding: 4rem;
overflow: hidden;
}
.sidebar {
width: 20rem;
height: 100%;
background: #222;
}
main {
flex: 1;
background: #333;
position: relative;
overflow: hidden;
}
</style>
</head>
<body>
<div class="container">
<div class="sidebar"></div>
<main>
<div id="card"></div>
</main>
</div>
<script type="module">
import { makeDraggable } from "./Draggable.ts";
makeDraggable(document.getElementById("card"));
</script>
</body>
</html>

15
src/draggable.attempts/6/vite.config.ts

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
server: { port: 10000, allowedHosts: ["dev.mkr.thefeathers.co"] },
plugins: [],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
Loading…
Cancel
Save