import React, { useCallback, useEffect, useRef, useState } from "react"; import { BookOpen, SidebarSimple, ChatCircle, MagnifyingGlass, PencilSimple, X, CheckCircle, NotePencil, Stack, } from "@phosphor-icons/react"; import { api, readPreference, savePreference } from "./api.js"; import { IconButton, Modal, Spinner } from "./ui.jsx"; import { slug } from "./Markdown.jsx"; import { Assistant } from "./Assistant.jsx"; import { ImportDialog } from "./ImportDialog.jsx"; import { ConfirmationDialog } from "./ConfirmationDialog.jsx"; import { useConfirmation } from "./hooks/useConfirmation.js"; import { HighlightsDialog } from "./HighlightsDialog.jsx"; import { MaterialEditorDialog } from "./MaterialEditorDialog.jsx"; import { LibraryPanel } from "./LibraryPanel.jsx"; import { ReaderPane } from "./ReaderPane.jsx"; import { CommandPalette } from "./CommandPalette.jsx"; import { captureSelection, applyArticleHighlights, scrollToArticleText, } from "./selection.js"; import { readThemePreference, setDocumentTheme, ReaderThemeContext } from "./theme.js"; import { GenerationDialog, RevisionDialog } from "./AuthoringDialogs.jsx"; import "./styles.css"; import { LearningContext } from "./LearningContext.jsx"; import { LearningEditor, NotebookDialog, ReviewDialog, } from "./LearningDialogs.jsx"; import { ComparePane, ComparePicker } from "./ComparePane.jsx"; /** @param {{ initialMaterialId?: string, onMaterialChange?: (id: string) => void, onOpenSettings?: () => void, onOpenNote?: (id: string) => void }} props */ export function ReaderWorkspace({ initialMaterialId, onMaterialChange, onOpenSettings = () => {}, onOpenNote }) { const [materials, setMaterials] = useState([]), [providers, setProviders] = useState([]), [kind, setKind] = useState(readPreference("kind", "article")), [activeId, setActiveId] = useState(initialMaterialId || readPreference("materialId", "")), [material, setMaterial] = useState(null), [highlights, setHighlights] = useState([]), [libraryOpen, setLibraryOpen] = useState( readPreference("libraryOpen", true), ), [assistantOpen, setAssistantOpen] = useState( readPreference("assistantOpen", true), ), [libraryWidth, setLibraryWidth] = useState( readPreference("libraryWidth", 250), ), [assistantWidth, setAssistantWidth] = useState( readPreference("assistantWidth", 420), ), [assistantExpanded, setAssistantExpanded] = useState(false), [readingMode, setReadingMode] = useState(false), [viewportWidth, setViewportWidth] = useState(window.innerWidth), [theme] = useState(readThemePreference()), [generation, setGeneration] = useState(null), [revisionsOpen, setRevisionsOpen] = useState(false), [importing, setImporting] = useState(false), [highlightOpen, setHighlightOpen] = useState(false), [outlineOpen, setOutlineOpen] = useState(false), [outline, setOutline] = useState([]), [context, setContext] = useState(null), [selection, setSelection] = useState(null), [search, setSearch] = useState(""), [commandOpen, setCommandOpen] = useState(false), [learning, setLearning] = useState({ notes: [], cards: [], attempts: [] }), [learningEditor, setLearningEditor] = useState(null), [notebookOpen, setNotebookOpen] = useState(false), [reviewOpen, setReviewOpen] = useState(false), [learningRevision, setLearningRevision] = useState(0), [comparePickerOpen, setComparePickerOpen] = useState(false), [compareId, setCompareId] = useState(""), [compareWidth, setCompareWidth] = useState( Math.max(300, window.innerWidth * 0.45), ), [comparePage, setComparePage] = useState(1), [compareNavigation, setCompareNavigation] = useState(null), [draftRequest, setDraftRequest] = useState(null), [chatRequest, setChatRequest] = useState(null), [pendingHit, setPendingHit] = useState(null), [page, setPage] = useState(1), [pageNavigation, setPageNavigation] = useState(0), [loading, setLoading] = useState(true), [toast, setToast] = useState(null), [editing, setEditing] = useState(false), [edit, setEdit] = useState({}), [narrow, setNarrow] = useState(window.innerWidth < 1000); const initialMaterialRef = useRef(initialMaterialId), mounted = useRef(true), scopeRef = useRef(null), articlePositionRef = useRef(null), articleSaveQueue = useRef(Promise.resolve()), articleRef = useRef(), readerRef = useRef(), searchInputRef = useRef(null), screenshotRef = useRef(), initial = useRef(true), scrollTimer = useRef(), toastTimer = useRef(), positionReady = useRef(false), activeRef = useRef(activeId); activeRef.current = activeId; initialMaterialRef.current = initialMaterialId; const { confirm, dialogProps } = useConfirmation(); const noteUpdates = useRef(new Map()); useEffect(() => { mounted.current = true; const previousTitle = document.title; return () => { mounted.current = false; document.title = previousTitle; }; }, []); useEffect(() => { setDocumentTheme(theme, scopeRef.current); }, [theme]); useEffect(() => { document.title = material?.title ? material.title + " — Odre" : "Odre"; }, [material?.title]); const notify = useCallback((text, type = "success") => { if (!mounted.current) return; setToast({ text, type }); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 6000); }, []); const refreshProviders = useCallback( async () => { const rows = await api("/providers"); if (mounted.current) setProviders(rows); }, [], ); const refreshLibrary = useCallback(async () => { const list = await api("/materials"); if (!mounted.current) return list; setMaterials(list); if (initial.current && !initialMaterialRef.current) { initial.current = false; const stored = new URLSearchParams(location.search).get("material") || readPreference("materialId", ""); const selected = list.find((m) => m.id === stored) || list.find((m) => m.kind === readPreference("kind", "article")); if (selected) { setActiveId(selected.id); setKind(selected.kind); } else setActiveId(""); } return list; }, []); useEffect(() => { Promise.all([refreshLibrary(), refreshProviders()]) .catch((e) => notify(e.message, "error")) .finally(() => { if (mounted.current) setLoading(false); }); return () => { clearTimeout(toastTimer.current); clearTimeout(scrollTimer.current); }; }, []); useEffect(() => { if (initialMaterialId && initialMaterialId !== activeRef.current) setActiveId(initialMaterialId); }, [initialMaterialId]); useEffect(() => { const resize = () => { const root = scopeRef.current; const width = root?.clientWidth || window.innerWidth; if (root) root.style.setProperty("--reader-height", `${Math.max(180, window.innerHeight - Math.max(0, root.getBoundingClientRect().top))}px`); setViewportWidth(width); setNarrow(width < 1000); }; const observer = new ResizeObserver(resize); if (scopeRef.current) observer.observe(scopeRef.current); resize(); window.addEventListener("resize", resize); return () => { observer.disconnect(); window.removeEventListener("resize", resize); }; }, []); useEffect(() => { if (narrow) { setAssistantExpanded(false); setLibraryOpen(false); setAssistantOpen(false); } }, [narrow]); useEffect(() => { savePreference("materialId", activeId); setReadingMode(false); setAssistantExpanded(false); setContext(null); setSelection(null); setGeneration(null); setRevisionsOpen(false); setEditing(false); setHighlightOpen(false); setOutlineOpen(false); if (compareId === activeId) setCompareId(""); setMaterial(null); setHighlights([]); setLearning({ notes: [], cards: [], attempts: [] }); setLearningEditor(null); setNotebookOpen(false); setOutline([]); positionReady.current = false; if (!activeId) { setLoading(false); return; } const controller = new AbortController(); setLoading(true); Promise.all([ api("/materials/" + activeId, { signal: controller.signal }), api("/materials/" + activeId + "/highlights", { signal: controller.signal, }), api(`/materials/${activeId}/learning`, { signal: controller.signal }), ]) .then(([m, h, l]) => { if (controller.signal.aborted || !mounted.current) return; if ( new URLSearchParams(location.search).get("material") === m.id && new URLSearchParams(location.search).get("reading") === "1" ) setReadingMode(true); setMaterial(m); setKind(m.kind); setPage(m.position.page || 1); setHighlights(h); setLearning(l); document.title = m.title + " — Odre"; }) .catch((e) => { if (e.name !== "AbortError") { notify(e.message, "error"); setActiveId(""); } }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => { controller.abort(); clearTimeout(scrollTimer.current); saveArticlePosition(); CSS.highlights?.delete("odre-saved"); }; }, [activeId]); useEffect(() => { if (material?.kind !== "article" || !articleRef.current) return; setOutline( [...material.body.matchAll(/^(#{2,3})\s+(.+)$/gm)].map((m) => ({ title: m[2].replace(/[*_`]/g, ""), depth: m[1].length - 2, anchor: slug(m[2].replace(/[*_`]/g, "")), })), ); const root = articleRef.current; const paint = () => applyArticleHighlights(root, highlights); paint(); const observer = new MutationObserver(paint); observer.observe(root, { childList: true, subtree: true }); let live = true; const restore = () => { if (!live || positionReady.current) return; const scroll = readerRef.current; if (scroll) scroll.scrollTop = (material.position.scrollRatio || 0) * Math.max(0, scroll.scrollHeight - scroll.clientHeight); }; const layoutObserver = new ResizeObserver(restore); layoutObserver.observe(root); requestAnimationFrame(restore); document.fonts.ready.then(restore); return () => { live = false; observer.disconnect(); layoutObserver.disconnect(); }; }, [material?.body, highlights, activeId]); useEffect(() => { if (!materials.some((m) => m.status === "indexing")) return; const timer = setInterval(async () => { try { const list = await refreshLibrary(); const row = list.find((m) => m.id === activeRef.current); if (row) setMaterial((current) => current && current.id === row.id ? { ...current, ...row, sources: current.sources } : current, ); } catch (e) { notify(e.message, "error"); } }, 1500); return () => clearInterval(timer); }, [materials.some((m) => m.status === "indexing")]); const toggleLibrary = () => { setReadingMode(false); setLibraryOpen((v) => { const next = !v; savePreference("libraryOpen", next); if (narrow && next) setAssistantOpen(false); return next; }); }; const toggleAssistant = () => { setReadingMode(false); setAssistantExpanded(false); setAssistantOpen((v) => { const next = !v; savePreference("assistantOpen", next); if (narrow && next) setLibraryOpen(false); return next; }); }; useEffect(() => { const keys = (e) => { const target = e.target; const editingText = target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)); const command = e.metaKey || e.ctrlKey; if (command && e.key.toLowerCase() === "k") { e.preventDefault(); e.stopImmediatePropagation(); if (!document.querySelector('[role="dialog"]')) { setCommandOpen(true); } return; } if (e.key === "Escape" && !document.querySelector('[role="dialog"]')) { if (readingMode) { setReadingMode(false); return; } if ( document.activeElement === searchInputRef.current && searchInputRef.current?.value ) { setSearch(""); return; } setSelection(null); if (narrow) { setLibraryOpen(false); setAssistantOpen(false); } return; } if (editingText) return; if (command && e.key.toLowerCase() === "b") { e.preventDefault(); toggleLibrary(); } if (command && e.key.toLowerCase() === "j") { e.preventDefault(); toggleAssistant(); } }; document.addEventListener("keydown", keys, true); return () => document.removeEventListener("keydown", keys, true); }, [narrow, readingMode]); const changeKind = (value) => { setKind(value); savePreference("kind", value); const last = readPreference("last-" + value, ""); const nextId = materials.some((m) => m.id === last && m.kind === value) ? last : ""; setActiveId(nextId); if (nextId && nextId !== activeId) onMaterialChange?.(nextId); setSearch(""); }; const openLibrary = () => { if (onMaterialChange) { saveArticlePosition(); onMaterialChange(""); return; } setActiveId(""); setSearch(""); setLibraryOpen(true); savePreference("libraryOpen", true); if (narrow) setAssistantOpen(false); }; const openAddMaterial = (nextKind) => { if (nextKind && nextKind !== kind) changeKind(nextKind); setImporting(true); }; const openMaterial = (m) => { if (m.id === compareId) setCompareId(""); setKind(m.kind); setActiveId(m.id); savePreference("kind", m.kind); savePreference("last-" + m.kind, m.id); onMaterialChange?.(m.id); if (narrow) setLibraryOpen(false); }; const onImported = async (m) => { await refreshLibrary(); if (m.id) openMaterial(m); }; const chooseContext = (s) => { setReadingMode(false); setContext(s); setSelection(null); setAssistantOpen(true); if (narrow) setLibraryOpen(false); }; const saveHighlight = async () => { if (!selection || !material) return; try { const { toolbar, ...body } = selection; const saved = await api("/materials/" + material.id + "/highlights", { method: "POST", body, }); setHighlights((v) => [...v, saved]); setSelection(null); window.getSelection()?.removeAllRanges(); notify("Highlight saved."); } catch (e) { notify(e.message, "error"); } }; const goPage = useCallback( (value) => { if (!material || material.kind !== "book") return; const number = Math.max( 1, Math.min(material.page_count || 5000, Number(value) || 1), ); setPage(number); setPageNavigation((value) => value + 1); setSelection(null); }, [material?.kind, material?.page_count], ); const visiblePdfPage = useCallback((number) => { setPage(number); setSelection(null); }, []); const captureArticleSelection = () => { const selected = captureSelection(articleRef.current); if (selected) setSelection(selected); }; const saveArticlePosition = () => { clearTimeout(scrollTimer.current); const pending = articlePositionRef.current; if (!pending) return; articlePositionRef.current = null; articleSaveQueue.current = articleSaveQueue.current.catch(() => {}).then(() => api(`/materials/${pending.id}`, { method: "PATCH", body: { position: pending.position } }), ).catch((error) => notify(error.message, "error")); }; const scrollArticle = () => { setSelection(null); if (!material || !positionReady.current || !readerRef.current) return; const el = readerRef.current; const ratio = el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight); articlePositionRef.current = { id: material.id, position: { scrollRatio: Math.max(0, Math.min(1, ratio)) } }; clearTimeout(scrollTimer.current); scrollTimer.current = setTimeout(saveArticlePosition, 500); }; const updateDiagram = useCallback( async (oldCode, newCode) => { const updated = await api("/materials/" + material.id, { method: "PATCH", body: { content: material.body.replace(oldCode, newCode), expected_version: material.version }, }); setMaterial(updated); }, [material?.id, material?.body], ); const startEdit = () => { setEdit({ title: material.title, subject: material.subject, content: material.body, sources: material.sources.map((source) => ({ ...source })), }); setEditing(true); }; const saveMaterialEdit = async (event) => { event.preventDefault(); try { const updated = await api("/materials/" + material.id, { method: "PATCH", body: { ...edit, expected_version: material.version }, }); setMaterial(updated); setEditing(false); await refreshLibrary(); notify("Material saved."); } catch (error) { notify(error.message, "error"); } }; const removeMaterial = () => confirm({ title: `Delete ${material.kind === "book" ? "book" : "article"}?`, description: material.title, detail: "This material, its conversations, highlights, reading notes, and review cards will be removed. Download an export or library backup first if you want to keep them.", label: material.kind === "book" ? "Delete book" : "Delete article", action: async () => { await api("/materials/" + material.id, { method: "DELETE" }); setEditing(false); setActiveId(""); onMaterialChange?.(""); await refreshLibrary(); notify("Material deleted."); }, }); const removeSource = (index) => confirm({ title: "Delete source?", description: material.sources[index].title, detail: "This reference will be removed from future answers. Existing conversations keep their saved citations. Links written in the article are kept.", label: "Delete source", action: async () => { setMaterial( await api(`/materials/${material.id}/sources/${index}`, { method: "DELETE", body: { expected_version: material.version }, }), ); await refreshLibrary(); notify("Source deleted."); }, }); const updateHighlightNote = (highlightId, note) => { setHighlights((items) => items.map((item) => (item.id === highlightId ? { ...item, note } : item)), ); }; const saveHighlightNote = (highlightId, note) => { const pending = noteUpdates.current.get(highlightId) || Promise.resolve(); const next = pending .catch(() => {}) .then(() => api("/highlights/" + highlightId, { method: "PATCH", body: { note } }), ); noteUpdates.current.set(highlightId, next); next .finally(() => { if (noteUpdates.current.get(highlightId) === next) noteUpdates.current.delete(highlightId); }) .catch(() => {}); return next; }; const removeHighlight = (highlight) => confirm({ title: "Delete highlight?", description: highlight.quote.slice(0, 180), detail: "The saved passage and its note will be deleted. The original material is kept.", label: "Delete highlight", action: async () => { await noteUpdates.current.get(highlight.id)?.catch(() => {}); await api("/highlights/" + highlight.id, { method: "DELETE" }); setHighlights((items) => items.filter((item) => item.id !== highlight.id), ); notify("Highlight deleted."); }, }); const clearHighlightNote = (highlight) => confirm({ title: "Delete note?", description: "Keep the highlighted passage and remove only your note.", label: "Delete note", action: async () => { await saveHighlightNote(highlight.id, ""); updateHighlightNote(highlight.id, ""); notify("Note deleted."); }, }); const assistantLimit = narrow ? Math.max(280, viewportWidth - 24) : Math.max( 320, viewportWidth - (libraryOpen ? Math.min(libraryWidth, viewportWidth * 0.22) + 8 : 0) - 392, ); const assistantDisplayWidth = Math.min(assistantWidth, assistantLimit); const resizePanel = (side, e) => { e.preventDefault(); const start = e.clientX, initialWidth = side === "left" ? libraryWidth : assistantDisplayWidth; scopeRef.current?.classList.add("panel-resizing"); const move = (ev) => { const max = side === "left" ? Math.min( 370, viewportWidth - (assistantOpen ? assistantDisplayWidth : 0) - 400, ) : assistantLimit; const width = Math.max( side === "left" ? 220 : Math.min(320, assistantLimit), Math.min( max, initialWidth + (ev.clientX - start) * (side === "left" ? 1 : -1), ), ); if (side === "left") setLibraryWidth(width); else setAssistantWidth(width); }; const end = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end); scopeRef.current?.classList.remove("panel-resizing"); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end, { once: true }); window.addEventListener("pointercancel", end, { once: true }); }; useEffect(() => { savePreference("libraryWidth", libraryWidth); savePreference("assistantWidth", assistantWidth); }, [libraryWidth, assistantWidth]); const filtered = materials.filter( (m) => m.kind === kind && `${m.title} ${m.subject}`.toLowerCase().includes(search.toLowerCase()), ); const jumpHighlight = (h) => { positionReady.current = true; if (h.page) { goPage(h.page); setHighlightOpen(false); return; } const root = articleRef.current; if (!root) return; if (scrollToArticleText(root, h.quote, { behavior: "smooth" })) { setHighlightOpen(false); return; } notify("This passage has changed since it was highlighted.", "error"); }; const refreshLearning = async (mid = activeId) => { const next = await api(`/materials/${mid}/learning`); if (!mounted.current) return; if (activeRef.current === mid) setLearning(next); setLearningRevision((v) => v + 1); }; const openCard = (data) => setLearningEditor({ type: "card", data }); const askFeedback = ({ exercise, answer }) => { setReadingMode(false); const text = `Review my answer to this exercise. Explain what is correct, identify specific mistakes, and guide me through any missing steps. Use the material as evidence and cite the relevant passage or source. Answer in the language of my response.\n\nQuestion:\n${exercise.question}\n\nMy answer:\n${answer}\n\nReference solution:\n${exercise.solution || "No reference solution supplied."}`; if (text.length > 20000) { notify( "This exercise and answer exceed the chat limit. Shorten your answer before requesting feedback.", "error", ); return; } setContext(null); setAssistantExpanded(false); setAssistantOpen(true); if (narrow) setLibraryOpen(false); setDraftRequest({ materialId: material.id, token: Date.now(), text, }); }; const saveAttempt = async (exerciseKey, answer) => { const mid = material.id; await api(`/materials/${mid}/attempts`, { method: "POST", body: { exerciseKey, answer }, }); if (activeRef.current === mid) setLearning((v) => ({ ...v, attempts: [ ...v.attempts.filter((a) => a.exercise_key !== exerciseKey), { exercise_key: exerciseKey, answer }, ], })); }; const saveLearning = async (draft) => { const { type } = learningEditor, mid = learningEditor.materialId || material.id; if (type === "highlight") { if (draft.id) { await saveHighlightNote(draft.id, draft.body); updateHighlightNote(draft.id, draft.body); } else { const { toolbar, ...location } = draft.selection; const saved = await api(`/materials/${mid}/highlights`, { method: "POST", body: { ...location, note: draft.body }, }); if (activeRef.current === mid) setHighlights((v) => [...v, saved]); setSelection(null); window.getSelection()?.removeAllRanges(); } } else { const resource = type === "card" ? "cards" : "notes"; const saved = await api( draft.id ? `/${resource}/${draft.id}` : `/materials/${mid}/${resource}`, { method: draft.id ? "PATCH" : "POST", body: type === "card" ? { question: draft.question, answer: draft.answer, quote: draft.quote || "", page: draft.page || null, noteId: draft.note_id || null, } : { title: draft.title, body: draft.body, quote: draft.quote || "", page: draft.page || null, chatId: draft.chat_id || null, messageId: draft.message_id || null, }, }, ); if (activeRef.current === mid) setLearning((current) => ({ ...current, [resource]: [ saved, ...current[resource].filter((item) => item.id !== saved.id), ], })); setLearningRevision((v) => v + 1); } notify(type === "card" ? "Review card saved." : "Note saved."); }; const removeLearning = (type, item) => confirm({ title: type === "card" ? "Delete review card?" : "Delete note?", description: (item.title || item.question).slice(0, 160), label: "Delete", action: async () => { await api(`/${type === "card" ? "cards" : "notes"}/${item.id}`, { method: "DELETE", }); await refreshLearning(item.material_id); notify("Deleted."); }, }); const chooseComparison = (m) => { setReadingMode(false); setCompareId(m.id); setCompareWidth(Math.max(300, Math.min(viewportWidth * 0.45, 700))); setCompareNavigation(null); setLibraryOpen(false); setAssistantOpen(false); setAssistantExpanded(false); }; const openComparisonPage = (mid, p) => { if (!mid || !materials.some((m) => m.id === mid)) { notify("The comparison material is no longer available.", "error"); return; } setCompareId(mid); setCompareNavigation({ page: p, token: Date.now() }); if (narrow) setAssistantOpen(false); }; const askBoth = (s) => { setReadingMode(false); setAssistantOpen(true); setAssistantExpanded(false); if (narrow) setLibraryOpen(false); if (s.quote) setContext({ quote: s.quote }); else setDraftRequest({ materialId: material.id, token: Date.now(), text: "Compare the explanations in these two materials. Identify where they agree, how their approaches differ, and cite each source separately.", }); }; const openSearchHit = (hit) => { setReadingMode(false); const m = materials.find( (m) => m.id === (hit.material_id || hit.materialId), ); if (!m) return; openMaterial(m); setPendingHit({ ...hit, token: Date.now() }); setCommandOpen(false); setNotebookOpen(false); setReviewOpen(false); }; useEffect(() => { if (!pendingHit || loading || material?.id !== pendingHit.material_id) return; const hit = pendingHit; positionReady.current = true; const timer = setTimeout(() => { if (hit.type === "chat" || (hit.type === "origin" && hit.chat_id)) { setAssistantOpen(true); if (narrow) setLibraryOpen(false); setChatRequest({ materialId: material.id, chatId: hit.chat_id, messageId: hit.message_id, token: Date.now(), }); } else if (hit.type === "note") { setNotebookOpen(true); setTimeout( () => document .getElementById(`note-${hit.item_id}`) ?.scrollIntoView({ block: "center" }), 100, ); } else if (hit.page) goPage(hit.page); else if (articleRef.current) { const root = articleRef.current; const anchor = hit.heading ? root.querySelector("#" + CSS.escape(slug(hit.heading))) : null; if (anchor) anchor.scrollIntoView({ block: "start" }); else { const term = (hit.quote || hit.query || "") .slice(0, 65) .toLocaleLowerCase(); if (term) scrollToArticleText(root, term); } } setPendingHit(null); }, 160); return () => clearTimeout(timer); }, [pendingHit, loading, material?.id]); return (
setReviewOpen(true)}>
{ e.preventDefault(); setCommandOpen(true); }} >
setImporting(true)} onOpenMaterial={openMaterial} onShowHighlights={() => { if (material) setHighlightOpen(true); else notify("Open a material to see its highlights."); }} onSettings={onOpenSettings} onResize={(event) => resizePanel("left", event)} /> {narrow && (libraryOpen || assistantOpen) && ( {material?.kind === "article" && } setSelection(null)} >
)} setReviewOpen(true)} />
{outline.length ? ( outline.map((item, i) => ( )) ) : (

{material?.kind === "book" ? "This PDF has no embedded table of contents. Use the page control or book search." : "Add Markdown headings to organize your article."}

)}
saveHighlightNote(highlightId, note).catch((error) => notify(error.message, "error"), ) } onEditNote={(h) => setLearningEditor({ type: "highlight", data: { ...h, body: h.note } }) } onCard={openCard} /> setLearningEditor({ type: "note", data })} onCreate={() => setLearningEditor({ type: "note", data: { title: "", body: "" } }) } onDelete={(n) => removeLearning("note", n)} onOrigin={(n) => openSearchHit({ ...n, type: "origin" })} onCard={openCard} onCapture={async (note) => { const saved = await api(`/notes/${note.id}/capture`, { method: "POST", body: {} }); notify("Saved to Notes."); onOpenNote?.(saved.id); }} /> setLearningEditor(null)} onSave={saveLearning} material={ learningEditor?.materialId && learningEditor.materialId !== material?.id ? { id: learningEditor.materialId, sources: [] } : material } /> setLearningEditor({ type: "card", data, materialId: data.material_id, }) } onDelete={(c) => removeLearning("card", c)} onSource={(c) => openSearchHit({ ...c, type: c.note_id ? "note" : "origin", item_id: c.note_id, }) } /> setEdit((current) => ({ ...current, [field]: value })) } onSourcesChange={(sources) => setEdit((current) => ({ ...current, sources })) } onSave={saveMaterialEdit} onDelete={removeMaterial} /> { if (!open) setGeneration(null); }} material={generation?.material} selection={generation?.selection} providers={providers} onSettings={onOpenSettings} onApplied={async (updated) => { if (updated.id === activeRef.current) { setMaterial(updated); positionReady.current = true; } else openMaterial(updated); setGeneration(null); await refreshLibrary(); notify("Article saved."); }} /> { setMaterial(updated); await refreshLibrary(); notify("Previous version restored."); }} /> {toast && (
{toast.text} setToast(null)} >
)}
); }