@@ -32,6 +54,15 @@
+
+
+
+ Editing Component:
+
+
+
+
+
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: ''
};