/** * Component Info Panel * * Floating "datasheet" popover shown when the user hovers a card in the * Component Picker. It combines two data sources: * * 1. The already-loaded ComponentMetadata (name, category, pin count, live * default properties, tags) — always available, no network. * 2. An optional hand-authored Markdown datasheet (see `componentDocs.ts` * and `component-docs/`) with the richer prose, pinout, wiring tips, * plus the component's brand and a purchase link. Lazy-loaded + cached. * * The panel is INTERACTIVE: the mouse can move off the card onto the panel to * scroll a long datasheet or click the Buy link without it closing. This is * driven from the modal via a grace-period hide timer — `onPanelEnter` cancels * the pending hide, `onPanelLeave` re-arms it. Rendered through a portal to * so the modal's `overflow` never clips it, and flipped/clamped to stay * inside the viewport. */ import React from 'react'; import { createPortal } from 'react-dom'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { PropertyDescriptor } from '../types/component-metadata'; import { loadDoc, type ComponentDoc } from './componentDocs'; export interface PanelData { id: string; // component / board id — used to look up the Markdown doc name: string; category: string; // already display-formatted (e.g. "Sensors") description?: string; pinCount: number; properties: PropertyDescriptor[]; tags: string[]; thumbnail?: string; pro_only?: boolean; } export interface HoverTarget { data: PanelData; rect: DOMRect; // bounding box of the hovered card, in viewport coords } /** Delay before the panel appears — long enough to not flash on a fly-by. */ export const HOVER_DELAY = 160; // Bulk / opaque properties that are never useful in a datasheet popover // (base64 blobs, embedded source, framebuffers, …). const HIDDEN_PROPS = new Set([ 'imageData', 'wasmBase64', 'sourceC', 'romBytes', 'chipJson', 'programFile', 'programTarget', ]); /** Only allow real web links through to the Buy button (no javascript:, etc.). */ function safeHref(url?: string): string | null { if (!url) return null; return /^https?:\/\//i.test(url) ? url : null; } function formatValue(p: PropertyDescriptor): string { const raw = p.defaultValue; let def = raw === undefined || raw === null || raw === '' ? '' : String(raw); if (def.length > 40) def = def.slice(0, 39) + '…'; if (p.min !== undefined || p.max !== undefined) { const range = `${p.min ?? '?'}–${p.max ?? '?'}`; return def ? `${def} (${range})` : range; } if (p.options && p.options.length) { const opts = p.options.join(' / '); // A long option list would blow out the row — fall back to the default. return opts.length > 44 ? def || String(p.options[0]) : opts; } return def || '—'; } interface ComponentInfoPanelProps { target: HoverTarget; /** Called when the pointer enters the panel — cancels the pending hide. */ onPanelEnter: () => void; /** Called when the pointer leaves the panel — re-arms the hide timer. */ onPanelLeave: () => void; } export const ComponentInfoPanel: React.FC = ({ target, onPanelEnter, onPanelLeave, }) => { const ref = React.useRef(null); const [pos, setPos] = React.useState<{ left: number; top: number } | null>(null); const [doc, setDoc] = React.useState(null); const { data, rect } = target; // Pull the authored Markdown datasheet (if any) for this id. React.useEffect(() => { let cancelled = false; setDoc(null); loadDoc(data.id).then((d) => { if (!cancelled) setDoc(d); }); return () => { cancelled = true; }; }, [data.id]); // The panel is portaled to , OUTSIDE the React root container, so // React's synthetic onMouseEnter/onMouseLeave never fire on it (React binds // event delegation to the root). Attach NATIVE listeners on the node itself // so the "keep the panel open while the pointer is over it" bridge works. // Handlers are read through refs so the listeners bind once per mount. const enterRef = React.useRef(onPanelEnter); const leaveRef = React.useRef(onPanelLeave); enterRef.current = onPanelEnter; leaveRef.current = onPanelLeave; React.useEffect(() => { const el = ref.current; if (!el) return; const onEnter = () => enterRef.current(); const onLeave = () => leaveRef.current(); el.addEventListener('mouseenter', onEnter); el.addEventListener('mouseleave', onLeave); return () => { el.removeEventListener('mouseenter', onEnter); el.removeEventListener('mouseleave', onLeave); }; }, []); // Measure the rendered panel and flip/clamp it into the viewport. Runs // before paint so there is no visible jump from the fallback position, and // re-runs when the doc loads (which changes the panel's height). React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const margin = 12; const gap = 12; const w = el.offsetWidth; const h = el.offsetHeight; const vw = window.innerWidth; const vh = window.innerHeight; const fitsRight = rect.right + gap + w <= vw - margin; const fitsLeft = rect.left - gap - w >= margin; let left: number; let top: number; if (fitsRight || fitsLeft) { // Side placement (preferred): never overlaps the card horizontally. left = fitsRight ? rect.right + gap : rect.left - w - gap; top = Math.max(margin, Math.min(rect.top, vh - h - margin)); } else { // Neither side fits (narrow viewport / zoom). Dock below the card — or // above if there is no room — so the panel never covers its own trigger // and block the add-click. left = Math.max(margin, Math.min(rect.left, vw - w - margin)); const below = rect.bottom + gap; top = below + h <= vh - margin ? below : Math.max(margin, rect.top - gap - h); } setPos({ left, top }); }, [rect, doc]); const svgThumb = data.thumbnail && data.thumbnail.trim().startsWith(' !HIDDEN_PROPS.has(p.name)); const shownProps = visibleProps.slice(0, 8); const hiddenCount = visibleProps.length - shownProps.length; const brand = doc?.brand; const buyHref = safeHref(doc?.buy); return createPortal(
e.stopPropagation()} style={{ left: pos?.left ?? rect.right + 12, top: pos?.top ?? rect.top, opacity: pos ? 1 : 0, }} >
{svgThumb && (
)}
{data.name} {data.category} {data.pro_only && PRO} {data.pinCount > 0 && {data.pinCount} pins} {brand && by {brand}}
{/* Authored datasheet supersedes the thin auto-generated description. */} {doc?.body ? (
{doc.body}
) : ( data.description &&

{data.description}

)} {shownProps.length > 0 && (
Properties
{shownProps.map((p) => (
{p.name} {formatValue(p)}
))}
{hiddenCount > 0 &&
+{hiddenCount} more
}
)} {data.tags && data.tags.length > 0 && (
{data.tags.slice(0, 6).map((t) => ( {t} ))}
)} {buyHref && ( )}
, document.body, ); };