// 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, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js'; import { getExampleList, loadExample } from './examples.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); 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.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; // 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(); state.placingGate = null; 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)); state.connections.push({ from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index }); evaluateAll(); } 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)); state.connections.push({ from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex }); evaluateAll(); } 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) { state.dragging = gate; state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y }; canvas.style.cursor = 'grabbing'; } }); canvas.addEventListener('mouseup', e => { // 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; evaluateAll(true); // record waveform on intentional toggle } } state.dragging = null; dragStartPos = null; }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); 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 (state.hoveredGate && document.activeElement === document.body) { e.preventDefault(); 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; } // 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', () => { const name = prompt('Component name:', 'MyComponent'); 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(); }