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:
106
js/events.js
106
js/events.js
@@ -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) {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user