"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import { AnimatePresence, motion } from "motion/react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { EASE_EXPO } from "../motion/easings.ts"; import { Avatar } from "../content/Media.tsx"; import { Button } from "../ui/Button.tsx"; import { clamp, useAnnouncer, useControllable, useElementSize, useIsoLayoutEffect, } from "./internal.ts"; export type AnnotationReply = { id: string; author: string; body: string; time?: string; }; export type Annotation = { id: string; /** Position as a fraction of the surface, 0–1. */ x: number; y: number; author: string; body: string; time?: string; resolved?: boolean; replies?: readonly AnnotationReply[]; }; const makeId = () => `n${Math.random().toString(36).slice(2, 9)}`; /** Positions stay inside the surface and free of float noise. */ const spot = (n: number) => Math.round(clamp(n, 0.01, 0.99) * 1e4) / 1e4; /** * Review pins on anything: drop a numbered pin, discuss it in a thread, * resolve it, and find every conversation in the list beside the work. */ export function Annotator({ label, children, annotations, defaultAnnotations = [], onAnnotationsChange, author, now = "Just now", selected, defaultSelected = null, onSelectedChange, createId = makeId, className = "", style, }: { label: string; /** The surface under review: an image, a page, a design. */ children: ReactNode; annotations?: readonly Annotation[]; defaultAnnotations?: readonly Annotation[]; onAnnotationsChange?: (annotations: Annotation[]) => void; /** Name used for new comments and replies. Omit for a read-only review. */ author?: string; /** Time label for new comments. */ now?: string; selected?: string | null; defaultSelected?: string | null; onSelectedChange?: (id: string | null) => void; createId?: () => string; className?: string; style?: CSSProperties; }) { const id = useId(); const reduce = useReducedMotion(); const [message, announce] = useAnnouncer(); const [list, setList] = useControllable( annotations, defaultAnnotations, onAnnotationsChange && ((next) => onAnnotationsChange(next as Annotation[])), ); const [open, setOpen] = useControllable( selected, defaultSelected, onSelectedChange, ); const [commenting, setCommenting] = useState(false); const [draft, setDraft] = useState<{ x: number; y: number; body: string; } | null>(null); const [showResolved, setShowResolved] = useState(false); const [surfaceRef, surface] = useElementSize(); const pinRefs = useRef(new Map()); const returnFocus = useRef(null); const editable = !!author; const numbered = list.map((a, i) => ({ ...a, number: i + 1 })); const shown = numbered.filter( (a) => showResolved || !a.resolved || a.id === open, ); const active = numbered.find((a) => a.id === open); const openCount = numbered.filter((a) => !a.resolved).length; const patch = ( target: string, change: (a: Annotation) => Annotation | null, ) => setList( list.flatMap((a) => a.id === target ? [change(a)].filter(Boolean) : [a], ) as Annotation[], ); const close = () => { const target = open ?? returnFocus.current; setOpen(null); if (target) returnFocus.current = target; }; useIsoLayoutEffect(() => { const target = returnFocus.current; if (!target || open) return; returnFocus.current = null; pinRefs.current.get(target)?.focus(); }); const place = (x: number, y: number) => { setOpen(null); setDraft({ x: spot(x), y: spot(y), body: "" }); announce( "New comment. Arrow keys move the pin; type, then press Control or Command Enter to post.", ); }; const post = () => { if (!draft || !draft.body.trim() || !author) return; const created: Annotation = { id: createId(), x: draft.x, y: draft.y, author, body: draft.body.trim(), time: now, }; setList([...list, created]); setDraft(null); setCommenting(false); setOpen(created.id); announce(`Comment ${list.length + 1} posted.`); }; const onSurfaceDown = (e: PointerEvent) => { if ( !commenting || e.button !== 0 || (e.target as HTMLElement).closest( ".mizu-annotator-pin, .mizu-annotator-thread", ) ) return; const r = e.currentTarget.getBoundingClientRect(); if (!r.width || !r.height) return; // Keep the surface from taking focus back from the composer that opens next. e.preventDefault(); place((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); }; // Keyboard shortcuts while focus is inside the annotator. const onKey = (e: KeyboardEvent) => { const typing = (e.target as HTMLElement).closest("textarea, input"); if (e.key === "Escape") { if (draft) { e.preventDefault(); setDraft(null); announce("Comment discarded."); } else if (open) { e.preventDefault(); close(); } else if (commenting) setCommenting(false); return; } if (typing) return; if ( (e.key === "c" || e.key === "C") && editable && !e.metaKey && !e.ctrlKey ) { e.preventDefault(); setCommenting(!commenting); announce( commenting ? "Comment mode off." : "Comment mode: click the work, or press Enter to place a pin.", ); } }; const drag = useRef<{ id: string; moved: boolean } | null>(null); const dragged = useRef(false); const movePin = (target: string, x: number, y: number) => patch(target, (a) => ({ ...a, x: spot(x), y: spot(y) })); const popoverSide = (x: number) => surface.width && x * surface.width + 340 > surface.width ? "left" : "right"; // Threads open beside their pin, and upward from pins in the lower part of the work. const anchor = ({ x, y }: { x: number; y: number }): CSSProperties => ({ ...(popoverSide(x) === "right" ? { left: `${x * 100}%` } : { right: `${(1 - x) * 100}%` }), ...(y > 0.55 ? { bottom: `${(1 - y) * 100}%` } : { top: `${y * 100}%` }), }); const docked = surface.width > 0 && surface.width < 560; const transition = reduce ? { duration: 0 } : { duration: 0.32, ease: EASE_EXPO }; return (

{label}

{openCount} open {list.length - openCount > 0 && ` · ${list.length - openCount} resolved`}

{editable && ( )}
{ if ( commenting && !draft && e.target === e.currentTarget && e.key === "Enter" ) { e.preventDefault(); place(0.5, 0.5); } }} >
{children}
    {shown.map((a) => ( ))} {draft && (
  1. )}
{(draft || active) && ( 0.55 ? "" : undefined } style={docked ? undefined : anchor((draft ?? active)!)} initial={reduce ? false : { opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={ reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.97 } } transition={transition} > {draft ? ( setDraft({ ...draft, body })} onSubmit={post} onCancel={() => setDraft(null)} onNudge={(dx, dy) => setDraft({ ...draft, x: spot(draft.x + dx), y: spot(draft.y + dy), }) } submitLabel="Post" /> ) : ( { patch(active!.id, (a) => ({ ...a, replies: [ ...(a.replies ?? []), { id: createId(), author: author!, body, time: now }, ], })); announce("Reply posted."); }} onResolve={() => { patch(active!.id, (a) => ({ ...a, resolved: !a.resolved, })); announce( active!.resolved ? `Comment ${active!.number} reopened.` : `Comment ${active!.number} resolved.`, ); if (!active!.resolved && !showResolved) close(); }} onDelete={() => { patch(active!.id, () => null); setOpen(null); announce(`Comment ${active!.number} deleted.`); }} onClose={close} /> )} )}

{editable ? "Press C for comment mode, then click the work or press Enter to place a pin. Arrow keys move a focused pin." : ""}

{message}
); } function Composer({ label, value, onChange, onSubmit, onCancel, onNudge, submitLabel, }: { label: string; value: string; onChange: (value: string) => void; onSubmit: () => void; onCancel: () => void; onNudge?: (dx: number, dy: number) => void; submitLabel: string; }) { const ref = useRef(null); useEffect(() => { const frame = requestAnimationFrame(() => ref.current?.focus()); return () => cancelAnimationFrame(frame); }, []); return (
{ e.preventDefault(); onSubmit(); }} >