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
parent
e99ded70b5
commit
5dac9bca5c
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue