Files
logic-gates/js/events.js
Jose Luis 53d600fcb0 fix: horizontal toolbar layout + fix component button placement
Redesign toolbar sections to use horizontal button rows instead of
vertical stacking. Fix component placement by attaching click handlers
directly to dynamically created buttons and passing correct gate object
shape to getComponentWidth/Height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:06:23 +01:00

355 lines
14 KiB
JavaScript

// 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';
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 ====================
document.querySelectorAll('.gate-btn').forEach(btn => {
btn.addEventListener('click', () => {
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;
});
});
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', () => {
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();
}