"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export type AnnotateMarkType =
| "underline"
| "circle"
| "box"
| "highlight"
| "strike"
| "bracket";
export interface AnnotateProps {
color?: string;
highlightColor?: string;
duration?: number;
gap?: number;
strokeWidth?: number;
roughness?: number;
padding?: number;
seed?: number;
trigger?: "mount" | "inView";
reducedMotion?: boolean;
className?: string;
children?: ReactNode;
}
export interface AnnotateMarkProps {
type: AnnotateMarkType;
color?: string;
children?: ReactNode;
}
interface Line {
x: number;
y: number;
w: number;
h: number;
}
interface MarkGeom {
type: AnnotateMarkType;
tint?: string;
lines: Line[];
}
interface Stroke {
d: string;
under: boolean;
tint?: string;
width: number;
slot: number;
from: number;
to: number;
}
type Phase = "idle" | "drawing" | "done";
const TYPES: readonly AnnotateMarkType[] = [
"underline",
"circle",
"box",
"highlight",
"strike",
"bracket",
];
const LAG = 0.12;
const INK_TOP = 0.17;
const BASELINE = 0.78;
const INK_BOTTOM = 0.94;
const X_MIDDLE = 0.57;
const MOTION_QUERY = "(prefers-reduced-motion: reduce)";
const mulberry32 = (seed: number) => () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const r2 = (v: number) => Math.round(v * 100) / 100;
const r1 = (v: number) => Math.round(v * 10) / 10;
const ease = (x: number) => 0.5 * (1 - (1 - x) * (1 - x)) + 0.5 * x * x * (3 - 2 * x);
function bowed(
ax: number,
ay: number,
bx: number,
by: number,
rand: () => number,
rough: number,
scale: number,
): string {
const dx = bx - ax;
const dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const bend = (rand() * 2 - 1) * len * 0.012 * rough * scale * (4 / 3);
const b1 = bend * (0.7 + rand() * 0.6);
const b2 = bend * (0.7 + rand() * 0.6);
const k1 = 0.2 + rand() * 0.2;
const k2 = 0.6 + rand() * 0.2;
return `C${r2(ax + dx * k1 + nx * b1)} ${r2(ay + dy * k1 + ny * b1)} ${r2(ax + dx * k2 + nx * b2)} ${r2(ay + dy * k2 + ny * b2)} ${r2(bx)} ${r2(by)}`;
}
function roughLine(
x1: number,
y1: number,
x2: number,
y2: number,
rand: () => number,
rough: number,
pass: number,
): string {
const scale = pass ? 0.6 : 1;
const j = 0.9 * rough * scale;
const ax = x1 + (rand() * 2 - 1) * j;
const ay = y1 + (rand() * 2 - 1) * j;
const bx = x2 + (rand() * 2 - 1) * j;
const by = y2 + (rand() * 2 - 1) * j;
return `M${r2(ax)} ${r2(ay)}${bowed(ax, ay, bx, by, rand, rough, scale)}`;
}
function roughPoly(
points: [number, number][],
rand: () => number,
rough: number,
pass: number,
): string {
const scale = pass ? 0.6 : 1;
const j = 0.55 * rough * scale;
const q = points.map(
([x, y]) => [x + (rand() * 2 - 1) * j, y + (rand() * 2 - 1) * j] as const,
);
let d = `M${r2(q[0][0])} ${r2(q[0][1])}`;
for (let i = 1; i < q.length; i++) {
d += bowed(q[i - 1][0], q[i - 1][1], q[i][0], q[i][1], rand, rough, scale);
}
return d;
}
function smooth(points: [number, number][]): string {
let d = `M${r2(points[0][0])} ${r2(points[0][1])}`;
const last = points.length - 1;
for (let i = 0; i < last; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(last, i + 2)];
d += `C${r2(p1[0] + (p2[0] - p0[0]) / 6)} ${r2(p1[1] + (p2[1] - p0[1]) / 6)} ${r2(p2[0] - (p3[0] - p1[0]) / 6)} ${r2(p2[1] - (p3[1] - p1[1]) / 6)} ${r2(p2[0])} ${r2(p2[1])}`;
}
return d;
}
function circlePath(
cx: number,
cy: number,
a: number,
b: number,
rand: () => number,
rough: number,
): { d: string; len: number } {
const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
const count = Math.max(24, Math.min(48, Math.round(perimeter / 10)));
const sweep = Math.PI * 2 + 0.35;
const start = -Math.PI / 2 + (rand() * 2 - 1) * 1.1;
const turn = rand() < 0.5 ? 1 : -1;
const waves = [0.03, 0.02, 0.012].map((amp) => ({
amp: amp * rough * (0.6 + rand() * 0.4),
freq: 1 + rand() * 2.5,
phase: rand() * Math.PI * 2,
}));
const drift = (rand() < 0.5 ? -1 : 1) * 0.035 * Math.min(rough, 1.5);
const points: [number, number][] = [];
for (let i = 0; i < count; i++) {
const t = i / (count - 1);
const angle = start + turn * sweep * t;
let r = 1 + drift * t;
for (const w of waves) r += w.amp * Math.sin(w.freq * Math.PI * 2 * t + w.phase);
points.push([cx + Math.cos(angle) * a * r, cy + Math.sin(angle) * b * r]);
}
return { d: smooth(points), len: (perimeter * sweep) / (Math.PI * 2) };
}
function union(lines: Line[]): Line {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const l of lines) {
x0 = Math.min(x0, l.x);
y0 = Math.min(y0, l.y);
x1 = Math.max(x1, l.x + l.w);
y1 = Math.max(y1, l.y + l.h);
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function buildStrokes(
geom: MarkGeom[],
seed: number,
roughness: number,
padding: number,
strokeWidth: number,
): Stroke[] {
const rough = Math.max(0, roughness);
const passes = rough < 0.05 ? 1 : 2;
const pad = Math.max(0, padding);
const pen = Math.max(0, strokeWidth);
const half = pen / 2;
const out: Stroke[] = [];
geom.forEach((mark, slot) => {
const rand = mulberry32(seed * 9973 + slot);
const segments: { len: number; paths: string[]; width: number; under: boolean }[] = [];
const add = (len: number, width: number, under: boolean, draw: (pass: number) => string) => {
const paths: string[] = [];
for (let pass = 0; pass < passes; pass++) paths.push(draw(pass));
segments.push({ len: Math.max(len, 1), paths, width, under });
};
switch (mark.type) {
case "underline":
for (const l of mark.lines) {
const y = l.y + l.h * (BASELINE + 0.1) + pad * 0.4 + half;
const x1 = l.x - pad * 0.4;
const x2 = l.x + l.w + pad * 0.4;
add(x2 - x1, pen, false, (pass) => roughLine(x1, y, x2, y, rand, rough, pass));
}
break;
case "strike":
for (const l of mark.lines) {
const y = l.y + l.h * X_MIDDLE;
const x1 = l.x - pad * 0.5;
const x2 = l.x + l.w + pad * 0.5;
add(x2 - x1, pen, false, (pass) => roughLine(x1, y, x2, y, rand, rough, pass));
}
break;
case "highlight":
for (const l of mark.lines) {
const hand = Math.min(rough, 2);
const y = l.y + l.h * ((INK_TOP + INK_BOTTOM) / 2);
const x1 = l.x - pad * 0.4;
const x2 = l.x + l.w + pad * 0.4;
add(x2 - x1, l.h * 0.85, true, (pass) => {
const dy = (rand() * 2 - 1) * l.h * 0.05 * hand;
const tilt = (rand() * 2 - 1) * l.h * 0.04 * hand;
const sx = (rand() * 2 - 1) * pad * 0.3 * Math.min(hand, 1);
const ex = (rand() * 2 - 1) * pad * 0.3 * Math.min(hand, 1);
return roughLine(x1 + sx, y + dy - tilt, x2 + ex, y + dy + tilt, rand, rough, pass);
});
}
break;
case "box":
for (const l of mark.lines) {
const x0 = l.x - pad * 0.7 - half;
const x1 = l.x + l.w + pad * 0.7 + half;
const y0 = l.y + l.h * INK_TOP - pad * 0.7 - half;
const y1 = l.y + l.h * INK_BOTTOM + pad * 0.7 + half;
const over = () => (2 + rand() * 2) * Math.min(rough, 1);
const sides: [number, number, number, number][] = [
[x0 - over(), y0, x1 + over(), y0],
[x1, y0 - over(), x1, y1 + over()],
[x1 + over(), y1, x0 - over(), y1],
[x0, y1 + over(), x0, y0 - over()],
];
for (const [ax, ay, bx, by] of sides) {
add(Math.hypot(bx - ax, by - ay), pen, false, (pass) =>
roughLine(ax, ay, bx, by, rand, rough, pass),
);
}
}
break;
case "circle": {
const u = union(mark.lines);
const first = mark.lines[0].h;
const last = mark.lines[mark.lines.length - 1].h;
const top = u.y + first * INK_TOP;
const bottom = u.y + u.h - last * (1 - INK_BOTTOM);
const b = (bottom - top) * 0.62 + pad + half;
const core = (bottom - top) * 0.32;
const a = u.w / 2 / Math.sqrt(1 - (core * core) / (b * b)) + pad * 0.3 + half;
const loop = circlePath(u.x + u.w / 2, (top + bottom) / 2, a, b, rand, rough);
segments.push({ len: loop.len, paths: [loop.d], width: pen, under: false });
break;
}
case "bracket": {
const u = union(mark.lines);
const first = mark.lines[0].h;
const last = mark.lines[mark.lines.length - 1].h;
const top = u.y + first * INK_TOP - pad * 0.7 - half;
const bottom = u.y + u.h - last * (1 - INK_BOTTOM) + pad * 0.7 + half;
const left = u.x - pad - half;
const right = u.x + u.w + pad + half;
const arm = Math.min(12, Math.max(4, mark.lines[0].h * 0.32));
const len = arm * 2 + (bottom - top);
add(len, pen, false, (pass) =>
roughPoly(
[
[left + arm, top],
[left, top],
[left, bottom],
[left + arm, bottom],
],
rand,
rough,
pass,
),
);
add(len, pen, false, (pass) =>
roughPoly(
[
[right - arm, top],
[right, top],
[right, bottom],
[right - arm, bottom],
],
rand,
rough,
pass,
),
);
break;
}
}
const total = segments.reduce((sum, s) => sum + s.len, 0);
let acc = 0;
for (const seg of segments) {
const from = acc / total;
acc += seg.len;
const to = acc / total;
const lag = seg.paths.length > 1 ? (to - from) * LAG : 0;
seg.paths.forEach((d, pass) => {
out.push({
d,
under: seg.under,
tint: mark.tint,
width: seg.width,
slot,
from: pass ? from + lag : from,
to: pass ? to : to - lag,
});
});
}
});
return out;
}
function measureMarks(root: HTMLElement): MarkGeom[] | null {
const layoutWidth = root.offsetWidth;
const layoutHeight = root.offsetHeight;
const box = root.getBoundingClientRect();
if (layoutWidth === 0 || box.width === 0) return null;
const sx = box.width / layoutWidth;
const sy = layoutHeight > 0 && box.height > 0 ? box.height / layoutHeight : sx;
const ox = box.left + root.clientLeft * sx;
const oy = box.top + root.clientTop * sy;
const out: MarkGeom[] = [];
root.querySelectorAll<HTMLElement>("[data-annotate]").forEach((el) => {
if (el.closest("[data-annotate-root]") !== root) return;
const type = el.dataset.annotate as AnnotateMarkType;
if (!TYPES.includes(type)) return;
const lines: Line[] = [];
for (const rect of Array.from(el.getClientRects())) {
const w = rect.width / sx;
const h = rect.height / sy;
if (w < 1 || h < 1) continue;
const x = (rect.left - ox) / sx + root.scrollLeft;
const y = (rect.top - oy) / sy + root.scrollTop;
const prev = lines[lines.length - 1];
if (prev && Math.abs(prev.y - y) < 2 && x <= prev.x + prev.w + 2 && x + w >= prev.x - 2) {
const right = Math.max(prev.x + prev.w, x + w);
const bottom = Math.max(prev.y + prev.h, y + h);
prev.x = Math.min(prev.x, x);
prev.y = Math.min(prev.y, y);
prev.w = right - prev.x;
prev.h = bottom - prev.y;
} else {
lines.push({ x, y, w, h });
}
}
if (lines.length === 0) return;
out.push({
type,
tint: el.dataset.annotateColor || undefined,
lines: lines.map((l) => ({ x: r1(l.x), y: r1(l.y), w: r1(l.w), h: r1(l.h) })),
});
});
return out;
}
const keyOf = (geom: MarkGeom[]) =>
geom
.map(
(m) =>
`${m.type}:${m.tint ?? ""}:${m.lines.map((l) => `${l.x},${l.y},${l.w},${l.h}`).join(";")}`,
)
.join("|");
const subscribeMotion = (onChange: () => void) => {
const query = window.matchMedia(MOTION_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const readMotion = () => window.matchMedia(MOTION_QUERY).matches;
const readMotionOnServer = () => false;
export default function Annotate({
color = "#a855f7",
highlightColor = "#c084fc",
duration = 700,
gap = 250,
strokeWidth = 2,
roughness = 1,
padding = 5,
seed = 7,
trigger = "inView",
reducedMotion = false,
className = "",
children,
}: AnnotateProps) {
const rootRef = useRef<HTMLDivElement>(null);
const pathEls = useRef<(SVGPathElement | null)[]>([]);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const geomKey = useRef("");
const [geom, setGeom] = useState<MarkGeom[]>([]);
const systemReduced = useSyncExternalStore(subscribeMotion, readMotion, readMotionOnServer);
const still = reducedMotion || systemReduced;
const strokes = useMemo(
() => buildStrokes(geom, seed, roughness, padding, strokeWidth),
[geom, seed, roughness, padding, strokeWidth],
);
const live = useRef({ duration, gap, still, strokes });
const run = useRef<{ phase: Phase; t: number }>({ phase: "idle", t: 0 });
const written = useRef(new WeakMap<SVGPathElement, number>());
useLayoutEffect(() => {
live.current = { duration, gap, still, strokes };
});
const remeasure = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const next = measureMarks(root);
if (!next) return;
const key = keyOf(next);
if (key === geomKey.current) return;
geomKey.current = key;
setGeom(next);
}, []);
const paint = useCallback(() => {
const cfg = live.current;
const r = run.current;
const dur = Math.max(cfg.duration, 1) / 1000;
const beat = dur + Math.max(cfg.gap, 0) / 1000;
const finished = cfg.still || r.phase === "done";
const cache = written.current;
cfg.strokes.forEach((s, k) => {
const el = pathEls.current[k];
if (!el) return;
let p = 1;
if (!finished) {
if (r.phase === "idle") {
p = 0;
} else {
const u = (r.t - s.slot * beat) / dur;
p = ease(Math.min(Math.max((u - s.from) / Math.max(s.to - s.from, 1e-6), 0), 1));
}
}
const last = cache.get(el);
if (last === p) return;
cache.set(el, p);
el.style.strokeDashoffset = String(1 - p);
if (last === undefined || (last > 0) !== (p > 0)) {
el.style.visibility = p > 0 ? "visible" : "hidden";
}
});
}, []);
const step = useCallback(
(dt: number): void | false => {
const cfg = live.current;
const r = run.current;
if (cfg.still || r.phase !== "drawing" || cfg.strokes.length === 0) {
paint();
return false;
}
r.t += dt;
const dur = Math.max(cfg.duration, 1) / 1000;
const beat = dur + Math.max(cfg.gap, 0) / 1000;
const marks = cfg.strokes[cfg.strokes.length - 1].slot + 1;
if (r.t >= (marks - 1) * beat + dur) r.phase = "done";
paint();
if (r.phase === "done") return false;
},
[paint],
);
useLayoutEffect(() => {
drawRef.current = step;
return () => {
drawRef.current = null;
};
}, [step]);
const loop = useAnimationLoop({
target: rootRef,
onResize: () => remeasure(),
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
useLayoutEffect(() => {
remeasure();
});
useLayoutEffect(() => {
paint();
if (run.current.phase === "drawing") loop.start();
}, [strokes, paint, loop]);
useLayoutEffect(() => {
if (still) run.current.phase = "done";
paint();
}, [still, paint]);
useEffect(() => {
if (!("fonts" in document)) return;
let alive = true;
const fonts = document.fonts;
const again = () => {
if (alive) remeasure();
};
fonts.ready.then(again).catch(() => {});
fonts.addEventListener("loadingdone", again);
return () => {
alive = false;
fonts.removeEventListener("loadingdone", again);
};
}, [remeasure]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const begin = () => {
if (run.current.phase !== "idle") return;
run.current.phase = "drawing";
run.current.t = 0;
loop.start();
};
if (trigger === "mount" || typeof IntersectionObserver === "undefined") {
begin();
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
begin();
});
observer.observe(root);
return () => observer.disconnect();
}, [trigger, loop]);
return (
<div ref={rootRef} data-annotate-root="" className={`relative isolate ${className}`.trim()}>
<div>{children}</div>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 h-full w-full overflow-visible"
>
{strokes.map((s, k) =>
s.under ? (
<path
key={k}
ref={(el) => {
pathEls.current[k] = el;
}}
d={s.d}
fill="none"
stroke={s.tint ?? highlightColor}
strokeOpacity={0.35}
strokeWidth={s.width}
strokeLinecap="butt"
strokeLinejoin="round"
pathLength={1}
strokeDasharray="1 1"
/>
) : null,
)}
</svg>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 h-full w-full overflow-visible"
>
{strokes.map((s, k) =>
s.under ? null : (
<path
key={k}
ref={(el) => {
pathEls.current[k] = el;
}}
d={s.d}
fill="none"
stroke={s.tint ?? color}
strokeWidth={s.width}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray="1 1"
/>
),
)}
</svg>
</div>
);
}
export function AnnotateMark({ type, color, children }: AnnotateMarkProps) {
if (type === "highlight") {
return (
<mark
data-annotate={type}
data-annotate-color={color}
className="bg-transparent text-inherit"
>
{children}
</mark>
);
}
return (
<span data-annotate={type} data-annotate-color={color}>
{children}
</span>
);
}