221 lines
8.6 KiB
TypeScript
221 lines
8.6 KiB
TypeScript
"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 `<Rule origin={x}/>`). */
|
||
onHandoff?: (x: number) => void;
|
||
/** `<BrandMark/>` (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 <BrandMark/>: 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<HTMLSpanElement>(null);
|
||
const line = useRef<HTMLSpanElement>(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<SVGPathElement>("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 (
|
||
<span ref={wrap} className={cn("relative inline-block [.js_&]:opacity-0", className)}>
|
||
{children}
|
||
{handoff && (
|
||
<span
|
||
ref={line}
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute w-px bg-gold opacity-0"
|
||
style={{ height: handoffLength, left: 0, top: "100%" }}
|
||
/>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default DrawMark;
|