feat(simulator): discoverable wire-color UI on desktop

Changing a wire's color on desktop was keyboard-only (select wire + 0-9/c/l/m/p/y)
with no visible control — the floating color palette (SelectionActionBar) only
rendered on touch devices, so desktop users had no way to discover it. Now:
- the wire SelectionActionBar (top-center, with the color palette) also shows on
  desktop when a wire is selected and the sim is stopped (it is pinned top-center
  so it never covers pins); component/board bars stay touch-only.
- right-clicking a wire opens a context menu with the color swatches + delete.
Keyboard shortcuts still work. Mirrors the existing board context-menu pattern.
This commit is contained in:
David Montero 2026-06-19 19:35:46 +02:00
parent 8c32b16589
commit 0c935c5b29
1 changed files with 133 additions and 11 deletions

View File

@ -230,6 +230,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
x: number;
y: number;
} | null>(null);
// Right-click context menu for a wire (color swatches + delete).
const [wireContextMenu, setWireContextMenu] = useState<{
wireId: string;
x: number;
y: number;
} | null>(null);
// Board removal confirmation dialog
const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
// Board Options modal — id of the board whose options are being edited.
@ -2431,7 +2437,23 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}}
onContextMenu={(e) => {
e.preventDefault();
if (wireInProgress) cancelWireCreation();
if (wireInProgress) {
cancelWireCreation();
return;
}
// Right-click on a wire → open its color / delete context menu.
// Right-clicks on a board are handled by the board element's own
// onContextMenu (which stops propagation), so this only fires over
// empty canvas or a wire. Disabled while the simulation runs
// (canvas is interact-only then).
if (interactionRunning) return;
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
if (wire) {
setSelectedWire(wire.id);
setWireContextMenu({ wireId: wire.id, x: e.clientX, y: e.clientY });
}
}}
onClick={(e) => {
if (wireInProgress) {
@ -2592,14 +2614,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
/>
)}
{/* Floating action bar for the current selection primary delete UI
for touch devices (no Delete key, no right-click). Hidden:
- while creating a wire (would fight the wire-mode banner)
- on desktop (delete is bound to the Delete key, rotate is in
the right-click menu the floating bar covered nearby pins
and intercepted clicks on buttons during simulation)
- while the simulator is running (canvas is read-only). */}
{!wireInProgress && isTouchDevice && !interactionRunning &&
{/* Floating action bar (top-center) for the current selection. Hidden
while creating a wire (fights the wire-mode banner) and while the
simulator is running (canvas is read-only).
- WIRE selection shows on BOTH desktop and touch: it carries the
color palette, which is otherwise only reachable on desktop via
the 0-9 / c,l,m,p,y keyboard shortcuts (not discoverable). The
bar is pinned top-center so it never covers pins near the wire.
- COMPONENT selection stays touch-only desktop already has the
Delete key + the right-click rotate menu. */}
{!wireInProgress && !interactionRunning &&
(() => {
if (selectedWireId) {
const wire = wires.find((w) => w.id === selectedWireId);
@ -2613,7 +2637,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
recordUpdateWire(selectedWireId, { color: wire.color }, { color });
}}
onDelete={() => {
// Recorded so the touch / mobile delete is also undoable.
// Recorded so the delete is also undoable.
recordRemoveWire(selectedWireId);
setSelectedWire(null);
}}
@ -2621,7 +2645,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
/>
);
}
if (selectedComponentId) {
if (isTouchDevice && selectedComponentId) {
const c = components.find((x) => x.id === selectedComponentId);
if (!c) return null;
const meta = registry.getById(c.metadataId);
@ -2855,6 +2879,104 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
/>
{/* Board right-click context menu */}
{wireContextMenu &&
(() => {
const wire = wires.find((w) => w.id === wireContextMenu.wireId);
if (!wire) return null;
return (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 9998 }}
onClick={() => setWireContextMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setWireContextMenu(null);
}}
/>
<div
style={{
position: 'fixed',
left: wireContextMenu.x,
top: wireContextMenu.y,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 6,
padding: 8,
zIndex: 9999,
width: 188,
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
fontSize: 13,
}}
>
<div style={{ padding: '2px 4px 8px', color: '#888', fontSize: 11 }}>
{t('editor.selectionBar.changeColor')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.values(WIRE_KEY_COLORS).map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
recordUpdateWire(
wireContextMenu.wireId,
{ color: wire.color },
{ color },
);
setWireContextMenu(null);
}}
style={{
width: 22,
height: 22,
borderRadius: '50%',
backgroundColor: color,
border:
color.toLowerCase() === wire.color?.toLowerCase()
? '2px solid #fff'
: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
/>
))}
</div>
<button
type="button"
onClick={() => {
recordRemoveWire(wireContextMenu.wireId);
setSelectedWire(null);
setWireContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
marginTop: 8,
padding: '7px 6px',
background: 'none',
border: 'none',
borderTop: '1px solid #3c3c3c',
color: '#e06c75',
cursor: 'pointer',
fontSize: 13,
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none';
}}
>
{t('editor.selectionBar.deleteKind.wire')}
</button>
</div>
</>
);
})()}
{boardContextMenu &&
(() => {
const board = boards.find((b) => b.id === boardContextMenu.boardId);