"use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; import { buildReportSlides, hasSlides, reportPageAnchorIds, slideIndexForPlace, type ReaderView } from "yt-dlp-transcript-common/lib/report/slides"; import { useReaderPlace } from "yt-dlp-transcript-common/components/report/slides/useReaderPlace"; import { ViewSwitch } from "yt-dlp-transcript-common/components/report/slides/ViewSwitch"; import CopyButton from "@/components/CopyButton"; import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs"; import type { Anchor, Note, NotesRead } from "@/lib/annotations/types"; import { useNotes } from "@/lib/annotations/useNotes"; import { ShareNotes } from "@/components/notes/NotesProvider"; import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody"; import ArticleShapes from "./ArticleShapes"; import ArticleVideo from "./ArticleVideo"; import type { VideoProjectLink } from "./AddToVideo"; import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom"; import EvidencePanel, { useEvidence } from "./EvidencePanel"; import { Composer, NoteCard, anchorLabel } from "./NoteCards"; // The article, its notes, and its evidence, in one screen: the text in a // ~70ch column, the notes in a rail beside it (a drawer below 1100px). // // select text → "Note" (or `n`) a note on that quote // "+ note" on a heading a note on the section // a citation its evidence in the rail; a note on it there // "+ whole article" a note on all of it // // Notes on a quote are found again in the text as it is NOW (lib/annotations/ // anchor.mjs: the quote, then its context) and marked; one whose quote is gone // is still listed, flagged "orphaned", under its section. // // Keys (not while typing): j/k move between notes, r resolves, e edits, n // notes the selection (or the whole article), Esc closes whatever is open. // // THE ARTICLE'S OTHER SHAPES (release 22): Article | Slides | Overview, the // published page's switch (`?rv=`, the place in the hash), so a draft is read // as slides before it ships. The text stays mounted (hidden) under them, its // marks with it; the notes rail stays beside them (ArticleShapes). The switch // sticks to the top of the reading column while the text scrolls, so a switch // keeps the place the reader is at; a place lands below it (`scroll-mt-16`). type Filter = "open" | "resolved" | "all"; // The article's element for a slide's anchor: a claim, a block, or the block // that stands for one of the page's own places. function placeEl(root: HTMLElement, anchorId: string): HTMLElement | null { const q = (sel: string) => root.querySelector(sel); if (anchorId === "report-head") return q('[data-block="title"]'); if (anchorId === "in-brief" || anchorId === "found") return q('[data-block="summary"]') ?? q('[data-block="title"]'); if (anchorId === "report-end") { const sections = root.querySelectorAll("section[data-block]"); return q('[data-block="method"]') ?? sections[sections.length - 1] ?? null; } const id = CSS.escape(anchorId); return q(`[data-claim="${id}"]`) ?? q(`[data-block="${id}"]`); } const MARK_CLASS = "rounded-sm bg-[color-mix(in_srgb,var(--color-sel)_22%,transparent)] text-inherit data-[active=true]:bg-[color-mix(in_srgb,var(--color-sel)_48%,transparent)] data-[status=resolved]:bg-transparent data-[status=resolved]:underline data-[status=resolved]:decoration-[var(--color-good)] data-[status=wontfix]:bg-transparent"; type ComposerState = { anchor: Anchor; label: string } | null; export default function ArticleReader({ site, report, view, initialNotes, initialFilter = "open", initialNote = null, meta, video, videoProjects = [], viewError, }: { site: string; report: string; view: ReportPageView; initialNotes: NotesRead; initialFilter?: Filter; initialNote?: string | null; meta: ReactNode; /** The report's own video (report.json `video`), played with timed notes. */ video?: { file: string; src: string; poster?: string; caption?: string } | null; viewError?: string | null; /** The article's LINKED video projects: where "Add to video" puts a cited span. */ videoProjects?: VideoProjectLink[]; }) { const notesApi = useNotes({ article: `${site}/${report}` }, { initial: initialNotes }); const { notes, write, busy, error } = notesApi; // Stable across note writes: ArticleBody is memoised on it (its marks are // laid over the rendered DOM). The player reads live notes through // , not through this element. const videoSlot = useMemo( () => video ? ( ) : null, [site, report, video?.file, video?.src, video?.poster, video?.caption], ); const [filter, setFilter] = useState(initialFilter); const [selected, setSelected] = useState(initialNote); const [editing, setEditing] = useState(null); const [composer, setComposer] = useState(null); const [pending, setPending] = useState<{ anchor: Anchor; label: string; x: number; y: number } | null>(null); const [cite, setCite] = useState(null); const [orphans, setOrphans] = useState>(new Set()); const [hover, setHover] = useState(null); const [drawer, setDrawer] = useState(false); const bodyRef = useRef(null); const evidence = useEvidence(site, report, cite); const shapes = hasSlides(view); const { place, go } = useReaderPlace(); const shape: ReaderView = shapes ? place.view : "article"; const slides = useMemo(() => buildReportSlides(view), [view]); const order = useMemo(() => reportPageAnchorIds(view), [view]); // Back to the text at a slide's place: scrolled to, focused. const toArticle = useCallback( (anchorId: string | null) => { go({ view: "article", anchor: anchorId }, true); if (!anchorId) return; requestAnimationFrame(() => { const el = bodyRef.current && placeEl(bodyRef.current, anchorId); if (!el) return; el.scrollIntoView({ block: "start" }); if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1"); el.focus({ preventScroll: true }); }); }, [go], ); const switchTo = useCallback( (next: ReaderView) => { let anchor: string | null = place.anchor; if (shape === "article" && bodyRef.current) { // The part of the text being read: the last place at the top of the // column, under the sticky switch, where a jump lands it (its // scroll-margin-top), give or take a line — as the export site's reader. const main = bodyRef.current.closest("main"); const top = main?.getBoundingClientRect().top ?? 0; const first = slides.length > 0 ? placeEl(bodyRef.current, slides[0].anchorId) : null; const line = top + (first ? parseFloat(getComputedStyle(first).scrollMarginTop) || 0 : 0) + 32; const named = place.anchor; anchor = null; let namedLater = false; for (const s of slides) { const el = placeEl(bodyRef.current, s.anchorId); if (!el) continue; if (anchor === null || el.getBoundingClientRect().top <= line) { anchor = s.anchorId; namedLater = false; } else if (s.anchorId === named) { namedLater = el.getBoundingClientRect().top < (main?.getBoundingClientRect().bottom ?? window.innerHeight); } } // Near the end, where the last places cannot scroll up to that line, // the place the URL names (a jump, Esc from a slide) wins while it is // still on screen below it. if (namedLater) anchor = named; } else if (shape === "slides") { anchor = slides[slideIndexForPlace(slides, place, order)]?.anchorId ?? null; } if (next === "article") toArticle(anchor); else go({ view: next, anchor }, true); }, [place, shape, slides, order, go, toArticle], ); const titles = useMemo(() => { const t: Record = { title: "title", subtitle: "subtitle", summary: "summary", method: "method" }; for (const s of view.sections) t[s.id] = s.title; return t; }, [view]); const numbers = useMemo(() => Object.fromEntries(Object.entries(view.citations).map(([id, c]) => [id, c.number])), [view]); const blockOrder = useMemo(() => ["title", "subtitle", "summary", ...view.sections.map((s) => s.id), "method"], [view]); const visible = useMemo(() => { const keep = (n: Note) => filter === "all" || (filter === "open" ? n.status === "open" : n.status !== "open"); const rank = (n: Note) => { const a = n.anchor; if (a.kind === "whole") return -1; if (a.kind === "text" || a.kind === "section") return blockOrder.indexOf(a.section) + 0.5; if (a.kind === "cite") return 1000 + (numbers[a.cite] ?? 0); return 2000; }; return notes.filter(keep).sort((a, b) => rank(a) - rank(b) || a.at.localeCompare(b.at)); }, [notes, filter, blockOrder, numbers]); // ---- marks --------------------------------------------------------------- useLayoutEffect(() => { const root = bodyRef.current; if (!root) return; unwrapMarks(root); const lost = new Set(); for (const n of visible) { const a = n.anchor; if (a.kind !== "text") continue; const block = root.querySelector(`[data-block="${CSS.escape(a.section)}"]`); if (!block) { lost.add(n.id); continue; } const at = locateQuote(blockText(block).text, a); if (!at.found) { lost.add(n.id); continue; } wrapRange(block, at.start, at.end, { "data-note": n.id, "data-status": n.status }, MARK_CLASS); } // Citation notes: mark the citation itself. for (const el of Array.from(root.querySelectorAll("[data-cite][data-has-note]"))) el.removeAttribute("data-has-note"); for (const n of visible) { if (n.anchor.kind === "cite") { for (const el of Array.from(root.querySelectorAll(`[data-cite="${CSS.escape(n.anchor.cite)}"]`))) el.setAttribute("data-has-note", "true"); } } setOrphans((prev) => (prev.size === lost.size && [...lost].every((id) => prev.has(id)) ? prev : lost)); }, [visible]); // The selected / hovered note's marks light up. useEffect(() => { const root = bodyRef.current; if (!root) return; for (const m of Array.from(root.querySelectorAll("mark[data-note]"))) { const id = m.getAttribute("data-note"); m.setAttribute("data-active", String(id === selected || id === hover)); } }, [selected, hover, visible]); const focusNote = useCallback((id: string, scroll = true) => { setSelected(id); if (!scroll) return; requestAnimationFrame(() => { const mark = bodyRef.current?.querySelector(`mark[data-note="${CSS.escape(id)}"]`); mark?.scrollIntoView({ block: "center", behavior: "smooth" }); document.querySelector(`[data-note-card="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" }); }); }, []); // A deep link (`?note=`) lands on its note, whatever the filter says. useEffect(() => { if (!initialNote) return; const n = notes.find((x) => x.id === initialNote); if (n && filter !== "all" && (filter === "open") !== (n.status === "open")) setFilter("all"); focusNote(initialNote); // once, on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ---- selection → "Note" --------------------------------------------------- const readSelection = useCallback(() => { const root = bodyRef.current; if (!root) return; const sel = selectionIn(root); if (!sel) { setPending(null); return; } const section = sel.block.getAttribute("data-block") ?? ""; const q = quoteAnchor(blockText(sel.block).text, sel.start, sel.end); if (!q.quote) { setPending(null); return; } const anchor: Anchor = { kind: "text", section, ...q }; setPending({ anchor, label: anchorLabel(anchor, titles, numbers), x: sel.rect.right, y: sel.rect.top }); }, [titles, numbers]); const openComposer = useCallback((anchor: Anchor) => { setComposer({ anchor, label: anchorLabel(anchor, titles, numbers) }); setPending(null); setDrawer(true); window.getSelection()?.removeAllRanges(); }, [titles, numbers]); // The body's buttons talk to the reader through a context whose value never // changes (so the memoised body never re-renders): it reads the latest // callbacks from a ref. const latest = useRef({ setCite, openComposer, setDrawer }); latest.current = { setCite, openComposer, setDrawer }; const actions = useMemo( () => ({ openCite: (id) => { latest.current.setCite(id); latest.current.setDrawer(true); }, noteSection: (section) => latest.current.openComposer({ kind: "section", section }), }), [], ); const add = async (anchor: Anchor, text: string) => { const note = await write({ op: "add", text, anchor }); if (note) { setComposer(null); if (filter === "resolved") setFilter("open"); focusNote(note.id, false); } }; // ---- keys ----------------------------------------------------------------- useEffect(() => { const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if (t && (t.closest("input, textarea, select, [contenteditable=true]") || e.metaKey || e.ctrlKey || e.altKey)) return; const idx = visible.findIndex((n) => n.id === selected); if (e.key === "j" || e.key === "k") { if (!visible.length) return; e.preventDefault(); const next = e.key === "j" ? (idx < 0 ? 0 : Math.min(visible.length - 1, idx + 1)) : idx < 0 ? 0 : Math.max(0, idx - 1); focusNote(visible[next].id); } else if (e.key === "r" && idx >= 0) { e.preventDefault(); void write({ op: "status", id: visible[idx].id, status: "resolved" }); } else if (e.key === "e" && idx >= 0 && visible[idx].author === "operator") { e.preventDefault(); setEditing(visible[idx].id); } else if (e.key === "n") { e.preventDefault(); if (pending) openComposer(pending.anchor); else if (cite) openComposer({ kind: "cite", cite }); else openComposer({ kind: "whole" }); } else if (e.key === "Escape") { setComposer(null); setEditing(null); setPending(null); setCite(null); setDrawer(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [visible, selected, pending, cite, write, focusNote, openComposer]); const counts = { open: notes.filter((n) => n.status === "open").length, resolved: notes.filter((n) => n.status !== "open").length, all: notes.length, }; const citeNotes = cite ? notes.filter((n) => n.anchor.kind === "cite" && n.anchor.cite === cite) : []; const card = (n: Note) => ( focusNote(n.id)} onHover={(on) => setHover(on ? n.id : null)} onEdit={() => setEditing(n.id)} onCancelEdit={() => setEditing(null)} write={write} /> ); return (
{meta} {viewError &&

citations not shown: {viewError}

} {shapes && (
)} {shape !== "article" && }
{pending && ( )}
); }