125 lines
3.4 KiB
TypeScript
125 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import React, { useId, useMemo } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { cn } from "../utils/cn";
|
|
|
|
interface PenCircleProps {
|
|
children: React.ReactNode;
|
|
delay?: number;
|
|
className?: string;
|
|
color?: string;
|
|
}
|
|
|
|
/**
|
|
* Ballpoint Pen Circle Component
|
|
*
|
|
* Draws a hand-drawn ellipse around children using SVG.
|
|
* Key: uses preserveAspectRatio="none" so the ellipse stretches
|
|
* to match the element dimensions regardless of text length.
|
|
*/
|
|
|
|
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;
|
|
};
|
|
}
|
|
|
|
function generateCirclePath(rng: () => number) {
|
|
// We draw an ellipse in a 0-100 x 0-100 viewBox.
|
|
// preserveAspectRatio="none" will stretch it to fit the element.
|
|
const cx = 50;
|
|
const cy = 50;
|
|
|
|
// Radii in viewBox units — will be stretched by the element
|
|
const rx = 50;
|
|
const ry = 50;
|
|
|
|
// Wobble for organic feel
|
|
const w = () => (rng() - 0.5) * 6;
|
|
|
|
const k = 0.5522847;
|
|
const kx = rx * k;
|
|
const ky = ry * k;
|
|
|
|
// 4 cardinal points with wobble
|
|
const top = { x: cx + w(), y: cy - ry + w() * 0.5 };
|
|
const right = { x: cx + rx + w() * 0.3, y: cy + w() };
|
|
const bottom = { x: cx + w(), y: cy + ry + w() * 0.5 };
|
|
const left = { x: cx - rx + w() * 0.3, y: cy + w() };
|
|
|
|
// End slightly offset from start for imperfect closure
|
|
const endOffX = (rng() - 0.5) * 8;
|
|
const endOffY = (rng() - 0.5) * 4;
|
|
|
|
return [
|
|
`M ${top.x},${top.y}`,
|
|
`C ${top.x + kx + w()},${top.y + w()} ${right.x + w()},${right.y - ky + w()} ${right.x},${right.y}`,
|
|
`C ${right.x + w()},${right.y + ky + w()} ${bottom.x + kx + w()},${bottom.y + w()} ${bottom.x},${bottom.y}`,
|
|
`C ${bottom.x - kx + w()},${bottom.y + w()} ${left.x + w()},${left.y + ky + w()} ${left.x},${left.y}`,
|
|
`C ${left.x + w()},${left.y - ky + w()} ${top.x - kx + w()},${top.y + w()} ${top.x + endOffX},${top.y + endOffY}`,
|
|
].join(" ");
|
|
}
|
|
|
|
export const PenCircle: React.FC<PenCircleProps> = ({
|
|
children,
|
|
delay = 0,
|
|
className = "",
|
|
color = "rgba(37, 99, 235, 0.65)", // Blue ballpoint pen
|
|
}) => {
|
|
const id = useId();
|
|
|
|
const path = useMemo(() => {
|
|
const rng = createRng(id);
|
|
return generateCirclePath(rng);
|
|
}, [id]);
|
|
|
|
return (
|
|
<span className={cn("relative inline-block px-2 py-1", className)}>
|
|
<svg
|
|
className="absolute pointer-events-none overflow-visible"
|
|
preserveAspectRatio="none"
|
|
viewBox="-5 -5 110 110"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
style={{
|
|
top: "-6px",
|
|
left: "-8px",
|
|
width: "calc(100% + 16px)",
|
|
height: "calc(100% + 12px)",
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<motion.path
|
|
d={path}
|
|
initial={{ pathLength: 0, opacity: 0 }}
|
|
whileInView={{ pathLength: 1, opacity: 1 }}
|
|
viewport={{ once: true, margin: "-5%" }}
|
|
transition={{
|
|
pathLength: {
|
|
duration: 0.7,
|
|
delay: delay,
|
|
ease: [0.4, 0, 0.2, 1],
|
|
},
|
|
opacity: {
|
|
duration: 0.1,
|
|
delay: delay,
|
|
},
|
|
}}
|
|
stroke={color}
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
fill="none"
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
</svg>
|
|
{children}
|
|
</span>
|
|
);
|
|
};
|