beacon / frontend /src /components /FigurePopover.jsx
kiyer's picture
fix: scrollable popovers; default annotation mode is historical context
ebbff43
Raw
History Blame Contribute Delete
4.08 kB
import React from 'react';
import { MODES } from '../lib/modes.js';
import { NoteBody } from './MarginNote.jsx';
// FigurePopover β€” popover for a figure mention click.
// Chrome cloned from CitePopover: fixed position near click, viewport clamping,
// flip above when near bottom, backdrop click + Esc + Γ— close.
// Width: min(480px, 92vw).
//
// Props:
// fig β€” { id, label, caption, page, hasImage }
// n β€” figure number string (from the mention, e.g. "2")
// pdfHash β€” sha256 hex string for image URL construction
// pos β€” { x, y } click position
// commentary β€” full commentary map from App
// generating β€” full generating map from App
// onGenerate β€” (figId, modeId) => void
// onClose β€” () => void
export default function FigurePopover({ fig, n, pdfHash, pos, commentary, generating, onGenerate, onClose }) {
const ref = React.useRef(null);
const [mode, setMode] = React.useState('history');
const [imgError, setImgError] = React.useState(false);
const key = `${fig.id}:${mode}`;
const entry = commentary[key];
const isGenerating = !!generating[key];
React.useLayoutEffect(() => {
const el = ref.current; if (!el) return;
const r = el.getBoundingClientRect();
let x = pos.x, y = pos.y + 10;
if (x + r.width > window.innerWidth - 16) x = window.innerWidth - r.width - 16;
if (y + r.height > window.innerHeight - 16) y = pos.y - r.height - 10;
el.style.left = Math.max(8, x) + 'px';
el.style.top = Math.max(8, y) + 'px';
el.style.opacity = 1;
}, [fig, pos]);
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
function handleDotClick(modeId) {
setMode(modeId);
if (!commentary[`${fig.id}:${modeId}`]) {
onGenerate(fig.id, modeId);
}
}
function handleRegenerate() {
onGenerate(fig.id, mode);
}
const imgSrc = `/api/figures/${pdfHash}/${fig.id}.png`;
return (
<div>
<div className="ap-pop-backdrop" onClick={onClose}></div>
<div className="ap-pop ap-figure-popover" ref={ref} style={{ opacity: 0 }}>
<div className="ap-pop-rank">plate &middot; figure {n}</div>
{fig.hasImage && !imgError && (
<img
src={imgSrc}
alt={fig.label}
style={{ maxWidth: '100%', display: 'block', marginBottom: '10px' }}
onError={() => setImgError(true)}
/>
)}
<div className="ap-pop-abstract">{fig.caption}</div>
<div className="ap-pop-links" style={{ borderTop: '1px solid color-mix(in oklab, var(--ink) 12%, var(--paper))', marginTop: '12px', paddingTop: '10px' }}></div>
<div className="ap-note-head" style={{ marginTop: '2px' }}>
<span className="ap-note-mode" style={{ '--mode-c': 'var(--mc-' + mode + ')' }}>
{MODES.find((m) => m.id === mode)?.label}
</span>
{entry && entry.source === 'llm' && !isGenerating && (
<span className="ap-note-gen" title="generated by the annotator">&#10022;</span>
)}
{entry && !isGenerating && (
<button className="ap-note-regen" title="regenerate this note" onClick={handleRegenerate}>&#8635;</button>
)}
</div>
<NoteBody entry={entry} generating={isGenerating} onRegenerate={handleRegenerate} />
<div className="ap-note-modes" role="group" aria-label="commentary mode" style={{ marginTop: '9px' }}>
{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={() => handleDotClick(mm.id)}
>{mm.glyph}</button>
))}
</div>
<button className="ap-pop-close" onClick={onClose} title="close">&times;</button>
</div>
</div>
);
}