import type { TimelineAnnotationSource } from "@opengeni/sdk"; import { XIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { usePortalContainer } from "../ui/portal-container"; import { usePortalTokenStyle } from "../lib/use-portal-token-style"; import { AnnotationAccentRow, AnnotationNoteField, AnnotationNotePreview, AnnotationQuoteSourceButton, type TimelineAnnotationLike, } from "./timeline-annotation-chrome"; import { ANNOTATION_REVIEW_DIALOG_WIDTH_PX, clampAnnotationDialogPlacement, scrollAnnotationRowIntoList, } from "./timeline-annotation-layout"; import { annotationDisplayOrdinal, cssEscapeAttribute } from "./timeline-annotation-shared"; import { useOpenGeniText } from "../messages/runtime"; function focusableElements(root: HTMLElement): HTMLElement[] { return [ ...root.querySelectorAll( 'button:not([disabled]),textarea:not([disabled]),input:not([disabled]),[href],[tabindex]:not([tabindex="-1"])', ), ].filter((element) => element.tabIndex >= 0 && !element.closest("[inert]")); } export function TimelineAnnotationsDialog({ annotations, editable, focusAnnotationId, onFocusConsumed, onUpdate, onRemove, onRevealSource, triggerRef, countLabel, onDismiss, onCommit, }: { annotations: readonly TimelineAnnotationLike[]; editable: boolean; focusAnnotationId?: string | null | undefined; onFocusConsumed?: (() => void) | undefined; onUpdate?: ((id: string, note: string) => void) | undefined; onRemove?: ((id: string) => void) | undefined; onRevealSource?: ((source: TimelineAnnotationSource) => boolean) | undefined; triggerRef: RefObject; countLabel: string; onDismiss: (restoreFocus: boolean) => void; onCommit: () => void; }) { const t = useOpenGeniText(); const [unavailableId, setUnavailableId] = useState(null); const [position, setPosition] = useState({ left: 12, top: 12, maxHeight: 0 }); const panelRef = useRef(null); const headerRef = useRef(null); const listRef = useRef(null); const noteRefs = useRef(new Map()); const focusedAnnotationIdRef = useRef(null); const portalContainer = usePortalContainer(); const portalStyle = usePortalTokenStyle(triggerRef.current); const revealNote = (id: string) => { const row = listRef.current?.querySelector( `[data-og-annotation-id="${cssEscapeAttribute(id)}"]`, ); scrollAnnotationRowIntoList(listRef.current, row); noteRefs.current.get(id)?.focus(); }; const commitNotes = (fromId: string) => { const incomplete = annotations.filter((annotation) => { const live = noteRefs.current.get(annotation.id); return (live?.value ?? annotation.note).trim().length === 0; }); if (incomplete.length === 0) { onCommit(); return; } const fromIndex = annotations.findIndex((annotation) => annotation.id === fromId); const next = incomplete.find( (item) => annotations.findIndex((annotation) => annotation.id === item.id) > fromIndex, ) ?? incomplete[0]!; revealNote(next.id); }; useLayoutEffect(() => { const updatePosition = () => { const rect = triggerRef.current?.getBoundingClientRect(); if (!rect) return; const contentHeight = (headerRef.current?.offsetHeight ?? 44) + (listRef.current?.scrollHeight ?? 200); setPosition( clampAnnotationDialogPlacement({ triggerLeft: rect.left, triggerTop: rect.top, triggerBottom: rect.bottom, contentHeight, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, panelWidth: ANNOTATION_REVIEW_DIALOG_WIDTH_PX, }), ); }; updatePosition(); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [annotations, triggerRef]); useLayoutEffect(() => { if (!focusAnnotationId) { focusedAnnotationIdRef.current = null; return; } // A consumed request must not reclaim focus or scroll on layout/callback updates. if (focusedAnnotationIdRef.current === focusAnnotationId) return; focusedAnnotationIdRef.current = null; if (position.maxHeight <= 0) return; revealNote(focusAnnotationId); const note = noteRefs.current.get(focusAnnotationId); if (note && document.activeElement === note) { focusedAnnotationIdRef.current = focusAnnotationId; onFocusConsumed?.(); } }, [focusAnnotationId, onFocusConsumed, annotations.length, position.maxHeight]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.stopPropagation(); onDismiss(true); return; } if (event.key !== "Tab" || !panelRef.current) return; const focusable = focusableElements(panelRef.current); if (focusable.length === 0) return; const first = focusable[0]!; const last = focusable[focusable.length - 1]!; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; const onPointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node)) return; if (panelRef.current?.contains(event.target) || triggerRef.current?.contains(event.target)) { return; } onDismiss(false); }; document.addEventListener("keydown", onKeyDown); document.addEventListener("pointerdown", onPointerDown); if (!focusAnnotationId) { window.setTimeout(() => { if (document.activeElement === triggerRef.current) panelRef.current?.focus(); }, 0); } return () => { document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown); }; }, [focusAnnotationId, onDismiss, triggerRef]); if (typeof document === "undefined") return null; return createPortal(

{countLabel}

{annotations.map((annotation, index) => { const ordinal = annotationDisplayOrdinal(annotation, index); return (

{t("timelineAnnotationsDialog.annotationOrdinal", { ordinal })}

{editable && onRemove ? ( ) : null}
{unavailableId === annotation.id ? (

{t("timelineAnnotationsDialog.sourceIsOutsideTheLoaded")}

) : null} {editable && onUpdate ? ( { if (node) noteRefs.current.set(annotation.id, node); else noteRefs.current.delete(annotation.id); }} onUpdate={onUpdate} onCommit={() => commitNotes(annotation.id)} /> ) : ( )}
); })}
, portalContainer ?? document.body, ); } export default TimelineAnnotationsDialog;