feat(ui): pins always on in the inspector, above the chat, and traceable

Three things from using it:

- The pin map is why the dialog gets opened, so hiding it behind a checkbox
  meant every visit started with a click. Pins are always drawn now and the
  toggle is gone (with its translation key).
- The dialog sat at z-index 1300 and the pro agent chat panel docks over the
  canvas at 8000, so on a wide layout the dialog opened UNDERNEATH the chat -
  invisible, on the very area the chat covers. It now sits at 8500: above the
  chat, still below the component picker.
- On a 32-pin board a name in the gutter could not be matched to a pad by
  eye. Hovering (or focusing) either the label or the dot now lights the
  whole run - label, leader line and pad together.
This commit is contained in:
David Montero Crespo 2026-08-04 02:43:53 +02:00
parent 34faca355e
commit 4f68109421
11 changed files with 74 additions and 75 deletions

View File

@ -15,9 +15,11 @@
display: flex;
flex-direction: column;
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55);
/* Above every canvas overlay (wire menus sit at ~1001); below the picker
modal stack (9000+), which is never open at the same time anyway. */
z-index: 1300;
/* Above every canvas overlay (wire menus ~1001) AND above the pro agent
chat panel (8000/8001), which docks over the canvas — the dialog opens
from a right-click on the very thing the chat covers, so it has to win.
Still below the component picker (9000+), which is never open with it. */
z-index: 8500;
pointer-events: all;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
@ -200,6 +202,14 @@
stroke: #777;
stroke-width: 1;
stroke-dasharray: 2 2;
transition: stroke 0.1s;
}
/* Tracing a name back to its pad: the whole run lights at once. */
.pid-pin-leader--lit line {
stroke: #4dd0e1;
stroke-width: 1.6;
stroke-dasharray: none;
}
.pid-pin-dot {
@ -219,9 +229,11 @@
cursor: default;
}
.pid-pin-dot:hover:not(:disabled) {
transform: scale(1.5);
.pid-pin-dot:hover:not(:disabled),
.pid-pin-dot--lit {
transform: scale(1.6);
background: #4dd0e1;
box-shadow: 0 0 6px rgba(77, 208, 225, 0.9);
}
/* Signal tinting — the same data the wire classifier reads. */
@ -252,8 +264,10 @@
cursor: default;
}
.pid-pin-label:hover:not(:disabled) {
.pid-pin-label:hover:not(:disabled),
.pid-pin-label--lit {
color: #4dd0e1;
font-weight: 700;
}
.pid-pin-label--left {
@ -276,19 +290,10 @@
transform: translate(-50%, 0);
}
.pid-show-pins {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #ccc;
cursor: pointer;
user-select: none;
}
.pid-wire-hint {
color: #4dd0e1;
.pid-pin-hint {
font-size: 11px;
color: #999;
text-align: center;
}
/* ── Actions ─────────────────────────────────────────────────────────── */

View File

@ -8,10 +8,11 @@
* Datasheet tabs on the right.
*
* The preview is the REAL web component, mounted and scaled — not a drawing.
* With "Show pins" enabled (off by default), pin markers are laid over it at
* their true positions and labels flank the art on the side each pin lives on
* (pinInspectorLayout). Everything is driven by the element's own pinInfo and
* measured size, so any part present or future works with zero per-part data.
* Pin markers are laid over it at their true positions and their labels flank
* the art on the side each pin lives on (pinInspectorLayout); hovering a name
* lights its leader line and its pad so the two can be traced to each other.
* Everything is driven by the element's own pinInfo and measured size, so any
* part present or future works with zero per-part data.
*
* Clicking a pin keeps the exact contract of the old dialog: onPinSelect ->
* the canvas starts (or finishes) a wire through handlePinClick, with its
@ -99,12 +100,9 @@ interface PartInspectorDialogProps {
previewAttributes?: Record<string, string>;
}
const PREVIEW_ART = 150; // collapsed preview box (the part's own art)
/**
* Pin-map box. Smaller than the collapsed preview on purpose: in this mode the
* part's art is HIDDEN (it ate the dialog for no information) and only the pin
* dots, their labels and a faint outline of the body are drawn, so the spatial
* arrangement still reads while the panel stays compact.
* The art box the part is scaled into. The pin gutters are added around it, so
* the dialog's left column ends up a little wider than this.
*/
const PINS_ART = 190;
@ -131,10 +129,9 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
const previewHostRef = useRef<HTMLDivElement>(null);
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
const [tab, setTab] = useState<'properties' | 'datasheet'>('properties');
// Pins are HIDDEN by default (explicit product decision): most opens are
// for a property tweak or a delete, and the quiet preview keeps the dialog
// compact until pins are actually wanted.
const [showPins, setShowPins] = useState(false);
// The pin the pointer is on — its leader line and dot light up together, so
// a name in the gutter can be traced to the pad it belongs to.
const [hoverPin, setHoverPin] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [layout, setLayout] = useState<PinLayoutResult | null>(null);
const [previewFailed, setPreviewFailed] = useState(false);
@ -247,7 +244,7 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
return;
}
const pins: InspectorPinInput[] = (el.pinInfo && el.pinInfo.length ? el.pinInfo : pinInfo) ?? [];
const art = showPins ? PINS_ART : PREVIEW_ART;
const art = PINS_ART;
const result = layoutInspectorPins(pins, { width: w, height: h }, {
maxArtWidth: art,
maxArtHeight: art,
@ -270,7 +267,7 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
// (a colour edit, an LED flip) changes the digest and repaints; a bare
// re-render does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentMetadata, propsKey, pinsKey, showPins, previewTagName]);
}, [componentMetadata, propsKey, pinsKey, previewTagName]);
// ── Position: viewport coords, clamped, portaled to body ─────────────────
// Re-run on every SIZE change, not just on the inputs we can name. The
@ -391,12 +388,12 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
{/* Left column: live preview, pin toggle, actions. */}
<div className="pid-left">
<div
className={`pid-preview${showPins ? ' pid-preview--pins' : ''}`}
className="pid-preview pid-preview--pins"
style={
layout
? {
width: layout.artWidth + (showPins ? layout.padLeft + layout.padRight : 0),
height: layout.artHeight + (showPins ? layout.padTop + layout.padBottom : 0),
width: layout.artWidth + layout.padLeft + layout.padRight,
height: layout.artHeight + layout.padTop + layout.padBottom,
}
: undefined
}
@ -404,11 +401,7 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
<div
className="pid-preview-art"
ref={previewHostRef}
style={
layout && showPins
? { left: layout.padLeft, top: layout.padTop }
: undefined
}
style={layout ? { left: layout.padLeft, top: layout.padTop } : undefined}
/>
{previewFailed && !svgThumb && <div className="pid-preview-missing">—</div>}
{previewFailed && svgThumb && (
@ -416,17 +409,29 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
)}
{/* Pin markers + labels, at their true positions. */}
{showPins &&
layout &&
{layout &&
layout.pins.map((p) => {
const ox = layout.padLeft;
const oy = layout.padTop;
const clickable = Boolean(onPinSelect);
const activate = () => onPinSelect?.(componentId, p.name);
const lit = hoverPin === p.name;
// Hovering EITHER the label or the dot lights the whole run:
// label, leader and pad. On a 32-pin board that is the only
// way to be sure which pad a gutter name belongs to.
const trace = {
onMouseEnter: () => setHoverPin(p.name),
onMouseLeave: () => setHoverPin((c) => (c === p.name ? null : c)),
onFocus: () => setHoverPin(p.name),
onBlur: () => setHoverPin((c) => (c === p.name ? null : c)),
};
return (
<React.Fragment key={p.name}>
{p.needsLeader && (
<svg className="pid-pin-leader" aria-hidden="true">
<svg
className={`pid-pin-leader${lit ? ' pid-pin-leader--lit' : ''}`}
aria-hidden="true"
>
<line
x1={ox + p.dotX}
y1={oy + p.dotY}
@ -437,19 +442,21 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
)}
<button
type="button"
className={`pid-pin-dot pid-sig-${p.signalKind}`}
className={`pid-pin-dot pid-sig-${p.signalKind}${lit ? ' pid-pin-dot--lit' : ''}`}
style={{ left: ox + p.dotX, top: oy + p.dotY }}
title={pinTitle(p.name, pinInfo.find((q) => q.name === p.name)?.description)}
onClick={clickable ? activate : undefined}
disabled={!clickable}
{...trace}
/>
<button
type="button"
className={`pid-pin-label pid-pin-label--${p.edge}`}
className={`pid-pin-label pid-pin-label--${p.edge}${lit ? ' pid-pin-label--lit' : ''}`}
style={{ left: ox + p.labelX, top: oy + p.labelY }}
title={pinTitle(p.name, pinInfo.find((q) => q.name === p.name)?.description)}
onClick={clickable ? activate : undefined}
disabled={!clickable}
{...trace}
>
{p.name}
</button>
@ -459,17 +466,13 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
</div>
{pinInfo.length > 0 && (
<label className="pid-show-pins">
<input
type="checkbox"
checked={showPins}
onChange={(e) => setShowPins(e.target.checked)}
/>
{t('editor.inspector.showPins')}
{wireInProgress && showPins && (
<span className="pid-wire-hint">{t('editor.componentProps.tapToConnect')}</span>
)}
</label>
<div className="pid-pin-hint">
{wireInProgress
? t('editor.componentProps.tapToConnect')
: onPinSelect
? t('editor.componentProps.tapToWire')
: t('editor.componentProps.pinRoles')}
</div>
)}
<div className="pid-actions">

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "Eigenschaften",
"datasheetTab": "Datenblatt",
"showPins": "Pins anzeigen"
"datasheetTab": "Datenblatt"
}
},
"about": {

View File

@ -393,8 +393,7 @@
},
"inspector": {
"propertiesTab": "Properties",
"datasheetTab": "Datasheet",
"showPins": "Show pins"
"datasheetTab": "Datasheet"
}
},
"about": {

View File

@ -393,8 +393,7 @@
},
"inspector": {
"propertiesTab": "Propiedades",
"datasheetTab": "Ficha técnica",
"showPins": "Ver pines"
"datasheetTab": "Ficha técnica"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "Propriétés",
"datasheetTab": "Fiche technique",
"showPins": "Afficher les broches"
"datasheetTab": "Fiche technique"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "Proprietà",
"datasheetTab": "Scheda tecnica",
"showPins": "Mostra i pin"
"datasheetTab": "Scheda tecnica"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "プロパティ",
"datasheetTab": "データシート",
"showPins": "ピンを表示"
"datasheetTab": "データシート"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "Propriedades",
"datasheetTab": "Ficha técnica",
"showPins": "Mostrar pinos"
"datasheetTab": "Ficha técnica"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "Свойства",
"datasheetTab": "Спецификация",
"showPins": "Показать выводы"
"datasheetTab": "Спецификация"
}
},
"about": {

View File

@ -391,8 +391,7 @@
},
"inspector": {
"propertiesTab": "属性",
"datasheetTab": "数据手册",
"showPins": "显示引脚"
"datasheetTab": "数据手册"
}
},
"about": {