import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { diagramTargetName } from '@plannotator/core/diagram-anchor'; import { cn } from '../../lib/utils'; import { projectElement, type ScreenRect } from '../../utils/diagram-projection'; import type { DiagramCanvasHandle } from './DiagramCanvas'; import type { DiagramComposerDraft, DiagramHover, ResolvedDiagramComment } from './useDiagramComments'; /** * Rings, numbered badges, the hover chip and the composer's anchor point, * projected from each part's `getBBox()` through `getScreenCTM()`. The * layer is a sibling of the transformed wrapper, so a ring is the same 2px * at every zoom. Reprojection is one requestAnimationFrame pass per change * (viewport, host resize, a new render, a new comment list): a design * constant, not a limit. */ /** Ring inset from the part's box, in overlay pixels. */ const RING_PAD_PX = 3; interface Projected { readonly id: string; readonly number: number; readonly label: string; readonly resolved: boolean; readonly whole: boolean; readonly rect: ScreenRect; readonly additional: readonly ScreenRect[]; } function pad(rect: ScreenRect): ScreenRect { return { left: rect.left - RING_PAD_PX, top: rect.top - RING_PAD_PX, width: rect.width + RING_PAD_PX * 2, height: rect.height + RING_PAD_PX * 2, }; } function ringStyle(rect: ScreenRect): React.CSSProperties { return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; } export function DiagramOverlay({ handle, resolved, hover, composer, selectedCommentId, onSelectComment, renderComposer, }: { handle: DiagramCanvasHandle; resolved: readonly ResolvedDiagramComment[]; hover: DiagramHover | null; composer: DiagramComposerDraft | null; selectedCommentId: string | null; onSelectComment: ((id: string | null) => void) | undefined; /** The composer, given the primary ring's rectangle to sit beside. */ renderComposer: (anchorRect: ScreenRect) => React.ReactNode; }) { const [projected, setProjected] = useState([]); const [hoverRect, setHoverRect] = useState(null); const [draftRects, setDraftRects] = useState<{ primary: ScreenRect; additional: readonly ScreenRect[]; } | null>(null); const frameRef = useRef(null); const { viewport, hostRef, panIntoView } = handle; // One projection pass per change, after the wrapper's transform has // been written to the DOM (layout effect), on the next frame. useLayoutEffect(() => { const run = () => { frameRef.current = null; const host = hostRef.current; if (host === null) return; const hostRect = host.getBoundingClientRect(); const next: Projected[] = []; for (const entry of resolved) { if (entry.element === null) continue; const rect = projectElement(entry.element, hostRect); if (rect === null) continue; next.push({ id: entry.id, number: entry.number, label: entry.label, resolved: entry.resolved, whole: entry.whole, rect: pad(rect), additional: entry.additional .map((el) => projectElement(el, hostRect)) .filter((r): r is ScreenRect => r !== null) .map(pad), }); } setProjected(next); const hovered = hover === null ? null : projectElement(hover.element, hostRect); setHoverRect(hovered === null ? null : pad(hovered)); if (composer === null) { setDraftRects(null); } else { const primary = projectElement(composer.primary.element, hostRect); setDraftRects( primary === null ? null : { primary: pad(primary), additional: composer.additional .map((extra) => projectElement(extra.element, hostRect)) .filter((r): r is ScreenRect => r !== null) .map(pad), }, ); } }; if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); if (typeof requestAnimationFrame === 'function') { frameRef.current = requestAnimationFrame(run); } else { run(); } return () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); frameRef.current = null; }; }, [composer, hostRef, hover, resolved, viewport]); // Selecting a comment in the panel pans its part into view; the ring // pulses through the selected class below. const selectedRect = projected.find((entry) => entry.id === selectedCommentId)?.rect; const pannedForRef = useRef(null); useEffect(() => { if (selectedRect === undefined || selectedCommentId === null) return; if (pannedForRef.current === selectedCommentId) return; pannedForRef.current = selectedCommentId; panIntoView(selectedRect); }, [panIntoView, selectedCommentId, selectedRect]); useEffect(() => { if (selectedCommentId === null) pannedForRef.current = null; }, [selectedCommentId]); return (
{projected.map((entry) => (