beacon / frontend /src /components /MarginNote.jsx
kiyer's picture
fix: timer cleanup on unmount in MarginNote/NotebookDrawer; wire live userNotes/paperNote into onSaveSession
4894c4f
Raw
History Blame Contribute Delete
5.08 kB
import React from 'react';
import { MODES, modeById } from '../lib/modes.js';
// NoteBody — shared note-body state renderer (loading / streaming / error / done).
// Extracted so FigurePopover can reuse the exact same state logic without copy-paste.
// Props match the shape MarginNote already uses internally.
// entry — commentary entry or undefined
// generating — boolean
// onRegenerate — () => void
export function NoteBody({ entry, generating, onRegenerate }) {
if (generating && !entry?.text) {
return <div className="ap-note-loading">consulting the literature<span className="ap-ellipsis"></span></div>;
}
if (entry && entry.streaming && entry.text) {
return <div className="ap-note-text">{entry.text}</div>;
}
if (entry && entry.error) {
return (
<div className="ap-note-error">
{entry.message ? entry.message + ' — ' : 'the annotator did not answer. '}
<button className="ap-textbtn" onClick={onRegenerate}>try again</button>
</div>
);
}
if (entry) {
return <div className="ap-note-text">{entry.text}</div>;
}
return <div className="ap-note-loading">awaiting annotation&hellip;</div>;
}
// MarginNote — a single margin annotation card.
// Differences from prototype:
// 1. lit lookup comes through props (litByPara, litPapers) instead of window.ASTROPARSE_DATA.
// 2. modeById imported from lib/modes.js instead of global.
// Everything else (classNames, DOM structure, hover/mode/regen handlers) is identical.
export default function MarginNote({ para, mode, entry, generating, hovered, onHover, onSetMode, onRegenerate, onCite, top, noteRef, litByPara, litPapers, litCount, userNote, onSaveNote }) {
const m = modeById[mode];
const lit = (litByPara[para.id] || []).slice(0, litCount ?? 3).map((id) => litPapers[id]).filter(Boolean);
const [noteOpen, setNoteOpen] = React.useState(false);
const [draftText, setDraftText] = React.useState(userNote?.text || '');
const timerRef = React.useRef(null);
React.useEffect(() => { setDraftText(userNote?.text || ''); }, [userNote?.text]);
React.useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []);
const handleNoteChange = (e) => {
const val = e.target.value;
setDraftText(val);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
onSaveNote && onSaveNote(para.id, val);
}, 400);
};
const handleNoteBlur = () => {
if (timerRef.current) clearTimeout(timerRef.current);
onSaveNote && onSaveNote(para.id, draftText);
if (!draftText.trim()) setNoteOpen(false);
};
return (
<aside
ref={noteRef}
className={'ap-note' + (hovered ? ' is-hovered' : '')}
style={{ top: top, '--mode-c': 'var(--mc-' + mode + ')' }}
onMouseEnter={() => onHover(para.id)}
onMouseLeave={() => onHover(null)}
data-comment-anchor={'note-' + para.id}
>
<div className="ap-note-head">
<span className="ap-note-mode">{m.label}</span>
{entry && entry.source === 'llm' && !generating && <span className="ap-note-gen" title="generated by the annotator">&#10022;</span>}
{entry && !generating && (
<button className="ap-note-regen" title="regenerate this note" onClick={() => onRegenerate(para.id, mode)}>&#8635;</button>
)}
<button
className={'ap-note-write' + (noteOpen || userNote?.text ? ' is-active' : '')}
title="write a note"
onClick={() => { setNoteOpen(true); }}
>&#x270E;</button>
</div>
<NoteBody entry={entry} generating={generating} onRegenerate={() => onRegenerate(para.id, mode)} />
{lit.length > 0 && (
<div className="ap-note-cites">
{lit.map((l, i) => (
<button key={l.id} className="ap-cite" onClick={(e) => onCite(l, e)}>
<span className="ap-cite-n">{i + 1}</span> {l.short}
</button>
))}
</div>
)}
<div className="ap-note-modes" role="group" aria-label="commentary mode">
{MODES.map((mm) => (
<button
key={mm.id}
className={'ap-mode-dot' + (mm.id === mode ? ' is-active' : '')}
style={{ '--mode-c': 'var(--mc-' + mm.id + ')' }}
title={mm.label}
onClick={() => onSetMode(para.id, mm.id)}
>{mm.glyph}</button>
))}
</div>
{(noteOpen || userNote?.text) && (
<div className="ap-user-note">
<span className="ap-user-note-mark">&#x270E;</span>
<textarea
className="ap-user-note-text"
value={draftText}
placeholder="your notes…"
onChange={handleNoteChange}
onBlur={handleNoteBlur}
autoFocus={noteOpen && !userNote?.text}
rows={1}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
/>
</div>
)}
</aside>
);
}