velxio/frontend/src/components/simulator/PinPickerDialog.tsx

345 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<PinPickerDialogProps> = ({
targetId,
title,
subtitle,
pins,
onPinSelect,
onClose,
onRotate,
onDelete,
}) => {
const { t } = useTranslation();
const overlayRef = useRef<HTMLDivElement | null>(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 (
<div
ref={overlayRef}
role="dialog"
aria-modal="true"
aria-label={`Pick a pin on ${title}`}
onClick={(e) => {
// 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',
}}
>
<div
style={{
background: '#2d2d2d',
color: '#fff',
border: '1px solid #555',
borderRadius: 12,
width: '100%',
maxWidth: 420,
maxHeight: '90%',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 -8px 24px rgba(0,0,0,0.6)',
overflow: 'hidden',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 14px',
borderBottom: '1px solid #444',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{title}</span>
{subtitle && (
<span style={{ fontSize: 12, color: '#9aa', marginTop: 2 }}>{subtitle}</span>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label={t('editor.pinPicker.close')}
style={{
background: 'transparent',
border: 'none',
color: '#ccc',
cursor: 'pointer',
fontSize: 22,
width: 36,
height: 36,
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
×
</button>
</div>
{/* Filter input — handy when boards have 30+ pins */}
{pins.length > 8 && (
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
<input
type="text"
value={filter}
onChange={(e) => 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,
}}
/>
</div>
)}
{/* Pin list */}
<div
style={{
flex: 1,
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
padding: '4px 8px 8px',
}}
>
{filteredPins.length === 0 ? (
<div style={{ padding: 16, color: '#999', fontSize: 13 }}>{t('editor.pinPicker.noMatch')}</div>
) : (
filteredPins.map((pin) => (
<button
key={pin.name}
type="button"
onClick={() => onPinSelect(targetId, pin.name)}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
textAlign: 'left',
padding: '12px 12px',
margin: '2px 0',
background: '#3d3d3d',
color: '#fff',
border: '1px solid transparent',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
minHeight: 44,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#4a4a4a';
e.currentTarget.style.borderColor = '#007acc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#3d3d3d';
e.currentTarget.style.borderColor = 'transparent';
}}
>
<span style={{ color: '#00d9ff', fontWeight: 600, marginRight: 10 }}>
{pin.name}
</span>
{pin.description && (
<span style={{ color: '#aaa', fontSize: 12, flex: 1, minWidth: 0 }}>
{pin.description}
</span>
)}
<span
aria-hidden="true"
style={{ marginLeft: 'auto', color: '#007acc', fontSize: 16, fontWeight: 700 }}
>
→
</span>
</button>
))
)}
</div>
{/* 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) && (
<div
style={{
display: 'flex',
gap: 8,
padding: '10px 12px',
borderTop: '1px solid #444',
background: '#252525',
flexShrink: 0,
}}
>
{onRotate && (
<button
type="button"
onClick={onRotate}
style={footerButtonStyle}
aria-label={t('editor.pinPicker.rotate')}
>
<RotateGlyph /> {t('editor.pinPicker.rotate')}
</button>
)}
{onDelete && (
<button
type="button"
onClick={onDelete}
style={{
...footerButtonStyle,
marginLeft: onRotate ? 0 : 'auto',
background: '#3a2424',
borderColor: '#5a2c2c',
color: '#e06c75',
}}
aria-label={t('editor.pinPicker.delete')}
>
<TrashGlyph /> {t('editor.pinPicker.delete')}
</button>
)}
</div>
)}
</div>
</div>
);
};
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 = () => (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
);
const TrashGlyph: React.FC = () => (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);