feat: allow removing boards from workspace

Closes #14

- Right-click context menu on boards with "Remove board" option
- Delete/Backspace key removes the active board (when no component selected)
- Confirmation dialog before removal showing connected wire count
- Removing a board also removes all connected wires and cleans up
  file groups, simulators, bridges, and VFS
- Cannot remove the last remaining board (button disabled + guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feature/remove-board-from-workspace
David Montero Crespo 2026-03-29 01:55:10 -03:00
parent e99ded70b5
commit 5dac9bca5c
3 changed files with 129 additions and 6 deletions

View File

@ -39,6 +39,7 @@ interface BoardOnCanvasProps {
led13?: boolean; led13?: boolean;
isActive?: boolean; isActive?: boolean;
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
onPinClick: (componentId: string, pinName: string, x: number, y: number) => void; onPinClick: (componentId: string, pinName: string, x: number, y: number) => void;
zoom?: number; zoom?: number;
} }
@ -49,6 +50,7 @@ export const BoardOnCanvas = ({
led13 = false, led13 = false,
isActive = false, isActive = false,
onMouseDown, onMouseDown,
onContextMenu,
onPinClick, onPinClick,
zoom = 1, zoom = 1,
}: BoardOnCanvasProps) => { }: BoardOnCanvasProps) => {
@ -146,6 +148,7 @@ export const BoardOnCanvas = ({
zIndex: 1, zIndex: 1,
}} }}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e); }} onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e); }}
onContextMenu={onContextMenu}
/> />
)} )}

View File

