"use client"; import { useLayoutEffect, useRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { gsap, DUR, MM } from "@/lib/gsap"; import { STROKE_IN, WIPE_IN, playSheen, primeMark } from "@/components/motion/Draw"; export interface DrawMarkProps { /** `load` = on mount (hero), `view` = at 80 % in view (once), `scrub` = scroll-tied (Prinzip). */ play?: "load" | "view" | "scrub"; /** One-time champagne sweep over the wolf stroke after drawing. */ sheen?: boolean; /** Continue the neck stroke downward into the first hairline (hero only). */ handoff?: boolean; /** Length of the handoff hairline in px when the SVG has no `#handoff` path (24–48). */ handoffLength?: number; delay?: number; className?: string; onComplete?: () => void; /** Receives the handoff x in px from the wrapper's left edge (for ``). */ onHandoff?: (x: number) => void; /** `` (Server Component, passed as children). */ children: ReactNode; } /** Timing of the draw sequence in seconds (spec §5.5). */ const T = { vLeft: [0, 0.35], vRight: [0.25, 0.35], wolf: [0.5, 0.7], detail: [1.0, 0.3], handoff: [1.2, 0.2], sheen: 1.5 } as const; /** * Client shell around : V-left → V-right → wolf → (handoff) → sheen. * Paths are ordered by the classes `.wolfmark-v` / `.wolfmark-wolf`; an inline * client SVG without those classes is drawn in DOM order. */ export function DrawMark({ play = "load", sheen = false, handoff = false, handoffLength = 40, delay = 0, className, onComplete, onHandoff, children, }: DrawMarkProps) { const wrap = useRef(null); const line = useRef(null); const onCompleteRef = useRef(onComplete); const onHandoffRef = useRef(onHandoff); /* Read imperatively, never a dependency: the hero measures the gap and pushes a new length on every resize, and re-running the effect would `mm.revert()` the whole matchMedia scope and replay the monogram draw mid-interaction. */ const handoffLengthRef = useRef(handoffLength); useLayoutEffect(() => { onCompleteRef.current = onComplete; onHandoffRef.current = onHandoff; }); useLayoutEffect(() => { handoffLengthRef.current = handoffLength; const el = line.current; if (el) el.style.height = `${handoffLength}px`; }, [handoffLength]); useLayoutEffect(() => { const el = wrap.current; const svg = el?.querySelector("svg"); if (!el || !svg) return; const all = Array.from(svg.querySelectorAll("path")); const handoffPath = all.find((p) => p.classList.contains("wolfmark-handoff")) ?? null; let vs = all.filter((p) => p.classList.contains("wolfmark-v")); let wolves = all.filter((p) => p.classList.contains("wolfmark-wolf") && p !== handoffPath); if (!vs.length && !wolves.length) { // Unknown SVG: everything in DOM order – first two strokes as "V", the rest as "wolf". const drawable = all.filter((p) => p.hasAttribute("data-draw")); const seq = drawable.length ? drawable : all; vs = seq.slice(0, 2); wolves = seq.slice(2); } const mainWolf = wolves[0] ?? null; const details = wolves.slice(1); const sequence = [...vs, ...wolves, ...(handoffPath ? [handoffPath] : [])]; const lineEl = line.current; const useLine = handoff && !handoffPath && !!lineEl; /* The path geometry is fixed in SVG user space – only the CTM changes on resize. Sample once (2 × 121 `getPointAtLength` calls), then re-project the cached point. */ let anchor: { x: number; y: number } | null | undefined; const sampleAnchor = (): { x: number; y: number } | null => { if (!mainWolf) return null; const end = mainWolf.getPointAtLength(mainWolf.getTotalLength()); const pts: { x: number; y: number }[] = []; let maxY = -Infinity; vs.forEach((v) => { const len = v.getTotalLength(); for (let i = 0; i <= 120; i++) { const p = v.getPointAtLength((len * i) / 120); pts.push({ x: p.x, y: p.y }); if (p.y > maxY) maxY = p.y; } }); const pool = pts.filter((p) => p.y >= maxY - 2); return (pool.length ? pool : [{ x: end.x, y: end.y }]).reduce((a, b) => Math.abs(b.x - end.x) < Math.abs(a.x - end.x) ? b : a, ); }; /** Position of the handoff hairline: the lowest V point nearest the wolf's end. */ const placeLine = (): number | null => { if (!mainWolf || !lineEl) return null; try { const ctm = svg.getScreenCTM(); if (!ctm) return null; if (anchor === undefined) anchor = sampleAnchor(); if (!anchor) return null; const rect = el.getBoundingClientRect(); const sp = new DOMPoint(anchor.x, anchor.y).matrixTransform(ctm); const x = sp.x - rect.left; const y = sp.y - rect.top; lineEl.style.left = `${x}px`; lineEl.style.top = `${y}px`; lineEl.style.height = `${handoffLengthRef.current}px`; el.style.setProperty("--handoff-x", `${x}px`); return x; } catch { /* A measurement failure must never strand the mark – the reveal below runs anyway. */ return null; } }; const mm = gsap.matchMedia(); mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => { if (ctx.conditions?.reduce) { gsap.set(el, { opacity: 1 }); if (useLine) { const x = placeLine(); gsap.set(lineEl, { scaleY: 1, opacity: 1 }); if (x !== null) onHandoffRef.current?.(x); } onCompleteRef.current?.(); return; } const mode = primeMark(svg, sequence); let hx: number | null = null; if (useLine) { hx = placeLine(); gsap.set(lineEl, { scaleY: 0, transformOrigin: "50% 0%", opacity: 1 }); } gsap.set(el, { opacity: 1 }); const tl = gsap.timeline({ paused: play !== "load", delay: play === "load" ? delay : 0, onComplete: () => { if (hx !== null) onHandoffRef.current?.(hx); if (sheen && play !== "scrub" && mainWolf) playSheen(mainWolf, DUR.sheen); onCompleteRef.current?.(); }, }); const ease = "power2.inOut"; if (mode === "wipe") { /* Interlocked filled artwork: one slice down the whole mark (see Draw.tsx). */ tl.to(svg, { ...WIPE_IN, duration: T.wolf[0] + T.wolf[1], ease }, T.vLeft[0]); } else { if (vs[0]) tl.to(vs[0], { ...STROKE_IN, duration: T.vLeft[1], ease }, T.vLeft[0]); if (vs[1]) tl.to(vs[1], { ...STROKE_IN, duration: T.vRight[1], ease }, T.vRight[0]); vs.slice(2).forEach((v, i) => tl.to(v, { ...STROKE_IN, duration: 0.3, ease }, T.vRight[0] + 0.1 * (i + 1))); if (mainWolf) tl.to(mainWolf, { ...STROKE_IN, duration: T.wolf[1], ease }, T.wolf[0]); if (details.length) tl.to(details, { ...STROKE_IN, duration: T.detail[1], ease, stagger: 0.08 }, T.detail[0]); if (handoffPath) tl.to(handoffPath, { ...STROKE_IN, duration: T.handoff[1], ease: "power2.out" }, T.handoff[0]); } /* The hairline lives outside the svg, so it follows either reveal. */ if (useLine) tl.to(lineEl, { scaleY: 1, duration: T.handoff[1] + 0.1, ease: "power2.out" }, T.handoff[0]); if (play === "view") { gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tl.play() } }); } else if (play === "scrub") { tl.eventCallback("onComplete", null); gsap.to(tl, { progress: 1, ease: "none", scrollTrigger: { trigger: el, start: "top 80%", end: "bottom 40%", scrub: 0.6 }, }); } /* Keep the hairline attached to the mark when the viewport changes – one rAF per frame, not one full re-projection per resize event. */ let raf = 0; const onResize = () => { if (!useLine || raf) return; raf = requestAnimationFrame(() => { raf = 0; placeLine(); }); }; window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); if (raf) cancelAnimationFrame(raf); }; }); return () => mm.revert(); }, [play, sheen, handoff, delay]); return ( {children} {handoff && ( ); } export default DrawMark;