diff --git a/src/components/AnimateEntry.tsx b/src/components/AnimateEntry.tsx index c4f66ec..dded012 100644 --- a/src/components/AnimateEntry.tsx +++ b/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 & { - as?: React.ElementType; - delay?: number; - } ->( +export interface AnimateEntryProps extends React.HTMLAttributes { + as?: React.ElementType; + delay?: number; +} + +export const AnimateEntry = forwardRef( ( { children, className, as: Component = "div", delay = 100, ...props }, ref, diff --git a/src/draggable.attempts/6/Draggable.ts b/src/draggable.attempts/6/Draggable.ts index 9eaeb0f..cfe3fc6 100644 --- a/src/draggable.attempts/6/Draggable.ts +++ b/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 void>( - func: T, - wait: number, -): T { - let timeoutId: number | null = null; - return function (this: ThisParameterType, ...args: Parameters) { - 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); } diff --git a/src/draggable.attempts/6/Draggable2.tsx b/src/draggable.attempts/6/Draggable2.tsx index 2451307..669048d 100644 --- a/src/draggable.attempts/6/Draggable2.tsx +++ b/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 & { +export type DraggableProps = React.HtmlHTMLAttributes & { as?: React.ElementType; + onViewportEnter?: () => void; + onViewportExit?: () => void; children: React.ReactNode; + initialRotation?: number; }; export const Draggable = forwardRef( ( - { as: Comp = "div", children, className, ...props }: DraggableProps, + { + as: Comp = "div", + children, + className, + onViewportEnter, + onViewportExit, + initialRotation, + ...props + }: DraggableProps, ref, ) => { const cardRef = useRef(null); useEffect(() => { if (!cardRef.current) return; - return makeDraggable(cardRef.current); + return makeDraggable(cardRef.current, { + onViewportEnter, + onViewportExit, + initialRotation, + }); }, []); return ( diff --git a/src/pages/main/Contact.tsx b/src/pages/main/Contact.tsx index 02af05e..5abdc23 100644 --- a/src/pages/main/Contact.tsx +++ b/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); - const [visible, setVisible] = useState(cardRotations.length); + const [visible, setVisible] = useState(contactCards.length); useEffect(() => { const deob = () => { @@ -108,10 +112,12 @@ const Contact: React.FC = () => { Start over? )} - {cardRotations.map((rot, i) => ( + {contactCards.map((rot, i) => ( 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}> { return [timeout, clearTimers] as const; }; +export function debounce void>( + func: T, + wait: number, +): T { + let timeoutId: number | null = null; + return function (this: ThisParameterType, ...args: Parameters) { + 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; +}