From 9ec3367253ed90deb2739073926a8d9ab1809696 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Fri, 20 Mar 2026 04:47:34 +0100 Subject: [PATCH] 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 --- js/events.js | 108 +++++++++++++++++++++++++++++++++++++++++++++++-- js/renderer.js | 39 +++++++++++++++++- js/state.js | 7 +++- 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/js/events.js b/js/events.js index 1d14c3b..dcb064c 100644 --- a/js/events.js +++ b/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) { - 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 diff --git a/js/renderer.js b/js/renderer.js index cba61e8..24a6c7f 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -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(); diff --git a/js/state.js b/js/state.js index 7f56b58..75636e2 100644 --- a/js/state.js +++ b/js/state.js @@ -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 };