879 lines
32 KiB
React
879 lines
32 KiB
React
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 (
|
|
<img
|
|
src={`${IMG_PATH}${icon}.svg`}
|
|
alt={type || 'fx'}
|
|
style={{
|
|
width: size, height: size, flexShrink: 0,
|
|
filter: 'invert(0.95) brightness(2)',
|
|
objectFit: 'contain',
|
|
...style,
|
|
}}
|
|
onError={(e) => { 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 (
|
|
<svg width={w} height="24" viewBox={`0 0 ${w} 24`} fill="none" style={{ flexShrink: 0 }}>
|
|
<line x1="0" y1="12" x2={w - 6} y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<polyline points={`${w-10},7 ${w-4},12 ${w-10},17`} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── IN / OUT Terminator ─────────────────────────
|
|
function Terminator({ label, isLeft }) {
|
|
return (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 6, flexShrink: 0,
|
|
flexDirection: isLeft ? "row" : "row-reverse",
|
|
}}>
|
|
{!isLeft && <ChainArrow />}
|
|
<div style={{
|
|
padding: "7px 12px", borderRadius: 6, fontSize: 10, fontWeight: 700,
|
|
letterSpacing: ".12em", background: T.blueDim, color: T.blue,
|
|
border: `1px solid ${T.blue}60`, textTransform: "uppercase", whiteSpace: "nowrap",
|
|
}}>
|
|
{label}
|
|
</div>
|
|
{isLeft && <ChainArrow />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Vertical Connection Bracket ───────────────────
|
|
function VertBracket({ height, direction }) {
|
|
// direction: 'down' (split) or 'up' (merge)
|
|
const dh = Math.max(height, 18);
|
|
return (
|
|
<svg width="20" height={dh} viewBox={`0 0 20 ${dh}`} fill="none" style={{ flexShrink: 0 }}>
|
|
<line x1="10" y1="0" x2="10" y2={dh} stroke={T.border} strokeWidth="1.5" />
|
|
<line x1="10" y1={direction === "down" ? 0 : dh - 6}
|
|
x2={direction === "down" ? 16 : 16}
|
|
y2={direction === "down" ? 0 : dh - 6}
|
|
stroke={T.border} strokeWidth="1.5" />
|
|
<polyline points={direction === "down"
|
|
? "14,4 18,0 14,-4"
|
|
: `14,${dh-2} 18,${dh-6} 14,${dh-10}`}
|
|
fill="none" stroke={T.border} strokeWidth="1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div
|
|
draggable
|
|
onClick={() => 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 */}
|
|
<div style={{
|
|
position: "absolute", top: 0, left: 0, right: 0, height: 3,
|
|
background: `linear-gradient(90deg, #B080C0, #80B0C0)`,
|
|
}} />
|
|
{/* BYPASS LED */}
|
|
<div onClick={(e) => { 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 */}
|
|
<div style={{ flex: 1, padding: "14px 10px 6px", display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 700, color: color,
|
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
|
|
}}>
|
|
<BlockIcon type="split" size={16} />
|
|
<span>SPLIT {st.label}</span>
|
|
</div>
|
|
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
|
|
{st.desc}
|
|
</div>
|
|
</div>
|
|
{/* Split path indicators */}
|
|
<div style={{
|
|
display: "flex", borderTop: `1px solid ${T.border}44`,
|
|
}}>
|
|
<div style={{
|
|
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
|
|
padding: "1px 0", color: "#B080C0",
|
|
letterSpacing: ".1em", borderRight: `1px solid ${T.border}44`,
|
|
}}>
|
|
A {splitType === "ab" && (splitParams.activePath === "B" ? "—" : "✓")}
|
|
</div>
|
|
<div style={{
|
|
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
|
|
padding: "1px 0", color: "#80B0C0",
|
|
letterSpacing: ".1em",
|
|
}}>
|
|
B {splitType === "ab" && splitParams.activePath === "B" ? "✓" : "—"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div
|
|
draggable
|
|
onClick={() => 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)",
|
|
}}
|
|
>
|
|
<div style={{
|
|
position: "absolute", top: 0, left: 0, right: 0, height: 3,
|
|
background: `linear-gradient(90deg, #80B0C0, #60A080)`,
|
|
}} />
|
|
<div onClick={(e) => { 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}`,
|
|
}} />
|
|
<div style={{ flex: 1, padding: "14px 10px 6px", display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 700, color: "#80B0C0",
|
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
|
|
}}>
|
|
<BlockIcon type="merge" size={16} />
|
|
<span>MERGE</span>
|
|
</div>
|
|
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
|
|
Blend {mergeParams.blend ?? 100}%
|
|
</div>
|
|
</div>
|
|
<div style={{
|
|
display: "flex", borderTop: `1px solid ${T.border}44`,
|
|
}}>
|
|
<div style={{
|
|
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
|
|
padding: "1px 0", color: "#B080C0",
|
|
letterSpacing: ".1em", borderRight: `1px solid ${T.border}44`,
|
|
}}>
|
|
Lv {mergeParams.levelA ?? 100}
|
|
</div>
|
|
<div style={{
|
|
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
|
|
padding: "1px 0", color: "#80B0C0",
|
|
letterSpacing: ".1em",
|
|
}}>
|
|
Lv {mergeParams.levelB ?? 100}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div
|
|
draggable
|
|
onClick={() => 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 */}
|
|
<div style={{
|
|
position: "absolute", top: 0, left: 0, right: 0, height: 3,
|
|
background: color,
|
|
boxShadow: enabled ? `0 0 6px ${color}` : "none",
|
|
}} />
|
|
{/* Drag handle */}
|
|
<div style={{
|
|
position: "absolute", top: 5, left: 5, fontSize: 9, color: T.textDim,
|
|
letterSpacing: 1.5, lineHeight: 1, cursor: "grab", opacity: 0.5,
|
|
}}>
|
|
⋮⋮
|
|
</div>
|
|
{/* BYPASS LED */}
|
|
<div onClick={(e) => { 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 */}
|
|
<div style={{
|
|
flex: 1, padding: "14px 10px 6px",
|
|
display: "flex", flexDirection: "column", justifyContent: "center",
|
|
}}>
|
|
{pathLabel && (
|
|
<div style={{
|
|
position: "absolute", top: 5, right: 20, fontSize: 7,
|
|
fontWeight: 700, color: pathLabel === "A" ? "#B080C0" : "#80B0C0",
|
|
letterSpacing: ".08em", background: `${T.bg}88`,
|
|
padding: "0 4px", borderRadius: 2, lineHeight: 1.4,
|
|
}}>
|
|
{pathLabel === "A" ? "A" : "B"}
|
|
</div>
|
|
)}
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 600, color: T.textPrimary,
|
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
lineHeight: 1.3, paddingRight: 16,
|
|
}}>
|
|
{block.name || block.type || "FX"}
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
|
|
<BlockIcon type={block.type} size={14} />
|
|
<span style={{
|
|
fontSize: 9, fontWeight: 700, color, letterSpacing: ".08em",
|
|
textTransform: "uppercase", background: `${color}18`,
|
|
padding: "1px 5px", borderRadius: 3,
|
|
}}>
|
|
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Empty state placeholder block ──────────────────
|
|
function EmptyBlock({ label }) {
|
|
return (
|
|
<div style={{
|
|
minWidth: 120, width: 120, height: 76, borderRadius: 8,
|
|
border: `1px dashed ${T.border}44`, flexShrink: 0,
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: 9, color: T.textDim, letterSpacing: ".06em",
|
|
}}>
|
|
{label || "+"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 = () => (
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScrollMain}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
padding: "10px 12px", overflowX: "auto", overflowY: "hidden",
|
|
minHeight: 96, background: T.bg,
|
|
borderBottom: `1px solid ${T.border}`,
|
|
scrollBehavior: "smooth", WebkitOverflowScrolling: "touch",
|
|
}}
|
|
>
|
|
<Terminator label="IN" isLeft />
|
|
{blocks.map((block, idx) => (
|
|
<div key={block.id} draggable
|
|
onDragStart={(e) => handleDragStart(e, idx)}
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, idx)}
|
|
onDragEnd={handleDragEnd}
|
|
style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}
|
|
>
|
|
<BlockTile
|
|
block={block}
|
|
selected={block.id === selectedBlockId}
|
|
index={idx}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
))}
|
|
{blocks.length === 0 && (
|
|
<div style={{
|
|
flexShrink: 0, padding: "22px 20px", color: T.textDim, fontSize: 12,
|
|
textAlign: "center", border: `1px dashed ${T.border}`, borderRadius: 8, minWidth: 140,
|
|
}}>
|
|
No blocks in chain
|
|
</div>
|
|
)}
|
|
<Terminator label="OUT" isLeft={false} />
|
|
</div>
|
|
);
|
|
|
|
// ── 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 (
|
|
<div style={{ background: T.bg, borderBottom: `1px solid ${T.border}`, position: "relative" }}>
|
|
{/* Row 1: Main path + Path A */}
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScrollMain}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
padding: "8px 12px 0", overflowX: "auto", overflowY: "hidden",
|
|
minHeight: 76, scrollBehavior: "smooth",
|
|
WebkitOverflowScrolling: "touch",
|
|
}}
|
|
>
|
|
<Terminator label="IN" isLeft />
|
|
|
|
{/* Pre-split blocks */}
|
|
{beforeSplit.map((block, idx) => (
|
|
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<BlockTile
|
|
block={block}
|
|
selected={block.id === selectedBlockId}
|
|
index={idx}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
))}
|
|
|
|
{/* Split block */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<SplitBlockTile
|
|
block={splitBlock}
|
|
selected={splitBlock.id === selectedBlockId}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
|
|
{/* Path A blocks (parallel section) */}
|
|
{pathABlocks.map((block, idx) => (
|
|
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<BlockTile
|
|
block={block}
|
|
selected={block.id === selectedBlockId}
|
|
index={idx}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
pathLabel="A"
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
))}
|
|
|
|
{/* Merge block */}
|
|
{pathABlocks.length > 0 && (
|
|
<ChainArrow />
|
|
)}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<MergeBlockTile
|
|
block={mergeBlock}
|
|
selected={mergeBlock.id === selectedBlockId}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
|
|
{/* Post-merge blocks */}
|
|
{afterMerge.map((block, idx) => (
|
|
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<BlockTile
|
|
block={block}
|
|
selected={block.id === selectedBlockId}
|
|
index={idx}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
onModelBrowser={onModelBrowser}
|
|
/>
|
|
<ChainArrow />
|
|
</div>
|
|
))}
|
|
|
|
<Terminator label="OUT" isLeft={false} />
|
|
</div>
|
|
|
|
{/* Vertical connector zone */}
|
|
<div style={{
|
|
display: "flex", alignItems: "stretch",
|
|
padding: "0 12px", position: "relative",
|
|
}}>
|
|
{/* Spacer area before split */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0, minHeight: 24 }}>
|
|
{/* IN terminator + pre-split blocks + split-blocks width as spacer */}
|
|
<div style={{ width: "calc(100%)", position: "relative" }}>
|
|
{/* We use absolute positioning relative to the row width */}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Path B */}
|
|
<div
|
|
ref={scrollBRef}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
padding: "2px 12px 8px", overflowX: "auto", overflowY: "hidden",
|
|
minHeight: 76, scrollBehavior: "smooth",
|
|
WebkitOverflowScrolling: "touch",
|
|
borderTop: `1px solid ${T.border}33`,
|
|
}}
|
|
>
|
|
{/* Align spacer for IN + before split + split block */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 4, flexShrink: 0,
|
|
paddingRight: 4, opacity: 0.3, pointerEvents: "none",
|
|
}}>
|
|
{/* Estimate: IN label (40px) + arrows per pre block + block widths + split block + arrows */}
|
|
{beforeSplit.length > 0 && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
{beforeSplit.map((b, i) => (
|
|
<div key={b.id} style={{
|
|
minWidth: 124, height: 20, fontSize: 8, color: T.textDim,
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
}}>
|
|
<span>──</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Show bracket connected from split block */}
|
|
</div>
|
|
|
|
{/* Downward arrow indicator */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 4, flexShrink: 0, opacity: 0.6,
|
|
}}>
|
|
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" style={{ flexShrink: 0 }}>
|
|
<line x1="8" y1="0" x2="8" y2="18" stroke={T.blue} strokeWidth="1.5" strokeDasharray="3,2" />
|
|
<line x1="2" y1="18" x2="18" y2="18" stroke={T.blue} strokeWidth="1.5" />
|
|
<polyline points="14,14 18,18 14,22" fill="none" stroke={T.blue} strokeWidth="1.5" />
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Path B blocks */}
|
|
{pathBBlocks.length === 0 && (
|
|
<div style={{
|
|
flexShrink: 0, padding: "16px 16px", color: T.textDim, fontSize: 10,
|
|
border: `1px dashed ${T.border}44`, borderRadius: 6,
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
}}>
|
|
<span style={{ color: "#80B0C0", fontSize: 8, fontWeight: 700, letterSpacing: ".1em" }}>PATH B</span>
|
|
<span>Add blocks to Path B</span>
|
|
</div>
|
|
)}
|
|
{pathBBlocks.map((block, idx) => (
|
|
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
<BlockTile
|
|
block={block}
|
|
selected={block.id === selectedBlockId}
|
|
index={idx + beforeSplit.length + 1}
|
|
onSelect={onSelectBlock}
|
|
onToggle={onToggleBlock}
|
|
pathLabel="B"
|
|
/>
|
|
<ChainArrow narrow />
|
|
</div>
|
|
))}
|
|
|
|
{/* Upward arrow to merge */}
|
|
{pathBBlocks.length > 0 && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0, opacity: 0.6 }}>
|
|
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" style={{ flexShrink: 0 }}>
|
|
<line x1="14" y1="24" x2="14" y2="6" stroke={T.blue} strokeWidth="1.5" strokeDasharray="3,2" />
|
|
<line x1="4" y1="6" x2="20" y2="6" stroke={T.blue} strokeWidth="1.5" />
|
|
<polyline points="8,10 4,6 8,2" fill="none" stroke={T.blue} strokeWidth="1.5" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gap spacer for merge + after-merge + OUT terminator - we add a flexible spacer */}
|
|
<div style={{ flex: 1, minWidth: 20 }} />
|
|
|
|
{/* Path B label at end */}
|
|
{pathBBlocks.length > 0 && (
|
|
<div style={{
|
|
flexShrink: 0, fontSize: 7, fontWeight: 700, color: "#80B0C0",
|
|
letterSpacing: ".12em", textTransform: "uppercase",
|
|
padding: "0 8px", opacity: 0.5,
|
|
}}>
|
|
Path B
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Path labels */}
|
|
<div style={{
|
|
position: "absolute", left: 0, top: 0, bottom: 0,
|
|
width: 20, display: "flex", flexDirection: "column",
|
|
pointerEvents: "none", opacity: 0.4,
|
|
}}>
|
|
<div style={{
|
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: 8, fontWeight: 700, color: "#B080C0", writingMode: "vertical-rl",
|
|
letterSpacing: ".2em",
|
|
}}>
|
|
A
|
|
</div>
|
|
<div style={{
|
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: 8, fontWeight: 700, color: "#80B0C0", writingMode: "vertical-rl",
|
|
letterSpacing: ".2em",
|
|
}}>
|
|
B
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── Render ──
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
{model.type === "parallel" ? renderParallelChain() : renderSerialChain()}
|
|
|
|
{/* ── Add/Remove bar ── */}
|
|
<div style={{
|
|
display: "flex", gap: 8, padding: "8px 12px",
|
|
borderBottom: `1px solid ${T.border}`, background: T.panel,
|
|
}}>
|
|
<button onClick={onAddBlock} style={{
|
|
flex: 1, padding: "10px", borderRadius: 7, background: T.surface,
|
|
border: `1px dashed ${T.border}`, color: T.textSec, fontSize: 12,
|
|
fontWeight: 600, cursor: "pointer", letterSpacing: ".04em", transition: "all .12s",
|
|
}}>
|
|
+ Add Block
|
|
</button>
|
|
{selectedBlockId && (
|
|
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
|
|
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)",
|
|
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12,
|
|
fontWeight: 600, cursor: "pointer", transition: "all .12s",
|
|
}}>
|
|
✕ Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|