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

366 lines
11 KiB
TypeScript

/**
* SelectionActionBar
*
* Floating toolbar pinned to the top-center of the simulator canvas. Visible
* whenever the user has something selected (wire / component / board). Gives
* touch users a clear way to delete or rotate a selection without keyboard
* shortcuts or right-click — the only way these actions were available before.
*
* Renders nothing when nothing is selected.
*
* Touch propagation note: the simulator canvas binds *native* touch listeners
* with `addEventListener` and calls `preventDefault()` on touchend, which
* suppresses the synthetic `click` event for buttons rendered above the
* canvas. We therefore call `stopPropagation()` on the *native* touchstart
* (React's synthetic stopPropagation does not affect native listeners) and
* also wire each button to fire on `touchend` directly so taps work.
*/
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { WIRE_KEY_COLORS } from '../../utils/wireUtils';
export type SelectionKind = 'wire' | 'component' | 'board';
interface SelectionActionBarProps {
kind: SelectionKind | null;
/** Human label, e.g. "Wire", "Arduino Uno", "LED" */
label: string;
/** Show the rotate button (components only). */
canRotate?: boolean;
onDelete: () => void;
onRotate?: () => void;
onDeselect: () => void;
onColorChange?: (color: string) => void;
currentColor?: string;
}
const ICON_SIZE = 16;
const TrashIcon: React.FC = () => (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
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>
);
const RotateIcon: React.FC = () => (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
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 CloseIcon: React.FC = () => (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
/**
* Bind native touch listeners to a button so taps fire the action even when
* the simulator canvas (with its own native `addEventListener('touchend')`
* + preventDefault) would otherwise swallow the synthetic click.
*
* Strategy: stopPropagation on the native touchstart so the canvas listener
* never sees the event, and call the handler directly on touchend. This
* runs as a layout effect so the handler closure is current after every
* render — no ref-during-render hacks needed.
*/
function useTouchSafeAction(
ref: React.RefObject<HTMLElement | null>,
handler: () => void,
) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const stopNative = (e: Event) => e.stopPropagation();
const onTouchEndNative = (e: TouchEvent) => {
e.stopPropagation();
e.preventDefault();
handler();
};
el.addEventListener('touchstart', stopNative, { passive: false });
el.addEventListener('touchend', onTouchEndNative, { passive: false });
return () => {
el.removeEventListener('touchstart', stopNative);
el.removeEventListener('touchend', onTouchEndNative);
};
}, [ref, handler]);
}
export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
kind,
label,
canRotate,
onDelete,
onRotate,
onDeselect,
onColorChange,
currentColor,
}) => {
const { t } = useTranslation();
const [showPalette, setShowPalette] = React.useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const deleteRef = useRef<HTMLButtonElement | null>(null);
const rotateRef = useRef<HTMLButtonElement | null>(null);
const deselectRef = useRef<HTMLButtonElement | null>(null);
const colorToggleRef = useRef<HTMLButtonElement | null>(null);
// Reset the palette when the selection kind or colour-change handler changes
// (i.e. the user switched from one wire to another, or deselected).
useEffect(() => {
setShowPalette(false);
}, [kind, onColorChange]);
// Stop native touch events from reaching the canvas listener so it can't
// call preventDefault on touchend (which would kill the synthetic click).
useEffect(() => {
const el = containerRef.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);
};
}, [kind]);
useTouchSafeAction(deleteRef, onDelete);
useTouchSafeAction(rotateRef, onRotate ?? noop);
useTouchSafeAction(deselectRef, onDeselect);
useTouchSafeAction(colorToggleRef, () => setShowPalette(!showPalette));
if (!kind) return null;
return (
<div
ref={containerRef}
role="toolbar"
aria-label={t('editor.selectionBar.label')}
className="selection-action-bar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 8,
padding: '6px 8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
zIndex: 50,
fontSize: 13,
color: '#e0e0e0',
pointerEvents: 'auto',
touchAction: 'none',
}}
>
<span
style={{
padding: '0 8px 0 4px',
color: '#bbb',
maxWidth: 180,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{label}
</span>
{kind === 'wire' && onColorChange && (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<button
type="button"
ref={colorToggleRef}
onClick={() => setShowPalette(!showPalette)}
style={{ ...buttonStyle, padding: '6px 8px' }}
title={t('editor.selectionBar.changeColor')}
aria-label={t('editor.selectionBar.changeColor')}
>
<div
style={{
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: currentColor || '#22c55e',
border: '2px solid rgba(255,255,255,0.2)',
}}
/>
</button>
{showPalette && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexWrap: 'wrap',
gap: 8,
padding: '10px',
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
justifyContent: 'center',
width: 240,
zIndex: 101,
}}
>
{Object.values(WIRE_KEY_COLORS).map((color) => (
<ColorButton
key={color}
color={color}
isSelected={color.toLowerCase() === currentColor?.toLowerCase()}
onClick={() => {
onColorChange(color);
setShowPalette(false);
}}
/>
))}
</div>
)}
</div>
)}
{canRotate && onRotate && (
<button
type="button"
ref={rotateRef}
onClick={onRotate}
style={buttonStyle}
title={t('editor.selectionBar.rotate90')}
aria-label={t('editor.selectionBar.rotate')}
>
<RotateIcon />
<span className="selection-action-bar__label" style={buttonLabelStyle}>
{t('editor.selectionBar.rotate')}
</span>
</button>
)}
<button
type="button"
ref={deleteRef}
onClick={onDelete}
style={{ ...buttonStyle, color: '#e06c75' }}
title={t(`editor.selectionBar.deleteKind.${kind}`)}
aria-label={t(`editor.selectionBar.deleteKind.${kind}`)}
>
<TrashIcon />
<span className="selection-action-bar__label" style={buttonLabelStyle}>
{t('editor.selectionBar.delete')}
</span>
</button>
<button
type="button"
ref={deselectRef}
onClick={onDeselect}
style={{ ...buttonStyle, padding: '6px 8px' }}
title={t('editor.selectionBar.deselect')}
aria-label={t('editor.selectionBar.deselect')}
>
<CloseIcon />
</button>
</div>
);
};
const ColorButton: React.FC<{
color: string;
isSelected: boolean;
onClick: () => void;
}> = ({ color, isSelected, onClick }) => {
const ref = useRef<HTMLButtonElement | null>(null);
useTouchSafeAction(ref, onClick);
return (
<button
ref={ref}
type="button"
onClick={onClick}
style={{
width: 22,
height: 22,
borderRadius: '50%',
backgroundColor: color,
border: isSelected ? '2px solid #fff' : '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
boxShadow: isSelected ? '0 0 6px rgba(255,255,255,0.5)' : 'none',
}}
title={color}
/>
);
};
const noop = () => {};
const buttonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 6,
color: 'inherit',
cursor: 'pointer',
fontSize: 13,
lineHeight: 1,
minHeight: 36, // touch-friendly hit target
};
const buttonLabelStyle: React.CSSProperties = {
// Hidden on very narrow toolbars; kept visible by default for clarity
};