@ -46,6 +46,7 @@ export const SimulatorCanvas = () => {
updateComponentState, updateComponentState,
addComponent, addComponent,
removeComponent, removeComponent,
removeBoard,
updateComponent, updateComponent,
serialMonitorOpen, serialMonitorOpen,
toggleSerialMonitor, toggleSerialMonitor,
@ -104,6 +105,11 @@ export const SimulatorCanvas = () => {
const [sensorControlComponentId, setSensorControlComponentId] = useState<string | null>(null); const [sensorControlComponentId, setSensorControlComponentId] = useState<string | null>(null);
const [sensorControlMetadataId, setSensorControlMetadataId] = useState<string | null>(null); const [sensorControlMetadataId, setSensorControlMetadataId] = useState<string | null>(null);
// Board context menu (right-click)
const [boardContextMenu, setBoardContextMenu] = useState<{ boardId: string; x: number; y: number } | null>(null);
// Board removal confirmation dialog
const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
// Click vs drag detection // Click vs drag detection
const [clickStartTime, setClickStartTime] = useState<number>(0); const [clickStartTime, setClickStartTime] = useState<number>(0);
const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 }); const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 });
@ -754,18 +760,23 @@ export const SimulatorCanvas = () => {
return () => cleanups.forEach(fn => fn()); return () => cleanups.forEach(fn => fn());
}, [components, wires, boards]); }, [components, wires, boards]);
// Handle keyboard delete // Handle keyboard delete for components and boards
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedComponentId) { if (e.key === 'Delete' || e.key === 'Backspace') {
removeComponent(selectedComponentId); if (selectedComponentId) {
setSelectedComponentId(null); removeComponent(selectedComponentId);
setSelectedComponentId(null);
} else if (activeBoardId && boards.length > 1) {
// Only allow deleting boards if more than one exists
setBoardToRemove(activeBoardId);
}
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedComponentId, removeComponent]); }, [selectedComponentId, removeComponent, activeBoardId, boards.length]);
// Handle component selection from modal // Handle component selection from modal
const handleSelectComponent = (metadata: ComponentMetadata) => { const handleSelectComponent = (metadata: ComponentMetadata) => {
@ -1400,6 +1411,11 @@ export const SimulatorCanvas = () => {
setDraggedComponentId(`__board__:${board.id}`); setDraggedComponentId(`__board__:${board.id}`);
setDragOffset({ x: world.x - board.x, y: world.y - board.y }); setDragOffset({ x: world.x - board.x, y: world.y - board.y });
}} }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setBoardContextMenu({ boardId: board.id, x: e.clientX, y: e.clientY });
}}
onPinClick={handlePinClick} onPinClick={handlePinClick}
zoom={zoom} zoom={zoom}
/> />
@ -1478,6 +1494,101 @@ export const SimulatorCanvas = () => {
}} }}
/> />
{/* Board right-click context menu */}
{boardContextMenu && (() => {
const board = boards.find((b) => b.id === boardContextMenu.boardId);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : 'Board';
const connectedWires = wires.filter(
(w) => w.start.componentId === boardContextMenu.boardId || w.end.componentId === boardContextMenu.boardId
).length;
return (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 9998 }}
onClick={() => setBoardContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setBoardContextMenu(null); }}
/>
<div
style={{
position: 'fixed',
left: boardContextMenu.x,
top: boardContextMenu.y,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 6,
padding: '4px 0',
zIndex: 9999,
minWidth: 180,
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
fontSize: 13,
}}
>
<div style={{ padding: '6px 14px', color: '#888', fontSize: 11, borderBottom: '1px solid #3c3c3c', marginBottom: 2 }}>
{label}
</div>
<button
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '7px 14px', background: 'none', border: 'none',
color: boards.length <= 1 ? '#555' : '#e06c75', cursor: boards.length <= 1 ? 'default' : 'pointer',
fontSize: 13, textAlign: 'left',
}}
disabled={boards.length <= 1}
title={boards.length <= 1 ? 'Cannot remove the last board' : undefined}
onMouseEnter={(e) => { if (boards.length > 1) (e.currentTarget.style.background = '#2a2d2e'); }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}
onClick={() => {
setBoardContextMenu(null);
setBoardToRemove(boardContextMenu.boardId);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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" />
</svg>
Remove board
{connectedWires > 0 && <span style={{ color: '#888', fontSize: 11 }}>({connectedWires} wire{connectedWires > 1 ? 's' : ''})</span>}
</button>
</div>
</>
);
})()}
{/* Board removal confirmation dialog */}
{boardToRemove && (() => {
const board = boards.find((b) => b.id === boardToRemove);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : 'Board';
const connectedWires = wires.filter(
(w) => w.start.componentId === boardToRemove || w.end.componentId === boardToRemove
).length;
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#1e1e1e', border: '1px solid #3c3c3c', borderRadius: 8, padding: '20px 24px', maxWidth: 380, boxShadow: '0 8px 32px rgba(0,0,0,0.6)' }}>
<h3 style={{ margin: '0 0 10px', color: '#e0e0e0', fontSize: 15 }}>Remove {label}?</h3>
<p style={{ margin: '0 0 16px', color: '#999', fontSize: 13, lineHeight: 1.5 }}>
This will remove the board from the workspace
{connectedWires > 0 && <> and <strong style={{ color: '#e06c75' }}>{connectedWires} connected wire{connectedWires > 1 ? 's' : ''}</strong></>}
. This action cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setBoardToRemove(null)}
style={{ padding: '6px 16px', background: '#333', border: '1px solid #555', borderRadius: 4, color: '#ccc', cursor: 'pointer', fontSize: 13 }}
>
Cancel
</button>
<button
onClick={() => { removeBoard(boardToRemove); setBoardToRemove(null); }}
style={{ padding: '6px 16px', background: '#e06c75', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', fontSize: 13 }}
>
Remove
</button>
</div>
</div>
</div>
);
})()}
</div> </div>
); );
}; };

View File

@ -472,6 +472,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
}, },
removeBoard: (boardId: string) => { removeBoard: (boardId: string) => {
const board = get().boards.find((b) => b.id === boardId);
getBoardSimulator(boardId)?.stop(); getBoardSimulator(boardId)?.stop();
simulatorMap.delete(boardId); simulatorMap.delete(boardId);
pinManagerMap.delete(boardId); pinManagerMap.delete(boardId);
@ -484,8 +485,16 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const activeBoardId = s.activeBoardId === boardId const activeBoardId = s.activeBoardId === boardId
? (boards[0]?.id ?? null) ? (boards[0]?.id ?? null)
: s.activeBoardId; : s.activeBoardId;
return { boards, activeBoardId }; // Remove wires connected to this board
const wires = s.wires.filter((w) =>
w.start.componentId !== boardId && w.end.componentId !== boardId
);
return { boards, activeBoardId, wires };
}); });
// Clean up file group in editor store
if (board) {
useEditorStore.getState().deleteFileGroup(board.activeFileGroupId);
}
}, },
updateBoard: (boardId: string, updates: Partial<BoardInstance>) => { updateBoard: (boardId: string, updates: Partial<BoardInstance>) => {