272 lines
9.3 KiB
TypeScript
272 lines
9.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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,
|
|||
|
|
);
|
|||
|
|
};
|