import React, { useState, useEffect, useRef, useCallback } from 'react'; 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 { playEngineStart, playEngineStop } from './engine/uiSounds.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 BottomSheet from './components/BottomSheet.jsx'; import { CHIPTUNE_PRESET } from './presets/chiptune.js'; import { useIsMobile } from './hooks/useIsMobile.js'; import { usePinchZoom } from './hooks/usePinchZoom.js'; import { getModulesByCategory } from './engine/moduleRegistry.js'; export default function App({ onSwitchToGame }) { const [, forceUpdate] = useState(0); const containerRef = useRef(null); const portPositions = useRef({}); const [tempWire, setTempWire] = useState(null); const connectingRef = useRef(null); const [presetModal, setPresetModal] = useState(null); const importRef = useRef(null); const isMobile = useIsMobile(); const [menuOpen, setMenuOpen] = useState(false); // Pinch-to-zoom on mobile const getZoom = useCallback(() => state.zoom, []); const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []); usePinchZoom(containerRef, getZoom, setZoom); // Subscribe to state changes useEffect(() => { const unsub = subscribe(() => forceUpdate(n => n + 1)); return unsub; }, []); // Auto-load on mount, or load chiptune demo if empty useEffect(() => { const loaded = autoLoad(); if (!loaded || state.modules.length === 0) { // Load chiptune demo preset deserialize(CHIPTUNE_PRESET); } }, []); // Auto-save interval useEffect(() => { const interval = setInterval(autoSave, 3000); return () => clearInterval(interval); }, []); // Port position reporting const handlePortPosition = useCallback((moduleId, portName, direction, el) => { const key = `${moduleId}-${portName}-${direction}`; portPositions.current[key] = el; }, []); // Start connecting a wire const handleStartConnect = useCallback((info) => { connectingRef.current = info; const containerRect = containerRef.current.getBoundingClientRect(); setTempWire({ portType: info.portType, startX: info.startX - containerRect.left, startY: info.startY - containerRect.top, endX: info.startX - containerRect.left, endY: info.startY - containerRect.top, }); }, []); // Find port-dot element at pointer position (including nearby) // Robust port detection โ€” searches all port-dots by bounding rect distance // instead of elementFromPoint (which gets blocked by SVG wire overlay) const findPortAtPoint = (clientX, clientY) => { const portDots = document.querySelectorAll('.port-dot[data-module-id]'); let closest = null; let closestDist = 18; for (const dot of portDots) { const rect = dot.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2); if (dist < closestDist) { closestDist = dist; closest = dot; } } return closest; }; // Canvas pointer events const handlePointerDown = useCallback((e) => { 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) { // On mobile (touch), single finger on empty canvas = pan if (isMobile && e.pointerType === 'touch') { state.panning = true; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; e.preventDefault(); return; } state.selectedModuleId = null; emit(); } }, [isMobile]); const handlePointerMove = useCallback((e) => { if (state.panning && state.panStart) { state.camX = e.clientX - state.panStart.x; state.camY = e.clientY - state.panStart.y; emit(); return; } 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; } if (connectingRef.current && containerRef.current) { const containerRect = containerRef.current.getBoundingClientRect(); setTempWire(prev => prev ? { ...prev, endX: e.clientX - containerRect.left, endY: e.clientY - containerRect.top, } : null); } }, []); const handlePointerUp = useCallback((e) => { if (state.panning) { state.panning = false; state.panStart = null; } if (state.dragging) { state.dragging = null; emit(); } // End connecting if (connectingRef.current) { const portEl = findPortAtPoint(e.clientX, e.clientY); if (portEl) { finishConnection(portEl); } connectingRef.current = null; setTempWire(null); } }, []); const finishConnection = (portEl) => { const from = connectingRef.current; if (!from) return; // Read data attributes directly โ€” clean and reliable const targetModuleId = parseInt(portEl.dataset.moduleId); const targetPort = portEl.dataset.portName; const targetDirection = portEl.dataset.portDirection; if (!targetModuleId || !targetPort || !targetDirection) return; if (targetModuleId === from.moduleId && targetPort === from.port) return; // Determine from/to let fromMod, fromPort, toMod, toPort; if (from.direction === 'output' && targetDirection === 'input') { fromMod = from.moduleId; fromPort = from.port; toMod = targetModuleId; toPort = targetPort; } else if (from.direction === 'input' && targetDirection === 'output') { fromMod = targetModuleId; fromPort = targetPort; toMod = from.moduleId; toPort = from.port; } else { return; // same direction โ€” invalid } const connId = addConnection(fromMod, fromPort, toMod, toPort); if (connId && state.isRunning) { const conn = state.connections.find(c => c.id === connId); if (conn) connectWire(conn); } }; const handleWheel = useCallback((e) => { e.preventDefault(); const delta = -e.deltaY * 0.001; state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta)); emit(); }, []); const handleContextMenu = useCallback((e) => e.preventDefault(), []); // Zoom controls (Google Maps style) const handleZoomIn = useCallback(() => { state.zoom = Math.min(3, state.zoom * 1.25); emit(); }, []); const handleZoomOut = useCallback(() => { state.zoom = Math.max(0.3, state.zoom / 1.25); emit(); }, []); const handleZoomReset = useCallback(() => { state.zoom = 1; state.camX = 0; state.camY = 0; emit(); }, []); // Center view on all modules const handleCenterView = useCallback(() => { if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; } const container = containerRef.current; const cw = container?.clientWidth || 800; const ch = container?.clientHeight || 600; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const m of state.modules) { minX = Math.min(minX, m.x); minY = Math.min(minY, m.y); maxX = Math.max(maxX, m.x + 200); maxY = Math.max(maxY, m.y + 150); } const cx = (minX + maxX) / 2 * state.zoom; const cy = (minY + maxY) / 2 * state.zoom; state.camX = cw / 2 - cx; state.camY = ch / 2 - cy; emit(); }, []); const handleToggleAudio = async () => { if (state.isRunning) { stopAudio(); playEngineStop(); } else { await startAudio(); playEngineStart(); } emit(); }; const handleAddModule = (type) => { const x = (-state.camX + 300) / state.zoom + Math.random() * 50; const y = (-state.camY + 200) / state.zoom + Math.random() * 50; addModule(type, x, y); if (state.isRunning) rebuildGraph(); }; const handleImport = async (e) => { const file = e.target.files[0]; if (!file) return; await importPatch(file); emit(); e.target.value = ''; }; const handleLoadDemo = () => { deserialize(CHIPTUNE_PRESET); if (state.isRunning) rebuildGraph(); emit(); }; const handleClearCanvas = () => { if (state.isRunning) stopAudio(); deserialize({ modules: [], connections: [] }); emit(); }; // Flatten all modules for mobile grid const allModuleDefs = Object.values(getModulesByCategory()).flat(); return (
{/* Toolbar */}
{onSwitchToGame && !isMobile && ( )} Reaktor {!isMobile &&
} {!isMobile && ( )} {!isMobile &&
} {!isMobile && (
)} {!isMobile &&
} {!isMobile && ( <>
)} {state.isRunning ? 'โ— LIVE' : 'โ—‹ OFF'} {!isMobile && ( {state.modules.length} modules ยท {state.connections.length} wires )} {isMobile && ( )}
{/* Mobile menu overlay */} {isMobile && menuOpen && (
setMenuOpen(false)}>
e.stopPropagation()}> {onSwitchToGame && ( )}
)} {/* Main canvas area */}
{/* Grid background */} {/* Wire layer */} {/* Modules container */}
{state.modules.map(mod => ( ))}
{/* Zoom controls */}
{/* Desktop palette */} {!isMobile && }
{/* Mobile action bar */} {isMobile && (
)} {/* Mobile bottom sheet with modules */} {isMobile && (
{allModuleDefs.map(def => (
handleAddModule(def.type)}> {def.icon} {def.name}
))}
)} {/* Status bar (hidden on mobile via CSS) */}
Reaktor โ€” MontLab Modular Synth Zoom: {(state.zoom * 100).toFixed(0)}% RClick: pan ยท Wheel: zoom ยท Drag port: wire ยท Click wire: delete
{presetModal && setPresetModal(null)} />}
); }