feat: drag selection box to select, move, and delete multiple gates

Click and drag on empty space to draw a selection rectangle. Gates
inside the box get selected (cyan dashed outline). Drag any selected
gate to move all of them together. Delete/Backspace removes all
selected gates and their connections. Escape clears the selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 04:47:34 +01:00
parent 12d7331d2c
commit 9ec3367253
3 changed files with 148 additions and 6 deletions

View File

@@ -40,6 +40,35 @@ export function initEvents() {
return;
}
// Update selection box
if (state.selectionBox) {
state.selectionBox.endX = world.x;
state.selectionBox.endY = world.y;
return;
}
// Multi-drag selected gates
if (state.multiDrag) {
if (dragStartPos && !dragMoved) {
const dx = e.offsetX - dragStartPos.x;
const dy = e.offsetY - dragStartPos.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragMoved = true;
}
if (dragMoved) {
const dx = world.x - state.multiDrag.startX;
const dy = world.y - state.multiDrag.startY;
for (const orig of state.multiDrag.origins) {
const gate = state.gates.find(g => g.id === orig.id);
if (gate) {
gate.x = orig.x + dx;
gate.y = orig.y + dy;
}
}
evaluateAll();
}
return;
}
state.hoveredPort = findPortAt(world.x, world.y);
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
@@ -60,6 +89,7 @@ export function initEvents() {
}
canvas.style.cursor = state.placingGate ? 'crosshair'
: state.selectionBox ? 'crosshair'
: state.hoveredPort ? 'pointer'
: state.hoveredGate ? 'grab'
: 'default';
@@ -147,10 +177,33 @@ export function initEvents() {
// Drag any gate (including INPUT/CLOCK)
const gate = findGateAt(world.x, world.y);
if (gate) {
// If clicking a selected gate → multi-drag all selected
if (state.selectedGates.includes(gate.id)) {
state.multiDrag = {
startX: world.x,
startY: world.y,
origins: state.selectedGates.map(id => {
const g = state.gates.find(g => g.id === id);
return g ? { id: g.id, x: g.x, y: g.y } : null;
}).filter(Boolean)
};
canvas.style.cursor = 'grabbing';
return;
}
// Clicking an unselected gate → clear selection, drag just this one
state.selectedGates = [];
state.dragging = gate;
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
canvas.style.cursor = 'grabbing';
return;
}
// Click on empty space → clear selection and start selection box
state.selectedGates = [];
state.selectionBox = {
startX: world.x, startY: world.y,
endX: world.x, endY: world.y
};
});
canvas.addEventListener('mouseup', e => {
@@ -161,6 +214,41 @@ export function initEvents() {
return;
}
// Finish selection box → select gates inside
if (state.selectionBox) {
const box = state.selectionBox;
const x1 = Math.min(box.startX, box.endX);
const y1 = Math.min(box.startY, box.endY);
const x2 = Math.max(box.startX, box.endX);
const y2 = Math.max(box.startY, box.endY);
// Only select if box is big enough (not just a click)
if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
state.selectedGates = state.gates
.filter(g => {
const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS:');
const gw = isDynamic ? getComponentWidth(g) : GATE_W;
const gh = isDynamic ? getComponentHeight(g) : GATE_H;
// Gate overlaps selection box
return g.x + gw > x1 && g.x < x2 && g.y + gh > y1 && g.y < y2;
})
.map(g => g.id);
if (state.selectedGates.length > 0) {
console.log(`[select] ${state.selectedGates.length} gate(s) selected`);
}
}
state.selectionBox = null;
dragStartPos = null;
return;
}
// Finish multi-drag
if (state.multiDrag) {
state.multiDrag = null;
dragStartPos = null;
return;
}
// Toggle INPUT/CLOCK only on click (no drag movement)
if (state.dragging && !dragMoved) {
const gate = state.dragging;
@@ -168,7 +256,6 @@ export function initEvents() {
gate.value = gate.value ? 0 : 1;
console.log(`[toggle] ${gate.type}#${gate.id}${gate.value}`);
evaluateAll(true); // record waveform on intentional toggle
// Log all gate values after evaluation
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
}
}
@@ -241,8 +328,22 @@ export function initEvents() {
keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.hoveredGate && document.activeElement === document.body) {
e.preventDefault();
if (document.activeElement !== document.body) return;
e.preventDefault();
// Delete all selected gates
if (state.selectedGates.length > 0) {
for (const gateId of state.selectedGates) {
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
state.gates = state.gates.filter(g => g.id !== gateId);
delete state.waveData[gateId];
}
console.log(`[delete] removed ${state.selectedGates.length} gate(s)`);
state.selectedGates = [];
state.hoveredGate = null;
evaluateAll();
} else if (state.hoveredGate) {
// Delete single hovered gate
const gateId = state.hoveredGate.id;
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
state.gates = state.gates.filter(g => g.id !== gateId);
@@ -254,6 +355,7 @@ export function initEvents() {
if (e.key === 'Escape') {
state.placingGate = null;
state.connecting = null;
state.selectedGates = [];
}
// Pan with arrow keys

View File

@@ -32,10 +32,25 @@ export function screenToWorld(sx, sy) {
};
}
function drawSelectionHighlight(gate) {
if (!state.selectedGates.includes(gate.id)) return;
const isDynamic = gate.type.startsWith('COMPONENT:') || gate.type.startsWith('BUS:');
const w = isDynamic ? getComponentWidth(gate) : GATE_W;
const h = isDynamic ? getComponentHeight(gate) : GATE_H;
const pad = 4;
ctx.strokeStyle = '#44ddff';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10);
ctx.stroke();
ctx.setLineDash([]);
}
function drawGate(gate) {
// Special gate types have different rendering
if (gate.type.startsWith('BUS:')) return drawBusGate(gate);
if (gate.type.startsWith('COMPONENT:')) return drawComponentGate(gate);
if (gate.type.startsWith('BUS:')) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
const color = GATE_COLORS[gate.type];
const isHovered = state.hoveredGate === gate;
@@ -125,6 +140,8 @@ function drawGate(gate) {
ctx.lineWidth = 1.5;
ctx.stroke();
});
drawSelectionHighlight(gate);
}
function drawBusGate(gate) {
@@ -519,6 +536,23 @@ function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function drawSelectionBox() {
if (!state.selectionBox) return;
const box = state.selectionBox;
const x = Math.min(box.startX, box.endX);
const y = Math.min(box.startY, box.endY);
const w = Math.abs(box.endX - box.startX);
const h = Math.abs(box.endY - box.startY);
ctx.fillStyle = '#44ddff0a';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#44ddff88';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 3]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
function drawPlacingGhost() {
if (!state.placingGate) return;
ctx.globalAlpha = 0.5;
@@ -572,6 +606,7 @@ function draw() {
state.gates.forEach(drawGate);
drawConnectingWire();
drawBusCutLine();
drawSelectionBox();
drawPlacingGhost();
ctx.restore();

View File

@@ -45,5 +45,10 @@ export const state = {
editingComponentId: null, // ID of component being edited (null = new component)
// Bus cutting (shift+drag)
busCutting: null // { startX, startY, endX, endY } in world coords, or null
busCutting: null, // { startX, startY, endX, endY } in world coords, or null
// Multi-selection
selectedGates: [], // array of gate IDs currently selected
selectionBox: null, // { startX, startY, endX, endY } in world coords while dragging
multiDrag: null // { startX, startY, origins: [{id, x, y}] } while dragging selected gates
};