Files
mxpic_EDA/frontend/canvas.html
T

3137 lines
112 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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@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: #060b16;
--bg-card: #0d1626;
--text-main: #f6f8fb;
--text-muted: #93a3b8;
--accent: #6ee7ff;
--accent-hover: #7c3aed;
--accent-green: #34d399;
--accent-warm: #f97316;
--border: #28364c;
--border-strong: #42516a;
--input-bg: #09111f;
--panel-rail: #09111f;
--panel-header: #111c2f;
--panel-body: #0d1626;
--canvas-bg: #07101f;
--danger: #ef4444;
--folder-icon: #f8c14a;
--shadow: rgba(0, 0, 0, 0.34);
}
body.light-mode {
--bg-main: #edf3f8;
--bg-card: #ffffff;
--text-main: #132032;
--text-muted: #64758a;
--accent: #2563eb;
--accent-hover: #0f9f7a;
--accent-green: #16a34a;
--accent-warm: #38bdf8;
--border: #d5e0eb;
--border-strong: #b8c7d8;
--input-bg: #f4f8fb;
--panel-rail: #dde8f2;
--panel-header: #f8fbff;
--panel-body: #ffffff;
--canvas-bg: #eef5fb;
--folder-icon: #38bdf8;
--shadow: rgba(37, 99, 235, 0.12);
}
.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: 'IBM Plex Sans', "Segoe UI", sans-serif;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(0deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px),
radial-gradient(circle at 18% 8%, rgba(124, 58, 237, 0.14), transparent 26%),
var(--canvas-bg);
background-size: 40px 40px, 40px 40px, auto, auto;
color: var(--text-main);
overflow: hidden;
}
body.light-mode,
body.light-mode #root {
background:
linear-gradient(90deg, rgba(37, 99, 235, 0.04) 1px, transparent 1px),
linear-gradient(0deg, rgba(37, 99, 235, 0.04) 1px, transparent 1px),
radial-gradient(circle at 18% 8%, rgba(56, 189, 248, 0.12), transparent 26%),
var(--canvas-bg);
background-size: 40px 40px, 40px 40px, auto, auto;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-main);
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
details {
margin-left: 8px;
}
summary {
cursor: pointer;
padding: 4px 0;
color: var(--text-main);
}
summary.tree-folder {
font-weight: 500;
color: var(--accent);
line-height: 18px;
}
summary.tree-folder::marker {
color: var(--text-muted);
font-size: 0.74rem;
line-height: 1;
}
.component-leaf {
cursor: grab;
padding: 4px 6px;
margin-left: 15px;
margin-top: 2px;
word-break: break-all;
white-space: normal;
border-radius: 8px;
color: var(--text-muted);
transition: background 0.2s ease, color 0.2s ease;
}
.component-leaf:hover {
background: rgba(110, 231, 255, 0.08);
color: var(--text-main);
}
.component-card,
.category-card {
display: flex;
flex-direction: column;
align-items: center;
cursor: grab;
margin-left: 0;
margin-top: 4px;
margin-bottom: 4px;
padding: 8px;
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent),
var(--input-bg);
border: 1px solid var(--border);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.component-grid,
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 8px;
padding: 8px 0 4px 18px;
align-items: stretch;
}
.component-card:hover,
.category-card:hover {
background: var(--bg-card);
border-color: var(--accent);
box-shadow: 0 12px 22px rgba(37, 99, 235, 0.12);
}
.component-card-icon,
.category-card-icon {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
pointer-events: none;
}
.component-card-icon img,
.category-card-icon img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.component-card-name,
.category-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:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
var(--panel-body);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 14px 30px var(--shadow);
}
.left-block-header,
.right-block-header {
background: var(--panel-header);
padding: 8px 10px;
font-weight: 600;
font-family: 'IBM Plex Mono', Consolas, monospace;
font-size: 0.72em;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.left-block-body,
.right-block-body {
padding: 12px;
font-size: 0.85em;
min-height: 0;
background: var(--panel-body);
}
.placeholder-block {
border: 1px dashed var(--border-strong);
padding: 12px;
color: var(--text-muted);
text-align: center;
background: var(--input-bg);
border-radius: 8px;
}
.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: rgba(110, 231, 255, 0.08);
color: var(--text-main);
}
input[type="number"],
input[type="text"],
select {
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,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(110, 231, 255, 0.13);
}
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(--input-bg) !important;
}
.canvas-tabs {
display: flex;
align-items: center;
background: rgba(9, 17, 31, 0.96);
border-bottom: 1px solid var(--border);
padding: 6px 8px;
height: 42px;
gap: 6px;
overflow-x: auto;
white-space: nowrap;
box-sizing: border-box;
backdrop-filter: blur(12px);
}
.canvas-tab {
display: flex;
align-items: center;
min-height: 28px;
padding: 4px 12px;
border-radius: 8px;
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, border-color 0.2s;
}
.canvas-tab:hover {
color: var(--text-main);
border-color: var(--border-strong);
}
.canvas-tab.active {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #04101f;
border-color: transparent;
font-weight: 700;
}
body.light-mode .canvas-tabs {
background: rgba(255, 255, 255, 0.9);
}
body.light-mode .component-card,
body.light-mode .placeholder-block {
background: #f4f8fb;
}
body.light-mode .component-card:hover,
body.light-mode .component-leaf:hover {
background: #eaf4ff;
}
body.light-mode .react-flow__controls button {
background-color: #ffffff !important;
fill: #0f172a !important;
}
.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;
}
.vertical-resize-handle {
height: 8px;
cursor: row-resize;
border-radius: 6px;
background: transparent;
flex: 0 0 auto;
position: relative;
}
.vertical-resize-handle::after {
content: "";
position: absolute;
left: 36%;
right: 36%;
top: 3px;
height: 2px;
background: rgba(110, 231, 255, 0.08);
border-radius: 2px;
}
.vertical-resize-handle:hover::after {
background: var(--accent);
}
.mini-btn {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 8px;
cursor: pointer;
height: 30px;
padding: 0 10px;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
}
.mini-btn:hover {
color: var(--text-main);
border-color: var(--accent);
}
.tree-summary-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: calc(100% - 18px);
vertical-align: middle;
}
.folder-icon {
font-size: 0;
width: 18px;
height: 14px;
flex: 0 0 auto;
position: relative;
border-radius: 3px;
background: linear-gradient(135deg, var(--folder-icon), var(--accent-warm));
box-shadow: inset 0 -5px 0 rgba(255, 255, 255, 0.16);
}
.folder-icon::before {
content: "";
position: absolute;
left: 2px;
top: -4px;
width: 9px;
height: 5px;
border-radius: 3px 3px 0 0;
background: var(--folder-icon);
}
.folder-icon.project-folder {
background: linear-gradient(135deg, #60a5fa, #2563eb);
}
.folder-icon.project-folder::before {
background: #93c5fd;
}
.folder-icon.canvas-folder {
background: linear-gradient(135deg, var(--accent-green), #0f766e);
}
.folder-icon.canvas-folder::before {
background: #6ee7b7;
}
.folder-icon.library-folder {
background: linear-gradient(135deg, var(--folder-icon), var(--accent-warm));
}
.tree-summary-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-card {
min-height: 94px;
}
.category-card-name {
color: var(--text-main);
font-weight: 600;
}
.category-card-count {
margin-top: 3px;
font-size: 0.62rem;
color: var(--text-muted);
pointer-events: none;
}
.tree-expander {
margin-left: auto;
color: var(--text-muted);
font-size: 0.72rem;
transition: transform 0.15s ease;
}
.tree-delete-btn {
width: 20px;
height: 20px;
border: 1px solid rgba(239, 68, 68, 0.45);
border-radius: 6px;
background: rgba(239, 68, 68, 0.08);
color: #fca5a5;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
padding: 0;
flex: 0 0 auto;
}
.tree-delete-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: var(--danger);
color: #fecaca;
}
body.light-mode .tree-delete-btn {
color: #b91c1c;
}
details[open] > summary .tree-expander {
transform: rotate(90deg);
}
.workspace-name-input {
min-width: 90px;
max-width: 180px;
height: 24px;
padding: 2px 6px;
font-size: 0.85em;
background: transparent;
border: 1px solid transparent;
color: inherit;
}
.tree-name-input {
width: 100%;
min-width: 0;
height: 24px;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text-main);
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
}
.tree-name-input:focus {
background: var(--input-bg);
border-color: var(--accent);
outline: none;
}
.workspace-name-input:focus {
background: var(--bg-card);
border-color: var(--accent);
color: var(--text-main);
}
.app-log-terminal {
height: 112px;
flex: 0 0 auto;
overflow: auto;
background: #020617;
color: #c7f9ff;
border-top: 1px solid var(--border);
padding: 8px 12px;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.76rem;
line-height: 1.45;
box-sizing: border-box;
}
body.light-mode .app-log-terminal {
background: #f8fafc;
color: #1d4ed8;
}
</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: "'IBM Plex Sans', 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-main)',
fontWeight: 600,
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}} title={data.componentDisplayName}>
{data.componentDisplayName}
</div>
{data.componentName && data.componentName !== data.componentDisplayName && (
<div style={{
marginTop: '-5px',
fontSize: '0.46rem',
color: 'var(--text-muted)',
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}} title={data.componentName}>
{data.componentName}
</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.componentName === nextProps.data.componentName &&
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 EditableCanvasTabName = ({ page, active, onRename }) => {
const [value, setValue] = useState(page.name);
useEffect(() => {
setValue(page.name);
}, [page.name, page.id]);
if (!active || page.type === 'project') {
return <span>{page.name}</span>;
}
const commit = () => {
const nextName = value.trim();
if (nextName && nextName !== page.name) {
onRename(page.id, nextName);
} else {
setValue(page.name);
}
};
return (
<input
className="workspace-name-input"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setValue(page.name);
event.currentTarget.blur();
}
}}
/>
);
};
const EditableTreeCanvasName = ({ pageId, name, canRename, onRename, onOpen }) => {
const [value, setValue] = useState(name);
useEffect(() => {
setValue(name);
}, [name]);
if (!canRename) {
return (
<span className="tree-summary-name" onClick={onOpen}>
{name}
</span>
);
}
const commit = () => {
const nextName = value.trim();
if (nextName && nextName !== name) {
onRename(pageId, nextName);
} else {
setValue(name);
}
};
return (
<input
className="tree-name-input"
value={value}
onClick={(event) => event.stopPropagation()}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setValue(name);
event.currentTarget.blur();
}
}}
/>
);
};
const isLibraryComponentLeaf = (node) => node && node.__type__ === 'component';
const getCategoryComponents = (categoryNode) => {
return Object.entries(categoryNode || {})
.filter(([, childData]) => isLibraryComponentLeaf(childData))
.map(([childName, childData]) => ({
name: childData.__name__ || childName,
category: childData.__category__ || childData.__name__ || childName
}));
};
const CategoryCard = ({ name, components = [] }) => {
const componentNames = components.map(component => component.name).filter(Boolean);
const firstComponent = componentNames[0] || name;
const handleDragStart = (event) => {
const dragData = JSON.stringify({
type: 'category',
category: name,
name: firstComponent,
components: componentNames
});
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', dragData);
event.dataTransfer.effectAllowed = 'move';
};
return (
<div className="category-card" draggable onDragStart={handleDragStart}>
<div className="category-card-icon">
<IconImg category={name} />
</div>
<div className="category-card-name" title={name}>
{name}
</div>
<div className="category-card-count">{componentNames.length} components</div>
</div>
);
};
const TreeNode = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const componentName = children.__name__;
const componentCategory = children.__category__ || 'default';
const isUserCell = children.__cell__ === true;
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) => {
const dragData = JSON.stringify(
isUserCell
? { name: componentName, type: 'composite' }
: { name: componentName, category: componentCategory }
);
console.log("DRAG START: Sending data ->", dragData);
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', 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 entries = children ? Object.entries(children) : [];
const hasChildren = entries.length > 0;
const isComponentGrid = hasChildren && entries.every(([, childData]) => isLibraryComponentLeaf(childData));
const isCategoryGrid = hasChildren && entries.every(([, childData]) => {
const childEntries = Object.entries(childData || {});
return childEntries.length > 0 && childEntries.every(([, grandChild]) => isLibraryComponentLeaf(grandChild));
});
return (
<details>
<summary className="tree-folder">
<span className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{hasChildren && (
isCategoryGrid ? (
<div className="category-grid">
{entries.map(([childName, childData]) => (
<CategoryCard key={childName} name={childName} components={getCategoryComponents(childData)} />
))}
</div>
) : isComponentGrid ? (
<div className="category-grid">
<CategoryCard name={name} components={getCategoryComponents(children)} />
</div>
) : (
entries.map(([childName, childData]) => (
<TreeNode key={childName} name={childName} children={childData} />
))
)
)}
</details>
);
};
const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas }) => {
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' }}>
<span className="tree-summary-row">
<span className="folder-icon project-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{composites.map(comp => (
<ProjectTreeNode key={comp.pageId || comp.__name__} name={comp.__name__} children={comp} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
))}
</details>
);
}
if (children && children.__type__ === 'instance') {
const instanceName = children.__instance__ || name;
const pageName = children.__page__;
return (
<div
className="component-leaf"
style={{ marginLeft: 15 }}
onClick={() => onSelectInstance && onSelectInstance(pageName, instanceName)}
>
<span style={{ color: 'var(--accent)', marginRight: '4px' }}>[]</span>
{instanceName}
</div>
);
}
if (children && children.__type__ === 'composite') {
const compositeName = children.__name__ || name;
const cellName = children.__cellName__ || compositeName;
const tree = children.tree || {};
const handleDragStart = (event) => {
const dragData = JSON.stringify({ name: cellName, type: 'composite' });
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
};
const handleOpen = (event) => {
if (event.target.closest('.tree-expander')) return;
if (event.target.closest('.tree-delete-btn')) return;
if (onOpenComposite) onOpenComposite(cellName);
};
const handleDeleteCanvas = (event) => {
event.preventDefault();
event.stopPropagation();
if (onDeleteCanvas) onDeleteCanvas(cellName);
};
return (
<details>
<summary className="tree-folder" draggable onDragStart={handleDragStart} onClick={handleOpen}>
<span className="tree-summary-row">
<span className="folder-icon canvas-folder"></span>
<EditableTreeCanvasName
pageId={children.pageId}
name={name}
canRename={children.pageId && children.__name__ === cellName}
onRename={onRenameCanvas}
onOpen={() => onOpenComposite && onOpenComposite(cellName)}
/>
<button className="tree-delete-btn" type="button" title={`Delete ${cellName}`} onClick={handleDeleteCanvas}>x</button>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{Object.keys(tree).length > 0 ? (
Object.entries(tree).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} canvasName={cellName} onSelectInstance={onSelectInstance} />
))
) : (
<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 className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{Object.entries(children).map(([childName, childData]) => (
<ProjectTreeNode key={childName} name={childName} children={childData} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
))}
</details>
);
};
const CompositeComponentTree = ({ name, children, canvasName, onSelectInstance }) => {
if (children && children.__type__ === 'component') {
const displayText = children.__instance__ || name;
return (
<div
className="component-leaf"
style={{ marginLeft: 15 }}
onClick={() => onSelectInstance && onSelectInstance(canvasName, displayText)}
>
<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 }}>
<span className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{hasChildren &&
Object.entries(children).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} canvasName={canvasName} onSelectInstance={onSelectInstance} />
))
}
</details>
);
}
return null;
};
const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => {
const [projectPanelHeight, setProjectPanelHeight] = useState(270);
const [resizingProjectPanel, setResizingProjectPanel] = useState(false);
const leftPanelRef = useRef(null);
useEffect(() => {
if (!resizingProjectPanel) return;
const onMouseMove = (event) => {
if (!leftPanelRef.current) return;
const rect = leftPanelRef.current.getBoundingClientRect();
const nextHeight = event.clientY - rect.top - 12;
setProjectPanelHeight(Math.min(620, Math.max(150, nextHeight)));
};
const onMouseUp = () => setResizingProjectPanel(false);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [resizingProjectPanel]);
const handleProjectToggle = () => {
onProjectToggle();
};
const handleLibraryToggle = () => {
onToggle();
};
return (
<aside style={{
width: width, background: 'var(--panel-rail)', borderRight: '1px solid var(--border)',
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflow: 'hidden', gap: 12
}} ref={leftPanelRef}>
<div style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0, overflow: 'hidden' }}>
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, height: projectPanelHeight, flex: '0 0 auto', marginBottom: 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} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
);
} 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} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
);
}
})
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No project loaded</p>
)}
</div>
</div>
<div className="vertical-resize-handle" onMouseDown={(event) => { event.preventDefault(); setResizingProjectPanel(true); }} />
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: '1 1 0', marginBottom: 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>
</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 selectedComponentName = selectedNode?.data?.componentName || '';
const availableComponentsFromNode = Array.isArray(selectedNode?.data?.availableComponents)
? selectedNode.data.availableComponents.filter(Boolean)
: [];
const availableComponents = Array.from(new Set([...availableComponentsFromNode, selectedComponentName].filter(Boolean)));
const canChooseComponent = availableComponentsFromNode.length > 0;
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' }}>
{canChooseComponent && (
<div style={{ marginBottom: '15px' }}>
<label>Component</label>
<select
value={selectedNode.data.componentName || availableComponents[0]}
onChange={(event) => {
const componentName = event.target.value;
onUpdateNode(selectedNode.id, {
data: {
componentName,
label: componentName
}
});
}}
>
{availableComponents.map(componentName => (
<option key={componentName} value={componentName}>{componentName}</option>
))}
</select>
</div>
)}
{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)' }}>Edit</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;
tree[instanceName] = {
__type__: 'component',
__name__: compName,
__instance__: 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 currentProjectName = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get('project') || 'project_1';
}, []);
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 [themeMode, setThemeMode] = useState(() => localStorage.getItem('mxpic-theme') || 'dark');
const [logs, setLogs] = useState([{ time: new Date().toLocaleTimeString(), message: 'Editor ready.' }]);
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({});
useEffect(() => {
document.body.classList.toggle('light-mode', themeMode === 'light');
localStorage.setItem('mxpic-theme', themeMode);
}, [themeMode]);
const addLog = useCallback((message) => {
setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]);
}, []);
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) return;
initializedRef.current = true;
const makeProjectPage = () => ({
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
name: currentProjectName,
type: 'project',
nodes: [],
edges: [],
port: { x: 0, y: 0, a: 0 }
});
const findCategory = (compName) => {
let category = '';
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);
return category;
};
const pageFromYaml = (cellName, content) => {
const doc = jsyaml.load(content) || {};
const nodeNameMap = {};
const nodes = [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'Port', angle: 0 },
draggable: true,
selectable: true,
deletable: false,
}
];
const edges = [];
Object.entries(doc.instances || {}).forEach(([instName, inst]) => {
const compPath = inst.component || '';
const compName = compPath.split('/').pop();
const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
nodeNameMap[instName] = nodeId;
nodes.push({
id: nodeId,
type: 'rotatableNode',
position: {
x: parseFloat(inst.x) || 0,
y: parseFloat(inst.y) || 0,
},
data: {
label: compName,
componentName: compName,
category: findCategory(compName),
rotation: parseFloat(inst.rotation) || 0,
componentDisplayName: instName,
},
});
});
const links = doc.bundles?.output_bus?.links;
if (links) {
const linkArray = Array.isArray(links) ? links : [links];
linkArray.forEach(link => {
if (!link.from || !link.to) return;
const [fromInst, fromPort] = link.from.split(':');
const [toInst, toPort] = link.to.split(':');
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (!sourceId || !targetId) return;
edges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
type: 'smoothstep',
style: { stroke: 'var(--accent)', strokeWidth: 2 },
});
});
}
return {
id: `cell-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
name: doc.name || cellName,
type: doc.type === 'project' ? 'project' : 'composite',
nodes,
edges,
port: { x: 50, y: 150, a: 0 }
};
};
const loadProject = async () => {
const projectPage = makeProjectPage();
try {
const response = await fetch(`/api/projects/${encodeURIComponent(currentProjectName)}`);
if (!response.ok) {
setPages([projectPage]);
setActivePageId(projectPage.id);
setProjectCompositeMap({ [currentProjectName]: [] });
return;
}
const data = await response.json();
const cellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content));
setPages([projectPage, ...cellPages]);
setActivePageId(projectPage.id);
setProjectCompositeMap({ [currentProjectName]: cellPages.map(page => page.name) });
setStandaloneComposites([]);
const nextTrees = {};
cellPages.forEach(page => {
nextTrees[page.name] = buildCompInstanceTree(page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library);
});
setCompositeTrees(nextTrees);
} catch (error) {
setPages([projectPage]);
setActivePageId(projectPage.id);
setProjectCompositeMap({ [currentProjectName]: [] });
}
};
loadProject();
}, [library, currentProjectName]);
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 openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]);
const selectInstanceInPage = useCallback((pageName, instanceName) => {
if (!pageName || !instanceName) return;
const targetPage = pages.find(p => p.name === pageName);
if (!targetPage) return;
setActivePageId(targetPage.id);
setPages(prev => prev.map(page => {
if (page.id !== targetPage.id) {
return {
...page,
nodes: page.nodes.map(node => ({ ...node, selected: false }))
};
}
return {
...page,
nodes: page.nodes.map(node => ({
...node,
selected: node.id !== 'page-port' && (node.data?.componentDisplayName === instanceName || node.id === instanceName)
}))
};
}));
}, [pages]);
const openProject = useCallback((name) => {
setPages(prev => {
const existing = prev.find(p => p.name === name && p.type === 'project');
if (existing) {
setActivePageId(existing.id);
return prev.map(p => p.id === existing.id ? { ...p, isClosed: false } : p);
}
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.map(p => p.id === existing.id ? { ...p, isClosed: false } : p);
}
const newComposite = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: name,
type: 'composite',
isClosed: false,
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 renameCanvas = useCallback((pageId, requestedName) => {
const normalizedName = requestedName.trim().replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^[._]+|[._]+$/g, '');
if (!normalizedName) return;
const pageToRename = pages.find(p => p.id === pageId);
if (!pageToRename || pageToRename.type === 'project' || pageToRename.name === normalizedName) return;
const nameTaken = pages.some(p => p.id !== pageId && p.name === normalizedName);
if (nameTaken) {
addLog(`Canvas rename failed: "${normalizedName}" already exists.`);
return;
}
const oldName = pageToRename.name;
setPages(prev => prev.map(p => {
const renamedPage = p.id === pageId ? { ...p, name: normalizedName } : p;
return {
...renamedPage,
nodes: renamedPage.nodes.map(node => {
if (node.data?.type === 'composite' && node.data.componentName === oldName) {
return {
...node,
data: {
...node.data,
componentName: normalizedName,
componentDisplayName: node.data.componentDisplayName === oldName ? normalizedName : node.data.componentDisplayName,
label: normalizedName
}
};
}
return node;
})
};
}));
setProjectCompositeMap(prev => {
const next = {};
Object.entries(prev).forEach(([project, cells]) => {
next[project] = cells.map(name => name === oldName ? normalizedName : name);
});
return next;
});
setStandaloneComposites(prev => prev.map(name => name === oldName ? normalizedName : name));
setCompositeTrees(prev => {
const next = { ...prev };
if (Object.prototype.hasOwnProperty.call(next, oldName)) {
next[normalizedName] = next[oldName];
delete next[oldName];
}
return next;
});
fetch(`/api/projects/${encodeURIComponent(currentProjectName)}/cells/${encodeURIComponent(oldName)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: normalizedName })
}).then(response => {
if (response.ok) addLog(`Renamed canvas "${oldName}" to "${normalizedName}".`);
}).catch(() => addLog(`Renamed canvas locally; saved file rename did not complete.`));
}, [pages, currentProjectName, addLog]);
const createCell = useCallback(() => {
const existingNames = new Set(pages.filter(p => p.type === 'composite').map(p => p.name));
let index = existingNames.size + 1;
let cellName = `canvas_${index}`;
while (existingNames.has(cellName)) {
index += 1;
cellName = `canvas_${index}`;
}
const newCell = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: cellName,
type: 'composite',
isClosed: false,
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 }
};
setPages(prev => [...prev, newCell]);
setActivePageId(newCell.id);
setProjectCompositeMap(prev => ({
...prev,
[currentProjectName]: [...(prev[currentProjectName] || []), cellName]
}));
}, [pages, currentProjectName]);
const closePage = useCallback((pageId) => {
setPages(prev => {
const closed = prev.map(p => p.id === pageId ? { ...p, isClosed: true } : p);
if (activePageId === pageId) {
const idx = prev.findIndex(p => p.id === pageId);
const openPages = closed.filter(p => !p.isClosed);
const nextActive = openPages[Math.min(idx, openPages.length - 1)] || openPages[openPages.length - 1] || null;
setActivePageId(nextActive ? nextActive.id : null);
}
return closed;
});
}, [activePageId]);
const deleteCanvas = useCallback((cellName) => {
if (!cellName) return;
if (!window.confirm(`Delete canvas "${cellName}" from this project?`)) return;
const pageToDelete = pages.find(p => p.type === 'composite' && p.name === cellName);
setPages(prev => {
const withoutCell = prev
.filter(p => !(p.type === 'composite' && p.name === cellName))
.map(p => {
if (p.type !== 'project') return p;
return {
...p,
nodes: p.nodes.filter(node => node.data?.componentName !== cellName),
edges: p.edges.filter(edge => {
const removedNodeIds = new Set(p.nodes.filter(node => node.data?.componentName === cellName).map(node => node.id));
return !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target);
})
};
});
if (activePageId === pageToDelete?.id) {
const nextActive = withoutCell.find(p => !p.isClosed) || withoutCell[0] || null;
setActivePageId(nextActive ? nextActive.id : null);
}
return withoutCell;
});
setProjectCompositeMap(prev => {
const next = {};
Object.entries(prev).forEach(([project, cells]) => {
next[project] = cells.filter(name => name !== cellName);
});
return next;
});
setStandaloneComposites(prev => prev.filter(name => name !== cellName));
setCompositeTrees(prev => {
const next = { ...prev };
delete next[cellName];
return next;
});
fetch(`/api/projects/${encodeURIComponent(currentProjectName)}/cells/${encodeURIComponent(cellName)}`, {
method: 'DELETE'
}).then(response => {
if (response.ok) addLog(`Deleted canvas "${cellName}".`);
else addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`);
}).catch(() => addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`));
}, [pages, activePageId, currentProjectName, addLog]);
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 => {
const currentList = prev[projectName] || [];
const instanceName = newNode.data.componentDisplayName || parsedData.name;
if (currentList.includes(instanceName)) return prev;
return {
...prev,
[projectName]: [...currentList, instanceName]
};
});
}
return;
}
if (parsedData.type === 'composite') {
if (!activePageId) return;
if (activePage?.type === 'composite' && parsedData.name === activePage.name) {
addLog(`Skipped self-reference: "${parsedData.name}" cannot be placed inside itself.`);
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') {
setProjectCompositeMap(prev => {
const currentList = prev[activePage.name] || [];
const instanceName = newNode.data.componentDisplayName || parsedData.name;
if (currentList.includes(instanceName)) return prev;
return {
...prev,
[activePage.name]: [...currentList, instanceName]
};
});
}
return;
}
if (!activePageId) {
alert('Please open a composite page first.');
return;
}
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
if (parsedData.type === 'category') {
const availableComponents = Array.isArray(parsedData.components)
? parsedData.components
.map(component => typeof component === 'string' ? component : component?.name)
.filter(Boolean)
: [];
const selectedComponent = parsedData.name || availableComponents[0] || parsedData.category;
if (!selectedComponent) {
addLog('Skipped category placement: no components were found in this library category.');
return;
}
const componentDisplayName = generateComponentDisplayName();
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position,
data: {
label: selectedComponent,
componentName: selectedComponent,
availableComponents: availableComponents.length > 0 ? availableComponents : [selectedComponent],
libraryCategory: parsedData.category || 'default',
category: parsedData.category || 'default',
rotation: 0,
componentDisplayName: componentDisplayName
},
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
return;
}
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 projectPagesByName = new Map();
pages
.filter(p => p.type === 'project')
.forEach(project => {
if (!projectPagesByName.has(project.name) || project.id === activePageId) {
projectPagesByName.set(project.name, project);
}
});
const projectPages = Array.from(projectPagesByName.values());
projectPages.forEach(project => {
const projectNodeItems = project.nodes
.filter(node => node.id !== 'page-port' && node.data?.componentName)
.map(node => {
const instanceName = node.data.componentDisplayName || node.id;
const componentName = node.data.componentName;
const compPage = pages.find(p => p.name === componentName && p.type === 'composite');
if (compPage) {
return {
__type__: 'composite',
__name__: instanceName,
__cellName__: componentName,
tree: compositeTrees[componentName] || {},
pageId: compPage.id
};
}
return {
__type__: 'instance',
__name__: instanceName,
__instance__: instanceName,
__page__: project.name
};
});
const unplacedCells = (projectCompositeMap[project.name] || [])
.filter(name => !projectNodeItems.some(item => item.__name__ === name || item.__cellName__ === name))
.map(name => {
const compPage = pages.find(p => p.name === name && p.type === 'composite');
return {
__type__: 'composite',
__name__: name,
__cellName__: name,
tree: compositeTrees[name] || {},
pageId: compPage ? compPage.id : name
};
});
items.push({
type: 'project',
name: project.name,
composites: [...projectNodeItems, ...unplacedCells]
});
});
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, activePageId]);
const libraryWithCells = useMemo(() => {
const cellEntries = {};
pages
.filter(page => page.type === 'composite')
.forEach(page => {
cellEntries[page.name] = {
__type__: 'component',
__name__: page.name,
__category__: 'composite',
__cell__: true
};
});
return {
...cellEntries,
...(library || {})
};
}, [pages, library]);
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
# =============================================
schema_version: "2.0.0"
kind: cell
project: ${currentProjectName}
name: ${activePage.name}
type: ${activePage.type === 'project' ? 'project' : '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;
const compName = n.data.componentName || '';
return ` ${instanceName}:
component: ${compName}
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({
project: currentProjectName,
cell: activePage.name,
content: yamlContent,
}),
});
if (!response.ok) {
const errData = await response.json();
addLog(errData.error || 'Save failed, unknown error');
return;
}
const result = await response.json();
addLog('Successfully saved: ' + result.path);
} catch (err) {
addLog('Save error: ' + err.message);
}
}, [activePage, library, buildBundlesYaml, findComponentPath, currentProjectName, addLog]);
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={libraryWithCells} treeKey={treeKey} expanded={expanded}
onToggle={handleToggle} treeRef={treeContainerRef} width={leftWidth}
onOpenComposite={openPage}
onOpenProject={openProject}
onSelectInstance={selectInstanceInPage}
onRenameCanvas={renameCanvas}
onDeleteCanvas={deleteCanvas}
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>
<div className="canvas-tab" style={{ cursor: 'pointer' }} onClick={createCell}>
+ Cell
</div>
{openTabs.map(page => (
<div key={page.id} className={`canvas-tab ${page.id === activePageId ? 'active' : ''}`} onClick={() => switchPage(page.id)}>
<EditableCanvasTabName page={page} active={page.id === activePageId} onRename={renameCanvas} />
<button onClick={(e) => { e.stopPropagation(); closePage(page.id); }}>x</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: 'rgba(13, 22, 38, 0.9)', padding: '8px 12px', borderRadius: '8px',
border: '1px solid var(--border)', boxShadow: '0 16px 34px var(--shadow)', backdropFilter: 'blur(14px)'
}}>
<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>
<button className="mini-btn" onClick={() => setThemeMode(themeMode === 'light' ? 'dark' : 'light')}>
{themeMode === 'light' ? 'Dark Mode' : 'Bright Mode'}
</button>
<button className="mini-btn" onClick={() => { window.location.href = '/dashboard'; }}>
Dashboard
</button>
<button className="mini-btn" onClick={() => { window.location.href = '/logout'; }}>
Logout
</button>
</div>
{activePage && (
<button
onClick={handleBuildLayout}
style={{
position: 'absolute',
bottom: 20,
right: 20,
zIndex: 10,
background: 'linear-gradient(135deg, var(--accent), var(--accent-hover))',
color: '#04101f',
border: 'none',
padding: '12px 20px',
borderRadius: 8,
fontWeight: '700',
cursor: 'pointer',
fontSize: '0.85em',
boxShadow: '0 16px 34px rgba(37,99,235,0.24)',
}}
>
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 className="app-log-terminal">
{logs.map((entry, index) => (
<div key={`${entry.time}-${index}`}>[{entry.time}] {entry.message}</div>
))}
</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 %}