import { useState, useRef, useCallback, useMemo } from "react"; // ── Design tokens ────────────────────────────────── const T = { bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", }; // ── Split type metadata ──────────────────────────── const SPLIT_TYPES = { y: { label: "Y", desc: "Split evenly", icon: "⬡" }, ab: { label: "A/B", desc: "Route all to A or B", icon: "⇄" }, cascade: { label: "Cascade", desc: "A feeds into B", icon: "⇢" }, a_to_b: { label: "A→B", desc: "Serial path extension", icon: "↷" }, }; // ── Type-to-color mapping ─────────────────────────── const TYPE_COLORS = { od: T.amber, overdrive: T.amber, drive: T.amber, dist: T.red, distortion: T.red, fuzz: "#D060E0", mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue, tremolo: T.blue, vibrato: T.blue, rotary: T.blue, delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0", pitch: "#A060E0", comp: T.green, compressor: T.green, gate: T.green, filter: "#D0A060", wah: "#D0A060", eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040", volume: T.textSec, boost: T.amber, split: "#B080C0", merge: "#80B0C0", }; // ── Type-to-SVG-icon mapping (from pipedal icon set) ── const TYPE_ICON_MAP = { od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion", dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion", fuzz: "fx_distortion", delay: "fx_delay", echo: "fx_delay", reverb: "fx_reverb", chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger", phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator", rotary: "fx_modulator", modulator: "fx_modulator", pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch", comp: "fx_compressor", compressor: "fx_compressor", limiter: "fx_limiter", gate: "fx_gate", noise_gate: "fx_gate", filter: "fx_filter", wah: "fx_filter", eq: "fx_eq", graphic_eq: "fx_eq", parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq", ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial", nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier", amplifier: "fx_amplifier", volume: "fx_constant", level: "fx_constant", boost: "fx_constant", split: "fx_split_a", merge: "fx_mixer", tuner: "fx_analyzer", mixer: "fx_mixer", utility: "fx_utility", plugin: "fx_plugin", instrument: "fx_instrument", reamp: "fx_converter", looper: "fx_oscillator", }; const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/'; // ── SVG Block Icon component ───────────────────────── function BlockIcon({ type, size = 14, style = {} }) { const key = (type || '').toLowerCase().trim(); let icon = 'fx_plugin'; let found = false; for (const [k, v] of Object.entries(TYPE_ICON_MAP)) { if (key === k || key.startsWith(k) || key.includes(k)) { icon = v; found = true; break; } } if (!found && key) { // Try direct match by replacing spaces/special chars const slug = key.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); if (TYPE_ICON_MAP[slug]) icon = TYPE_ICON_MAP[slug]; } return ( {type { e.target.style.display = 'none'; }} /> ); } function getBlockColor(type) { const key = (type || "").toLowerCase().trim(); for (const [k, v] of Object.entries(TYPE_COLORS)) { if (key === k || key.startsWith(k) || key.includes(k)) return v; } return T.amber; } // ── Chain Arrow SVG ────────────────────────────── function ChainArrow({ color = T.border, narrow = false }) { const w = narrow ? 14 : 18; return ( ); } // ── IN / OUT Terminator ───────────────────────── function Terminator({ label, isLeft }) { return (
{!isLeft && }
{label}
{isLeft && }
); } // ── Vertical Connection Bracket ─────────────────── function VertBracket({ height, direction }) { // direction: 'down' (split) or 'up' (merge) const dh = Math.max(height, 18); return ( ); } // ── Split Block Tile ───────────────────────────── function SplitBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) { const splitParams = block.params || {}; const splitType = splitParams.splitType || "y"; const st = SPLIT_TYPES[splitType] || SPLIT_TYPES.y; const enabled = !block.bypassed; const color = getBlockColor("split"); const longPressRef = useRef(null); const handleTouchStart = useCallback(() => { longPressRef.current = setTimeout(() => { longPressRef.current = null; onModelBrowser?.(block.id); }, 500); }, [block.id, onModelBrowser]); const handleTouchEnd = useCallback(() => { if (longPressRef.current) { clearTimeout(longPressRef.current); longPressRef.current = null; } }, []); return (
onSelect(block.id)} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchEnd} onMouseDown={handleTouchEnd} onMouseUp={handleTouchEnd} style={{ minWidth: 130, width: 130, height: 76, borderRadius: 8, background: `linear-gradient(135deg, ${T.panel}, #1A1520)`, border: `2px solid ${selected ? T.amber : color}60`, opacity: enabled ? 1 : 0.35, cursor: "pointer", display: "flex", flexDirection: "column", position: "relative", overflow: "hidden", transition: "all .15s ease", flexShrink: 0, boxShadow: selected ? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22` : "none", transform: selected ? "scale(1.05)" : "scale(1)", }} > {/* Gradient accent */}
{/* BYPASS LED */}
{ e.stopPropagation(); onToggle(block.id); }} style={{ position: "absolute", top: 6, right: 6, width: 10, height: 10, borderRadius: "50%", background: enabled ? T.green : T.textDim, boxShadow: enabled ? `0 0 8px ${T.green}` : "none", transition: "all .15s", cursor: "pointer", zIndex: 2, border: `1px solid ${enabled ? `${T.green}88` : T.border}`, }} /> {/* Content */}
SPLIT {st.label}
{st.desc}
{/* Split path indicators */}
A {splitType === "ab" && (splitParams.activePath === "B" ? "—" : "✓")}
B {splitType === "ab" && splitParams.activePath === "B" ? "✓" : "—"}
); } // ── Merge Block Tile ────────────────────────────── function MergeBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) { const mergeParams = block.params || {}; const enabled = !block.bypassed; const longPressRef = useRef(null); const handleTouchStart = useCallback(() => { longPressRef.current = setTimeout(() => { longPressRef.current = null; onModelBrowser?.(block.id); }, 500); }, [block.id, onModelBrowser]); const handleTouchEnd = useCallback(() => { if (longPressRef.current) { clearTimeout(longPressRef.current); longPressRef.current = null; } }, []); return (
onSelect(block.id)} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchEnd} onMouseDown={handleTouchEnd} onMouseUp={handleTouchEnd} style={{ minWidth: 130, width: 130, height: 76, borderRadius: 8, background: `linear-gradient(135deg, #15201A, ${T.panel})`, border: `2px solid ${selected ? T.amber : "#80B0C0"}60`, opacity: enabled ? 1 : 0.35, cursor: "pointer", display: "flex", flexDirection: "column", position: "relative", overflow: "hidden", transition: "all .15s ease", flexShrink: 0, boxShadow: selected ? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22` : "none", transform: selected ? "scale(1.05)" : "scale(1)", }} >
{ e.stopPropagation(); onToggle(block.id); }} style={{ position: "absolute", top: 6, right: 6, width: 10, height: 10, borderRadius: "50%", background: enabled ? T.green : T.textDim, boxShadow: enabled ? `0 0 8px ${T.green}` : "none", transition: "all .15s", cursor: "pointer", zIndex: 2, border: `1px solid ${enabled ? `${T.green}88` : T.border}`, }} />
MERGE
Blend {mergeParams.blend ?? 100}%
Lv {mergeParams.levelA ?? 100}
Lv {mergeParams.levelB ?? 100}
); } // ── Regular Block Tile ──────────────────────────── function BlockTile({ block, selected, onSelect, onToggle, pathLabel, onModelBrowser }) { const color = getBlockColor(block.type); const enabled = !block.bypassed; const longPressRef = useRef(null); const handleTouchStart = useCallback(() => { longPressRef.current = setTimeout(() => { longPressRef.current = null; onModelBrowser?.(block.id); }, 500); }, [block.id, onModelBrowser]); const handleTouchEnd = useCallback(() => { if (longPressRef.current) { clearTimeout(longPressRef.current); longPressRef.current = null; } }, []); return (
onSelect(block.id)} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchEnd} onMouseDown={handleTouchEnd} onMouseUp={handleTouchEnd} style={{ minWidth: 120, width: 120, height: 76, borderRadius: 8, background: pathLabel === "B" ? "#1A1820" : T.panel, border: `2px solid ${selected ? T.amber : T.border}`, opacity: enabled ? 1 : 0.35, cursor: "pointer", display: "flex", flexDirection: "column", position: "relative", overflow: "hidden", transition: "all .15s ease", flexShrink: 0, boxShadow: selected ? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22` : pathLabel === "B" ? "inset 0 0 0 1px rgba(128,176,192,0.15)" : "none", transform: selected ? "scale(1.05)" : "scale(1)", }} > {/* Color accent strip */}
{/* Drag handle */}
⋮⋮
{/* BYPASS LED */}
{ e.stopPropagation(); onToggle(block.id); }} style={{ position: "absolute", top: 6, right: 6, width: 10, height: 10, borderRadius: "50%", background: enabled ? T.green : T.textDim, boxShadow: enabled ? `0 0 8px ${T.green}` : "none", transition: "all .15s", cursor: "pointer", zIndex: 2, border: `1px solid ${enabled ? `${T.green}88` : T.border}`, }} title={enabled ? "Active — tap to bypass" : "Bypassed — tap to enable"} /> {/* Scribble strip */}
{pathLabel && (
{pathLabel === "A" ? "A" : "B"}
)}
{block.name || block.type || "FX"}
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
); } // ── Empty state placeholder block ────────────────── function EmptyBlock({ label }) { return (
{label || "+"}
); } // ── Chain model builder ────────────────────────── function buildChainModel(blocks) { const splitIdx = blocks.findIndex(b => b.type === "split"); const mergeIdx = blocks.findIndex(b => b.type === "merge"); // No split/merge — simple serial chain if (splitIdx === -1 || mergeIdx === -1) { return { type: "serial", blocks }; } // Split/merge present — parallel routing const beforeSplit = blocks.slice(0, splitIdx); const splitBlock = blocks[splitIdx]; const parallelBlocks = blocks.slice(splitIdx + 1, mergeIdx); const mergeBlock = blocks[mergeIdx]; const afterMerge = blocks.slice(mergeIdx + 1); const pathABlocks = parallelBlocks.filter(b => !b.path || b.path === "A"); const pathBBlocks = parallelBlocks.filter(b => b.path === "B"); return { type: "parallel", beforeSplit, splitBlock, pathABlocks, pathBBlocks, mergeBlock, afterMerge, parallelBlockCount: Math.max(pathABlocks.length, pathBBlocks.length), }; } // ── Block Chain (exported) ───────────────────────── export default function BlockChain({ blocks, selectedBlockId, onSelectBlock, onToggleBlock, onReorder, onAddBlock, onRemoveBlock, onModelBrowser, }) { const scrollRef = useRef(null); const scrollBRef = useRef(null); const [dragIdx, setDragIdx] = useState(null); const [scrollPos, setScrollPos] = useState(0); const model = useMemo(() => buildChainModel(blocks), [blocks]); // ── HTML5 drag-to-reorder ── const handleDragStart = useCallback((e, idx) => { setDragIdx(idx); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(idx)); }, []); const handleDragOver = useCallback((e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }, []); const handleDrop = useCallback((e, dropIdx) => { e.preventDefault(); if (dragIdx !== null && dragIdx !== dropIdx && onReorder) { onReorder(dragIdx, dropIdx); } setDragIdx(null); }, [dragIdx, onReorder]); const handleDragEnd = useCallback(() => { setDragIdx(null); }, []); // ── Auto-scroll selected block into view ── const scrollToSelected = useCallback(() => { if (!scrollRef.current || !selectedBlockId) return; const chainBlocks = model.type === "serial" ? blocks : [ ...model.beforeSplit, model.splitBlock, ...model.pathABlocks, model.mergeBlock, ...model.afterMerge, ].filter(Boolean); const idx = chainBlocks.findIndex(b => b.id === selectedBlockId); if (idx < 0) return; const child = scrollRef.current.children[idx + (model.type === "parallel" ? 1 : 1)]; if (child) child.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); }, [selectedBlockId, model, blocks]); const prevSelected = useRef(selectedBlockId); if (selectedBlockId !== prevSelected.current) { prevSelected.current = selectedBlockId; setTimeout(scrollToSelected, 50); } // ── Sync scroll between rows in parallel mode ── const handleScrollMain = useCallback((e) => { setScrollPos(e.target.scrollLeft); if (scrollBRef.current) { scrollBRef.current.scrollLeft = e.target.scrollLeft; } }, []); const selectedBlock = blocks.find(b => b.id === selectedBlockId); // ── Serial Chain Renderer ── const renderSerialChain = () => (
{blocks.map((block, idx) => (
handleDragStart(e, idx)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }} >
))} {blocks.length === 0 && (
No blocks in chain
)}
); // ── Parallel Chain Renderer ── const renderParallelChain = () => { const { beforeSplit, splitBlock, pathABlocks, pathBBlocks, mergeBlock, afterMerge, parallelBlockCount, } = model; // Build a paired layout for the parallel section const pairs = []; const maxLen = Math.max(pathABlocks.length, pathBBlocks.length); for (let i = 0; i < maxLen; i++) { pairs.push({ a: pathABlocks[i] || null, b: pathBBlocks[i] || null, }); } return (
{/* Row 1: Main path + Path A */}
{/* Pre-split blocks */} {beforeSplit.map((block, idx) => (
))} {/* Split block */}
{/* Path A blocks (parallel section) */} {pathABlocks.map((block, idx) => (
))} {/* Merge block */} {pathABlocks.length > 0 && ( )}
{/* Post-merge blocks */} {afterMerge.map((block, idx) => (
))}
{/* Vertical connector zone */}
{/* Spacer area before split */}
{/* IN terminator + pre-split blocks + split-blocks width as spacer */}
{/* We use absolute positioning relative to the row width */}
{/* Row 2: Path B */}
{/* Align spacer for IN + before split + split block */}
{/* Estimate: IN label (40px) + arrows per pre block + block widths + split block + arrows */} {beforeSplit.length > 0 && (
{beforeSplit.map((b, i) => (
──
))}
)} {/* Show bracket connected from split block */}
{/* Downward arrow indicator */}
{/* Path B blocks */} {pathBBlocks.length === 0 && (
PATH B Add blocks to Path B
)} {pathBBlocks.map((block, idx) => (
))} {/* Upward arrow to merge */} {pathBBlocks.length > 0 && (
)} {/* Gap spacer for merge + after-merge + OUT terminator - we add a flexible spacer */}
{/* Path B label at end */} {pathBBlocks.length > 0 && (
Path B
)}
{/* Path labels */}
A
B
); }; // ── Render ── return (
{model.type === "parallel" ? renderParallelChain() : renderSerialChain()} {/* ── Add/Remove bar ── */}
{selectedBlockId && ( )}
); }