From 82e3cc00ac5915321e8c3da04b6d5214c86ed05b Mon Sep 17 00:00:00 2001
From: Muthu Kumar <muthukumar@thefeathers.in>
Date: Wed, 9 Apr 2025 20:39:20 +0530
Subject: [PATCH] feat: attempt 6 at creating standalone draaggable (finally
 works!)

---
 src/draggable.attempts/6/Draggable.ts   | 243 ++++++++++++++++++++++++++++++++
 src/draggable.attempts/6/Draggable2.tsx |  40 ++++++
 src/draggable.attempts/6/index.html     |  72 ++++++++++
 src/draggable.attempts/6/vite.config.ts |  15 ++
 4 files changed, 370 insertions(+)
 create mode 100644 src/draggable.attempts/6/Draggable.ts
 create mode 100644 src/draggable.attempts/6/Draggable2.tsx
 create mode 100644 src/draggable.attempts/6/index.html
 create mode 100644 src/draggable.attempts/6/vite.config.ts

diff --git a/src/draggable.attempts/6/Draggable.ts b/src/draggable.attempts/6/Draggable.ts
new file mode 100644
index 0000000..9eaeb0f
--- /dev/null
+++ b/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 = "";
+	};
+}
diff --git a/src/draggable.attempts/6/Draggable2.tsx b/src/draggable.attempts/6/Draggable2.tsx
new file mode 100644
index 0000000..2451307
--- /dev/null
+++ b/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";
diff --git a/src/draggable.attempts/6/index.html b/src/draggable.attempts/6/index.html
new file mode 100644
index 0000000..2d42bc3
--- /dev/null
+++ b/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>
diff --git a/src/draggable.attempts/6/vite.config.ts b/src/draggable.attempts/6/vite.config.ts
new file mode 100644
index 0000000..75299c4
--- /dev/null
+++ b/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"),
+			},
+		},
+	},
+});