From 268013d0539e868cc2add38d1cb7a7611d5e3895 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Fri, 20 Mar 2026 02:54:04 +0100 Subject: [PATCH] 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 --- css/style.css | 125 +++++++++++++++++++++++++++++++++++++++++++++-- index.html | 49 +++++++++++++++---- js/components.js | 82 +++++++++++++++++++++++++++++++ js/constants.js | 12 ++++- js/events.js | 59 +++++++++++++++++++--- js/gates.js | 77 ++++++++++++++++++++++++----- js/renderer.js | 101 +++++++++++++++++++++++++++++++++++--- js/state.js | 7 ++- 8 files changed, 472 insertions(+), 40 deletions(-) diff --git a/css/style.css b/css/style.css index 7745be8..e6e427f 100644 --- a/css/style.css +++ b/css/style.css @@ -41,7 +41,7 @@ body { #toolbar { position: fixed; top: 0; left: 0; right: 0; - height: 48px; + height: 56px; background: #12121a; border-bottom: 1px solid #2a2a3a; display: flex; @@ -97,10 +97,60 @@ body { .action-btn.sim-btn:hover { background: #ff44aa22; } .action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; } +/* ==================== Toolbar Sections ==================== */ +.toolbar-section { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + height: 56px; + justify-content: flex-start; +} + +.section-label { + font-size: 8px; + color: #666; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + height: 10px; + line-height: 10px; +} + +.toolbar-section .gate-btn { + padding: 3px 8px; + font-size: 10px; + height: 20px; + display: flex; + align-items: center; +} + +.toolbar-section #saved-components { + display: flex; + gap: 2px; +} + +.toolbar-section .component-btn { + padding: 4px 8px; + font-size: 10px; + background: #1a1a2e; + border: 1px solid #2a2a3a; + border-radius: 4px; + color: #9900ff; + cursor: pointer; + transition: all 0.15s; +} + +.toolbar-section .component-btn:hover { + border-color: #9900ff; + color: #cc66ff; +} + /* ==================== Canvas ==================== */ #canvas { position: fixed; - top: 48px; left: 0; right: 0; bottom: 0; + top: 56px; left: 0; right: 0; bottom: 0; cursor: default; transition: left 0.2s ease; } @@ -273,14 +323,79 @@ body { color: #00e599; } +/* ==================== Component Editor Overlay ==================== */ +#component-editor-overlay { + position: fixed; + top: 56px; + left: 0; + right: 0; + height: 44px; + background: #1a1a2e; + border-bottom: 2px solid #9900ff; + z-index: 105; + display: flex; + align-items: center; + padding: 0 12px; + gap: 12px; +} + +#component-editor-bar { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +#component-editor-title { + color: #9900ff; + font-weight: 600; + font-size: 13px; + flex: 1; +} + +#component-editor-save, #component-editor-cancel { + padding: 4px 12px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.15s; +} + +#component-editor-save { + background: #00e599; + color: #000; +} + +#component-editor-save:hover { + background: #00ff99; + box-shadow: 0 0 10px #00e59944; +} + +#component-editor-cancel { + background: transparent; + border: 1px solid #ff4444; + color: #ff4444; +} + +#component-editor-cancel:hover { + background: #ff444422; +} + +/* Shift canvas when editor is active */ +#component-editor-overlay:not([style*="display: none"]) ~ #canvas { + top: calc(56px + 44px); +} + /* ==================== Puzzle Panels ==================== */ .puzzle-panel { display: none; position: fixed; - top: 48px; + top: 56px; left: 0; width: 340px; - height: calc(100vh - 48px); + height: calc(100vh - 56px); background: #12121a; border-right: 1px solid #2a2a3a; z-index: 95; @@ -293,7 +408,7 @@ body { } .puzzle-panel.puzzle-info { - top: 48px; + top: 56px; width: 340px; } diff --git a/index.html b/index.html index a28a016..e66ce42 100644 --- a/index.html +++ b/index.html @@ -9,17 +9,39 @@
- - - + + +
+ + + + +
+
- - - - - - + + +
+ + + + + + + +
+
+ + +
+ + +
+
+ +
+
@@ -32,6 +54,15 @@ + + + diff --git a/js/components.js b/js/components.js index 7e32221..c38c000 100644 --- a/js/components.js +++ b/js/components.js @@ -2,6 +2,12 @@ import { state } from './state.js'; import { GATE_W, GATE_H } from './constants.js'; +// Avoid circular imports - resize will be called from events.js +let resizeCallback = null; +export function setResizeCallback(fn) { + resizeCallback = fn; +} + /** * Save current circuit as a reusable component * Returns the component ID if successful @@ -204,3 +210,79 @@ export function importComponent(data) { state.customComponents[data.id] = data; return { success: true, component: data }; } + +/** + * Enter component editor mode + */ +export function enterComponentEditor() { + // Save current main circuit + state.savedMainCircuit = { + gates: JSON.parse(JSON.stringify(state.gates)), + connections: JSON.parse(JSON.stringify(state.connections)), + nextId: state.nextId + }; + + // Clear canvas for sub-circuit design + state.gates = []; + state.connections = []; + state.nextId = 1; + state.componentEditorActive = true; + state.placingGate = null; + state.connecting = null; + + // Show editor overlay + const overlay = document.getElementById('component-editor-overlay'); + overlay.style.display = 'flex'; + document.getElementById('component-editor-title').textContent = 'Editing Component: (New)'; + + // Resize canvas to account for editor bar + if (resizeCallback) resizeCallback(); +} + +/** + * Exit component editor mode + */ +export function exitComponentEditor(name, shouldSave) { + const overlay = document.getElementById('component-editor-overlay'); + overlay.style.display = 'none'; + + if (shouldSave && name) { + // Save the component + saveComponentFromCircuit(name); + } + + // Restore main circuit + if (state.savedMainCircuit) { + state.gates = state.savedMainCircuit.gates; + state.connections = state.savedMainCircuit.connections; + state.nextId = state.savedMainCircuit.nextId; + state.savedMainCircuit = null; + } + + state.componentEditorActive = false; + state.placingGate = null; + + // Update component buttons to show newly saved component + updateComponentButtons(); + + // Resize canvas via callback + if (resizeCallback) resizeCallback(); +} + +/** + * Update component buttons in toolbar + */ +export function updateComponentButtons() { + const container = document.getElementById('saved-components'); + container.innerHTML = ''; + + const components = getAllComponents(); + Object.values(components).forEach(comp => { + const btn = document.createElement('button'); + btn.className = 'component-btn'; + btn.dataset.componentId = comp.id; + btn.textContent = comp.name; + btn.title = `${comp.inputCount} input(s), ${comp.outputCount} output(s)`; + container.appendChild(btn); + }); +} diff --git a/js/constants.js b/js/constants.js index 882c57b..dbbc6a4 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,6 +1,7 @@ // Gate dimensions and rendering constants export const GATE_W = 100; export const GATE_H = 60; +export const COMP_W = 120; export const PORT_R = 7; export const GATE_COLORS = { @@ -18,9 +19,18 @@ export const SIGNAL_COLORS = [ export function gateInputCount(type) { if (type === 'CLOCK' || type === 'INPUT') return 0; if (type === 'NOT' || type === 'OUTPUT') return 1; + if (type.startsWith('COMPONENT:')) { + // Component types look up their input count from state + return 0; // Will be overridden by lookup in gates.js + } return 2; } export function gateOutputCount(type) { - return type === 'OUTPUT' ? 0 : 1; + if (type === 'OUTPUT') return 0; + if (type.startsWith('COMPONENT:')) { + // Component types look up their output count from state + return 0; // Will be overridden by lookup in gates.js + } + return 1; } diff --git a/js/events.js b/js/events.js index 1db80f4..58adeb5 100644 --- a/js/events.js +++ b/js/events.js @@ -1,13 +1,14 @@ // Event handlers — mouse, keyboard, toolbar, waveform controls -import { GATE_W, GATE_H } from './constants.js'; +import { GATE_W, GATE_H, COMP_W } from './constants.js'; import { state } from './state.js'; -import { evaluateAll, findGateAt, findPortAt } from './gates.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; @@ -19,6 +20,9 @@ function updateWaveZoomLabel() { 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; @@ -62,13 +66,27 @@ export function initEvents() { // Placing a new gate if (state.placingGate) { - state.gates.push({ + 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 - GATE_W / 2, - y: world.y - GATE_H / 2, + 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; @@ -311,4 +329,33 @@ export function initEvents() { } }); 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(); } diff --git a/js/gates.js b/js/gates.js index 2fbc12a..a5e6e89 100644 --- a/js/gates.js +++ b/js/gates.js @@ -1,13 +1,52 @@ // Gate evaluation and port geometry -import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js'; +import { GATE_W, GATE_H, COMP_W, PORT_R, gateInputCount as baseGateInputCount, gateOutputCount as baseGateOutputCount } from './constants.js'; import { state } from './state.js'; import { recordSample, setEvaluateAll } from './waveform.js'; +import { evaluateComponent } from './components.js'; + +// Wrappers that handle component types +export function gateInputCount(type) { + if (type.startsWith('COMPONENT:')) { + const componentId = type.substring(10); + const component = state.customComponents?.[componentId]; + return component ? component.inputCount : 0; + } + return baseGateInputCount(type); +} + +export function gateOutputCount(type) { + if (type.startsWith('COMPONENT:')) { + const componentId = type.substring(10); + const component = state.customComponents?.[componentId]; + return component ? component.outputCount : 0; + } + return baseGateOutputCount(type); +} + +export function getComponentWidth(gate) { + if (gate.type.startsWith('COMPONENT:')) { + const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1); + return Math.max(120, (count + 1) * 25); + } + return GATE_W; +} + +export function getComponentHeight(gate) { + if (gate.type.startsWith('COMPONENT:')) { + const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1); + return Math.max(60, (count + 1) * 25); + } + return GATE_H; +} export function getInputPorts(gate) { const count = gateInputCount(gate.type); const ports = []; + const isComponent = gate.type.startsWith('COMPONENT:'); + const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H; + for (let i = 0; i < count; i++) { - const spacing = GATE_H / (count + 1); + const spacing = gateHeight / (count + 1); ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' }); } return ports; @@ -16,8 +55,13 @@ export function getInputPorts(gate) { export function getOutputPorts(gate) { const count = gateOutputCount(gate.type); const ports = []; + const isComponent = gate.type.startsWith('COMPONENT:'); + const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W; + const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H; + for (let i = 0; i < count; i++) { - ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, index: i, type: 'output' }); + const spacing = gateHeight / (count + 1); + ports.push({ x: gate.x + gateWidth, y: gate.y + spacing * (i + 1), index: i, type: 'output' }); } return ports; } @@ -40,14 +84,19 @@ export function evaluate(gate, visited = new Set()) { } let result = 0; - switch (gate.type) { - case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break; - case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break; - case 'NOT': result = inputs[0] ? 0 : 1; break; - case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break; - case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break; - case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break; - case 'OUTPUT': result = inputs[0] || 0; break; + if (gate.type.startsWith('COMPONENT:')) { + const outputs = evaluateComponent(gate, inputs); + result = outputs[0] || 0; + } else { + switch (gate.type) { + case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break; + case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break; + case 'NOT': result = inputs[0] ? 0 : 1; break; + case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break; + case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break; + case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break; + case 'OUTPUT': result = inputs[0] || 0; break; + } } gate.value = result; return result; @@ -65,7 +114,11 @@ export function evaluateAll(recordWave = false) { setEvaluateAll(evaluateAll); export function findGateAt(x, y) { - return state.gates.find(g => x >= g.x && x <= g.x + GATE_W && y >= g.y && y <= g.y + GATE_H); + return state.gates.find(g => { + const w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W; + const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H; + return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h; + }); } export function findPortAt(x, y) { diff --git a/js/renderer.js b/js/renderer.js index 9b9310d..d8b1390 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -1,7 +1,7 @@ // Canvas rendering — gates, connections, grid -import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js'; +import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js'; import { state } from './state.js'; -import { getInputPorts, getOutputPorts } from './gates.js'; +import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js'; import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js'; let canvas, ctx; @@ -19,7 +19,8 @@ export function resize() { const sidebarW = sidebarOpen ? 340 : 0; canvas.width = window.innerWidth - sidebarW; const waveH = state.waveformVisible ? state.waveformHeight : 0; - canvas.height = window.innerHeight - 48 - waveH; + const editorH = state.componentEditorActive ? 44 : 0; + canvas.height = window.innerHeight - 56 - editorH - waveH; } // Convert screen coords to world coords (accounting for pan/zoom) @@ -31,6 +32,11 @@ export function screenToWorld(sx, sy) { } function drawGate(gate) { + // Component gates have different rendering + if (gate.type.startsWith('COMPONENT:')) { + return drawComponentGate(gate); + } + const color = GATE_COLORS[gate.type]; const isHovered = state.hoveredGate === gate; const isActive = gate.value === 1; @@ -110,6 +116,76 @@ function drawGate(gate) { }); } +function drawComponentGate(gate) { + const isHovered = state.hoveredGate === gate; + const isActive = gate.value === 1; + const w = getComponentWidth(gate); + const h = getComponentHeight(gate); + const color = '#9900ff'; + + if (isActive) { + ctx.shadowColor = color; + ctx.shadowBlur = 20 * state.zoom; + } + + ctx.fillStyle = isActive ? color + '22' : '#14141e'; + ctx.strokeStyle = isHovered ? '#fff' : color; + ctx.lineWidth = (isHovered ? 2.5 : 1.5); + + ctx.beginPath(); + ctx.roundRect(gate.x, gate.y, w, h, 8); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + + // Component name label + ctx.fillStyle = isActive ? '#fff' : color; + ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const componentName = gate.component?.name || 'Component'; + ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2); + + // Small ID label + ctx.font = '9px monospace'; + ctx.fillStyle = '#444'; + ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6); + + // Input ports + getInputPorts(gate).forEach(p => { + const isPortHovered = state.hoveredPort && + state.hoveredPort.gate === gate && + state.hoveredPort.index === p.index && + state.hoveredPort.type === 'input'; + const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index); + const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0; + + ctx.beginPath(); + ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); + ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e'); + ctx.fill(); + ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }); + + // Output ports + getOutputPorts(gate).forEach(p => { + const isPortHovered = state.hoveredPort && + state.hoveredPort.gate === gate && + state.hoveredPort.index === p.index && + state.hoveredPort.type === 'output'; + + ctx.beginPath(); + ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); + ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e'); + ctx.fill(); + ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }); +} + function drawConnection(conn) { const fromGate = state.gates.find(g => g.id === conn.from); const toGate = state.gates.find(g => g.id === conn.to); @@ -181,12 +257,25 @@ function drawPlacingGhost() { if (!state.placingGate) return; ctx.globalAlpha = 0.5; const world = screenToWorld(state.mouseX, state.mouseY); + + 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 count = Math.max(component.inputCount, component.outputCount); + w = 120; + h = Math.max(60, (count + 1) * 25); + } + } + const ghost = { - x: world.x - GATE_W / 2, - y: world.y - GATE_H / 2, + x: world.x - w / 2, + y: world.y - h / 2, type: state.placingGate, value: 0, - id: -1 + id: -1, + component: state.customComponents?.[state.placingGate.substring(10)] }; drawGate(ghost); ctx.globalAlpha = 1; diff --git a/js/state.js b/js/state.js index 5553e3e..6134f66 100644 --- a/js/state.js +++ b/js/state.js @@ -36,5 +36,10 @@ export const state = { simSpeed: 500, // ms per tick // Puzzle/Components - customComponents: {} // { id -> component definition } + customComponents: {}, // { id -> component definition } + + // Component Editor + componentEditorActive: false, + savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor + componentEditorName: '' };