Files
logic-gates/js/events.js
Jose Luis eb22a5ff62 feat: double-click component gates to edit their blueprint
Opens the component editor with the internal circuit loaded for
modification. On save, updates the component definition and all
existing instances in the main circuit.

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

446 lines
18 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, editComponentBlueprint, 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));
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) {
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;
console.log(`[toggle] ${gate.type}#${gate.id}${gate.value}`);
evaluateAll(true); // record waveform on intentional toggle
// Log all gate values after evaluation
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
if (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();
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 = `<span class="example-name">${ex.name}</span><span class="example-desc">${ex.description}</span>`;
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();
}