feat(simulator): wire color palette from PR #170 + Copilot suggestions

- Add color picker button to SelectionActionBar for wire selections
- Toggle palette using WIRE_KEY_COLORS swatches
- Pass currentColor and onColorChange from SimulatorCanvas
- Reset showPalette on kind/onColorChange change (Copilot suggestion)
- Use t('editor.selectionBar.changeColor') for title/aria-label (Copilot suggestion)
- Add changeColor i18n key to all 9 locale files

Co-authored-by: naweiss <naweiss@users.noreply.github.com>
This commit is contained in:
David Montero Crespo 2026-05-11 12:14:36 -03:00
parent 861e22572e
commit 8de51da5a5
11 changed files with 118 additions and 0 deletions

View File

@ -18,6 +18,7 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { WIRE_KEY_COLORS } from '../../utils/wireUtils';
export type SelectionKind = 'wire' | 'component' | 'board';
@ -30,6 +31,8 @@ interface SelectionActionBarProps {
onDelete: () => void;
onRotate?: () => void;
onDeselect: () => void;
onColorChange?: (color: string) => void;
currentColor?: string;
}
const ICON_SIZE = 16;
@ -126,12 +129,22 @@ export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
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).
@ -154,6 +167,7 @@ export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
useTouchSafeAction(deleteRef, onDelete);
useTouchSafeAction(rotateRef, onRotate ?? noop);
useTouchSafeAction(deselectRef, onDeselect);
useTouchSafeAction(colorToggleRef, () => setShowPalette(!showPalette));
if (!kind) return null;
@ -163,6 +177,8 @@ export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
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,
@ -196,6 +212,64 @@ export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
{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"
@ -240,6 +314,35 @@ export const SelectionActionBar: React.FC<SelectionActionBarProps> = ({
);
};
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 = {

View File

@ -2344,10 +2344,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
{!wireInProgress &&
(() => {
if (selectedWireId) {
const wire = wires.find((w) => w.id === selectedWireId);
return (
<SelectionActionBar
kind="wire"
label="Wire"
currentColor={wire?.color}
onColorChange={(color) => {
if (!wire) return;
recordUpdateWire(selectedWireId, { color: wire.color }, { color });
}}
onDelete={() => {
// Recorded so the touch / mobile delete is also undoable.
recordRemoveWire(selectedWireId);

View File

@ -234,6 +234,7 @@
"rotate": "Drehen",
"delete": "Löschen",
"deselect": "Auswahl aufheben",
"changeColor": "Farbe ändern",
"deleteKind": {
"wire": "Leitung löschen",
"component": "Komponente löschen",

View File

@ -234,6 +234,7 @@
"rotate": "Rotate",
"delete": "Delete",
"deselect": "Deselect",
"changeColor": "Change color",
"deleteKind": {
"wire": "Delete wire",
"component": "Delete component",

View File

@ -234,6 +234,7 @@
"rotate": "Rotar",
"delete": "Eliminar",
"deselect": "Deseleccionar",
"changeColor": "Cambiar color",
"deleteKind": {
"wire": "Eliminar cable",
"component": "Eliminar componente",

View File

@ -234,6 +234,7 @@
"rotate": "Rotation",
"delete": "Supprimer",
"deselect": "Désélectionner",
"changeColor": "Changer la couleur",
"deleteKind": {
"wire": "Supprimer le fil",
"component": "Supprimer le composant",

View File

@ -234,6 +234,7 @@
"rotate": "Ruota",
"delete": "Elimina",
"deselect": "Deseleziona",
"changeColor": "Cambia colore",
"deleteKind": {
"wire": "Elimina cavo",
"component": "Elimina componente",

View File

@ -234,6 +234,7 @@
"rotate": "回転",
"delete": "削除",
"deselect": "選択解除",
"changeColor": "色を変更",
"deleteKind": {
"wire": "配線を削除",
"component": "コンポーネントを削除",

View File

@ -234,6 +234,7 @@
"rotate": "Girar",
"delete": "Excluir",
"deselect": "Desselecionar",
"changeColor": "Mudar cor",
"deleteKind": {
"wire": "Excluir fio",
"component": "Excluir componente",

View File

@ -234,6 +234,7 @@
"rotate": "Повернуть",
"delete": "Удалить",
"deselect": "Снять выделение",
"changeColor": "Изменить цвет",
"deleteKind": {
"wire": "Удалить провод",
"component": "Удалить компонент",

View File

@ -234,6 +234,7 @@
"rotate": "旋转",
"delete": "删除",
"deselect": "取消选择",
"changeColor": "更改颜色",
"deleteKind": {
"wire": "删除导线",
"component": "删除组件",