velxio/frontend/src/components/ComponentInfoPanel.tsx

318 lines
11 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 { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { PropertyDescriptor } from '../types/component-metadata';
import { loadDoc, productPageHref, type ComponentDoc } from './componentDocs';
import { trackProductPageClick } from '../utils/analytics';
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
import './ComponentInfoPanel.css';
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, …).
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
export const HIDDEN_PROPS = new Set([
'imageData',
'wasmBase64',
'sourceC',
'romBytes',
'chipJson',
'programFile',
'programTarget',
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
// The Part Inspector reuses this list; sdFiles is a base64 payload too.
'sdFiles',
]);
/** Only allow real web links through to the Buy button (no javascript:, etc.). */
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
export function safeHref(url?: string): string | null {
if (!url) return null;
return /^https?:\/\//i.test(url) ? url : null;
}
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
export 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 || '—';
}
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
/**
* Load the authored Markdown datasheet (if any) for a component/board id.
* Thin hook over `loadDoc`'s cache so both the hover panel and the Part
* Inspector dialog share one code path (and one cache) for docs.
*/
export function useComponentDoc(id: string): ComponentDoc | null {
const [doc, setDoc] = React.useState<ComponentDoc | null>(null);
React.useEffect(() => {
let cancelled = false;
setDoc(null);
loadDoc(id).then((d) => {
if (!cancelled) setDoc(d);
});
return () => {
cancelled = true;
};
}, [id]);
return doc;
}
/**
* The panel's CONTENT, with no portal, no positioning and no header: the doc
* markdown (or the metadata description as fallback), the default-property
* rows, the tag chips and the Buy footer. The hover panel wraps it; the Part
* Inspector dialog embeds it as its Datasheet tab.
*/
export const ComponentInfoBody: React.FC<{
data: PanelData;
/** false = doc/description + tags + Buy only (the dialog has its own props UI). */
showProps?: boolean;
}> = ({ data, showProps = true }) => {
const { t: translate } = useTranslation();
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
const doc = useComponentDoc(data.id);
const visibleProps = data.properties.filter((p) => !HIDDEN_PROPS.has(p.name));
const shownProps = visibleProps.slice(0, 8);
const hiddenCount = visibleProps.length - shownProps.length;
const buyHref = safeHref(doc?.buy);
return (
<>
{/* Authored datasheet supersedes the thin auto-generated description.
91 of the 156 catalogue parts have NEITHER, so say so rather than
leaving the reader looking at an empty panel wondering if it broke. */}
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
{doc?.body ? (
<div className="cip-doc">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{doc.body}</ReactMarkdown>
</div>
) : data.description ? (
<p className="cip-desc">{data.description}</p>
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
) : (
<p className="cip-desc cip-desc--empty">
No datasheet written for this part yet.
{data.pinCount > 0 && ' Its pins are listed on the left.'}
</p>
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
)}
{showProps && 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={productPageHref(buyHref, data.id)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackProductPageClick(data.id, doc?.brand, buyHref)}
>
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
</svg>
{translate('editor.inspector.productPage')}
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
</a>
</div>
)}
</>
);
};
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 { data, rect } = target;
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
// The doc is loaded here TOO (same cached loadDoc as ComponentInfoBody):
// the panel needs it for the brand line in its header, and as a dependency
// of the measure effect below — the doc arriving changes the panel height.
const doc = useComponentDoc(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 brand = doc?.brand;
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>
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
<ComponentInfoBody data={data} />
</div>,
document.body,
);
};