| import React from 'react'; |
| import { MODES, modeById } from '../lib/modes.js'; |
|
|
| |
| |
| |
| |
| |
| |
| 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…</div>; |
| } |
|
|
| |
| |
| |
| |
| |
| 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">✦</span>} |
| {entry && !generating && ( |
| <button className="ap-note-regen" title="regenerate this note" onClick={() => onRegenerate(para.id, mode)}>↻</button> |
| )} |
| <button |
| className={'ap-note-write' + (noteOpen || userNote?.text ? ' is-active' : '')} |
| title="write a note" |
| onClick={() => { setNoteOpen(true); }} |
| >✎</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">✎</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> |
| ); |
| } |
|
|