import type {
DraftTimelineAnnotation,
TimelineAnnotation,
TimelineAnnotationSource,
} from "@opengeni/sdk";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "../lib/cn";
import { annotationNoteNeedsDisclosure } from "./timeline-annotation-layout";
import { useTimelineAnnotationSourceRoot } from "./timeline-annotation-reveal-context";
import {
annotationSourceLabel,
revealLoadedAnnotationSource,
truncateAnnotationQuote,
} from "./timeline-annotation-shared";
import { useOpenGeniText } from "../messages/runtime";
export type TimelineAnnotationLike = DraftTimelineAnnotation | TimelineAnnotation;
const NOTE_MAX_HEIGHT_PX = 160;
function autosizeNote(textarea: HTMLTextAreaElement | null): void {
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, 32), NOTE_MAX_HEIGHT_PX)}px`;
}
function quotePreview(quote: string): { text: string; multiline: boolean } {
const trimmed = quote.trim();
const multiline = trimmed.includes("\n");
return {
text: multiline ? trimmed : trimmed.replace(/\s+/g, " "),
multiline,
};
}
export function AnnotationQuoteSourceButton({
annotation,
lines = 2,
onRevealSource,
onUnavailable,
}: {
annotation: TimelineAnnotationLike;
lines?: 2 | 3;
onRevealSource?: ((source: TimelineAnnotationSource) => boolean) | undefined;
onUnavailable: (id: string | null) => void;
}) {
const t = useOpenGeniText();
const preview = quotePreview(annotation.quote);
const sourceLabel = annotationSourceLabel(t, annotation.source);
const sourceRoot = useTimelineAnnotationSourceRoot();
return (
);
}
export function AnnotationAccentRow({
children,
className,
}: {
children: ReactNode;
className?: string | undefined;
}) {
return (
);
}
export function AnnotationNotePreview({
note,
annotationId,
ordinal,
className,
}: {
note: string;
annotationId: string;
ordinal: number;
className?: string | undefined;
}) {
const t = useOpenGeniText();
const [expanded, setExpanded] = useState(false);
if (!note) return null;
const collapsible = annotationNoteNeedsDisclosure(note);
const contentId = `og-annotation-note-${annotationId}`;
return (
{note}
{collapsible ? (
) : null}
);
}
export function AnnotationNoteField({
annotation,
inputRef,
onUpdate,
onCommit,
}: {
annotation: TimelineAnnotationLike;
inputRef?: ((node: HTMLTextAreaElement | null) => void) | undefined;
onUpdate: (id: string, note: string) => void;
onCommit?: (() => void) | undefined;
}) {
const t = useOpenGeniText();
const localRef = useRef(null);
const setRef = (node: HTMLTextAreaElement | null) => {
localRef.current = node;
inputRef?.(node);
};
useLayoutEffect(() => {
const textarea = localRef.current;
if (!textarea) return;
// Ref callbacks can reattach on every scroll-driven parent render. Reflowing
// unchanged notes there interrupts native scrolling, particularly in WebKit.
autosizeNote(textarea);
if (typeof ResizeObserver === "undefined") return;
let width = textarea.getBoundingClientRect().width;
const observer = new ResizeObserver(() => {
const nextWidth = textarea.getBoundingClientRect().width;
if (nextWidth === width) return;
width = nextWidth;
autosizeNote(textarea);
});
observer.observe(textarea);
return () => observer.disconnect();
}, [annotation.note]);
return (