- Add touch-action: none on canvas to prevent browser scroll hijack - Single-finger touch on empty canvas now triggers pan (pointerType check) - Fix page bounce on mobile with position: fixed and 100dvh height Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
16 KiB
JavaScript
434 lines
16 KiB
JavaScript
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 { 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);
|
||
|
||
// 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 (
|
||
<div className="app">
|
||
{/* Toolbar */}
|
||
<div className="toolbar">
|
||
{onSwitchToGame && !isMobile && (
|
||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||
🎮 Game
|
||
</button>
|
||
)}
|
||
<span className="toolbar-title">Reaktor</span>
|
||
{!isMobile && <div className="toolbar-sep" />}
|
||
{!isMobile && (
|
||
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||
</button>
|
||
)}
|
||
{!isMobile && <div className="toolbar-sep" />}
|
||
{!isMobile && (
|
||
<div className="toolbar-group">
|
||
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||
</div>
|
||
)}
|
||
{!isMobile && <div className="toolbar-sep" />}
|
||
{!isMobile && (
|
||
<>
|
||
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||
🎮 Chiptune Demo
|
||
</button>
|
||
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||
🗑 Limpiar
|
||
</button>
|
||
<div className="toolbar-sep" />
|
||
</>
|
||
)}
|
||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||
</span>
|
||
{!isMobile && (
|
||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||
{state.modules.length} modules · {state.connections.length} wires
|
||
</span>
|
||
)}
|
||
{isMobile && (
|
||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile menu overlay */}
|
||
{isMobile && menuOpen && (
|
||
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||
{onSwitchToGame && (
|
||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||
🎮 Game
|
||
</button>
|
||
)}
|
||
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||
🎮 Chiptune Demo
|
||
</button>
|
||
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main canvas area */}
|
||
<div className="main-area">
|
||
<div
|
||
ref={containerRef}
|
||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onWheel={handleWheel}
|
||
onContextMenu={handleContextMenu}
|
||
>
|
||
{/* Grid background */}
|
||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||
<defs>
|
||
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
|
||
patternUnits="userSpaceOnUse"
|
||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||
</pattern>
|
||
</defs>
|
||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||
</svg>
|
||
|
||
{/* Wire layer */}
|
||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||
|
||
{/* Modules container */}
|
||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||
{state.modules.map(mod => (
|
||
<ModuleNode
|
||
key={mod.id}
|
||
mod={mod}
|
||
zoom={state.zoom}
|
||
onStartConnect={handleStartConnect}
|
||
onPortPosition={handlePortPosition}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zoom controls */}
|
||
<div className="zoom-controls">
|
||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||
{(state.zoom * 100).toFixed(0)}%
|
||
</button>
|
||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||
</div>
|
||
|
||
{/* Desktop palette */}
|
||
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||
</div>
|
||
|
||
{/* Mobile action bar */}
|
||
{isMobile && (
|
||
<div className="mobile-action-bar">
|
||
<button
|
||
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||
onClick={handleToggleAudio}
|
||
>
|
||
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||
</button>
|
||
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile bottom sheet with modules */}
|
||
{isMobile && (
|
||
<BottomSheet>
|
||
<div className="mobile-module-grid">
|
||
{allModuleDefs.map(def => (
|
||
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||
<span className="tile-icon">{def.icon}</span>
|
||
<span className="tile-name">{def.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</BottomSheet>
|
||
)}
|
||
|
||
{/* Status bar (hidden on mobile via CSS) */}
|
||
<div className="status-bar">
|
||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
|
||
</div>
|
||
|
||
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
|
||
</div>
|
||
);
|
||
}
|