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>
355 lines
14 KiB
JavaScript
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();
|
|
}
|