Browse Source

feat: add viewport exit & initialRotation support

master
Muthu Kumar 1 month ago
parent
commit
329b22f15b
Failed to extract signature
  1. 13
      src/components/AnimateEntry.tsx
  2. 76
      src/draggable.attempts/6/Draggable.ts
  3. 21
      src/draggable.attempts/6/Draggable2.tsx
  4. 18
      src/pages/main/Contact.tsx
  5. 26
      src/util/index.ts

13
src/components/AnimateEntry.tsx

@ -1,13 +1,12 @@
import React, { forwardRef } from "react";
import { css, cx } from "@emotion/css";
export const AnimateEntry = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
as?: React.ElementType;
delay?: number;
}
>(
export interface AnimateEntryProps extends React.HTMLAttributes<any> {
as?: React.ElementType;
delay?: number;
}
export const AnimateEntry = forwardRef<HTMLDivElement, AnimateEntryProps>(
(
{ children, className, as: Component = "div", delay = 100, ...props },
ref,

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

@ -1,28 +1,19 @@
import { clamp, debounce, normaliseAngleDifference } from "../../util/index.ts";
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 interface DraggableOpts {
initialRotation?: number;
onViewportExit?: () => void;
onViewportEnter?: () => void;
}
export function makeDraggable(card: HTMLElement) {
export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
// --- Initial Setup ---
const calculateInitialCenter = () => {
const calculateInitialCenter = (): Vec2 => {
const rect = card.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
@ -34,11 +25,11 @@ export function makeDraggable(card: HTMLElement) {
let initialCenter = calculateInitialCenter();
let center = { ...initialCenter };
let rotation = 0;
let rotation = opts.initialRotation ?? 0;
let dragging = false;
let offsetLocal = { x: 0, y: 0 };
let offsetLocal: Vec2 = { x: 0, y: 0 };
let velocity = { x: 0, y: 0 };
let velocity: Vec2 = { x: 0, y: 0 };
let angularVelocity = 0;
// --- Constants ---
@ -46,25 +37,34 @@ export function makeDraggable(card: HTMLElement) {
const springFactor = 0.2;
const maxAngularVelocity = 0.95;
const momentumDampening = 0.98;
const RESIZE_DEBOUNCE_MS = 100; // Debounce time for resize
const RESIZE_DEBOUNCE_MS = 100;
const VIEWPORT_CHECK_INTERVAL_MS = 100;
// --- State ---
let lastMousePosition = { x: 0, y: 0 };
let lastMousePosition: Vec2 = { x: 0, y: 0 };
let activePointerId: number | null = null;
let animationFrameId: number | null = null;
let isOutsideViewport = false;
// --- 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;
}
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) => {
@ -86,7 +86,6 @@ export function makeDraggable(card: HTMLElement) {
};
lastMousePosition = { x: e.pageX, y: e.pageY };
// Stop momentum on new grab
angularVelocity = 0;
};
@ -109,7 +108,6 @@ export function makeDraggable(card: HTMLElement) {
const targetRotation =
Math.atan2(my - center.y, mx - center.x) - Math.atan2(py, px);
// Apply spring physics to rotation
const shortestAngleDifference = normaliseAngleDifference(
targetRotation - rotation,
);
@ -146,7 +144,6 @@ export function makeDraggable(card: HTMLElement) {
// 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;
@ -186,9 +183,7 @@ export function makeDraggable(card: HTMLElement) {
if (Math.abs(angularVelocity) > 0.01) {
rotation += angularVelocity;
angularVelocity *= momentumDampening;
} else {
angularVelocity = 0;
}
} else angularVelocity = 0;
const speed = Math.sqrt(
velocity.x * velocity.x + velocity.y * velocity.y,
@ -199,9 +194,7 @@ export function makeDraggable(card: HTMLElement) {
center.y += velocity.y * 0.4;
velocity.x *= momentumDampening;
velocity.y *= momentumDampening;
} else {
velocity = { x: 0, y: 0 };
}
} else velocity = { x: 0, y: 0 };
}
const deltaX = center.x - initialCenter.x;
@ -212,6 +205,7 @@ export function makeDraggable(card: HTMLElement) {
rotate(${rotation}rad)
`;
checkViewportExit();
animationFrameId = requestAnimationFrame(render);
}

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

@ -3,21 +3,36 @@ import { makeDraggable } from "./Draggable.ts";
import { composeRefs } from "../../util/index.ts";
import { css, cx } from "@emotion/css";
export type DraggableProps = React.ComponentPropsWithRef<any> & {
export type DraggableProps = React.HtmlHTMLAttributes<any> & {
as?: React.ElementType;
onViewportEnter?: () => void;
onViewportExit?: () => void;
children: React.ReactNode;
initialRotation?: number;
};
export const Draggable = forwardRef<HTMLElement, DraggableProps>(
(
{ as: Comp = "div", children, className, ...props }: DraggableProps,
{
as: Comp = "div",
children,
className,
onViewportEnter,
onViewportExit,
initialRotation,
...props
}: DraggableProps,
ref,
) => {
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!cardRef.current) return;
return makeDraggable(cardRef.current);
return makeDraggable(cardRef.current, {
onViewportEnter,
onViewportExit,
initialRotation,
});
}, []);
return (

18
src/pages/main/Contact.tsx

@ -45,15 +45,19 @@ const CONTACT: Contact = {
"Blog": { value: "→", link: "https://MKRhere.com" },
};
const CARD_COUNT = 5;
// slightly random rotations within -20 to 20 degrees
const cardRotations = Array.from({ length: 1 }, () => {
const rotation = Math.random() * 40 - 20;
const CARD_ROTATION_VARIANCE = 20 * (Math.PI / 180);
const contactCards = Array.from({ length: CARD_COUNT }, () => {
const rotation =
Math.random() * CARD_ROTATION_VARIANCE - CARD_ROTATION_VARIANCE / 2;
return rotation;
});
const Contact: React.FC = () => {
const [contact, setContact] = useState<Contact>(CONTACT);
const [visible, setVisible] = useState(cardRotations.length);
const [visible, setVisible] = useState(contactCards.length);
useEffect(() => {
const deob = () => {
@ -108,10 +112,12 @@ const Contact: React.FC = () => {
<a href="/">Start over?</a>
</AnimateEntry>
)}
{cardRotations.map((rot, i) => (
{contactCards.map((rot, i) => (
<Draggable
key={i}
onOutsideViewport={() => setVisible(v => v - 1)}
onViewportExit={() => setVisible(v => v - 1)}
onViewportEnter={() => setVisible(v => v + 1)}
initialRotation={rot}
className={css`
width: 22rem;
height: 14rem;
@ -125,7 +131,7 @@ const Contact: React.FC = () => {
`}
ref={setupCursorTracking}>
<Flippable
defaultFlipped={i !== cardRotations.length - 1}
defaultFlipped={i !== contactCards.length - 1}
front={
<main
className={css`

26
src/util/index.ts

@ -50,6 +50,20 @@ export const getTimeout = () => {
return [timeout, clearTimers] as const;
};
export 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;
}, wait);
} as T;
}
export const ellipses = (text: string, length: number = 100) =>
text.length > length ? text.slice(0, length - 3) + "..." : text;
@ -104,3 +118,15 @@ export function setupCursorTracking(el: HTMLElement | null) {
el.style.setProperty("--y", y + "px");
});
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export 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;
}

Loading…
Cancel
Save