Browse Source

feat: add viewport exit & initialRotation support

master
Muthu Kumar 1 month ago
parent
commit
329b22f15b
Failed to extract signature
  1. 7
      src/components/AnimateEntry.tsx
  2. 74
      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

7
src/components/AnimateEntry.tsx

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

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

@ -1,28 +1,19 @@
import { clamp, debounce, normaliseAngleDifference } from "../../util/index.ts";
interface Vec2 { interface Vec2 {
x: number; x: number;
y: number; y: number;
} }
// --- Debounce Utility --- export interface DraggableOpts {
function debounce<T extends (...args: any[]) => void>( initialRotation?: number;
func: T, onViewportExit?: () => void;
wait: number, onViewportEnter?: () => void;
): 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) { export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
// --- Initial Setup --- // --- Initial Setup ---
const calculateInitialCenter = () => { const calculateInitialCenter = (): Vec2 => {
const rect = card.getBoundingClientRect(); const rect = card.getBoundingClientRect();
return { return {
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
@ -34,11 +25,11 @@ export function makeDraggable(card: HTMLElement) {
let initialCenter = calculateInitialCenter(); let initialCenter = calculateInitialCenter();
let center = { ...initialCenter }; let center = { ...initialCenter };
let rotation = 0; let rotation = opts.initialRotation ?? 0;
let dragging = false; 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; let angularVelocity = 0;
// --- Constants --- // --- Constants ---
@ -46,25 +37,34 @@ export function makeDraggable(card: HTMLElement) {
const springFactor = 0.2; const springFactor = 0.2;
const maxAngularVelocity = 0.95; const maxAngularVelocity = 0.95;
const momentumDampening = 0.98; 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 --- // --- State ---
let lastMousePosition = { x: 0, y: 0 }; let lastMousePosition: Vec2 = { x: 0, y: 0 };
let activePointerId: number | null = null; let activePointerId: number | null = null;
let animationFrameId: number | null = null; let animationFrameId: number | null = null;
let isOutsideViewport = false;
// --- Helpers --- // --- Helpers ---
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function normaliseAngleDifference(delta: number): number { const checkViewportExit = debounce(() => {
// Bring into range (-2PI, 2PI) // Don't check if we're dragging, user may still be able to move the card back into view
delta = delta % (2 * Math.PI); if (dragging) return;
if (delta > Math.PI) delta -= 2 * Math.PI;
else if (delta <= -Math.PI) delta += 2 * Math.PI; const rect = card.getBoundingClientRect();
return delta; 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 --- // --- Event Handlers ---
const down = (e: PointerEvent) => { const down = (e: PointerEvent) => {
@ -86,7 +86,6 @@ export function makeDraggable(card: HTMLElement) {
}; };
lastMousePosition = { x: e.pageX, y: e.pageY }; lastMousePosition = { x: e.pageX, y: e.pageY };
// Stop momentum on new grab
angularVelocity = 0; angularVelocity = 0;
}; };
@ -109,7 +108,6 @@ export function makeDraggable(card: HTMLElement) {
const targetRotation = const targetRotation =
Math.atan2(my - center.y, mx - center.x) - Math.atan2(py, px); Math.atan2(my - center.y, mx - center.x) - Math.atan2(py, px);
// Apply spring physics to rotation
const shortestAngleDifference = normaliseAngleDifference( const shortestAngleDifference = normaliseAngleDifference(
targetRotation - rotation, targetRotation - rotation,
); );
@ -146,7 +144,6 @@ export function makeDraggable(card: HTMLElement) {
// Debounced Resize Handler using the Reset-Reflow-Recalculate-Reapply strategy // Debounced Resize Handler using the Reset-Reflow-Recalculate-Reapply strategy
const handleResize = debounce(() => { const handleResize = debounce(() => {
console.log("handle resize triggered");
// 1. Store current visual state relative to the *old* initialCenter // 1. Store current visual state relative to the *old* initialCenter
const currentDeltaX = center.x - initialCenter.x; const currentDeltaX = center.x - initialCenter.x;
const currentDeltaY = center.y - initialCenter.y; const currentDeltaY = center.y - initialCenter.y;
@ -186,9 +183,7 @@ export function makeDraggable(card: HTMLElement) {
if (Math.abs(angularVelocity) > 0.01) { if (Math.abs(angularVelocity) > 0.01) {
rotation += angularVelocity; rotation += angularVelocity;
angularVelocity *= momentumDampening; angularVelocity *= momentumDampening;
} else { } else angularVelocity = 0;
angularVelocity = 0;
}
const speed = Math.sqrt( const speed = Math.sqrt(
velocity.x * velocity.x + velocity.y * velocity.y, velocity.x * velocity.x + velocity.y * velocity.y,
@ -199,9 +194,7 @@ export function makeDraggable(card: HTMLElement) {
center.y += velocity.y * 0.4; center.y += velocity.y * 0.4;
velocity.x *= momentumDampening; velocity.x *= momentumDampening;
velocity.y *= momentumDampening; velocity.y *= momentumDampening;
} else { } else velocity = { x: 0, y: 0 };
velocity = { x: 0, y: 0 };
}
} }
const deltaX = center.x - initialCenter.x; const deltaX = center.x - initialCenter.x;
@ -212,6 +205,7 @@ export function makeDraggable(card: HTMLElement) {
rotate(${rotation}rad) rotate(${rotation}rad)
`; `;
checkViewportExit();
animationFrameId = requestAnimationFrame(render); 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 { composeRefs } from "../../util/index.ts";
import { css, cx } from "@emotion/css"; import { css, cx } from "@emotion/css";
export type DraggableProps = React.ComponentPropsWithRef<any> & { export type DraggableProps = React.HtmlHTMLAttributes<any> & {
as?: React.ElementType; as?: React.ElementType;
onViewportEnter?: () => void;
onViewportExit?: () => void;
children: React.ReactNode; children: React.ReactNode;
initialRotation?: number;
}; };
export const Draggable = forwardRef<HTMLElement, DraggableProps>( export const Draggable = forwardRef<HTMLElement, DraggableProps>(
( (
{ as: Comp = "div", children, className, ...props }: DraggableProps, {
as: Comp = "div",
children,
className,
onViewportEnter,
onViewportExit,
initialRotation,
...props
}: DraggableProps,
ref, ref,
) => { ) => {
const cardRef = useRef<HTMLElement>(null); const cardRef = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
if (!cardRef.current) return; if (!cardRef.current) return;
return makeDraggable(cardRef.current); return makeDraggable(cardRef.current, {
onViewportEnter,
onViewportExit,
initialRotation,
});
}, []); }, []);
return ( return (

18
src/pages/main/Contact.tsx

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

26
src/util/index.ts

@ -50,6 +50,20 @@ export const getTimeout = () => {
return [timeout, clearTimers] as const; 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) => export const ellipses = (text: string, length: number = 100) =>
text.length > length ? text.slice(0, length - 3) + "..." : text; 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"); 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