// Event handlers — mouse, keyboard, toolbar, waveform controls import { GATE_W, GATE_H, COMP_W } from './constants.js'; import { state } from './state.js'; import { evaluateAll, findGateAt, findPortAt, getComponentWidth, getComponentHeight } from './gates.js'; import { manualStep, clearWaveData } from './waveform.js'; import { startSim, stopSim, adjustSpeed } from './simulation.js'; import { resize, screenToWorld } from './renderer.js'; import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js'; import { getLevel } from './levels.js'; import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js'; import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js'; import { getExampleList, loadExample } from './examples.js'; import { createBusFromCut } from './bus.js'; const PAN_SPEED = 40; function updateWaveZoomLabel() { const el = document.getElementById('wave-zoom-label'); if (el) el.textContent = `${state.waveZoom}px`; } export function initEvents() { const canvas = document.getElementById('canvas'); // Set up resize callback for component editor setResizeCallback(resize); // ==================== CANVAS MOUSE ==================== canvas.addEventListener('mousemove', e => { state.mouseX = e.offsetX; state.mouseY = e.offsetY; // Convert to world coords for gate/port detection const world = screenToWorld(e.offsetX, e.offsetY); // Update bus cut line endpoint if (state.busCutting) { state.busCutting.endX = world.x; state.busCutting.endY = world.y; 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); if (state.dragging) { // Detect if mouse actually moved (threshold of 4px to distinguish click vs drag) 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) { state.dragging.x = world.x - state.dragOffset.x; state.dragging.y = world.y - state.dragOffset.y; evaluateAll(); } } canvas.style.cursor = state.placingGate ? 'crosshair' : state.selectionBox ? 'crosshair' : state.hoveredPort ? 'pointer' : state.hoveredGate ? 'grab' : 'default'; }); let dragStartPos = null; let dragMoved = false; canvas.addEventListener('mousedown', e => { if (e.button !== 0) return; const world = screenToWorld(e.offsetX, e.offsetY); dragStartPos = { x: e.offsetX, y: e.offsetY }; dragMoved = false; // Shift+click on empty space → start bus cut if (e.shiftKey && !state.placingGate) { const port = findPortAt(world.x, world.y); const gate = findGateAt(world.x, world.y); if (!port && !gate) { state.busCutting = { startX: world.x, startY: world.y, endX: world.x, endY: world.y }; return; } } // Placing a new gate if (state.placingGate) { let w = GATE_W, h = GATE_H; if (state.placingGate.startsWith('COMPONENT:')) { const componentId = state.placingGate.substring(10); const component = state.customComponents?.[componentId]; if (component) { const fakeGate = { type: state.placingGate, component }; w = getComponentWidth(fakeGate); h = getComponentHeight(fakeGate); } } const newGate = { id: state.nextId++, type: state.placingGate, x: world.x - w / 2, y: world.y - h / 2, value: 0 }; if (state.placingGate.startsWith('COMPONENT:')) { newGate.component = state.customComponents[state.placingGate.substring(10)]; } state.gates.push(newGate); evaluateAll(); // Keep placingGate active so user can place multiple — right-click to cancel return; } // Port click — connecting const port = findPortAt(world.x, world.y); if (port) { if (state.connecting) { if (state.connecting.portType === 'output' && port.type === 'input') { state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); const conn = { from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index }; state.connections.push(conn); console.log(`[wire] ${conn.from}:${conn.fromPort} → ${conn.to}:${conn.toPort}`); evaluateAll(); console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', ')); } else if (state.connecting.portType === 'input' && port.type === 'output') { state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex)); const conn = { from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex }; state.connections.push(conn); console.log(`[wire] ${conn.from}:${conn.fromPort} → ${conn.to}:${conn.toPort}`); evaluateAll(); console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', ')); } state.connecting = null; } else { state.connecting = { gate: port.gate, portIndex: port.index, portType: port.type }; } return; } if (state.connecting) { state.connecting = null; return; } // 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 => { // Finish bus cut if (state.busCutting) { createBusFromCut(); state.busCutting = null; 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_IN:') || g.type.startsWith('BUS_OUT:'); 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; if (gate.type === 'INPUT' || gate.type === 'CLOCK') { gate.value = gate.value ? 0 : 1; console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`); evaluateAll(true); // record waveform on intentional toggle console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', ')); } } state.dragging = null; dragStartPos = null; }); // Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint canvas.addEventListener('dblclick', e => { const world = screenToWorld(e.offsetX, e.offsetY); const gate = findGateAt(world.x, world.y); if (!gate) return; // Double-click on component gate → edit its blueprint if (gate.type.startsWith('COMPONENT:') && gate.component) { editComponentBlueprint(gate); return; } // Double-click on I/O gates → rename (only inside component editor) if (state.componentEditorActive && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) { const current = gate.label || ''; const label = prompt(`Label for ${gate.type}#${gate.id}:`, current); if (label !== null) { gate.label = label.trim() || undefined; console.log(`[label] ${gate.type}#${gate.id} → "${gate.label || ''}"`); } } }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); // Right-click cancels placing mode if (state.placingGate) { state.placingGate = null; return; } const world = screenToWorld(e.offsetX, e.offsetY); const port = findPortAt(world.x, world.y); if (port && port.type === 'input') { state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); evaluateAll(); } else if (port && port.type === 'output') { state.connections = state.connections.filter(c => !(c.from === port.gate.id && c.fromPort === port.index)); evaluateAll(); } }); // ==================== MOUSE WHEEL ZOOM ==================== canvas.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; const newZoom = Math.max(0.2, Math.min(3, state.zoom + delta)); // Zoom towards mouse position const worldBefore = screenToWorld(e.offsetX, e.offsetY); state.zoom = newZoom; const worldAfter = screenToWorld(e.offsetX, e.offsetY); state.camX += (worldAfter.x - worldBefore.x) * state.zoom; state.camY += (worldAfter.y - worldBefore.y) * state.zoom; }, { passive: false }); // ==================== KEYBOARD ==================== const keysDown = new Set(); document.addEventListener('keydown', e => { keysDown.add(e.key); if (e.key === 'Delete' || e.key === 'Backspace') { 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); delete state.waveData[gateId]; state.hoveredGate = null; evaluateAll(); } } if (e.key === 'Escape') { state.placingGate = null; state.connecting = null; state.selectedGates = []; } // Pan with arrow keys if (e.key === 'ArrowLeft') { state.camX += PAN_SPEED; e.preventDefault(); } if (e.key === 'ArrowRight') { state.camX -= PAN_SPEED; e.preventDefault(); } if (e.key === 'ArrowUp') { state.camY += PAN_SPEED; e.preventDefault(); } if (e.key === 'ArrowDown') { state.camY -= PAN_SPEED; e.preventDefault(); } // Zoom with +/- keys if ((e.key === '+' || e.key === '=') && document.activeElement === document.body) { state.zoom = Math.min(3, state.zoom + 0.1); e.preventDefault(); } if ((e.key === '-' || e.key === '_') && document.activeElement === document.body) { state.zoom = Math.max(0.2, state.zoom - 0.1); e.preventDefault(); } // Reset view with 0 if (e.key === '0' && document.activeElement === document.body) { state.camX = 0; state.camY = 0; state.zoom = 1; } }); document.addEventListener('keyup', e => { keysDown.delete(e.key); }); // ==================== TOOLBAR DROPDOWNS ==================== function closeAllDropdowns() { document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open')); } document.querySelectorAll('.dropdown-toggle').forEach(toggle => { toggle.addEventListener('click', (e) => { e.stopPropagation(); const dropdown = toggle.parentElement; const wasOpen = dropdown.classList.contains('open'); closeAllDropdowns(); if (!wasOpen) dropdown.classList.add('open'); }); }); // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.toolbar-dropdown')) { closeAllDropdowns(); } }); // Gate buttons inside dropdowns document.querySelectorAll('.dropdown-menu .gate-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const gateName = btn.dataset.gate; // In puzzle mode, check if gate is allowed if (puzzleMode && currentLevel) { const level = getLevel(currentLevel.id); if (!level.availableGates.includes(gateName)) { alert(`${gateName} is not available in this level.`); return; } } state.placingGate = gateName; closeAllDropdowns(); }); }); // ==================== EXAMPLES ==================== const examplesMenu = document.getElementById('examples-menu'); getExampleList().forEach((ex, i) => { const btn = document.createElement('button'); btn.className = 'example-btn'; btn.innerHTML = `${ex.name}${ex.description}`; btn.addEventListener('click', (e) => { e.stopPropagation(); const hasGates = state.gates.length > 0; if (hasGates && !confirm('Load example? This will replace your current circuit.')) return; const data = loadExample(i); if (data) { state.gates = data.circuit.gates; state.connections = data.circuit.connections; state.nextId = data.circuit.nextId; if (data.camera) { state.camX = data.camera.camX; state.camY = data.camera.camY; state.zoom = data.camera.zoom; } clearWaveData(); evaluateAll(); } closeAllDropdowns(); }); examplesMenu.appendChild(btn); }); document.getElementById('clear-btn').addEventListener('click', () => { if (state.gates.length === 0 || confirm('Clear all gates and connections?')) { state.gates = []; state.connections = []; state.connecting = null; state.placingGate = null; clearWaveData(); } }); // Export/Import document.getElementById('export-btn').addEventListener('click', () => { exportAsFile(`circuit-${new Date().toISOString().slice(0, 10)}.json`); }); document.getElementById('import-btn').addEventListener('click', () => { document.getElementById('import-file').click(); }); document.getElementById('import-file').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const result = await importFromFile(file); if (result.success) { alert('Circuit imported successfully!'); evaluateAll(); } else { alert(`Import failed: ${result.error}`); } } catch (err) { alert(`Import error: ${err.message}`); } // Reset file input e.target.value = ''; }); // Help modal document.getElementById('help-btn').addEventListener('click', () => { document.getElementById('help-modal').classList.add('visible'); }); document.getElementById('help-close').addEventListener('click', () => { document.getElementById('help-modal').classList.remove('visible'); }); document.getElementById('help-modal').addEventListener('click', e => { if (e.target === e.currentTarget) document.getElementById('help-modal').classList.remove('visible'); }); // Waveform toggle document.getElementById('sim-btn').addEventListener('click', () => { state.waveformVisible = !state.waveformVisible; document.getElementById('waveform-panel').classList.toggle('visible', state.waveformVisible); document.getElementById('sim-btn').classList.toggle('active', state.waveformVisible); resize(); }); // ==================== WAVEFORM CONTROLS ==================== document.getElementById('wave-record').addEventListener('click', function() { state.recording = !state.recording; this.classList.toggle('active', state.recording); this.textContent = state.recording ? 'Record' : 'Paused'; }); document.getElementById('wave-clear').addEventListener('click', clearWaveData); document.getElementById('wave-step').addEventListener('click', manualStep); document.getElementById('wave-zoom-in').addEventListener('click', () => { state.waveZoom = Math.min(100, Math.round(state.waveZoom * 1.5)); updateWaveZoomLabel(); }); document.getElementById('wave-zoom-out').addEventListener('click', () => { state.waveZoom = Math.max(2, Math.round(state.waveZoom / 1.5)); updateWaveZoomLabel(); }); // Init zoom label updateWaveZoomLabel(); // ==================== SIMULATION CONTROLS ==================== document.getElementById('sim-run-btn').addEventListener('click', () => { if (state.simRunning) stopSim(); else startSim(); }); document.getElementById('sim-faster').addEventListener('click', () => adjustSpeed(-100)); document.getElementById('sim-slower').addEventListener('click', () => adjustSpeed(100)); // ==================== WAVEFORM PANEL RESIZE ==================== const resizeHandle = document.getElementById('wave-resize'); resizeHandle.addEventListener('mousedown', e => { state.resizingWave = true; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (state.resizingWave) { state.waveformHeight = Math.max(100, Math.min(500, window.innerHeight - e.clientY)); document.getElementById('waveform-panel').style.height = state.waveformHeight + 'px'; resize(); } }); document.addEventListener('mouseup', () => { state.resizingWave = false; }); // ==================== COMPONENT EDITOR ==================== document.getElementById('create-component-btn').addEventListener('click', (e) => { e.stopPropagation(); closeAllDropdowns(); enterComponentEditor(); }); document.getElementById('component-editor-save').addEventListener('click', () => { // If editing existing, pre-fill with current name const existingName = state.editingComponentId ? (state.customComponents[state.editingComponentId]?.name || 'MyComponent') : 'MyComponent'; const name = prompt('Component name:', existingName); if (name && name.trim()) { exitComponentEditor(name.trim(), true); } }); document.getElementById('component-editor-cancel').addEventListener('click', () => { if (confirm('Discard component without saving?')) { exitComponentEditor('', false); } }); // Update component buttons initially updateComponentButtons(); }