182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { useId, useMemo } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { cn } from "../utils/cn";
|
|
|
|
interface MarkerProps {
|
|
children: React.ReactNode;
|
|
delay?: number;
|
|
className?: string;
|
|
color?: string;
|
|
}
|
|
|
|
/**
|
|
* Copic Marker Component
|
|
*
|
|
* Uses hand-drawn filled polygon paths (NOT rects or strokes) for an
|
|
* organic, human feel. Each highlight band is a wobbly shape with
|
|
* bezier curves, not a perfect rectangle. Second strokes are offset
|
|
* to simulate imperfect re-highlighting.
|
|
*/
|
|
|
|
// Seeded PRNG for SSR-safe randomness
|
|
function createRng(seed: string) {
|
|
let s = 0;
|
|
for (let i = 0; i < seed.length; i++) {
|
|
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
|
}
|
|
return () => {
|
|
s = (s * 16807 + 0) % 2147483647;
|
|
return (s & 0x7fffffff) / 0x7fffffff;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a hand-drawn highlight band path.
|
|
* Instead of a rectangle, we trace an organic polygon with wobbly edges.
|
|
*
|
|
* The shape goes: left edge → top edge (left to right) → right edge → bottom edge (right to left)
|
|
* Each edge has 1-2 control points for subtle curves.
|
|
*
|
|
* ViewBox: 0 0 100 100 with preserveAspectRatio="none"
|
|
* So x=0..100 maps to text width, y=0..100 maps to text height.
|
|
*/
|
|
function generateBandPath(rng: () => number, bandIndex: number) {
|
|
// Vertical position: center band in the text height
|
|
// First band centered around y=50, second shifted up or down
|
|
const yOffset = bandIndex === 0 ? 0 : (rng() - 0.5) * 20; // -10 to +10 shift
|
|
const yCenter = 50 + yOffset;
|
|
|
|
// Band thickness: covers ~40-50% of text height
|
|
const halfHeight = 20 + rng() * 8; // 20-28 units (40-56% of viewBox)
|
|
const yTop = yCenter - halfHeight;
|
|
const yBottom = yCenter + halfHeight;
|
|
|
|
// Horizontal extent: slight random overshoot/undershoot
|
|
const xStart = -2 + rng() * 3; // -2 to 1
|
|
const xEnd = 99 + rng() * 3; // 99 to 102
|
|
|
|
// Generate wobbly top edge points (left to right)
|
|
const topWobble1 = yTop + (rng() - 0.5) * 6;
|
|
const topWobble2 = yTop + (rng() - 0.5) * 6;
|
|
const topWobble3 = yTop + (rng() - 0.5) * 6;
|
|
|
|
// Generate wobbly bottom edge points (right to left)
|
|
const botWobble1 = yBottom + (rng() - 0.5) * 6;
|
|
const botWobble2 = yBottom + (rng() - 0.5) * 6;
|
|
const botWobble3 = yBottom + (rng() - 0.5) * 6;
|
|
|
|
// Slight angle on left/right edges
|
|
const leftTopY = yTop + (rng() - 0.5) * 4;
|
|
const leftBotY = yBottom + (rng() - 0.5) * 4;
|
|
const rightTopY = yTop + (rng() - 0.5) * 4;
|
|
const rightBotY = yBottom + (rng() - 0.5) * 4;
|
|
|
|
// Build the path:
|
|
// Start at top-left, trace top edge with curves, down right edge,
|
|
// trace bottom edge with curves back left, close.
|
|
const path = [
|
|
// Start top-left
|
|
`M ${xStart},${leftTopY}`,
|
|
// Top edge: 3 curve segments left→right
|
|
`C ${xStart + 15},${topWobble1} ${xStart + 30},${topWobble1} ${33},${topWobble2}`,
|
|
`C ${45},${topWobble2} ${55},${topWobble3} ${67},${topWobble3}`,
|
|
`C ${78},${topWobble3} ${xEnd - 10},${rightTopY} ${xEnd},${rightTopY}`,
|
|
// Right edge down
|
|
`L ${xEnd + (rng() - 0.5) * 2},${rightBotY}`,
|
|
// Bottom edge: 3 curve segments right→left
|
|
`C ${xEnd - 10},${botWobble1} ${78},${botWobble1} ${67},${botWobble2}`,
|
|
`C ${55},${botWobble2} ${45},${botWobble3} ${33},${botWobble3}`,
|
|
`C ${xStart + 30},${botWobble3} ${xStart + 15},${leftBotY} ${xStart},${leftBotY}`,
|
|
// Close
|
|
`Z`,
|
|
].join(" ");
|
|
|
|
return path;
|
|
}
|
|
|
|
export const Marker: React.FC<MarkerProps> = ({
|
|
children,
|
|
delay = 0,
|
|
className = "",
|
|
color = "rgba(250, 204, 21, 0.5)",
|
|
}) => {
|
|
const id = useId();
|
|
const filterId = `marker-rough-${id.replace(/:/g, "")}`;
|
|
|
|
const bands = useMemo(() => {
|
|
const rng = createRng(id);
|
|
const numBands = rng() > 0.5 ? 2 : 1;
|
|
const result = [];
|
|
|
|
for (let i = 0; i < numBands; i++) {
|
|
result.push({
|
|
d: generateBandPath(rng, i),
|
|
delay: delay + i * 0.15,
|
|
duration: 0.4 + rng() * 0.15,
|
|
});
|
|
}
|
|
return result;
|
|
}, [id, delay]);
|
|
|
|
return (
|
|
<span className={cn("relative inline px-1", className)}>
|
|
<svg
|
|
className="absolute inset-0 h-full w-full pointer-events-none z-[-1] overflow-visible mix-blend-multiply"
|
|
preserveAspectRatio="none"
|
|
viewBox="0 0 100 100"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
<filter id={filterId} x="-5%" y="-5%" width="110%" height="110%">
|
|
{/* Very subtle edge wobble — alcohol ink bleed, not brush fraying */}
|
|
<feTurbulence
|
|
type="fractalNoise"
|
|
baseFrequency="0.04 0.3"
|
|
numOctaves="2"
|
|
seed={Math.abs(id.charCodeAt(1) || 42)}
|
|
result="noise"
|
|
/>
|
|
<feDisplacementMap
|
|
in="SourceGraphic"
|
|
in2="noise"
|
|
scale="1.5"
|
|
xChannelSelector="R"
|
|
yChannelSelector="G"
|
|
/>
|
|
</filter>
|
|
</defs>
|
|
|
|
{bands.map((band, i) => (
|
|
<motion.path
|
|
key={i}
|
|
d={band.d}
|
|
fill={color}
|
|
filter={`url(#${filterId})`}
|
|
initial={{ scaleX: 0, opacity: 0 }}
|
|
whileInView={{ scaleX: 1, opacity: 1 }}
|
|
viewport={{ once: true, margin: "-10%" }}
|
|
transition={{
|
|
scaleX: {
|
|
duration: band.duration,
|
|
delay: band.delay,
|
|
ease: [0.4, 0, 0.2, 1],
|
|
},
|
|
opacity: {
|
|
duration: 0.1,
|
|
delay: band.delay,
|
|
},
|
|
}}
|
|
style={{ transformOrigin: "left center" }}
|
|
/>
|
|
))}
|
|
</svg>
|
|
<span className="relative z-10 font-semibold" style={{ color: "#000" }}>
|
|
{children}
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|