velxio/frontend/src/components/ComponentInfoPanel.tsx

272 lines
9.3 KiB
TypeScript
Raw Normal View History

/**
* 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
* <body> 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<ComponentInfoPanelProps> = ({
target,
onPanelEnter,
onPanelLeave,
}) => {
const ref = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState<{ left: number; top: number } | null>(null);
const [doc, setDoc] = React.useState<ComponentDoc | null>(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 <body>, 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('<svg') ? data.thumbnail : null;
const visibleProps = data.properties.filter((p) => !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(
<div
ref={ref}
className="component-info-panel"
// Clicks inside the panel (Buy link, text selection) must not bubble
// through the React portal to the overlay's onClose and shut the picker.
onClick={(e) => e.stopPropagation()}
style={{
left: pos?.left ?? rect.right + 12,
top: pos?.top ?? rect.top,
opacity: pos ? 1 : 0,
}}
>
<div className="cip-header">
{svgThumb && (
<div className="cip-thumb" dangerouslySetInnerHTML={{ __html: svgThumb }} />
)}
<div className="cip-title">
<span className="cip-name">{data.name}</span>
<span className="cip-badges">
<span className="cip-cat">{data.category}</span>
{data.pro_only && <span className="cip-pro">PRO</span>}
{data.pinCount > 0 && <span className="cip-pins">{data.pinCount} pins</span>}
</span>
{brand && <span className="cip-brand">by {brand}</span>}
</div>
</div>
{/* Authored datasheet supersedes the thin auto-generated description. */}
{doc?.body ? (
<div className="cip-doc">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{doc.body}</ReactMarkdown>
</div>
) : (
data.description && <p className="cip-desc">{data.description}</p>
)}
{shownProps.length > 0 && (
<div className="cip-props">
<div className="cip-section-title">Properties</div>
<div className="cip-prop-list">
{shownProps.map((p) => (
<div className="cip-prop-row" key={p.name}>
<span className="cip-prop-name">{p.name}</span>
<span className="cip-prop-val">{formatValue(p)}</span>
</div>
))}
</div>
{hiddenCount > 0 && <div className="cip-more">+{hiddenCount} more</div>}
</div>
)}
{data.tags && data.tags.length > 0 && (
<div className="cip-tags">
{data.tags.slice(0, 6).map((t) => (
<span className="cip-tag" key={t}>
{t}
</span>
))}
</div>
)}
{buyHref && (
<div className="cip-footer">
<a
className="cip-buy"
href={buyHref}
target="_blank"
rel="noopener noreferrer"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
Buy
</a>
</div>
)}
</div>,
document.body,
);
};