Files
logic-gates/js/events.js
Jose Luis 268013d053 feat: sectioned toolbar + custom component editor
- Redesigned toolbar with I/O, Gates, and Components sections
- Component editor: sub-canvas mode to design reusable chips
  - Save/Cancel with main circuit state preservation
  - Components persist in localStorage
- Custom components render as purple chips with dynamic I/O ports
- Component evaluation simulates internal circuit as black box
- Toolbar height increased to 56px for section labels
- All height references updated consistently

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

362 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) {
w = getComponentWidth({ component });
h = getComponentHeight({ component });
}
}
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);
}
});
// Event delegation for saved component buttons
document.addEventListener('click', e => {
if (e.target.classList.contains('component-btn')) {
const componentId = e.target.dataset.componentId;
state.placingGate = `COMPONENT:${componentId}`;
}
});
// Update component buttons initially and when customComponents changes
updateComponentButtons();
}