2346 lines
83 KiB
HTML
2346 lines
83 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
{% raw %}
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>mxPIC Core - Canvas</title>
|
||
<link rel="preconnect" href="https://unpkg.com">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||
<script src="https://unpkg.com/reactflow@11/dist/umd/index.js" crossorigin></script>
|
||
<link rel="stylesheet" href="https://unpkg.com/reactflow@11/dist/style.css" />
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4/dist/js-yaml.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg-main: #0f172a;
|
||
--bg-card: #1e293b;
|
||
--text-main: #f8fafc;
|
||
--text-muted: #94a3b8;
|
||
--accent: #38bdf8;
|
||
--accent-hover: #0284c7;
|
||
--border: #334155;
|
||
--input-bg: #0b1120;
|
||
}
|
||
|
||
.left-block {
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.left-block-body {
|
||
min-height: 0;
|
||
flex: 1 1 0;
|
||
}
|
||
|
||
.project-tree-scroll {
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.project-tree-scroll details {
|
||
display: block;
|
||
}
|
||
|
||
.project-tree-scroll summary {
|
||
display: block;
|
||
list-style: none;
|
||
}
|
||
|
||
.project-tree-scroll summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
|
||
body,
|
||
html,
|
||
#root {
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: 'Inter', sans-serif;
|
||
background-color: var(--bg-main);
|
||
color: var(--text-main);
|
||
overflow: hidden;
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: var(--bg-main);
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
details {
|
||
margin-left: 8px;
|
||
}
|
||
|
||
summary {
|
||
cursor: pointer;
|
||
padding: 4px 0;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.tree-folder summary {
|
||
font-weight: 500;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.component-leaf {
|
||
cursor: grab;
|
||
padding: 4px 6px;
|
||
margin-left: 15px;
|
||
margin-top: 2px;
|
||
word-break: break-all;
|
||
white-space: normal;
|
||
border-radius: 4px;
|
||
color: var(--text-muted);
|
||
transition: background 0.2s ease, color 0.2s ease;
|
||
}
|
||
|
||
.component-leaf:hover {
|
||
background: var(--border);
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.component-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
cursor: grab;
|
||
margin-left: 15px;
|
||
margin-top: 4px;
|
||
margin-bottom: 4px;
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--border);
|
||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||
width: 120px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.component-card:hover {
|
||
background: var(--bg-card);
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.15);
|
||
}
|
||
|
||
.component-card-icon {
|
||
width: 100%;
|
||
height: 48px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 6px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.component-card-icon img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.component-card-name {
|
||
font-size: 0.7rem;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
word-break: break-all;
|
||
line-height: 1.2;
|
||
pointer-events: none;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.left-block,
|
||
.right-block {
|
||
background: var(--bg-main);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.left-block-header,
|
||
.right-block-header {
|
||
background: var(--bg-card);
|
||
padding: 2px 6px;
|
||
font-weight: 600;
|
||
font-size: 0.85em;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.left-block-body,
|
||
.right-block-body {
|
||
padding: 12px;
|
||
font-size: 0.85em;
|
||
min-height: 0;
|
||
}
|
||
|
||
.placeholder-block {
|
||
border: 1px dashed var(--border);
|
||
padding: 12px;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
background: var(--bg-main);
|
||
}
|
||
|
||
.toggle-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5em;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
transition: background 0.2s ease, color 0.2s ease;
|
||
width: 36px;
|
||
height: 36px;
|
||
line-height: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.toggle-btn:hover {
|
||
background: var(--border);
|
||
color: var(--text-main);
|
||
}
|
||
|
||
input[type="number"],
|
||
input[type="text"] {
|
||
background-color: var(--input-bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-main);
|
||
font-family: inherit;
|
||
font-size: 0.9em;
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
input[type="number"]:focus,
|
||
input[type="text"]:focus {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
||
}
|
||
|
||
label {
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.react-flow__controls button {
|
||
background-color: var(--bg-card) !important;
|
||
border-bottom: 1px solid var(--border) !important;
|
||
fill: var(--text-main) !important;
|
||
}
|
||
|
||
.react-flow__controls button:hover {
|
||
background-color: var(--border) !important;
|
||
}
|
||
|
||
.canvas-tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 8px;
|
||
height: 36px;
|
||
gap: 4px;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.canvas-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px 12px;
|
||
border-radius: 4px;
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.85em;
|
||
transition: background 0.2s, color 0.2s;
|
||
}
|
||
|
||
.canvas-tab.active {
|
||
background: #ffffff;
|
||
color: #1e293b;
|
||
border-color: #ffffff;
|
||
}
|
||
|
||
.canvas-tab button {
|
||
background: none;
|
||
border: none;
|
||
color: inherit;
|
||
margin-left: 6px;
|
||
cursor: pointer;
|
||
font-size: 1.2em;
|
||
line-height: 1;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.left-block {
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.left-block-body {
|
||
min-height: 0;
|
||
}
|
||
|
||
.project-tree-scroll {
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.project-tree-scroll details {
|
||
display: block;
|
||
}
|
||
|
||
.project-tree-scroll summary {
|
||
display: block;
|
||
list-style: none;
|
||
}
|
||
|
||
.project-tree-scroll summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef, useCallback, useMemo, memo } = React;
|
||
const {
|
||
ReactFlow,
|
||
ReactFlowProvider,
|
||
useNodesState,
|
||
useEdgesState,
|
||
Controls,
|
||
Background,
|
||
useReactFlow,
|
||
addEdge,
|
||
Handle,
|
||
Position,
|
||
useUpdateNodeInternals,
|
||
applyNodeChanges,
|
||
applyEdgeChanges,
|
||
} = window.ReactFlow;
|
||
|
||
|
||
const iconPromiseCache = {};
|
||
function fetchIcon(category) {
|
||
if (!iconPromiseCache[category]) {
|
||
let resolveFn;
|
||
const promise = new Promise((resolve) => {
|
||
resolveFn = resolve;
|
||
});
|
||
iconPromiseCache[category] = {
|
||
promise,
|
||
result: undefined,
|
||
resolved: false,
|
||
};
|
||
const url = `/api/icon/${category}`;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
iconPromiseCache[category].result = url;
|
||
iconPromiseCache[category].resolved = true;
|
||
resolveFn(url);
|
||
};
|
||
img.onerror = () => {
|
||
iconPromiseCache[category].result = null;
|
||
iconPromiseCache[category].resolved = true;
|
||
resolveFn(null);
|
||
};
|
||
img.src = url;
|
||
}
|
||
return iconPromiseCache[category];
|
||
}
|
||
|
||
|
||
const IconImg = memo(({ category, containerStyle }) => {
|
||
const [src, setSrc] = useState(() => {
|
||
if (!category) return undefined;
|
||
const cache = fetchIcon(category);
|
||
return cache.resolved ? cache.result : undefined;
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!category) {
|
||
setSrc(undefined);
|
||
return;
|
||
}
|
||
|
||
const cache = fetchIcon(category);
|
||
if (cache.resolved) {
|
||
if (src !== cache.result) {
|
||
setSrc(cache.result);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
cache.promise.then((result) => {
|
||
if (!cancelled) setSrc(result);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [category, src]);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
...containerStyle,
|
||
}}
|
||
>
|
||
{src !== undefined && src !== null && (
|
||
<img
|
||
src={src}
|
||
alt={category}
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '100%',
|
||
objectFit: 'contain',
|
||
pointerEvents: 'none',
|
||
}}
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none';
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}, (prevProps, nextProps) => prevProps.category === nextProps.category);
|
||
|
||
|
||
|
||
const RotatableNode = memo(({ id, data, selected }) => {
|
||
const updateNodeInternals = useUpdateNodeInternals();
|
||
const prevRotationRef = useRef(data.rotation);
|
||
const updateNodeInternalsRef = useRef(updateNodeInternals);
|
||
|
||
useEffect(() => {
|
||
updateNodeInternalsRef.current = updateNodeInternals;
|
||
}, [updateNodeInternals]);
|
||
|
||
useEffect(() => {
|
||
if (prevRotationRef.current !== data.rotation) {
|
||
updateNodeInternalsRef.current(id);
|
||
prevRotationRef.current = data.rotation;
|
||
}
|
||
}, [data.rotation, id]);
|
||
|
||
const baseHandleStyle = {
|
||
width: 10, height: 10,
|
||
background: 'var(--bg-main)',
|
||
border: '2px solid var(--accent)',
|
||
borderRadius: '50%',
|
||
};
|
||
const leftTopPort = { ...baseHandleStyle, top: '24%', transform: 'translate(-50%, -50%)' };
|
||
const leftBottomPort = { ...baseHandleStyle, top: '76%', transform: 'translate(-50%, -50%)' };
|
||
const rightTopPort = { ...baseHandleStyle, top: '24%', transform: 'translate(50%, -50%)' };
|
||
const rightBottomPort = { ...baseHandleStyle, top: '76%', transform: 'translate(50%, -50%)' };
|
||
|
||
return (
|
||
<div style={{
|
||
padding: '10px 15px',
|
||
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||
borderRadius: 6,
|
||
background: 'var(--bg-card)',
|
||
color: 'var(--text-main)',
|
||
minWidth: 100,
|
||
maxWidth: 140,
|
||
textAlign: 'center',
|
||
position: 'relative', transform: `rotate(${data.rotation || 0}deg)`,
|
||
transition: 'none',
|
||
boxSizing: 'border-box',
|
||
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
|
||
fontFamily: "'Inter', sans-serif",
|
||
}}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||
{!data.hideIcon && data.category && (
|
||
<div style={{ width: 128, height: 64 }}>
|
||
<IconImg category={data.category} />
|
||
</div>
|
||
)}
|
||
|
||
<div style={{
|
||
fontSize: '0.5rem',
|
||
color: 'var(--text-muted)',
|
||
width: '100%',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap'
|
||
}} title={data.componentDisplayName}>
|
||
{data.componentDisplayName}
|
||
</div>
|
||
</div>
|
||
|
||
<Handle type="source" position={Position.Left} id="port-lt-source" style={{ ...leftTopPort, zIndex: 10 }} />
|
||
<Handle type="target" position={Position.Left} id="port-lt-target" style={{ ...leftTopPort, zIndex: 5 }} />
|
||
<Handle type="source" position={Position.Left} id="port-lb-source" style={{ ...leftBottomPort, zIndex: 10 }} />
|
||
<Handle type="target" position={Position.Left} id="port-lb-target" style={{ ...leftBottomPort, zIndex: 5 }} />
|
||
<Handle type="source" position={Position.Right} id="port-rt-source" style={{ ...rightTopPort, zIndex: 10 }} />
|
||
<Handle type="target" position={Position.Right} id="port-rt-target" style={{ ...rightTopPort, zIndex: 5 }} />
|
||
<Handle type="source" position={Position.Right} id="port-rb-source" style={{ ...rightBottomPort, zIndex: 10 }} />
|
||
<Handle type="target" position={Position.Right} id="port-rb-target" style={{ ...rightBottomPort, zIndex: 5 }} />
|
||
</div>
|
||
);
|
||
}, (prevProps, nextProps) => {
|
||
return (
|
||
prevProps.id === nextProps.id &&
|
||
prevProps.selected === nextProps.selected &&
|
||
prevProps.data.componentDisplayName === nextProps.data.componentDisplayName &&
|
||
prevProps.data.category === nextProps.data.category &&
|
||
prevProps.data.rotation === nextProps.data.rotation &&
|
||
prevProps.data.hideIcon === nextProps.data.hideIcon
|
||
);
|
||
});
|
||
|
||
|
||
|
||
|
||
const PortNode = ({ id, data, selected }) => {
|
||
const angle = data.angle ?? 0;
|
||
return (
|
||
<div style={{
|
||
width: 30, height: 30, borderRadius: '50%',
|
||
background: selected ? 'var(--accent)' : 'var(--bg-card)',
|
||
border: selected ? '2px solid white' : '2px solid var(--accent)',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
color: selected ? 'white' : 'var(--accent)',
|
||
fontSize: 10, fontWeight: 'bold',
|
||
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
|
||
transform: `rotate(${angle}deg)`,
|
||
}}>
|
||
<span>C</span>
|
||
<Handle type="source" position={Position.Right} id="port-source" style={{ background: 'var(--accent)', width: 8, height: 8 }} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
const TreeNode = ({ name, children }) => {
|
||
if (children && children.__type__ === 'component') {
|
||
const componentName = children.__name__;
|
||
const componentCategory = children.__category__ || 'default';
|
||
const dragStartPos = useRef(null);
|
||
const dragReady = useRef(false);
|
||
|
||
const handleMouseDown = (event) => {
|
||
dragStartPos.current = { x: event.clientX, y: event.clientY };
|
||
dragReady.current = false;
|
||
};
|
||
|
||
const handleMouseMove = (event) => {
|
||
if (!dragStartPos.current) return;
|
||
const dx = event.clientX - dragStartPos.current.x;
|
||
const dy = event.clientY - dragStartPos.current.y;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||
dragReady.current = true;
|
||
}
|
||
};
|
||
|
||
const handleDragStart = (event) => {
|
||
if (!dragReady.current) {
|
||
event.preventDefault();
|
||
return false;
|
||
}
|
||
const dragData = JSON.stringify({ name: componentName, category: componentCategory });
|
||
console.log("🚀 DRAG START: Sending data ->", dragData);
|
||
event.dataTransfer.setData('application/reactflow', dragData);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
dragStartPos.current = null;
|
||
dragReady.current = false;
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
dragStartPos.current = null;
|
||
dragReady.current = false;
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="component-card"
|
||
draggable
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onDragStart={handleDragStart}
|
||
>
|
||
<div className="component-card-icon">
|
||
<IconImg category={componentCategory} />
|
||
</div>
|
||
<div className="component-card-name" title={name}>
|
||
{name}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const hasChildren = children && Object.keys(children).length > 0;
|
||
return (
|
||
<details>
|
||
<summary className="tree-folder">
|
||
<span style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>📂 {name}</span>
|
||
</summary>
|
||
{hasChildren &&
|
||
Object.entries(children).map(([childName, childData]) => (
|
||
<TreeNode key={childName} name={childName} children={childData} />
|
||
))
|
||
}
|
||
</details>
|
||
);
|
||
};
|
||
|
||
const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject }) => {
|
||
if (children && children.__type__ === 'project') {
|
||
const projectName = children.__name__ || name;
|
||
const composites = children.composites || [];
|
||
const handleDoubleClick = () => {
|
||
if (onOpenProject) onOpenProject(projectName);
|
||
};
|
||
return (
|
||
<details>
|
||
<summary className="tree-folder" onDoubleClick={handleDoubleClick} style={{ cursor: 'pointer' }}>
|
||
📁 {name}
|
||
</summary>
|
||
{composites.map(comp => (
|
||
<ProjectTreeNode key={comp.pageId || comp.__name__} name={comp.__name__} children={comp} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
|
||
))}
|
||
</details>
|
||
);
|
||
}
|
||
|
||
if (children && children.__type__ === 'composite') {
|
||
const compositeName = children.__name__ || name;
|
||
const tree = children.tree || {};
|
||
const handleDragStart = (event) => {
|
||
const dragData = JSON.stringify({ name: compositeName, type: 'composite' });
|
||
event.dataTransfer.setData('application/reactflow', dragData);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
};
|
||
const handleDoubleClick = () => {
|
||
if (onOpenComposite) onOpenComposite(compositeName);
|
||
};
|
||
return (
|
||
<details>
|
||
<summary className="tree-folder" draggable onDragStart={handleDragStart} onDoubleClick={handleDoubleClick}>
|
||
❖ {name}
|
||
</summary>
|
||
{Object.keys(tree).length > 0 ? (
|
||
Object.entries(tree).map(([childName, childData]) => (
|
||
<CompositeComponentTree key={childName} name={childName} children={childData} />
|
||
))
|
||
) : (
|
||
<div style={{ marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>No components</div>
|
||
)}
|
||
</details>
|
||
);
|
||
}
|
||
|
||
if (children && children.__type__ === 'technology') {
|
||
return (
|
||
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||
{name}: {children.description || '(empty)'}
|
||
</div>
|
||
);
|
||
}
|
||
if (children && children.__type__ === 'block') {
|
||
return (
|
||
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||
{name}: {children.description || '(empty)'}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const hasChildren = children && typeof children === 'object' && Object.keys(children).length > 0 && !children.__type__;
|
||
if (!hasChildren) {
|
||
return (
|
||
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)' }}>
|
||
{name}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<details>
|
||
<summary className="tree-folder">
|
||
<span style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>📁 {name}</span>
|
||
</summary>
|
||
{Object.entries(children).map(([childName, childData]) => (
|
||
<ProjectTreeNode key={childName} name={childName} children={childData} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
|
||
))}
|
||
</details>
|
||
);
|
||
};
|
||
|
||
const CompositeComponentTree = ({ name, children }) => {
|
||
if (children && children.__type__ === 'component') {
|
||
const instances = children.instances || [];
|
||
const displayText = instances.length > 0
|
||
? instances.join(', ')
|
||
: (children.__name__ || name);
|
||
|
||
return (
|
||
<div className="component-leaf" style={{ marginLeft: 15 }}>
|
||
<span style={{ color: 'var(--accent)', marginRight: '4px' }}>❖</span>
|
||
{displayText}
|
||
</div>
|
||
);
|
||
}
|
||
if (children && typeof children === 'object' && !children.__type__) {
|
||
const hasChildren = Object.keys(children).length > 0;
|
||
return (
|
||
<details>
|
||
<summary className="tree-folder" style={{ marginLeft: 8 }}>
|
||
📂 {name}
|
||
</summary>
|
||
{hasChildren &&
|
||
Object.entries(children).map(([childName, childData]) => (
|
||
<CompositeComponentTree key={childName} name={childName} children={childData} />
|
||
))
|
||
}
|
||
</details>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, activePage, onPortChange, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => {
|
||
const [portX, setPortX] = useState('');
|
||
const [portY, setPortY] = useState('');
|
||
const [portA, setPortA] = useState('');
|
||
const [activeBlock, setActiveBlock] = useState('project');
|
||
|
||
useEffect(() => {
|
||
if (activePage) {
|
||
setPortX(activePage.port.x.toString());
|
||
setPortY(activePage.port.y.toString());
|
||
setPortA(activePage.port.a.toString());
|
||
} else {
|
||
setPortX('');
|
||
setPortY('');
|
||
setPortA('');
|
||
}
|
||
}, [activePage?.id, activePage?.port.x, activePage?.port.y, activePage?.port.a]);
|
||
|
||
const handleSubmitX = () => {
|
||
if (!activePage) return;
|
||
const val = parseFloat(portX);
|
||
if (!isNaN(val)) {
|
||
onPortChange(activePage.id, { ...activePage.port, x: val });
|
||
} else {
|
||
setPortX(activePage.port.x.toString());
|
||
}
|
||
};
|
||
|
||
const handleSubmitY = () => {
|
||
if (!activePage) return;
|
||
const val = parseFloat(portY);
|
||
if (!isNaN(val)) {
|
||
onPortChange(activePage.id, { ...activePage.port, y: val });
|
||
} else {
|
||
setPortY(activePage.port.y.toString());
|
||
}
|
||
};
|
||
|
||
const handleSubmitA = () => {
|
||
if (!activePage) return;
|
||
const val = parseFloat(portA);
|
||
if (!isNaN(val)) {
|
||
onPortChange(activePage.id, { ...activePage.port, a: val });
|
||
} else {
|
||
setPortA(activePage.port.a.toString());
|
||
}
|
||
};
|
||
|
||
const handleProjectToggle = () => {
|
||
if (!projectExpanded) {
|
||
setActiveBlock('project');
|
||
}
|
||
onProjectToggle();
|
||
};
|
||
|
||
const handleLibraryToggle = () => {
|
||
if (!expanded) {
|
||
setActiveBlock('library');
|
||
}
|
||
onToggle();
|
||
};
|
||
|
||
return (
|
||
<aside style={{
|
||
width: width, background: 'var(--bg-card)', borderRight: '1px solid var(--border)',
|
||
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
|
||
boxSizing: 'border-box', overflow: 'hidden', gap: 12
|
||
}}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0, overflowY: 'auto', gap: 12 }}>
|
||
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: activeBlock === 'project' ? '3 1 0' : '1 1 0' }}>
|
||
<div className="left-block-header">
|
||
<span>Project Tree</span>
|
||
<button className="toggle-btn" onClick={handleProjectToggle} title={projectExpanded ? 'Collapse all' : 'Expand all'}>
|
||
{projectExpanded ? '▾' : '▸'}
|
||
</button>
|
||
</div>
|
||
<div className="left-block-body project-tree-scroll" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={projectTreeKey} ref={projectTreeRef}>
|
||
{projectTreeItems && projectTreeItems.length > 0 ? (
|
||
projectTreeItems.map(item => {
|
||
if (item.type === 'project') {
|
||
return (
|
||
<ProjectTreeNode key={item.name} name={item.name} children={{ __type__: 'project', __name__: item.name, composites: item.composites }} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
|
||
);
|
||
} else {
|
||
return (
|
||
<ProjectTreeNode key={item.name} name={item.name} children={{ __type__: 'composite', __name__: item.name, tree: item.tree || {}, pageId: item.pageId }} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
|
||
);
|
||
}
|
||
})
|
||
) : (
|
||
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No project loaded</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: activeBlock === 'library' ? '3 1 0' : '1 1 0' }}>
|
||
<div className="left-block-header">
|
||
<span>PDK Libraries</span>
|
||
<button className="toggle-btn" onClick={handleLibraryToggle} title={expanded ? 'Collapse all' : 'Expand all'}>
|
||
{expanded ? '▾' : '▸'}
|
||
</button>
|
||
</div>
|
||
<div className="left-block-body" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={treeKey} ref={treeRef}>
|
||
{library && Object.keys(library).length > 0 ? (
|
||
Object.entries(library).map(([key, value]) => (
|
||
<TreeNode key={key} name={key} children={value} />
|
||
))
|
||
) : (
|
||
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Loading library...</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="left-block" style={{ flexShrink: 0, minHeight: 180 }}>
|
||
<div className="left-block-header">Canva</div>
|
||
<div className="left-block-body" style={{ color: 'var(--text-muted)' }}>
|
||
{activePage ? (
|
||
<div>
|
||
<label>X Coordinate</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={portX}
|
||
onChange={(e) => setPortX(e.target.value)}
|
||
onBlur={handleSubmitX}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitX(); }}
|
||
/>
|
||
<br />
|
||
<label>Y Coordinate</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={portY}
|
||
onChange={(e) => setPortY(e.target.value)}
|
||
onBlur={handleSubmitY}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitY(); }}
|
||
/>
|
||
<br />
|
||
<label>Angle (deg)</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={portA}
|
||
onChange={(e) => setPortA(e.target.value)}
|
||
onBlur={handleSubmitA}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitA(); }}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p style={{ fontStyle: 'italic' }}>No canvas open</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
const RightPanel = ({ selectedNode, width, onRenameComponent, onUpdateNode }) => {
|
||
const [componentData, setComponentData] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [enlarged, setEnlarged] = useState(null);
|
||
const [editingComponentName, setEditingComponentName] = useState(false);
|
||
const [tempComponentName, setTempComponentName] = useState('');
|
||
const [localX, setLocalX] = useState('');
|
||
const [localY, setLocalY] = useState('');
|
||
const [localRotation, setLocalRotation] = useState('');
|
||
|
||
useEffect(() => {
|
||
const nodeId = selectedNode?.id;
|
||
if (!nodeId) {
|
||
setComponentData(null);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const compName = selectedNode?.data?.componentName;
|
||
if (!compName) {
|
||
setComponentData(null);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return;
|
||
|
||
setLoading(true);
|
||
fetch(`/api/component/${encodeURIComponent(compName)}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name });
|
||
setLoading(false);
|
||
})
|
||
.catch(() => setLoading(false));
|
||
}, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName]);
|
||
|
||
useEffect(() => {
|
||
if (selectedNode) {
|
||
setLocalX(selectedNode.position.x.toFixed(3));
|
||
setLocalY(selectedNode.position.y.toFixed(3));
|
||
const rot = selectedNode.id === 'page-port'
|
||
? (selectedNode.data?.angle ?? 0)
|
||
: (selectedNode.data?.rotation ?? 0);
|
||
setLocalRotation(rot.toFixed(3));
|
||
}
|
||
}, [selectedNode?.position.x, selectedNode?.position.y, selectedNode?.data?.rotation, selectedNode?.data?.angle, selectedNode?.id]);
|
||
|
||
const updatePosition = useCallback((id, axis, value) => {
|
||
const val = parseFloat(value);
|
||
if (isNaN(val)) return;
|
||
onUpdateNode(id, { position: { [axis]: val } });
|
||
}, [onUpdateNode]);
|
||
|
||
const updateRotation = useCallback((id, value) => {
|
||
const val = parseFloat(value);
|
||
if (isNaN(val)) return;
|
||
const clamped = Math.min(180, Math.max(-180, val));
|
||
const dataField = id === 'page-port' ? { angle: clamped } : { rotation: clamped };
|
||
onUpdateNode(id, { data: dataField });
|
||
}, [onUpdateNode]);
|
||
|
||
const formatPort = (port) => {
|
||
if (!port) return '—';
|
||
return `x:${port.x ?? '?'}, y:${port.y ?? '?'}, a:${port.a ?? '?'}, w:${port.width ?? '?'}`;
|
||
};
|
||
|
||
const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || '';
|
||
|
||
const handleStartEditName = () => {
|
||
setTempComponentName(currentComponentDisplayName);
|
||
setEditingComponentName(true);
|
||
};
|
||
|
||
const handleSaveName = () => {
|
||
const newName = tempComponentName.trim();
|
||
if (newName && selectedNode) {
|
||
onRenameComponent(selectedNode.id, newName);
|
||
}
|
||
setEditingComponentName(false);
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === 'Enter') {
|
||
handleSaveName();
|
||
} else if (e.key === 'Escape') {
|
||
setEditingComponentName(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<aside style={{
|
||
width: width, background: 'var(--bg-card)', borderLeft: '1px solid var(--border)',
|
||
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
|
||
boxSizing: 'border-box', overflowY: 'auto'
|
||
}}>
|
||
<div className="right-block" style={{ flexShrink: 0, minHeight: 200 }}>
|
||
<div className="right-block-header">Transforms</div>
|
||
<div className="right-block-body">
|
||
{selectedNode ? (
|
||
<div>
|
||
<label>X Coordinate</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={localX}
|
||
onChange={(e) => setLocalX(e.target.value)}
|
||
onBlur={() => {
|
||
const val = parseFloat(localX);
|
||
if (!isNaN(val) && selectedNode) {
|
||
updatePosition(selectedNode.id, 'x', val);
|
||
setLocalX(val.toFixed(3));
|
||
} else if (selectedNode) {
|
||
setLocalX(selectedNode.position.x.toFixed(3));
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
<br /><br />
|
||
<label>Y Coordinate</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={localY}
|
||
onChange={(e) => setLocalY(e.target.value)}
|
||
onBlur={() => {
|
||
const val = parseFloat(localY);
|
||
if (!isNaN(val) && selectedNode) {
|
||
updatePosition(selectedNode.id, 'y', val);
|
||
setLocalY(val.toFixed(3));
|
||
} else if (selectedNode) {
|
||
setLocalY(selectedNode.position.y.toFixed(3));
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
<br /><br />
|
||
<label>Angle (deg)</label>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
value={localRotation}
|
||
onChange={(e) => setLocalRotation(e.target.value)}
|
||
onBlur={() => {
|
||
const val = parseFloat(localRotation);
|
||
if (!isNaN(val) && selectedNode) {
|
||
updateRotation(selectedNode.id, val);
|
||
setLocalRotation(val.toFixed(3));
|
||
} else if (selectedNode) {
|
||
const rot = selectedNode.id === 'page-port'
|
||
? (selectedNode.data?.angle ?? 0)
|
||
: (selectedNode.data?.rotation ?? 0);
|
||
setLocalRotation(rot.toFixed(3));
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', textAlign: 'center' }}>Select a node to inspect</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedNode?.data?.componentName && (
|
||
<div className="right-block" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||
<div className="right-block-header">Parameters</div>
|
||
<div className="right-block-body" style={{ flex: 1, overflowY: 'auto' }}>
|
||
{loading ? (
|
||
<p style={{ color: 'var(--text-muted)' }}>Loading data...</p>
|
||
) : componentData ? (
|
||
<>
|
||
<div style={{ marginBottom: '15px' }}>
|
||
<label>Instance Name</label>
|
||
{editingComponentName ? (
|
||
<input
|
||
type="text"
|
||
value={tempComponentName}
|
||
onChange={(e) => setTempComponentName(e.target.value)}
|
||
onBlur={handleSaveName}
|
||
onKeyDown={handleKeyDown}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<div
|
||
style={{
|
||
cursor: 'pointer',
|
||
padding: '6px 8px',
|
||
backgroundColor: 'var(--input-bg)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '4px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
wordBreak: 'break-all',
|
||
color: 'var(--accent)'
|
||
}}
|
||
onClick={handleStartEditName}
|
||
title="Click to edit"
|
||
>
|
||
<span>{currentComponentDisplayName || componentData.name}</span>
|
||
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>✎</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ color: 'var(--text-muted)', lineHeight: '1.6' }}>
|
||
<p style={{ margin: '0 0 8px 0', wordBreak: 'break-all' }}>
|
||
<strong style={{ color: 'var(--text-main)' }}>Cell:</strong> {componentData.name}
|
||
</p>
|
||
<p style={{ margin: '0 0 8px 0' }}>
|
||
<strong style={{ color: 'var(--text-main)' }}>Foundry:</strong> {componentData.foundry}<br />
|
||
<strong style={{ color: 'var(--text-main)' }}>Process:</strong> {componentData.process}
|
||
</p>
|
||
</div>
|
||
|
||
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '4px' }}>Ports:</p>
|
||
<ul style={{ paddingLeft: 15, margin: '0 0 15px 0', color: 'var(--text-muted)' }}>
|
||
{componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
|
||
<li key={portName} style={{ letterSpacing: '0.5px' }}>
|
||
<span style={{ color: 'var(--accent)' }}>{portName}</span>: {formatPort(portInfo)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '4px' }}>Preview:</p>
|
||
<div style={{
|
||
border: '1px solid var(--border)', width: '100%', height: 100,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
background: 'var(--input-bg)', borderRadius: '4px',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<img
|
||
src={`/api/component/${encodeURIComponent(componentData.name)}/image`}
|
||
alt="Component layout"
|
||
loading="lazy"
|
||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', cursor: 'pointer' }}
|
||
onClick={() => setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image`)}
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none';
|
||
e.currentTarget.parentElement.innerHTML = '<span style="color:var(--text-muted)">No preview</span>';
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p style={{ color: 'var(--text-muted)' }}>No data available</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="right-block" style={{ marginTop: 'auto', flexShrink: 0 }}>
|
||
<div className="right-block-header">Inverse Design</div>
|
||
<div className="right-block-body placeholder-block">Requires AI Upgrade</div>
|
||
</div>
|
||
|
||
{enlarged && (
|
||
<div
|
||
style={{
|
||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||
backgroundColor: 'rgba(15, 23, 42, 0.9)', zIndex: 1000,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'zoom-out',
|
||
backdropFilter: 'blur(4px)'
|
||
}}
|
||
onClick={() => setEnlarged(null)}
|
||
>
|
||
<img
|
||
src={enlarged}
|
||
alt="Enlarged layout"
|
||
style={{ maxWidth: '90%', maxHeight: '90%', objectFit: 'contain', border: '1px solid var(--border)', background: 'var(--bg-main)' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
const ResizeHandle = ({ onMouseDown }) => (
|
||
<div
|
||
onMouseDown={onMouseDown}
|
||
style={{
|
||
width: 6, cursor: 'col-resize', background: 'transparent',
|
||
transition: 'background 0.2s', zIndex: 5, flexShrink: 0,
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--accent)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
/>
|
||
);
|
||
|
||
function findComponentPath(lib, compName) {
|
||
const path = [];
|
||
function walk(obj, currentPath) {
|
||
if (obj && obj.__type__ === 'component' && obj.__name__ === compName) {
|
||
path.push(...currentPath);
|
||
return true;
|
||
}
|
||
if (typeof obj === 'object') {
|
||
for (const [key, val] of Object.entries(obj)) {
|
||
if (walk(val, [...currentPath, key])) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
walk(lib, []);
|
||
return path;
|
||
}
|
||
|
||
|
||
function buildCompInstanceTree(compNodes, library) {
|
||
const tree = {};
|
||
compNodes.forEach(node => {
|
||
const compName = node.data.componentName;
|
||
const instanceName = node.data.componentDisplayName || node.id;
|
||
if (!compName) return;
|
||
const fullPath = findComponentPath(library, compName);
|
||
if (fullPath.length === 0) return;
|
||
|
||
let current = tree;
|
||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||
const seg = fullPath[i];
|
||
if (!current[seg]) current[seg] = {};
|
||
current = current[seg];
|
||
}
|
||
const lastDir = fullPath[fullPath.length - 1];
|
||
if (!current[lastDir]) {
|
||
current[lastDir] = {
|
||
__type__: 'component',
|
||
__name__: compName,
|
||
instances: []
|
||
};
|
||
}
|
||
if (!current[lastDir].instances.includes(instanceName)) {
|
||
current[lastDir].instances.push(instanceName);
|
||
}
|
||
});
|
||
return tree;
|
||
}
|
||
|
||
|
||
function buildCompTree(compNodes, library) {
|
||
const tree = {};
|
||
compNodes.forEach(node => {
|
||
const compName = node.data.componentName;
|
||
if (!compName) return;
|
||
const fullPath = findComponentPath(library, compName);
|
||
if (fullPath.length === 0) return;
|
||
let current = tree;
|
||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||
const seg = fullPath[i];
|
||
if (!current[seg]) current[seg] = {};
|
||
current = current[seg];
|
||
}
|
||
const leafName = fullPath[fullPath.length - 1];
|
||
if (!current[leafName]) {
|
||
current[leafName] = { __type__: 'component', __name__: compName };
|
||
}
|
||
});
|
||
return tree;
|
||
}
|
||
|
||
function App() {
|
||
const [pages, setPages] = useState([]);
|
||
const [activePageId, setActivePageId] = useState(null);
|
||
const reactFlowInstance = useReactFlow();
|
||
|
||
const [library, setLibrary] = useState(null);
|
||
const [treeKey, setTreeKey] = useState(0);
|
||
const [expanded, setExpanded] = useState(false);
|
||
const treeContainerRef = useRef(null);
|
||
|
||
const [projectTreeKey, setProjectTreeKey] = useState(0);
|
||
const [projectExpanded, setProjectExpanded] = useState(false);
|
||
const projectTreeContainerRef = useRef(null);
|
||
|
||
const [leftWidth, setLeftWidth] = useState(430);
|
||
const [rightWidth, setRightWidth] = useState(260);
|
||
const [dragging, setDragging] = useState(null);
|
||
|
||
const [gridSnap, setGridSnap] = useState(false);
|
||
|
||
const [clipboard, setClipboard] = useState({ nodes: [] });
|
||
|
||
const initializedRef = useRef(false);
|
||
|
||
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
|
||
const currentNodes = activePage ? activePage.nodes : [];
|
||
const currentEdges = activePage ? activePage.edges : [];
|
||
|
||
const [projectCompositeMap, setProjectCompositeMap] = useState({});
|
||
const [standaloneComposites, setStandaloneComposites] = useState([]);
|
||
const [compositeTrees, setCompositeTrees] = useState({});
|
||
|
||
const syncCompositePlacement = useCallback((projectName, compositeName, mode = 'add') => {
|
||
setStandaloneComposites(prev => {
|
||
if (mode === 'add') return prev.filter(name => name !== compositeName);
|
||
if (mode === 'remove' && !prev.includes(compositeName)) return [...prev, compositeName];
|
||
return prev;
|
||
});
|
||
|
||
setProjectCompositeMap(prev => {
|
||
const currentList = prev[projectName] || [];
|
||
if (mode === 'add') {
|
||
if (currentList.includes(compositeName)) return prev;
|
||
return {
|
||
...prev,
|
||
[projectName]: [...currentList, compositeName]
|
||
};
|
||
}
|
||
|
||
if (mode === 'remove') {
|
||
return {
|
||
...prev,
|
||
[projectName]: currentList.filter(name => name !== compositeName)
|
||
};
|
||
}
|
||
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
const syncAllCompositeTrees = useCallback((pagesToScan, libraryData) => {
|
||
if (!libraryData) return;
|
||
const nextTrees = {};
|
||
pagesToScan.forEach(page => {
|
||
if (page.type !== 'composite') return;
|
||
const compNodes = page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName);
|
||
nextTrees[page.name] = buildCompInstanceTree(compNodes, libraryData);
|
||
});
|
||
setCompositeTrees(prev => ({
|
||
...prev,
|
||
...nextTrees
|
||
}));
|
||
}, []);
|
||
|
||
const onNodesChange = useCallback((changes) => {
|
||
if (!activePageId) return;
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
const newNodes = applyNodeChanges(changes, p.nodes);
|
||
const portNode = newNodes.find(n => n.id === 'page-port');
|
||
let newPort = p.port;
|
||
if (portNode) {
|
||
const { x, y } = portNode.position;
|
||
const angle = portNode.data?.angle ?? 0;
|
||
if (x !== p.port.x || y !== p.port.y || angle !== p.port.a) {
|
||
newPort = { x, y, a: angle };
|
||
}
|
||
}
|
||
return { ...p, nodes: newNodes, port: newPort };
|
||
}));
|
||
}, [activePageId]);
|
||
|
||
const onEdgesChange = useCallback((changes) => {
|
||
if (!activePageId) return;
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return { ...p, edges: applyEdgeChanges(changes, p.edges) };
|
||
}));
|
||
}, [activePageId]);
|
||
|
||
const handleUpdateNode = useCallback((nodeId, update) => {
|
||
if (!activePageId) return;
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
const newNodes = p.nodes.map(n => {
|
||
if (n.id === nodeId) {
|
||
return {
|
||
...n,
|
||
position: update.position != null ? { ...n.position, ...update.position } : n.position,
|
||
data: { ...n.data, ...update.data }
|
||
};
|
||
}
|
||
return n;
|
||
});
|
||
let newPort = p.port;
|
||
if (nodeId === 'page-port') {
|
||
const portNode = newNodes.find(n => n.id === 'page-port');
|
||
if (portNode) {
|
||
newPort = { x: portNode.position.x, y: portNode.position.y, a: portNode.data?.angle ?? 0 };
|
||
}
|
||
}
|
||
return { ...p, nodes: newNodes, port: newPort };
|
||
}));
|
||
}, [activePageId]);
|
||
|
||
const handleCopy = useCallback(() => {
|
||
if (!activePage) return;
|
||
const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port');
|
||
if (selectedNodes.length > 0) {
|
||
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
|
||
}
|
||
}, [activePage]);
|
||
|
||
const handleCut = useCallback(() => {
|
||
if (!activePage) return;
|
||
const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port');
|
||
if (selectedNodes.length > 0) {
|
||
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
|
||
const selectedNodeIds = new Set(selectedNodes.map(n => n.id));
|
||
const newNodes = activePage.nodes.filter(n => !selectedNodeIds.has(n.id));
|
||
const newEdges = activePage.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
|
||
setPages(prev => prev.map(p => p.id === activePage.id ? { ...p, nodes: newNodes, edges: newEdges } : p));
|
||
}
|
||
}, [activePage, setPages]);
|
||
|
||
const handlePaste = useCallback(() => {
|
||
if (!activePage || clipboard.nodes.length === 0) return;
|
||
const newNodes = clipboard.nodes.map(node => ({
|
||
...node,
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
|
||
position: { x: node.position.x + 20, y: node.position.y + 20 },
|
||
selected: true,
|
||
data: { ...node.data, componentDisplayName: generateComponentDisplayName() }
|
||
}));
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePage.id) return p;
|
||
return { ...p, nodes: p.nodes.map(n => ({ ...n, selected: false })).concat(newNodes) };
|
||
}));
|
||
setClipboard({ nodes: newNodes });
|
||
}, [activePage, clipboard, generateComponentDisplayName]);
|
||
|
||
const handleDelete = useCallback(() => {
|
||
if (!activePage) return;
|
||
const selectedNodeIds = new Set(activePage.nodes.filter(n => n.selected && n.id !== 'page-port').map(n => n.id));
|
||
if (selectedNodeIds.size > 0) {
|
||
const newNodes = activePage.nodes.filter(n => !selectedNodeIds.has(n.id));
|
||
const newEdges = activePage.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
|
||
setPages(prev => prev.map(p => p.id === activePage.id ? { ...p, nodes: newNodes, edges: newEdges } : p));
|
||
}
|
||
}, [activePage]);
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (e) => {
|
||
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
|
||
return;
|
||
}
|
||
|
||
const cmdOrCtrl = e.ctrlKey || e.metaKey;
|
||
|
||
if (cmdOrCtrl && e.key.toLowerCase() === 'c') {
|
||
e.preventDefault();
|
||
handleCopy();
|
||
} else if (cmdOrCtrl && e.key.toLowerCase() === 'x') {
|
||
e.preventDefault();
|
||
handleCut();
|
||
} else if (cmdOrCtrl && e.key.toLowerCase() === 'v') {
|
||
e.preventDefault();
|
||
handlePaste();
|
||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
handleDelete();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [handleCopy, handleCut, handlePaste, handleDelete]);
|
||
|
||
const componentCounterRef = useRef(1);
|
||
|
||
const generateComponentDisplayName = useCallback(() => {
|
||
const name = `component_${componentCounterRef.current}`;
|
||
componentCounterRef.current += 1;
|
||
return name;
|
||
}, []);
|
||
|
||
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
|
||
if (!activePageId) return;
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return {
|
||
...p,
|
||
nodes: p.nodes.map(n => n.id === nodeId ? { ...n, data: { ...n.data, componentDisplayName: newComponentDisplayName } } : n)
|
||
};
|
||
}));
|
||
}, [activePageId]);
|
||
|
||
const fetchLibrary = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/library');
|
||
const data = await res.json();
|
||
setLibrary(data);
|
||
} catch (err) {
|
||
console.error('Failed to fetch library', err);
|
||
}
|
||
}, []);
|
||
useEffect(() => { fetchLibrary(); }, [fetchLibrary]);
|
||
|
||
const collectComponentNames = useCallback((lib) => {
|
||
const names = [];
|
||
const walk = (obj) => {
|
||
if (obj && obj.__type__ === 'component' && obj.__name__) {
|
||
names.push({ name: obj.__name__, category: obj.__category__ || 'default' });
|
||
}
|
||
if (typeof obj === 'object') {
|
||
Object.values(obj).forEach(walk);
|
||
}
|
||
};
|
||
walk(lib);
|
||
return names;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const input = document.getElementById('open-yaml-input');
|
||
if (!input) return;
|
||
|
||
const handleFile = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
const doc = jsyaml.load(text);
|
||
if (!doc.instances) {
|
||
alert('no instances found');
|
||
return;
|
||
}
|
||
|
||
const newNodes = [];
|
||
const newEdges = [];
|
||
const nodeNameMap = {};
|
||
const isProject = doc.type === 'project';
|
||
|
||
for (const [instName, inst] of Object.entries(doc.instances)) {
|
||
const compPath = inst.component || '';
|
||
const compName = compPath.split('/').pop();
|
||
let category = '';
|
||
|
||
if (!isProject && compName && library) {
|
||
const walk = (obj) => {
|
||
if (obj?.__type__ === 'component' && obj.__name__ === compName) {
|
||
category = obj.__category__ || '';
|
||
return true;
|
||
}
|
||
if (typeof obj === 'object') {
|
||
for (const v of Object.values(obj)) if (walk(v)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
walk(library);
|
||
}
|
||
|
||
const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||
nodeNameMap[instName] = nodeId;
|
||
|
||
newNodes.push({
|
||
id: nodeId,
|
||
type: 'rotatableNode',
|
||
position: {
|
||
x: parseFloat(inst.x) || 0,
|
||
y: parseFloat(inst.y) || 0,
|
||
},
|
||
data: {
|
||
label: isProject ? instName : compName,
|
||
componentName: isProject ? instName : compName,
|
||
category: isProject ? '' : category,
|
||
rotation: parseFloat(inst.rotation) || 0,
|
||
componentDisplayName: instName,
|
||
type: isProject ? 'composite' : undefined,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (!isProject) {
|
||
const links = doc.bundles?.output_bus?.links;
|
||
if (links) {
|
||
const linkArray = Array.isArray(links) ? links : [links];
|
||
linkArray.forEach(link => {
|
||
if (link.from && link.to) {
|
||
const [fromInst, fromPort] = link.from.split(':');
|
||
const [toInst, toPort] = link.to.split(':');
|
||
const sourceId = nodeNameMap[fromInst];
|
||
const targetId = nodeNameMap[toInst];
|
||
if (sourceId && targetId) {
|
||
newEdges.push({
|
||
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
|
||
source: sourceId,
|
||
target: targetId,
|
||
sourceHandle: fromPort,
|
||
targetHandle: toPort,
|
||
type: 'smoothstep',
|
||
style: { stroke: 'var(--accent)', strokeWidth: 2 },
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
||
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
|
||
const newPage = {
|
||
id: newPageId,
|
||
name: newPageName,
|
||
type: isProject ? 'project' : 'composite',
|
||
nodes: isProject ? newNodes : [
|
||
{
|
||
id: 'page-port',
|
||
type: 'portNode',
|
||
position: { x: 50, y: 150 },
|
||
data: { label: 'Port', angle: 0 },
|
||
draggable: true,
|
||
selectable: true,
|
||
deletable: false,
|
||
},
|
||
...newNodes,
|
||
],
|
||
edges: newEdges,
|
||
port: { x: 50, y: 150, a: 0 },
|
||
};
|
||
|
||
setPages(prev => [...prev, newPage]);
|
||
setActivePageId(newPageId);
|
||
|
||
if (isProject) {
|
||
setProjectCompositeMap(prev => ({
|
||
...prev,
|
||
[newPageName]: [...(prev[newPageName] || []), ...Object.keys(doc.instances)]
|
||
}));
|
||
} else {
|
||
setStandaloneComposites(prev => {
|
||
if (!prev.includes(newPageName)) return [...prev, newPageName];
|
||
return prev;
|
||
});
|
||
|
||
if (library) {
|
||
const compTree = {};
|
||
for (const inst of Object.values(doc.instances)) {
|
||
const compPath = inst.component || '';
|
||
const compName = compPath.split('/').pop();
|
||
if (!compName) continue;
|
||
const fullPath = findComponentPath(library, compName);
|
||
if (fullPath.length === 0) continue;
|
||
const emoIndex = fullPath.indexOf('EMO1_2ML_CU_Al_RDL');
|
||
const segments = emoIndex >= 0 ? fullPath.slice(emoIndex + 1) : fullPath.slice(1);
|
||
if (segments.length === 0) continue;
|
||
let current = compTree;
|
||
for (let i = 0; i < segments.length - 1; i++) {
|
||
const seg = segments[i];
|
||
if (!current[seg]) current[seg] = {};
|
||
current = current[seg];
|
||
}
|
||
const leaf = segments[segments.length - 1];
|
||
if (!current[leaf]) {
|
||
current[leaf] = { __type__: 'component', __name__: compName };
|
||
}
|
||
}
|
||
setCompositeTrees(prev => ({ ...prev, [newPageName]: compTree }));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
alert('yaml parse error: ' + err.message);
|
||
}
|
||
e.target.value = '';
|
||
};
|
||
|
||
input.addEventListener('change', handleFile);
|
||
return () => input.removeEventListener('change', handleFile);
|
||
}, [library]);
|
||
|
||
useEffect(() => {
|
||
setProjectCompositeMap(prev => {
|
||
const projectNames = pages.filter(p => p.type === 'project').map(p => p.name);
|
||
const filtered = {};
|
||
for (const name of projectNames) {
|
||
if (prev[name]) filtered[name] = prev[name];
|
||
}
|
||
return filtered;
|
||
});
|
||
setStandaloneComposites(prev => {
|
||
const compositeNames = pages.filter(p => p.type === 'composite').map(p => p.name);
|
||
return prev.filter(name => compositeNames.includes(name));
|
||
});
|
||
}, [pages]);
|
||
|
||
useEffect(() => {
|
||
if (library && !initializedRef.current) {
|
||
initializedRef.current = true;
|
||
const compList = collectComponentNames(library);
|
||
|
||
const projectId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
||
const projectPage = {
|
||
id: projectId,
|
||
name: 'MainProject',
|
||
type: 'project',
|
||
nodes: [],
|
||
edges: [],
|
||
port: { x: 0, y: 0, a: 0 }
|
||
};
|
||
|
||
let counter = 1;
|
||
const fixedComps1 = compList.slice(0, 3);
|
||
const compNodes1 = fixedComps1.map((comp, i) => {
|
||
const name = `component_${counter++}`;
|
||
return {
|
||
id: `node-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 5)}`,
|
||
type: 'rotatableNode',
|
||
position: { x: 100 + i * 300, y: 100 },
|
||
data: {
|
||
label: comp.name,
|
||
componentName: comp.name,
|
||
category: comp.category,
|
||
rotation: 0,
|
||
componentDisplayName: name
|
||
}
|
||
};
|
||
});
|
||
|
||
const comp1Id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
||
const comp1Page = {
|
||
id: comp1Id,
|
||
name: 'comp_1',
|
||
type: 'composite',
|
||
nodes: [
|
||
{
|
||
id: 'page-port',
|
||
type: 'portNode',
|
||
position: { x: 50, y: 150 },
|
||
data: { label: 'Port', angle: 0 },
|
||
draggable: true,
|
||
selectable: true,
|
||
deletable: false,
|
||
},
|
||
...compNodes1
|
||
],
|
||
edges: [],
|
||
port: { x: 50, y: 150, a: 0 }
|
||
};
|
||
|
||
const fixedComps2 = compList.slice(0, 3);
|
||
const compNodes2 = fixedComps2.map((comp, i) => {
|
||
const name = `component_${counter++}`;
|
||
return {
|
||
id: `node-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 10)}`,
|
||
type: 'rotatableNode',
|
||
position: { x: 100 + i * 300, y: 200 },
|
||
data: {
|
||
label: comp.name,
|
||
componentName: comp.name,
|
||
category: comp.category,
|
||
rotation: 0,
|
||
componentDisplayName: name
|
||
}
|
||
};
|
||
});
|
||
|
||
componentCounterRef.current = counter;
|
||
|
||
const comp2Id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
||
const comp2Page = {
|
||
id: comp2Id,
|
||
name: 'comp_2',
|
||
type: 'composite',
|
||
nodes: [
|
||
{
|
||
id: 'page-port',
|
||
type: 'portNode',
|
||
position: { x: 50, y: 250 },
|
||
data: { label: 'Port', angle: 0 },
|
||
draggable: true,
|
||
selectable: true,
|
||
deletable: false,
|
||
},
|
||
...compNodes2
|
||
],
|
||
edges: [],
|
||
port: { x: 50, y: 250, a: 0 }
|
||
};
|
||
|
||
setPages([projectPage, comp1Page, comp2Page]);
|
||
setActivePageId(projectId);
|
||
setProjectCompositeMap({ MainProject: ['comp_1', 'comp_2'] });
|
||
setCompositeTrees({
|
||
comp_1: buildCompInstanceTree(comp1Page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library),
|
||
comp_2: buildCompInstanceTree(comp2Page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library)
|
||
});
|
||
}
|
||
}, [library, collectComponentNames]);
|
||
|
||
useEffect(() => {
|
||
if (activePage && reactFlowInstance) {
|
||
reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 1 });
|
||
}
|
||
}, [activePage?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!library) return;
|
||
syncAllCompositeTrees(pages, library);
|
||
}, [pages, library, syncAllCompositeTrees]);
|
||
|
||
const selectedNode = useMemo(() => currentNodes.find(n => n.selected), [currentNodes]);
|
||
|
||
const openProject = useCallback((name) => {
|
||
setPages(prev => {
|
||
const existing = prev.find(p => p.name === name && p.type === 'project');
|
||
if (existing) {
|
||
setActivePageId(existing.id);
|
||
return prev;
|
||
}
|
||
const newProjectPage = {
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
|
||
name: name,
|
||
type: 'project',
|
||
nodes: [],
|
||
edges: [],
|
||
port: { x: 0, y: 0, a: 0 }
|
||
};
|
||
setActivePageId(newProjectPage.id);
|
||
setProjectCompositeMap(prevMap => ({ ...prevMap, [name]: prevMap[name] || [] }));
|
||
return [...prev, newProjectPage];
|
||
});
|
||
}, []);
|
||
|
||
const openPage = useCallback((name) => {
|
||
const belongsToProject = Object.values(projectCompositeMap).some(comps => comps.includes(name));
|
||
if (!belongsToProject && !standaloneComposites.includes(name)) {
|
||
setStandaloneComposites(prev => [...prev, name]);
|
||
}
|
||
setPages(prev => {
|
||
const existing = prev.find(p => p.name === name && p.type === 'composite');
|
||
if (existing) {
|
||
setActivePageId(existing.id);
|
||
return prev;
|
||
}
|
||
const newComposite = {
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
|
||
name: name,
|
||
type: 'composite',
|
||
nodes: [
|
||
{
|
||
id: 'page-port',
|
||
type: 'portNode',
|
||
position: { x: 50, y: 150 },
|
||
data: { label: 'Port', angle: 0 },
|
||
draggable: true,
|
||
selectable: true,
|
||
deletable: false,
|
||
}
|
||
],
|
||
edges: [],
|
||
port: { x: 50, y: 150, a: 0 }
|
||
};
|
||
setActivePageId(newComposite.id);
|
||
return [...prev, newComposite];
|
||
});
|
||
}, [projectCompositeMap, standaloneComposites]);
|
||
|
||
const closePage = useCallback((pageId) => {
|
||
setPages(prev => {
|
||
const pageToClose = prev.find(p => p.id === pageId);
|
||
const filtered = prev.filter(p => p.id !== pageId);
|
||
if (activePageId === pageId) {
|
||
const idx = prev.findIndex(p => p.id === pageId);
|
||
const nextActive = filtered[Math.min(idx, filtered.length - 1)] || null;
|
||
setActivePageId(nextActive ? nextActive.id : null);
|
||
}
|
||
return filtered;
|
||
});
|
||
}, [activePageId]);
|
||
|
||
const switchPage = useCallback((pageId) => {
|
||
setActivePageId(pageId);
|
||
}, []);
|
||
|
||
const handlePortChange = useCallback((pageId, newPort) => {
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== pageId) return p;
|
||
const portNodeId = 'page-port';
|
||
const nodes = p.nodes.map(n => {
|
||
if (n.id === portNodeId) {
|
||
return { ...n, position: { x: newPort.x, y: newPort.y }, data: { ...n.data, angle: newPort.a } };
|
||
}
|
||
return n;
|
||
});
|
||
if (!nodes.some(n => n.id === portNodeId)) {
|
||
nodes.push({
|
||
id: portNodeId,
|
||
type: 'portNode',
|
||
position: { x: newPort.x, y: newPort.y },
|
||
data: { label: 'Port', angle: newPort.a },
|
||
draggable: true,
|
||
selectable: true,
|
||
deletable: false,
|
||
});
|
||
}
|
||
return { ...p, port: newPort, nodes };
|
||
}));
|
||
}, []);
|
||
|
||
const onDragOver = useCallback((event) => {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
}, []);
|
||
|
||
const onDrop = useCallback((event) => {
|
||
event.preventDefault();
|
||
const rawData = event.dataTransfer.getData('application/reactflow');
|
||
console.log("📥 DROP EVENT: Received raw data ->", rawData);
|
||
if (!rawData) return;
|
||
let parsedData;
|
||
try {
|
||
parsedData = JSON.parse(rawData);
|
||
} catch (error) {
|
||
parsedData = { name: rawData, category: 'default' };
|
||
}
|
||
if (parsedData.type === 'standaloneComposite') {
|
||
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||
const newNode = {
|
||
id: Date.now().toString(),
|
||
type: 'rotatableNode',
|
||
position,
|
||
data: {
|
||
label: parsedData.name,
|
||
componentName: parsedData.name,
|
||
componentDisplayName: parsedData.name,
|
||
type: 'composite',
|
||
category: null,
|
||
rotation: 0
|
||
}
|
||
};
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return { ...p, nodes: p.nodes.concat(newNode) };
|
||
}));
|
||
if (activePage?.type === 'project') {
|
||
const projectName = activePage.name;
|
||
setStandaloneComposites(prev => prev.filter(name => name !== parsedData.name));
|
||
setProjectCompositeMap(prev => ({
|
||
...prev,
|
||
[projectName]: [...(prev[projectName] || []), parsedData.name]
|
||
}));
|
||
}
|
||
return;
|
||
}
|
||
if (parsedData.type === 'composite') {
|
||
if (!activePageId) return;
|
||
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||
const newNode = {
|
||
id: Date.now().toString(),
|
||
type: 'rotatableNode',
|
||
position,
|
||
data: {
|
||
label: parsedData.name,
|
||
componentName: parsedData.name,
|
||
componentDisplayName: parsedData.name,
|
||
type: 'composite',
|
||
category: null,
|
||
rotation: 0
|
||
}
|
||
};
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return { ...p, nodes: p.nodes.concat(newNode) };
|
||
}));
|
||
if (activePage?.type === 'project') {
|
||
syncCompositePlacement(activePage.name, parsedData.name, 'add');
|
||
}
|
||
return;
|
||
}
|
||
if (!activePageId) {
|
||
alert('Please open a composite page first.');
|
||
return;
|
||
}
|
||
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||
const componentDisplayName = generateComponentDisplayName();
|
||
const newNode = {
|
||
id: Date.now().toString(),
|
||
type: 'rotatableNode',
|
||
position,
|
||
data: {
|
||
label: parsedData.name,
|
||
componentName: parsedData.name,
|
||
category: parsedData.category,
|
||
rotation: 0,
|
||
componentDisplayName: componentDisplayName
|
||
},
|
||
};
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return { ...p, nodes: p.nodes.concat(newNode) };
|
||
}));
|
||
}, [activePageId, activePage, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement]);
|
||
|
||
const onConnect = useCallback((connection) => {
|
||
if (!activePageId) return;
|
||
setPages(prev => prev.map(p => {
|
||
if (p.id !== activePageId) return p;
|
||
return { ...p, edges: addEdge({ ...connection, type: 'smoothstep', style: { stroke: 'var(--accent)', strokeWidth: 2 } }, p.edges) };
|
||
}));
|
||
}, [activePageId]);
|
||
|
||
const expandAll = useCallback(() => {
|
||
if (treeContainerRef.current) {
|
||
treeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
|
||
}
|
||
}, []);
|
||
const collapseAll = useCallback(() => setTreeKey(k => k + 1), []);
|
||
const handleToggle = useCallback(() => {
|
||
if (expanded) { collapseAll(); setExpanded(false); }
|
||
else { expandAll(); setExpanded(true); }
|
||
}, [expanded, expandAll, collapseAll]);
|
||
|
||
const expandProjectAll = useCallback(() => {
|
||
if (projectTreeContainerRef.current) {
|
||
projectTreeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
|
||
}
|
||
}, []);
|
||
const collapseProjectAll = useCallback(() => setProjectTreeKey(k => k + 1), []);
|
||
const handleProjectToggle = useCallback(() => {
|
||
if (projectExpanded) { collapseProjectAll(); setProjectExpanded(false); }
|
||
else { expandProjectAll(); setProjectExpanded(true); }
|
||
}, [projectExpanded, expandProjectAll, collapseProjectAll]);
|
||
|
||
const handleResizeStart = useCallback((side) => (e) => {
|
||
e.preventDefault();
|
||
setDragging(side);
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!dragging) return;
|
||
const onMouseMove = (e) => {
|
||
if (dragging === 'left') {
|
||
setLeftWidth(Math.min(500, Math.max(150, e.clientX)));
|
||
} else if (dragging === 'right') {
|
||
const newWidth = window.innerWidth - e.clientX;
|
||
setRightWidth(Math.min(500, Math.max(150, newWidth)));
|
||
}
|
||
};
|
||
const onMouseUp = () => setDragging(null);
|
||
window.addEventListener('mousemove', onMouseMove);
|
||
window.addEventListener('mouseup', onMouseUp);
|
||
return () => {
|
||
window.removeEventListener('mousemove', onMouseMove);
|
||
window.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
}, [dragging]);
|
||
|
||
const toggleGridSnap = useCallback(() => {
|
||
setGridSnap(prev => !prev);
|
||
}, []);
|
||
|
||
const projectTreeItems = useMemo(() => {
|
||
const items = [];
|
||
const projectPages = pages.filter(p => p.type === 'project');
|
||
projectPages.forEach(project => {
|
||
const composites = (projectCompositeMap[project.name] || []).map(name => {
|
||
const compPage = pages.find(p => p.name === name && p.type === 'composite');
|
||
return {
|
||
__type__: 'composite',
|
||
__name__: name,
|
||
tree: compositeTrees[name] || {},
|
||
pageId: compPage ? compPage.id : name
|
||
};
|
||
});
|
||
items.push({
|
||
type: 'project',
|
||
name: project.name,
|
||
composites: composites
|
||
});
|
||
});
|
||
standaloneComposites.forEach(name => {
|
||
items.push({
|
||
type: 'standaloneComposite',
|
||
name: name,
|
||
tree: compositeTrees[name] || {},
|
||
pageId: pages.find(p => p.name === name && p.type === 'composite')?.id || name
|
||
});
|
||
});
|
||
return items;
|
||
}, [pages, library, projectCompositeMap, standaloneComposites, compositeTrees]);
|
||
|
||
const buildBundlesYaml = (page) => {
|
||
const { nodes, edges } = page;
|
||
const nodeMap = {};
|
||
nodes.forEach(n => { nodeMap[n.id] = n; });
|
||
|
||
let linksYaml = '';
|
||
if (edges.length > 0) {
|
||
const linkLines = edges.map(edge => {
|
||
const sourceNode = nodeMap[edge.source];
|
||
const targetNode = nodeMap[edge.target];
|
||
const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source;
|
||
const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target;
|
||
const fromPort = edge.sourceHandle || 'unknown';
|
||
const toPort = edge.targetHandle || 'unknown';
|
||
return ` - from: ${sourceName}:${fromPort}\n to: ${targetName}:${toPort}`;
|
||
});
|
||
linksYaml = linkLines.join('\n');
|
||
}
|
||
|
||
return `# 3. Bundles (Grouped links for multi-bus/parallel routing)
|
||
bundles:
|
||
output_bus:
|
||
routing_type: euler_bend
|
||
links:
|
||
${linksYaml}`;
|
||
};
|
||
|
||
const handleBuildLayout = useCallback(async () => {
|
||
if (!activePage) return;
|
||
const header = `# =============================================
|
||
# mxPIC Cell/Project Definition File
|
||
# =============================================
|
||
name: ${activePage.name}
|
||
type: ${'composite'}
|
||
version: "1.0.0"
|
||
|
||
# 1. External Ports (How this cell connects to the outside world)
|
||
ports:
|
||
- name: in0
|
||
layer: WG_CORE
|
||
x: 0.0
|
||
y: 0.0
|
||
angle: 180.0
|
||
width: 0.5
|
||
- name: out0
|
||
layer: WG_CORE
|
||
x: 100.0
|
||
y: 10.0
|
||
angle: 0.0
|
||
width: 0.5
|
||
- name: out1
|
||
layer: WG_CORE
|
||
x: 100.0
|
||
y: -10.0
|
||
angle: 0.0
|
||
width: 0.5
|
||
|
||
# 2. Instances (The sub-components dropped onto this canvas)
|
||
instances:`;
|
||
|
||
let instancesBlock = '';
|
||
if (activePage.type === 'project') {
|
||
const compositeNodes = activePage.nodes.filter(n => n.type === 'rotatableNode' && n.data?.type === 'composite');
|
||
instancesBlock = compositeNodes.map(n => {
|
||
const instanceName = n.data.componentDisplayName || n.data.componentName;
|
||
return ` ${instanceName}:
|
||
component:
|
||
x: ${n.position.x.toFixed(1)}
|
||
y: ${n.position.y.toFixed(1)}
|
||
rotation: ${(n.data.rotation || 0).toFixed(1)}
|
||
mirror: false
|
||
settings:
|
||
length:`;
|
||
}).join('\n\n');
|
||
} else {
|
||
const nodes = activePage.nodes.filter(n => n.id !== 'page-port');
|
||
instancesBlock = nodes.map(n => {
|
||
const data = n.data;
|
||
const instanceName = data.componentDisplayName || n.id;
|
||
const compName = data.componentName || '';
|
||
let componentPath = compName;
|
||
if (library && compName) {
|
||
const pathArr = findComponentPath(library, compName);
|
||
if (pathArr.length > 0) {
|
||
componentPath = pathArr.join('/');
|
||
}
|
||
}
|
||
const x = n.position.x.toFixed(1);
|
||
const y = n.position.y.toFixed(1);
|
||
const rotation = (data.rotation || 0).toFixed(1);
|
||
return ` ${instanceName}:
|
||
component: ${componentPath}
|
||
x: ${x}
|
||
y: ${y}
|
||
rotation: ${rotation}
|
||
mirror: false
|
||
settings:
|
||
length: ${compName.includes('MMI') ? '25.5' : ''}`;
|
||
}).join('\n\n');
|
||
}
|
||
|
||
const bundlesBlock = buildBundlesYaml(activePage);
|
||
const yamlContent = `${header}
|
||
${instancesBlock}
|
||
|
||
${bundlesBlock}`;
|
||
|
||
// send to backend
|
||
try {
|
||
const response = await fetch('/api/save-layout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: `${activePage.name}.yaml`, // file name
|
||
content: yamlContent,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errData = await response.json();
|
||
alert(errData.error || 'Save failed, unknown error');
|
||
return;
|
||
}
|
||
|
||
const result = await response.json();
|
||
alert('successfully saved : ' + result.path);
|
||
} catch (err) {
|
||
alert('save error: ' + err.message);
|
||
}
|
||
}, [activePage, library, buildBundlesYaml, findComponentPath]);
|
||
|
||
const onNodeDoubleClick = useCallback((event, node) => {
|
||
if (node.data?.type === 'composite') {
|
||
openPage(node.data.componentName);
|
||
}
|
||
}, [openPage]);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', width: '100%', height: '100%', userSelect: dragging ? 'none' : 'auto' }}>
|
||
<LeftPanel
|
||
projectTreeItems={projectTreeItems}
|
||
library={library} treeKey={treeKey} expanded={expanded}
|
||
onToggle={handleToggle} treeRef={treeContainerRef} width={leftWidth}
|
||
onOpenComposite={openPage}
|
||
onOpenProject={openProject}
|
||
activePage={activePage}
|
||
onPortChange={handlePortChange}
|
||
projectExpanded={projectExpanded}
|
||
onProjectToggle={handleProjectToggle}
|
||
projectTreeRef={projectTreeContainerRef}
|
||
projectTreeKey={projectTreeKey}
|
||
/>
|
||
<ResizeHandle onMouseDown={handleResizeStart('left')} />
|
||
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||
<div className="canvas-tabs">
|
||
<div className="canvas-tab" style={{ borderRight: '1px solid var(--border)', marginRight: 4, cursor: 'pointer' }} onClick={() => document.getElementById('open-yaml-input').click()}>
|
||
📂 Open Project
|
||
</div>
|
||
{pages.map(page => (
|
||
<div key={page.id} className={`canvas-tab ${page.id === activePageId ? 'active' : ''}`} onClick={() => switchPage(page.id)}>
|
||
<span>{page.name}</span>
|
||
<button onClick={(e) => { e.stopPropagation(); closePage(page.id); }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ flex: 1, position: 'relative' }}>
|
||
<div style={{
|
||
position: 'absolute', top: 15, right: 15, zIndex: 10,
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
background: 'var(--bg-card)', padding: '6px 12px', borderRadius: '8px',
|
||
border: '1px solid var(--border)', boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
|
||
}}>
|
||
<span style={{ fontSize: '0.85em', fontWeight: '500', color: 'var(--text-main)', userSelect: 'none' }}>Snap to Grid</span>
|
||
<div onClick={toggleGridSnap} style={{
|
||
width: 40, height: 20, borderRadius: 10,
|
||
background: gridSnap ? 'var(--accent)' : 'var(--input-bg)',
|
||
border: '1px solid ' + (gridSnap ? 'var(--accent)' : 'var(--border)'),
|
||
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||
padding: '0 2px', transition: 'background 0.3s, border-color 0.3s',
|
||
}}>
|
||
<div style={{
|
||
width: 16, height: 16, borderRadius: '50%',
|
||
background: '#fff',
|
||
transform: gridSnap ? 'translateX(20px)' : 'translateX(0)',
|
||
transition: 'transform 0.2s',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
|
||
{activePage && (
|
||
<button
|
||
onClick={handleBuildLayout}
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 20,
|
||
right: 20,
|
||
zIndex: 10,
|
||
background: 'var(--accent)',
|
||
color: 'var(--bg-main)',
|
||
border: 'none',
|
||
padding: '12px 20px',
|
||
borderRadius: 6,
|
||
fontWeight: '600',
|
||
cursor: 'pointer',
|
||
fontSize: '0.85em',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||
}}
|
||
>
|
||
Build Layout
|
||
</button>
|
||
)}
|
||
|
||
<ReactFlow
|
||
nodes={currentNodes}
|
||
edges={currentEdges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
onConnect={onConnect}
|
||
onNodeDoubleClick={onNodeDoubleClick}
|
||
nodeTypes={{ rotatableNode: RotatableNode, portNode: PortNode }}
|
||
snapToGrid={gridSnap}
|
||
snapGrid={[10, 10]}
|
||
nodesDraggable={true}
|
||
nodesConnectable={true}
|
||
elementsSelectable={true}
|
||
connectionRadius={50}
|
||
>
|
||
<Controls style={{ bottom: 15, left: 15 }} />
|
||
<Background color="#334155" gap={20} size={1} />
|
||
</ReactFlow>
|
||
</div>
|
||
</div>
|
||
|
||
<ResizeHandle onMouseDown={handleResizeStart('right')} />
|
||
<RightPanel
|
||
selectedNode={selectedNode}
|
||
width={rightWidth}
|
||
onRenameComponent={renameComponent}
|
||
onUpdateNode={handleUpdateNode}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(
|
||
|
||
<ReactFlowProvider>
|
||
<App />
|
||
<input type="file" id="open-yaml-input" accept=".yaml,.yml" style={{ display: 'none' }} />
|
||
</ReactFlowProvider>
|
||
);
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|
||
|
||
{% endraw %} |