Download frontend/src/components/MarginNote.jsx from kiyer/beacon: direct link, hf CLI and curl.
- Browser
- Download file 5.08 kB
-
https://huggingface.co/spaces/kiyer/beacon/resolve/main/frontend/src/components/MarginNote.jsx
- Command line
-
hf download hf://spaces/kiyer/beacon/frontend/src/components/MarginNote.jsx
-
curl -L -o MarginNote.jsx https://huggingface.co/spaces/kiyer/beacon/resolve/main/frontend/src/components/MarginNote.jsx
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…</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">✦</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> | |
| ); | |
| } | |