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:
108
js/events.js
108
js/events.js
@@ -40,6 +40,35 @@ export function initEvents() {
|
|||||||
return;
|
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.hoveredPort = findPortAt(world.x, world.y);
|
||||||
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(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'
|
canvas.style.cursor = state.placingGate ? 'crosshair'
|
||||||
|
: state.selectionBox ? 'crosshair'
|
||||||
: state.hoveredPort ? 'pointer'
|
: state.hoveredPort ? 'pointer'
|
||||||
: state.hoveredGate ? 'grab'
|
: state.hoveredGate ? 'grab'
|
||||||
: 'default';
|
: 'default';
|
||||||
@@ -147,10 +177,33 @@ export function initEvents() {
|
|||||||
// Drag any gate (including INPUT/CLOCK)
|
// Drag any gate (including INPUT/CLOCK)
|
||||||
const gate = findGateAt(world.x, world.y);
|
const gate = findGateAt(world.x, world.y);
|
||||||
if (gate) {
|
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.dragging = gate;
|
||||||
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
||||||
canvas.style.cursor = 'grabbing';
|
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 => {
|
canvas.addEventListener('mouseup', e => {
|
||||||
@@ -161,6 +214,41 @@ export function initEvents() {
|
|||||||
return;
|
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)
|
// Toggle INPUT/CLOCK only on click (no drag movement)
|
||||||
if (state.dragging && !dragMoved) {
|
if (state.dragging && !dragMoved) {
|
||||||
const gate = state.dragging;
|
const gate = state.dragging;
|
||||||
@@ -168,7 +256,6 @@ export function initEvents() {
|
|||||||
gate.value = gate.value ? 0 : 1;
|
gate.value = gate.value ? 0 : 1;
|
||||||
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
||||||
evaluateAll(true); // record waveform on intentional toggle
|
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(', '));
|
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);
|
keysDown.add(e.key);
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (state.hoveredGate && document.activeElement === document.body) {
|
if (document.activeElement !== document.body) return;
|
||||||
e.preventDefault();
|
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;
|
const gateId = state.hoveredGate.id;
|
||||||
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||||
state.gates = state.gates.filter(g => g.id !== gateId);
|
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||||
@@ -254,6 +355,7 @@ export function initEvents() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
state.connecting = null;
|
state.connecting = null;
|
||||||
|
state.selectedGates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan with arrow keys
|
// 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) {
|
function drawGate(gate) {
|
||||||
// Special gate types have different rendering
|
// Special gate types have different rendering
|
||||||
if (gate.type.startsWith('BUS:')) return drawBusGate(gate);
|
if (gate.type.startsWith('BUS:')) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
if (gate.type.startsWith('COMPONENT:')) return drawComponentGate(gate);
|
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
|
|
||||||
const color = GATE_COLORS[gate.type];
|
const color = GATE_COLORS[gate.type];
|
||||||
const isHovered = state.hoveredGate === gate;
|
const isHovered = state.hoveredGate === gate;
|
||||||
@@ -125,6 +140,8 @@ function drawGate(gate) {
|
|||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
drawSelectionHighlight(gate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBusGate(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;
|
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() {
|
function drawPlacingGhost() {
|
||||||
if (!state.placingGate) return;
|
if (!state.placingGate) return;
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = 0.5;
|
||||||
@@ -572,6 +606,7 @@ function draw() {
|
|||||||
state.gates.forEach(drawGate);
|
state.gates.forEach(drawGate);
|
||||||
drawConnectingWire();
|
drawConnectingWire();
|
||||||
drawBusCutLine();
|
drawBusCutLine();
|
||||||
|
drawSelectionBox();
|
||||||
drawPlacingGhost();
|
drawPlacingGhost();
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
@@ -45,5 +45,10 @@ export const state = {
|
|||||||
editingComponentId: null, // ID of component being edited (null = new component)
|
editingComponentId: null, // ID of component being edited (null = new component)
|
||||||
|
|
||||||
// Bus cutting (shift+drag)
|
// 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