/** * PinPickerDialog * * Touch-friendly modal for picking a pin from a component or board. Shown * primarily on mobile when the user can't reliably hit a 12px pin overlay. * * Used in two flows: * 1. Wire in progress + user taps a component or board body → this dialog * lists every pin so they can finish the wire by tapping a name. * 2. (Future) Long-press on a board → start a wire from a chosen pin. * * Touch propagation: same trick as SelectionActionBar / WireModeBanner — bind * native listeners to stop the canvas's own touch handler from running * preventDefault and swallowing our synthetic clicks. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; interface PinInfoLike { name: string; x: number; y: number; description?: string; } interface PinPickerDialogProps { /** Component or board ID — used by the parent to compute world-space pin coords. */ targetId: string; /** Display label, e.g. "Arduino Uno" or "7 Segment". */ title: string; /** Subtitle hint, e.g. "Tap a pin to connect". */ subtitle?: string; /** Pin list to render. */ pins: PinInfoLike[]; onPinSelect: (targetId: string, pinName: string) => void; onClose: () => void; /** Optional rotate action — only meaningful for components. */ onRotate?: () => void; /** Optional delete action — closes the dialog and removes the target. */ onDelete?: () => void; } export const PinPickerDialog: React.FC = ({ targetId, title, subtitle, pins, onPinSelect, onClose, onRotate, onDelete, }) => { const { t } = useTranslation(); const overlayRef = useRef(null); const [filter, setFilter] = useState(''); // Stop native touch events from reaching the simulator canvas listener. useEffect(() => { const el = overlayRef.current; if (!el) return; const stop = (e: Event) => e.stopPropagation(); el.addEventListener('touchstart', stop, { passive: false }); el.addEventListener('touchmove', stop, { passive: false }); el.addEventListener('touchend', stop, { passive: false }); el.addEventListener('mousedown', stop); return () => { el.removeEventListener('touchstart', stop); el.removeEventListener('touchmove', stop); el.removeEventListener('touchend', stop); el.removeEventListener('mousedown', stop); }; }, []); const filteredPins = useMemo(() => { const q = filter.trim().toLowerCase(); if (!q) return pins; return pins.filter( (p) => p.name.toLowerCase().includes(q) || (p.description && p.description.toLowerCase().includes(q)), ); }, [pins, filter]); return (
{ // Click on backdrop (not the panel itself) closes the dialog. if (e.target === overlayRef.current) onClose(); }} style={{ position: 'absolute', inset: 0, background: 'rgba(0, 0, 0, 0.5)', zIndex: 1100, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', padding: 8, touchAction: 'none', }} >
{/* Header */}
{title} {subtitle && ( {subtitle} )}
{/* Filter input — handy when boards have 30+ pins */} {pins.length > 8 && (
setFilter(e.target.value)} placeholder={t('editor.pinPicker.filterPins')} autoFocus={false} style={{ width: '100%', boxSizing: 'border-box', padding: '8px 10px', background: '#1f1f1f', color: '#fff', border: '1px solid #555', borderRadius: 6, fontSize: 14, }} />
)} {/* Pin list */}
{filteredPins.length === 0 ? (
{t('editor.pinPicker.noMatch')}
) : ( filteredPins.map((pin) => ( )) )}
{/* Action footer — Rotate / Delete pinned at the bottom of the sheet so they're always reachable, mirroring the property dialog. Only rendered when the parent passes the handlers. */} {(onRotate || onDelete) && (
{onRotate && ( )} {onDelete && ( )}
)}
); }; const footerButtonStyle: React.CSSProperties = { flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '10px 12px', background: '#3d3d3d', color: '#fff', border: '1px solid #555', borderRadius: 6, cursor: 'pointer', fontSize: 14, minHeight: 44, }; const RotateGlyph: React.FC = () => ( ); const TrashGlyph: React.FC = () => ( );