fix: robust wire connections via data attributes + chiptune demo preset
- Replace fragile DOM position matching in finishConnection with data-module-id/data-port-name/data-port-direction attributes - Add nearby port detection (8px radius) for easier connections - Wire glow effects with drop-shadow filters - Port dots z-index above wires for reliable click targeting - Chiptune demo preset: 2x square osc, envelopes, VCAs, mixer, filter, delay, distortion, scope — full 8-bit signal chain - "Chiptune Demo" toolbar button to load the example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
145
src/App.jsx
145
src/App.jsx
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, updateModulePosition } from './engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph, getAudioNode } from './engine/audioEngine.js';
|
||||
import { getModuleDef, PORT_TYPE } from './engine/moduleRegistry.js';
|
||||
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
|
||||
import { getModuleDef } from './engine/moduleRegistry.js';
|
||||
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
|
||||
import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
|
||||
export default function App() {
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -23,11 +24,12 @@ export default function App() {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount
|
||||
// Auto-load on mount, or load chiptune demo if empty
|
||||
useEffect(() => {
|
||||
const loaded = autoLoad();
|
||||
if (loaded && state.isRunning) {
|
||||
startAudio();
|
||||
if (!loaded || state.modules.length === 0) {
|
||||
// Load chiptune demo preset
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -56,43 +58,48 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Find port-dot element at pointer position (including nearby)
|
||||
const findPortAtPoint = (x, y) => {
|
||||
// First try exact hit
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (el && el.classList.contains('port-dot') && el.dataset.moduleId) {
|
||||
return el;
|
||||
}
|
||||
// Try a small radius around the point
|
||||
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
|
||||
const hit = document.elementFromPoint(x + dx, y + dy);
|
||||
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas pointer events
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||
// Middle click or Alt+click: start panning
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 2) {
|
||||
// Right click: pan
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// Left click on empty space: deselect
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
// Panning
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Module dragging
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Temp wire
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
@@ -108,80 +115,35 @@ export default function App() {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
// End connecting — check if we're over a port
|
||||
// End connecting
|
||||
if (connectingRef.current) {
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (target && target.classList.contains('port-dot')) {
|
||||
// Find which module/port this target belongs to
|
||||
const moduleEl = target.closest('.module');
|
||||
if (moduleEl) {
|
||||
finishConnection(target, e);
|
||||
}
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) {
|
||||
finishConnection(portEl);
|
||||
}
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl, e) => {
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
|
||||
// Find target module and port by inspecting DOM
|
||||
// Walk up to .module, find moduleId, then find port
|
||||
const moduleEl = portEl.closest('.module');
|
||||
if (!moduleEl) return;
|
||||
// Read data attributes directly — clean and reliable
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
|
||||
// Get all port-row elements to find index
|
||||
const portRows = moduleEl.querySelectorAll('.port-row');
|
||||
let targetModuleId = null;
|
||||
let targetPort = null;
|
||||
let targetDirection = null;
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
for (const mod of state.modules) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) continue;
|
||||
|
||||
const modX = mod.x * state.zoom;
|
||||
const modY = mod.y * state.zoom;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const moduleRect = moduleEl.getBoundingClientRect();
|
||||
|
||||
// Check if this module element matches
|
||||
const expectedLeft = modX + state.camX + containerRect.left;
|
||||
if (Math.abs(moduleRect.left - expectedLeft) > 5) continue;
|
||||
|
||||
targetModuleId = mod.id;
|
||||
|
||||
// Find which port-dot was clicked
|
||||
const allDots = moduleEl.querySelectorAll('.port-dot');
|
||||
const allInputs = def.inputs.map(p => p.name);
|
||||
const allOutputs = def.outputs.map(p => p.name);
|
||||
|
||||
allDots.forEach((dot, idx) => {
|
||||
if (dot === portEl) {
|
||||
if (idx < allInputs.length) {
|
||||
targetPort = allInputs[idx];
|
||||
targetDirection = 'input';
|
||||
} else {
|
||||
targetPort = allOutputs[idx - allInputs.length];
|
||||
targetDirection = 'output';
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (!targetModuleId || !targetPort) return;
|
||||
if (targetModuleId === from.moduleId) return; // No self-connections
|
||||
|
||||
// Determine from/to based on direction
|
||||
// Determine from/to
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
@@ -190,7 +152,7 @@ export default function App() {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else {
|
||||
return; // Invalid: same direction
|
||||
return; // same direction — invalid
|
||||
}
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
@@ -203,14 +165,12 @@ export default function App() {
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
const newZoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
state.zoom = newZoom;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Toolbar actions
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
@@ -223,10 +183,8 @@ export default function App() {
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
|
||||
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
|
||||
const id = addModule(type, x, y);
|
||||
if (state.isRunning) {
|
||||
rebuildGraph();
|
||||
}
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
@@ -237,6 +195,12 @@ export default function App() {
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleLoadDemo = () => {
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
emit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
@@ -255,6 +219,10 @@ export default function App() {
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
<div className="toolbar-sep" />
|
||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
@@ -286,6 +254,9 @@ export default function App() {
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} />
|
||||
|
||||
{/* Modules container (offset by camera) */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
@@ -298,9 +269,6 @@ export default function App() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Wire layer */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} />
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
@@ -311,10 +279,9 @@ export default function App() {
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
<span>Scroll: pan · Wheel: zoom · Click port + drag: wire · Click wire: delete</span>
|
||||
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
|
||||
</div>
|
||||
|
||||
{/* Preset modal */}
|
||||
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, updateModulePosition, isPortConnected, emit } from '../engine/state.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
@@ -66,7 +66,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{ left: mod.x * zoom, top: mod.y * zoom, transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
onPointerDown={() => { state.selectedModuleId = mod.id; emit(); }}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
// Don't deselect when clicking inside a module
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id; emit();
|
||||
}}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
@@ -81,6 +86,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="input"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
@@ -139,6 +148,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="output"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
|
||||
@@ -73,16 +73,19 @@ html, body, #root {
|
||||
.node-canvas.connecting { cursor: crosshair; }
|
||||
|
||||
.wires-svg {
|
||||
position: absolute; inset: 0; pointer-events: none; z-index: 1;
|
||||
position: absolute; inset: 0; pointer-events: none; z-index: 3;
|
||||
overflow: visible;
|
||||
}
|
||||
.wires-svg path {
|
||||
fill: none; stroke-width: 2.5; stroke-linecap: round;
|
||||
pointer-events: stroke; cursor: pointer;
|
||||
filter: drop-shadow(0 0 3px rgba(0,229,255,0.3));
|
||||
}
|
||||
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.75; }
|
||||
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.75; }
|
||||
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.75; }
|
||||
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; }
|
||||
.wires-svg path:hover { stroke-width: 4; opacity: 1; }
|
||||
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.8; }
|
||||
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.8; }
|
||||
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.8; }
|
||||
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; filter: none; }
|
||||
.wires-svg path:hover { stroke-width: 4; opacity: 1; filter: drop-shadow(0 0 6px rgba(0,229,255,0.6)); }
|
||||
|
||||
/* ===== Modules ===== */
|
||||
.module {
|
||||
@@ -128,7 +131,7 @@ html, body, #root {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border); background: var(--surface);
|
||||
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
||||
position: relative;
|
||||
position: relative; z-index: 5;
|
||||
}
|
||||
.port-dot.audio { border-color: var(--wire-audio); }
|
||||
.port-dot.control { border-color: var(--wire-control); }
|
||||
|
||||
74
src/presets/chiptune.js
Normal file
74
src/presets/chiptune.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Chiptune Demo Preset
|
||||
*
|
||||
* Signal flow:
|
||||
* Keyboard → [freq] Osc1 (square, melody) → VCA1 ← Envelope1 (short plucky)
|
||||
* Keyboard → [freq] Osc2 (square, -12 oct sub bass) → VCA2 ← Envelope2 (bass sustain)
|
||||
* LFO (vibrato) → Osc1 detune
|
||||
* VCA1 + VCA2 → Mixer → Filter (lowpass, slight resonance) → Delay → Distortion (light) → Output
|
||||
* Mixer → Scope (visualization)
|
||||
*
|
||||
* Layout: left-to-right signal flow, neatly arranged
|
||||
*/
|
||||
|
||||
export const CHIPTUNE_PRESET = {
|
||||
modules: [
|
||||
// Row 1: Keyboard & Sources
|
||||
{ id: 1, type: 'keyboard', x: 40, y: 40, params: { octave: 4 } },
|
||||
{ id: 2, type: 'oscillator', x: 280, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
{ id: 3, type: 'oscillator', x: 280, y: 260, params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ id: 4, type: 'lfo', x: 40, y: 280, params: { waveform: 'sine', frequency: 5.5, amplitude: 0.3 } },
|
||||
|
||||
// Row 2: Envelopes & VCAs
|
||||
{ id: 5, type: 'envelope', x: 500, y: 20, params: { attack: 0.005, decay: 0.15, sustain: 0.2, release: 0.1 } },
|
||||
{ id: 6, type: 'envelope', x: 500, y: 240, params: { attack: 0.005, decay: 0.3, sustain: 0.6, release: 0.2 } },
|
||||
{ id: 7, type: 'vca', x: 700, y: 20, params: { gain: 0.7 } },
|
||||
{ id: 8, type: 'vca', x: 700, y: 220, params: { gain: 0.5 } },
|
||||
|
||||
// Row 3: Mixer, processing, output
|
||||
{ id: 9, type: 'mixer', x: 900, y: 60, params: { gain1: 0.8, gain2: 0.6, gain3: 0.0, gain4: 0.0 } },
|
||||
{ id: 10, type: 'filter', x: 1100, y: 40, params: { type: 'lowpass', frequency: 3500, Q: 2.5 } },
|
||||
{ id: 11, type: 'delay', x: 1300, y: 40, params: { delayTime: 0.18, feedback: 0.35, wet: 0.25 } },
|
||||
{ id: 12, type: 'distortion', x: 1300, y: 280, params: { distortion: 0.15, wet: 0.3 } },
|
||||
{ id: 13, type: 'output', x: 1520, y: 120, params: { volume: -8 } },
|
||||
|
||||
// Scope
|
||||
{ id: 14, type: 'scope', x: 900, y: 320, params: {} },
|
||||
],
|
||||
connections: [
|
||||
// Keyboard → Oscillators (freq)
|
||||
{ id: 1, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 2, port: 'freq' } },
|
||||
{ id: 2, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 3, port: 'freq' } },
|
||||
|
||||
// Keyboard → Envelopes (gate)
|
||||
{ id: 3, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 5, port: 'gate' } },
|
||||
{ id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } },
|
||||
|
||||
// LFO → Osc1 detune (vibrato)
|
||||
{ id: 5, from: { moduleId: 4, port: 'out' }, to: { moduleId: 2, port: 'detune' } },
|
||||
|
||||
// Osc1 → VCA1, Envelope1 → VCA1 CV
|
||||
{ id: 6, from: { moduleId: 2, port: 'out' }, to: { moduleId: 7, port: 'in' } },
|
||||
{ id: 7, from: { moduleId: 5, port: 'out' }, to: { moduleId: 7, port: 'cv' } },
|
||||
|
||||
// Osc2 → VCA2, Envelope2 → VCA2 CV
|
||||
{ id: 8, from: { moduleId: 3, port: 'out' }, to: { moduleId: 8, port: 'in' } },
|
||||
{ id: 9, from: { moduleId: 6, port: 'out' }, to: { moduleId: 8, port: 'cv' } },
|
||||
|
||||
// VCA1 + VCA2 → Mixer
|
||||
{ id: 10, from: { moduleId: 7, port: 'out' }, to: { moduleId: 9, port: 'in1' } },
|
||||
{ id: 11, from: { moduleId: 8, port: 'out' }, to: { moduleId: 9, port: 'in2' } },
|
||||
|
||||
// Mixer → Filter → Delay → Distortion → Output
|
||||
{ id: 12, from: { moduleId: 9, port: 'out' }, to: { moduleId: 10, port: 'in' } },
|
||||
{ id: 13, from: { moduleId: 10, port: 'out' }, to: { moduleId: 11, port: 'in' } },
|
||||
{ id: 14, from: { moduleId: 11, port: 'out' }, to: { moduleId: 12, port: 'in' } },
|
||||
{ id: 15, from: { moduleId: 12, port: 'out' }, to: { moduleId: 13, port: 'left' } },
|
||||
{ id: 16, from: { moduleId: 12, port: 'out' }, to: { moduleId: 13, port: 'right' } },
|
||||
|
||||
// Mixer → Scope
|
||||
{ id: 17, from: { moduleId: 9, port: 'out' }, to: { moduleId: 14, port: 'in' } },
|
||||
],
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
masterVolume: -8,
|
||||
};
|
||||
Reference in New Issue
Block a user