5766 lines
220 KiB
HTML
5766 lines
220 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>
|
|
<script src="/canvas-helpers.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg-main: #05080f;
|
|
--bg-card: #0b1320;
|
|
--text-main: #f5f7fa;
|
|
--text-muted: #9aa8ba;
|
|
--accent: #45d6c8;
|
|
--accent-hover: #7fded5;
|
|
--accent-green: #39d98a;
|
|
--accent-warm: #f59e0b;
|
|
--border: rgba(163, 186, 212, 0.16);
|
|
--border-strong: rgba(177, 207, 232, 0.32);
|
|
--input-bg: #070e19;
|
|
--panel-rail: #070d16;
|
|
--panel-header: #0f1724;
|
|
--panel-body: #0a121d;
|
|
--canvas-bg: #060b13;
|
|
--danger: #ef4444;
|
|
--folder-icon: #f2c14e;
|
|
--shadow: rgba(0, 0, 0, 0.36);
|
|
--surface-highlight: rgba(255, 255, 255, 0.045);
|
|
--focus-ring: rgba(69, 214, 200, 0.22);
|
|
--floating-label-bg: rgba(15, 23, 36, 0.96);
|
|
--floating-label-border: rgba(142, 169, 198, 0.34);
|
|
--floating-label-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(69, 214, 200, 0.08);
|
|
--port-label-bg: rgba(13, 21, 33, 0.96);
|
|
--port-label-text: #cbd6e3;
|
|
--mini-button-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)), #101a29;
|
|
--mini-button-text: #d5dfec;
|
|
}
|
|
|
|
body.light-mode {
|
|
--bg-main: #eef3f6;
|
|
--bg-card: #ffffff;
|
|
--text-main: #122033;
|
|
--text-muted: #637184;
|
|
--accent: #087f73;
|
|
--accent-hover: #0f9d8f;
|
|
--accent-green: #14834f;
|
|
--accent-warm: #c57a00;
|
|
--border: rgba(30, 48, 69, 0.14);
|
|
--border-strong: rgba(30, 48, 69, 0.28);
|
|
--input-bg: #f6f9fb;
|
|
--panel-rail: #dfe8ee;
|
|
--panel-header: #f8fafc;
|
|
--panel-body: #ffffff;
|
|
--canvas-bg: #eef4f7;
|
|
--folder-icon: #0f9d8f;
|
|
--shadow: rgba(18, 32, 51, 0.12);
|
|
--surface-highlight: rgba(255, 255, 255, 0.72);
|
|
--focus-ring: rgba(8, 127, 115, 0.18);
|
|
--floating-label-bg: rgba(255, 255, 255, 0.94);
|
|
--floating-label-border: rgba(30, 48, 69, 0.18);
|
|
--floating-label-shadow: 0 10px 22px rgba(18, 32, 51, 0.14), 0 0 0 1px rgba(8, 127, 115, 0.08);
|
|
--port-label-bg: rgba(255, 255, 255, 0.96);
|
|
--port-label-text: #334155;
|
|
--mini-button-bg: linear-gradient(180deg, #ffffff, #eef5f8);
|
|
--mini-button-text: #17263a;
|
|
}
|
|
|
|
.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.026) 1px, transparent 1px),
|
|
linear-gradient(0deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px),
|
|
linear-gradient(135deg, rgba(69, 214, 200, 0.08), transparent 38%),
|
|
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(8, 127, 115, 0.045) 1px, transparent 1px),
|
|
linear-gradient(0deg, rgba(8, 127, 115, 0.035) 1px, transparent 1px),
|
|
linear-gradient(135deg, rgba(8, 127, 115, 0.08), transparent 42%),
|
|
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: 10px;
|
|
border-radius: 8px;
|
|
background:
|
|
linear-gradient(180deg, var(--surface-highlight), transparent 74%),
|
|
var(--input-bg);
|
|
border: 1px solid var(--border);
|
|
transition: transform 0.16s ease, 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);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.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, var(--surface-highlight), transparent 78%),
|
|
var(--panel-body);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-bottom: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 18px 38px 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.08em;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.build-gds-btn {
|
|
min-width: 106px;
|
|
height: 32px;
|
|
border: 1px solid rgba(69, 214, 200, 0.46);
|
|
border-radius: 8px;
|
|
padding: 0 11px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: #03231f;
|
|
background: linear-gradient(180deg, #7ee7dc, #35c4b8);
|
|
box-shadow: 0 10px 24px rgba(24, 166, 153, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.01em;
|
|
cursor: pointer;
|
|
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.build-gds-btn::before {
|
|
content: "";
|
|
width: 8px;
|
|
height: 8px;
|
|
border: 1.5px solid currentColor;
|
|
border-radius: 2px;
|
|
box-shadow: 5px 0 0 -2px currentColor, 0 5px 0 -2px currentColor;
|
|
opacity: 0.86;
|
|
}
|
|
|
|
.build-gds-btn:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
filter: brightness(1.04);
|
|
box-shadow: 0 14px 30px rgba(24, 166, 153, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.68);
|
|
}
|
|
|
|
.build-gds-btn:focus-visible,
|
|
.mini-btn:focus-visible,
|
|
.toggle-btn:focus-visible {
|
|
outline: 3px solid var(--focus-ring);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.build-gds-btn:disabled {
|
|
cursor: wait;
|
|
opacity: 0.68;
|
|
filter: saturate(0.6);
|
|
transform: none;
|
|
}
|
|
|
|
body.light-mode .build-gds-btn {
|
|
color: #f3fffd;
|
|
background: linear-gradient(180deg, #0f9d8f, #087f73);
|
|
box-shadow: 0 10px 22px rgba(8, 127, 115, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
.layout-preview {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: #08111f;
|
|
}
|
|
|
|
.layout-preview-toolbar {
|
|
position: absolute;
|
|
top: 14px;
|
|
left: 14px;
|
|
z-index: 12;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: rgba(13, 22, 38, 0.92);
|
|
box-shadow: 0 16px 34px var(--shadow);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
|
|
.layout-preview-toolbar label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
margin: 0;
|
|
color: var(--text-main);
|
|
font-size: 0.78rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.layout-preview-toolbar input[type="range"] {
|
|
width: 160px;
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.layout-preview-toolbar input[type="number"] {
|
|
width: 74px;
|
|
padding: 5px 7px;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.layout-preview-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: 58px 18px 18px;
|
|
box-sizing: border-box;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
.layout-preview-scroll-area {
|
|
min-width: 100%;
|
|
min-height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.layout-preview-stage {
|
|
flex: 0 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
.layout-preview-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
body.light-mode .layout-preview {
|
|
background: #eef4f8;
|
|
}
|
|
|
|
body.light-mode .layout-preview-toolbar {
|
|
background: rgba(255, 255, 255, 0.92);
|
|
}
|
|
|
|
.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(--mini-button-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--mini-button-text);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
height: 32px;
|
|
padding: 0 12px;
|
|
font-family: inherit;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
transition: color 0.16s ease, border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
|
|
}
|
|
|
|
.mini-btn:hover {
|
|
color: var(--text-main);
|
|
border-color: var(--accent);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
body.light-mode .mini-btn {
|
|
background: var(--mini-button-bg);
|
|
border-color: rgba(30, 48, 69, 0.18);
|
|
color: var(--mini-button-text);
|
|
box-shadow: 0 6px 14px rgba(18, 32, 51, 0.08);
|
|
}
|
|
|
|
.site-nav-actions {
|
|
position: fixed;
|
|
top: 14px;
|
|
right: 16px;
|
|
z-index: 80;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: rgba(13, 22, 38, 0.92);
|
|
box-shadow: 0 16px 34px var(--shadow);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
|
|
body.light-mode .site-nav-actions {
|
|
background: rgba(255, 255, 255, 0.94);
|
|
border-color: rgba(30, 48, 69, 0.16);
|
|
}
|
|
|
|
.canvas-toolbar {
|
|
position: absolute;
|
|
top: 15px;
|
|
right: 15px;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: rgba(13, 22, 38, 0.9);
|
|
box-shadow: 0 16px 34px var(--shadow);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
|
|
body.light-mode .canvas-toolbar {
|
|
background: rgba(255, 255, 255, 0.96);
|
|
border-color: rgba(30, 48, 69, 0.16);
|
|
}
|
|
|
|
.grid-snap-label {
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
body.light-mode .grid-snap-label {
|
|
color: #102033;
|
|
}
|
|
|
|
.build-layout-btn {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
z-index: 10;
|
|
height: 44px;
|
|
border: 1px solid rgba(69, 214, 200, 0.46);
|
|
border-radius: 8px;
|
|
padding: 0 18px;
|
|
color: #05231f;
|
|
background: linear-gradient(180deg, #7ee7dc, #34c5b8);
|
|
box-shadow: 0 16px 34px rgba(24, 166, 153, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
|
}
|
|
|
|
.build-layout-btn:hover {
|
|
transform: translateY(-1px);
|
|
filter: brightness(1.04);
|
|
box-shadow: 0 18px 38px rgba(24, 166, 153, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.68);
|
|
}
|
|
|
|
.build-layout-btn:focus-visible {
|
|
outline: 3px solid var(--focus-ring);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
body.light-mode .build-layout-btn {
|
|
color: #f3fffd;
|
|
background: linear-gradient(180deg, #0f9d8f, #087f73);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.component-card.compact-tree-card {
|
|
min-height: 18px;
|
|
padding: 2px 6px;
|
|
margin: 1px 0 1px 18px;
|
|
border-radius: 4px;
|
|
align-items: center;
|
|
background: transparent;
|
|
border-color: transparent;
|
|
box-shadow: none;
|
|
gap: 6px;
|
|
}
|
|
|
|
.component-card.compact-tree-card:hover {
|
|
background: rgba(110, 231, 255, 0.08);
|
|
border-color: var(--border);
|
|
box-shadow: none;
|
|
}
|
|
|
|
.component-card.compact-tree-card .component-card-name {
|
|
text-align: left;
|
|
line-height: 18px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.element-card-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex: 0 0 16px;
|
|
position: relative;
|
|
border: 1px solid var(--border);
|
|
background: rgba(6, 14, 24, 0.86);
|
|
}
|
|
|
|
.element-card-icon.port-icon {
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.element-card-icon.port-icon::before {
|
|
content: '';
|
|
position: absolute;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--accent-green);
|
|
top: 4px;
|
|
left: 4px;
|
|
box-shadow: 0 0 0 2px rgba(110, 231, 255, 0.18);
|
|
}
|
|
|
|
.element-card-icon.port-icon::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 5px;
|
|
height: 1px;
|
|
background: var(--accent-green);
|
|
top: 7px;
|
|
right: 1px;
|
|
}
|
|
|
|
.element-card-icon.anchor-icon {
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.element-card-icon.anchor-icon::before,
|
|
.element-card-icon.anchor-icon::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: var(--accent-warm);
|
|
top: 5px;
|
|
}
|
|
|
|
.element-card-icon.anchor-icon::before {
|
|
left: 1px;
|
|
}
|
|
|
|
.element-card-icon.anchor-icon::after {
|
|
right: 1px;
|
|
}
|
|
|
|
.element-card-icon.basic-icon {
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background: rgba(15, 23, 42, 0.92);
|
|
}
|
|
|
|
.element-card-icon.basic-icon::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 2px;
|
|
right: 2px;
|
|
top: 7px;
|
|
height: 2px;
|
|
border-radius: 999px;
|
|
background: var(--accent);
|
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.14);
|
|
}
|
|
|
|
.coordinate-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 8px;
|
|
align-items: end;
|
|
}
|
|
|
|
.coordinate-grid label {
|
|
display: grid;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.port-info-list {
|
|
display: grid;
|
|
gap: 6px;
|
|
margin: 0 0 15px 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.port-info-list li {
|
|
line-height: 1.45;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
|
|
.save-project-btn {
|
|
border: 1px solid rgba(45, 212, 191, 0.45);
|
|
background: linear-gradient(180deg, rgba(45, 212, 191, 0.16), rgba(13, 148, 136, 0.12));
|
|
color: var(--text-main);
|
|
border-radius: 5px;
|
|
padding: 5px 8px;
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.save-project-btn:disabled {
|
|
cursor: wait;
|
|
opacity: 0.66;
|
|
}
|
|
|
|
.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: linear-gradient(180deg, #040812, #02050b);
|
|
color: #b7efe9;
|
|
border-top: 1px solid var(--border);
|
|
padding: 10px 14px;
|
|
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: #155e58;
|
|
}
|
|
|
|
.route-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 9px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
color: var(--text-muted);
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
font-size: 0.72rem;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.route-chip::before {
|
|
content: "";
|
|
width: 20px;
|
|
height: 3px;
|
|
border-radius: 999px;
|
|
background: currentColor;
|
|
}
|
|
|
|
.link-mode-tabs {
|
|
position: relative;
|
|
display: inline-block;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
}
|
|
|
|
.link-mode-summary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-width: 118px;
|
|
height: 30px;
|
|
padding: 0 9px;
|
|
list-style: none;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.link-mode-summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.link-mode-label {
|
|
font-size: 0.72rem;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.link-mode-current {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: auto;
|
|
font-size: 0.72rem;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
}
|
|
|
|
.link-mode-current::before,
|
|
.link-mode-btn::before {
|
|
content: "";
|
|
width: 16px;
|
|
height: 3px;
|
|
border-radius: 999px;
|
|
background: currentColor;
|
|
}
|
|
|
|
.link-mode-menu {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
right: 0;
|
|
z-index: 40;
|
|
min-width: 154px;
|
|
display: grid;
|
|
gap: 4px;
|
|
padding: 6px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-card);
|
|
box-shadow: 0 18px 40px var(--shadow);
|
|
}
|
|
|
|
.link-mode-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
height: 28px;
|
|
padding: 0 9px;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-main);
|
|
font-size: 0.72rem;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.link-mode-btn.active {
|
|
background: rgba(14, 165, 233, 0.18);
|
|
border-color: currentColor;
|
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
|
}
|
|
|
|
.port-name-label {
|
|
position: absolute;
|
|
z-index: 4;
|
|
max-width: 58px;
|
|
padding: 1px 4px;
|
|
border-radius: 4px;
|
|
background: var(--port-label-bg);
|
|
border: 1px solid var(--floating-label-border);
|
|
color: var(--port-label-text);
|
|
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.18);
|
|
font-size: 0.42rem;
|
|
line-height: 1.2;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.build-progress {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 14px;
|
|
transform: translateX(-50%);
|
|
z-index: 20;
|
|
width: min(360px, calc(100% - 180px));
|
|
padding: 9px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-strong);
|
|
background: rgba(11, 19, 32, 0.94);
|
|
box-shadow: 0 18px 38px var(--shadow);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
|
|
.build-progress-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 7px;
|
|
font-size: 0.72rem;
|
|
color: var(--text-main);
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
}
|
|
|
|
.build-progress-track {
|
|
height: 7px;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
background: rgba(148, 163, 184, 0.18);
|
|
}
|
|
|
|
.build-progress-fill {
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent-green));
|
|
transition: width 0.28s ease;
|
|
}
|
|
|
|
.component-node-shell {
|
|
position: relative;
|
|
min-width: 0;
|
|
max-width: none;
|
|
width: 132px;
|
|
min-height: 82px;
|
|
text-align: center;
|
|
font-family: 'IBM Plex Sans', sans-serif;
|
|
}
|
|
|
|
.component-visual-body {
|
|
min-height: 74px;
|
|
padding: 10px 15px;
|
|
border-radius: 6px;
|
|
background: var(--bg-card);
|
|
color: var(--text-main);
|
|
box-sizing: border-box;
|
|
transition: none;
|
|
transform-origin: center center;
|
|
}
|
|
|
|
.component-floating-label {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: calc(100% + 7px);
|
|
transform: translateX(-50%);
|
|
z-index: 8;
|
|
min-width: 84px;
|
|
max-width: 160px;
|
|
padding: 3px 6px;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--floating-label-border);
|
|
background: var(--floating-label-bg);
|
|
color: var(--text-main);
|
|
box-shadow: var(--floating-label-shadow);
|
|
pointer-events: none;
|
|
}
|
|
|
|
body.light-mode .component-floating-label {
|
|
background: var(--floating-label-bg);
|
|
border-color: var(--floating-label-border);
|
|
box-shadow: var(--floating-label-shadow);
|
|
}
|
|
|
|
body.light-mode .port-name-label {
|
|
background: var(--port-label-bg);
|
|
border-color: var(--floating-label-border);
|
|
color: var(--port-label-text);
|
|
box-shadow: 0 5px 12px rgba(18, 32, 51, 0.1);
|
|
}
|
|
|
|
.box-size-readout {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin: 10px 0 14px;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
color: var(--text-main);
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
font-size: 0.68rem;
|
|
}
|
|
|
|
.box-size-readout span {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.component-floating-label strong,
|
|
.component-floating-label span {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.component-floating-label strong {
|
|
font-size: 0.52rem;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.component-floating-label span {
|
|
margin-top: 1px;
|
|
color: var(--text-muted);
|
|
font-size: 0.44rem;
|
|
}
|
|
|
|
.canvas-size-panel {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background:
|
|
linear-gradient(180deg, var(--surface-highlight), transparent 74%),
|
|
var(--input-bg);
|
|
}
|
|
|
|
.canvas-size-panel label {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
color: var(--text-muted);
|
|
font-size: 0.68rem;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
}
|
|
|
|
.canvas-size-title {
|
|
grid-column: 1 / -1;
|
|
color: var(--text-main);
|
|
font-size: 0.74rem;
|
|
font-weight: 700;
|
|
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
}
|
|
|
|
.canvas-boundary-node {
|
|
width: 100%;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
border: 4px solid rgba(69, 214, 200, 0.92);
|
|
background: rgba(69, 214, 200, 0.018);
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14), 0 0 22px rgba(69, 214, 200, 0.18);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ruler-point-node {
|
|
width: 12px;
|
|
height: 12px;
|
|
box-sizing: border-box;
|
|
border-radius: 50%;
|
|
border: 2px solid #f8fafc;
|
|
background: var(--accent-green);
|
|
box-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18), 0 8px 18px rgba(0, 0, 0, 0.28);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ruler-measurement-node {
|
|
min-width: 148px;
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(45, 212, 191, 0.5);
|
|
background: rgba(9, 18, 28, 0.94);
|
|
color: #e2f7f3;
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
|
|
font: 600 0.62rem/1.35 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
text-align: center;
|
|
transform: translate(-50%, -50%);
|
|
pointer-events: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
body.light-mode .ruler-measurement-node {
|
|
background: rgba(255, 255, 255, 0.96);
|
|
color: #12323a;
|
|
box-shadow: 0 10px 24px rgba(18, 32, 51, 0.14);
|
|
}
|
|
|
|
.ruler-status {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 24px;
|
|
transform: translateX(-50%);
|
|
z-index: 18;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(45, 212, 191, 0.38);
|
|
background: rgba(9, 18, 28, 0.9);
|
|
color: #c8f7f0;
|
|
font: 600 0.68rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
|
|
pointer-events: none;
|
|
}
|
|
|
|
body.light-mode .ruler-status {
|
|
background: rgba(255, 255, 255, 0.94);
|
|
color: #0f4c54;
|
|
}
|
|
</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,
|
|
SelectionMode,
|
|
useUpdateNodeInternals,
|
|
applyNodeChanges,
|
|
applyEdgeChanges,
|
|
} = window.ReactFlow;
|
|
const {
|
|
FORGE_COMPONENT_LABEL,
|
|
FORGE_COMPONENT_TYPE,
|
|
DEFAULT_COMPONENT_BOX_SIZE,
|
|
DEFAULT_CANVAS_SIZE,
|
|
PORT_NODE_SIZE,
|
|
ELEMENT_COMPONENTS,
|
|
BASIC_COMPONENTS,
|
|
createForgeArguments,
|
|
isForgeComponent,
|
|
isBasicComponent,
|
|
createBasicSettings,
|
|
normalizeBoxSize,
|
|
chooseCategoryComponent,
|
|
normalizeCanvasSize,
|
|
clampPositionToCanvas,
|
|
calculateLayoutBounds,
|
|
buildPortHandles,
|
|
buildElementPorts,
|
|
getBasicComponentMetadata,
|
|
buildInstancesYaml,
|
|
buildPageComponentPorts,
|
|
buildCanvasPortsYaml,
|
|
buildElementsYaml,
|
|
buildBundlesYaml: buildRouteBundlesYaml,
|
|
normalizeAngle,
|
|
createRouteSettings,
|
|
updateRouteField,
|
|
updateRouteXsection,
|
|
routeStyleForSettings,
|
|
findSameTypeRouteCrossing,
|
|
createRulerMeasurement,
|
|
createComponentSymbolMetrics,
|
|
FALLBACK_TECHNOLOGY_MANIFEST,
|
|
layoutToCanvasY
|
|
} = window.MxpicCanvasHelpers;
|
|
|
|
const FULL_SELECTION_MODE = SelectionMode && SelectionMode.Full ? SelectionMode.Full : 'full';
|
|
|
|
|
|
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={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'fill',
|
|
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 prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`);
|
|
const updateNodeInternalsRef = useRef(updateNodeInternals);
|
|
|
|
useEffect(() => {
|
|
updateNodeInternalsRef.current = updateNodeInternals;
|
|
}, [updateNodeInternals]);
|
|
|
|
useEffect(() => {
|
|
const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`;
|
|
if (prevTransformRef.current !== transformKey) {
|
|
updateNodeInternalsRef.current(id);
|
|
prevTransformRef.current = transformKey;
|
|
}
|
|
}, [data.rotation, data.flip, data.flop, id]);
|
|
|
|
useEffect(() => {
|
|
updateNodeInternalsRef.current(id);
|
|
}, [id, data.ports, data.componentName, data.boxSize]);
|
|
|
|
const baseHandleStyle = {
|
|
width: 10, height: 10,
|
|
background: 'var(--bg-main)',
|
|
border: '2px solid var(--accent)',
|
|
borderRadius: '50%',
|
|
};
|
|
const handlePositionMap = {
|
|
left: Position.Left,
|
|
right: Position.Right,
|
|
top: Position.Top,
|
|
bottom: Position.Bottom
|
|
};
|
|
const portHandles = useMemo(
|
|
() => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
|
|
[data.ports, data.rotation, data.flip, data.flop]
|
|
);
|
|
const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
|
|
const isAnchorElement = data.elementType === 'anchor';
|
|
const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize;
|
|
const iconSize = createComponentSymbolMetrics(componentSize);
|
|
const portLabelStyle = (portHandle) => {
|
|
const base = { ...portHandle.style };
|
|
if (portHandle.position === 'left') {
|
|
return { ...base, right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
|
|
}
|
|
if (portHandle.position === 'right') {
|
|
return { ...base, left: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'left' };
|
|
}
|
|
if (portHandle.position === 'top') {
|
|
return { ...base, bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
|
|
}
|
|
return { ...base, top: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="component-node-shell"
|
|
style={{ width: visualSize.width, minWidth: visualSize.width, maxWidth: visualSize.width, minHeight: visualSize.height }}
|
|
>
|
|
<div className="component-floating-label" title={data.componentDisplayName}>
|
|
<strong>{data.componentDisplayName}</strong>
|
|
{data.componentName && data.componentName !== data.componentDisplayName && (
|
|
<span title={data.componentName}>{data.componentName}</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="component-visual-body"
|
|
style={{
|
|
width: componentSize.width,
|
|
height: visualSize.height,
|
|
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
|
|
transform: `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
|
|
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
|
|
...(isAnchorElement ? {
|
|
width: PORT_NODE_SIZE,
|
|
minHeight: PORT_NODE_SIZE,
|
|
padding: 0,
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
} : {}),
|
|
}}
|
|
>
|
|
{isAnchorElement ? (
|
|
<span style={{ fontSize: 10, fontWeight: 800, color: selected ? 'var(--accent)' : 'var(--text-main)' }}>A</span>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', minHeight: '100%' }}>
|
|
{!data.hideIcon && data.category && (
|
|
<div style={{ width: iconSize.width, height: iconSize.height }}>
|
|
<IconImg category={data.category} />
|
|
</div>
|
|
)}
|
|
{!data.category && <div style={{ width: iconSize.width, height: iconSize.height, borderRadius: 4, border: '1px solid var(--border-strong)', background: 'rgba(148, 163, 184, 0.08)' }} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{portHandles.map((portHandle) => (
|
|
<React.Fragment key={portHandle.name}>
|
|
<Handle
|
|
type="source"
|
|
position={handlePositionMap[portHandle.position]}
|
|
id={portHandle.name}
|
|
title={portHandle.name}
|
|
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10 }}
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={handlePositionMap[portHandle.position]}
|
|
id={portHandle.name}
|
|
title={portHandle.name}
|
|
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }}
|
|
/>
|
|
<span className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
|
|
{portHandle.name}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
</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.flip === nextProps.data.flip &&
|
|
prevProps.data.flop === nextProps.data.flop &&
|
|
prevProps.data.boxSize === nextProps.data.boxSize &&
|
|
prevProps.data.hideIcon === nextProps.data.hideIcon &&
|
|
prevProps.data.ports === nextProps.data.ports
|
|
);
|
|
});
|
|
|
|
|
|
|
|
|
|
const PortNode = ({ id, data, selected }) => {
|
|
const angle = data.angle ?? 0;
|
|
const handleId = data.portName || data.componentDisplayName || 'port';
|
|
return (
|
|
<div style={{
|
|
width: PORT_NODE_SIZE, height: PORT_NODE_SIZE, 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>P</span>
|
|
<Handle type="source" position={Position.Right} id={handleId} style={{ background: 'var(--accent)', width: 8, height: 8 }} />
|
|
<Handle type="target" position={Position.Right} id={handleId} style={{ background: 'var(--accent)', width: 8, height: 8 }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AnchorNode = memo(({ id, data, selected }) => {
|
|
const updateNodeInternals = useUpdateNodeInternals();
|
|
const ports = data.ports || buildElementPorts('anchor');
|
|
const portHandles = useMemo(
|
|
() => buildPortHandles(ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
|
|
[ports, data.rotation, data.flip, data.flop]
|
|
);
|
|
const handlePositionMap = {
|
|
left: Position.Left,
|
|
right: Position.Right,
|
|
top: Position.Top,
|
|
bottom: Position.Bottom
|
|
};
|
|
const baseHandleStyle = {
|
|
width: 8,
|
|
height: 8,
|
|
background: 'var(--accent)',
|
|
border: '1px solid var(--bg-main)',
|
|
borderRadius: '50%'
|
|
};
|
|
|
|
useEffect(() => {
|
|
updateNodeInternals(id);
|
|
}, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]);
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'relative',
|
|
width: PORT_NODE_SIZE,
|
|
height: PORT_NODE_SIZE,
|
|
borderRadius: '50%',
|
|
background: selected ? 'var(--accent)' : 'var(--bg-card)',
|
|
border: selected ? '2px solid white' : '2px solid var(--accent)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: selected ? 'white' : 'var(--accent)',
|
|
fontSize: 10,
|
|
fontWeight: 800,
|
|
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
|
|
}}>
|
|
<span>A</span>
|
|
{portHandles.map((portHandle) => (
|
|
<React.Fragment key={portHandle.name}>
|
|
<Handle
|
|
type="source"
|
|
position={handlePositionMap[portHandle.position]}
|
|
id={portHandle.name}
|
|
title={portHandle.name}
|
|
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10 }}
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={handlePositionMap[portHandle.position]}
|
|
id={portHandle.name}
|
|
title={portHandle.name}
|
|
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const CanvasBoundaryNode = memo(({ data }) => (
|
|
<div className="canvas-boundary-node" title={`${data.size.width} x ${data.size.height} um`} />
|
|
));
|
|
|
|
const RulerPointNode = memo(({ data }) => {
|
|
const hiddenHandleStyle = {
|
|
width: 1,
|
|
height: 1,
|
|
opacity: 0,
|
|
border: 0,
|
|
background: 'transparent',
|
|
pointerEvents: 'none'
|
|
};
|
|
return (
|
|
<>
|
|
<div className="ruler-point-node" title={data.label || 'Ruler point'} />
|
|
<Handle type="source" position={Position.Right} id="route" style={hiddenHandleStyle} isConnectable={false} />
|
|
<Handle type="target" position={Position.Left} id="route" style={hiddenHandleStyle} isConnectable={false} />
|
|
</>
|
|
);
|
|
});
|
|
|
|
const RulerMeasurementNode = memo(({ data }) => (
|
|
<div className="ruler-measurement-node" title={data.title || data.label}>
|
|
{data.label}
|
|
</div>
|
|
));
|
|
|
|
const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => {
|
|
const offset = Number(data?.parallelOffset || 0);
|
|
let rawPoints = Array.isArray(data?.points) && data.points.length >= 2
|
|
? data.points.map(point => ({ x: Number(point.x), y: Number(point.y) })).filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
|
|
: [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];
|
|
if (!data?.freeRoute && rawPoints.length >= 2) {
|
|
const sourcePoint = data?.sourceSnap?.point || { x: sourceX, y: sourceY };
|
|
const targetPoint = data?.targetSnap?.point || { x: targetX, y: targetY };
|
|
rawPoints = [
|
|
{ x: Number(sourcePoint.x), y: Number(sourcePoint.y) },
|
|
...rawPoints.slice(1, -1),
|
|
{ x: Number(targetPoint.x), y: Number(targetPoint.y) }
|
|
];
|
|
}
|
|
const firstPoint = rawPoints[0] || { x: sourceX, y: sourceY };
|
|
const lastPoint = rawPoints[rawPoints.length - 1] || { x: targetX, y: targetY };
|
|
const dx = lastPoint.x - firstPoint.x;
|
|
const dy = lastPoint.y - firstPoint.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
const normalX = -dy / length;
|
|
const normalY = dx / length;
|
|
const points = rawPoints.map(point => ({
|
|
x: point.x + normalX * offset,
|
|
y: point.y + normalY * offset
|
|
}));
|
|
const path = points.length >= 2
|
|
? `M ${points[0].x},${points[0].y} ${points.slice(1).map(point => `L ${point.x},${point.y}`).join(' ')}`
|
|
: `M ${sourceX},${sourceY} Q ${(sourceX + targetX) / 2 + normalX * offset},${(sourceY + targetY) / 2 + normalY * offset} ${targetX},${targetY}`;
|
|
const routeStyle = {
|
|
...(style || {}),
|
|
fill: 'none',
|
|
strokeWidth: selected ? Number(style?.strokeWidth || 2.4) + 1.2 : style?.strokeWidth
|
|
};
|
|
return (
|
|
<g className="react-flow__edge">
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke="transparent"
|
|
strokeWidth={18}
|
|
className="react-flow__edge-interaction"
|
|
data-route-edge-id={id}
|
|
vectorEffect="non-scaling-stroke"
|
|
style={{ pointerEvents: data?.draft ? 'none' : 'stroke', cursor: data?.draft ? 'default' : 'pointer' }}
|
|
/>
|
|
<path
|
|
id={id}
|
|
d={path}
|
|
markerEnd={markerEnd}
|
|
className="react-flow__edge-path"
|
|
data-route-edge-id={id}
|
|
vectorEffect="non-scaling-stroke"
|
|
style={routeStyle}
|
|
/>
|
|
</g>
|
|
);
|
|
});
|
|
|
|
const LayoutSvgPreview = ({ page }) => {
|
|
const [layoutScale, setLayoutScale] = useState(100);
|
|
const previewBounds = useMemo(
|
|
() => page.layoutBounds || calculateLayoutBounds(page),
|
|
[page.layoutBounds, page.nodes, page.canvasSize]
|
|
);
|
|
const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100));
|
|
const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100;
|
|
const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100;
|
|
|
|
const updateScale = (value) => {
|
|
setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100)));
|
|
};
|
|
|
|
const handleWheel = (event) => {
|
|
event.preventDefault();
|
|
const direction = event.deltaY > 0 ? -1 : 1;
|
|
const step = event.shiftKey ? 5 : 15;
|
|
setLayoutScale(current => Math.min(800, Math.max(10, (Number(current) || 100) + direction * step)));
|
|
};
|
|
|
|
return (
|
|
<div className="layout-preview">
|
|
<div className="layout-preview-toolbar">
|
|
<label>
|
|
Scale
|
|
<input
|
|
type="range"
|
|
min="10"
|
|
max="800"
|
|
step="5"
|
|
value={normalizedScale}
|
|
onChange={(event) => updateScale(event.target.value)}
|
|
aria-label="Layout SVG preview scale"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="number"
|
|
min="10"
|
|
max="800"
|
|
step="5"
|
|
value={normalizedScale}
|
|
onChange={(event) => updateScale(event.target.value)}
|
|
aria-label="Layout SVG preview scale percent"
|
|
/>
|
|
%
|
|
</label>
|
|
</div>
|
|
<div className="layout-preview-canvas" onWheel={handleWheel}>
|
|
<div className="layout-preview-scroll-area">
|
|
<div
|
|
className="layout-preview-stage"
|
|
style={{ width: stageWidth, height: stageHeight }}
|
|
>
|
|
<img
|
|
className="layout-preview-image"
|
|
src={page.svgUrl}
|
|
alt={`${page.name} layout preview`}
|
|
style={{ objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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 selectableComponents = Array.from(new Set([FORGE_COMPONENT_LABEL, ...componentNames]));
|
|
|
|
const handleDragStart = (event) => {
|
|
const dragData = JSON.stringify({
|
|
type: 'category',
|
|
category: name,
|
|
name: FORGE_COMPONENT_LABEL,
|
|
components: selectableComponents
|
|
});
|
|
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 isVirtualElement = children.__element__ === true;
|
|
const isBasicElement = children.__basic__ === true;
|
|
const elementIconClass = isBasicElement
|
|
? 'element-card-icon basic-icon'
|
|
: children.__elementType__ === 'anchor'
|
|
? 'element-card-icon anchor-icon'
|
|
: 'element-card-icon port-icon';
|
|
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 (isBasicElement) {
|
|
const dragData = JSON.stringify({
|
|
name: componentName,
|
|
type: 'basic',
|
|
componentName,
|
|
settings: createBasicSettings(componentName)
|
|
});
|
|
event.dataTransfer.setData('application/reactflow', dragData);
|
|
event.dataTransfer.setData('text/plain', dragData);
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
return;
|
|
}
|
|
if (isVirtualElement) {
|
|
const dragData = JSON.stringify({
|
|
name: componentName,
|
|
type: 'element',
|
|
elementType: children.__elementType__,
|
|
ports: children.__ports__ || {}
|
|
});
|
|
event.dataTransfer.setData('application/reactflow', dragData);
|
|
event.dataTransfer.setData('text/plain', dragData);
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
return;
|
|
}
|
|
const dragData = JSON.stringify(
|
|
isUserCell
|
|
? { name: componentName, type: 'composite', ports: children.__ports__ || {} }
|
|
: { 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 ${isUserCell ? 'compact-tree-card' : ''}`}
|
|
draggable
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onDragStart={handleDragStart}
|
|
>
|
|
{(isVirtualElement || isBasicElement) && (
|
|
<div
|
|
className={elementIconClass}
|
|
aria-hidden="true"
|
|
></div>
|
|
)}
|
|
{!isUserCell && !isVirtualElement && !isBasicElement && (
|
|
<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 isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true);
|
|
const isDirectLeafGrid = isComponentGrid && entries.every(([, childData]) => (
|
|
childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true
|
|
));
|
|
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">></span>
|
|
</span>
|
|
</summary>
|
|
{hasChildren && (
|
|
isDirectLeafGrid ? (
|
|
<div className="category-grid">
|
|
{entries.map(([childName, childData]) => (
|
|
<TreeNode key={childName} name={childName} children={childData} />
|
|
))}
|
|
</div>
|
|
) : isCategoryGrid && !isElementComponentGrid ? (
|
|
<div className="category-grid">
|
|
{entries.map(([childName, childData]) => (
|
|
<CategoryCard key={childName} name={childName} components={getCategoryComponents(childData)} />
|
|
))}
|
|
</div>
|
|
) : isComponentGrid && !isElementComponentGrid ? (
|
|
<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">></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', ports: children.__ports__ || {} });
|
|
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">></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">></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">></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, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => {
|
|
const [projectPanelHeight, setProjectPanelHeight] = useState(270);
|
|
const [resizingProjectPanel, setResizingProjectPanel] = useState(false);
|
|
const leftPanelRef = useRef(null);
|
|
const size = normalizeCanvasSize(canvasSize);
|
|
|
|
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>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<button className="save-project-btn" onClick={onSaveProject} disabled={saveProjectBusy} title="Save YAML for all canvases">
|
|
{saveProjectBusy ? 'Saving' : 'Save'}
|
|
</button>
|
|
<button className="build-gds-btn" onClick={onBuildGds} disabled={buildGdsBusy} title="Build project GDS">
|
|
{buildGdsBusy ? 'Building' : 'Build GDS'}
|
|
</button>
|
|
<button className="toggle-btn" onClick={handleProjectToggle} title={projectExpanded ? 'Collapse all' : 'Expand all'}>
|
|
{projectExpanded ? '-' : '+'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="left-block-body project-tree-scroll" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={projectTreeKey} ref={projectTreeRef}>
|
|
<div className="canvas-size-panel">
|
|
<div className="canvas-size-title">Canvas Size</div>
|
|
<div>
|
|
<label>Width um</label>
|
|
<input
|
|
type="number"
|
|
min="100"
|
|
step="100"
|
|
value={size.width}
|
|
onChange={(event) => onCanvasSizeChange && onCanvasSizeChange('width', event.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label>Height um</label>
|
|
<input
|
|
type="number"
|
|
min="100"
|
|
step="100"
|
|
value={size.height}
|
|
onChange={(event) => onCanvasSizeChange && onCanvasSizeChange('height', event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{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, __ports__: item.__ports__ || {} }} 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, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
|
|
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 (selectedNode?.data?.elementType || isBasicComponent(compName)) {
|
|
setComponentData(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!compName) {
|
|
setComponentData(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (isForgeComponent(compName)) {
|
|
setComponentData(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return;
|
|
|
|
setLoading(true);
|
|
fetch(`/api/component/${encodeURIComponent(compName)}?project=${encodeURIComponent(projectName || '')}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name });
|
|
onUpdateNode(nodeId, {
|
|
data: {
|
|
ports: data.ports || {},
|
|
boxSize: normalizeBoxSize(data),
|
|
foundry: data.foundry || '',
|
|
process: data.process || ''
|
|
}
|
|
});
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, projectName, onUpdateNode]);
|
|
|
|
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 selectedPositionNodes = useMemo(
|
|
() => (selectedNodes.length > 0 ? selectedNodes : (selectedNode ? [selectedNode] : [])).filter(node => node && node.position),
|
|
[selectedNodes, selectedNode]
|
|
);
|
|
|
|
const updatePosition = useCallback((id, axis, value) => {
|
|
const val = parseFloat(value);
|
|
if (isNaN(val)) return;
|
|
if (selectedPositionNodes.length > 1 && selectedPositionNodes.some(node => node.id === id)) {
|
|
const baseNode = selectedPositionNodes.find(node => node.id === id) || selectedPositionNodes[0];
|
|
const delta = val - Number((baseNode.position && baseNode.position[axis]) || 0);
|
|
selectedPositionNodes.forEach(node => {
|
|
const currentValue = Number((node.position && node.position[axis]) || 0);
|
|
onUpdateNode(node.id, { position: { [axis]: currentValue + delta } });
|
|
});
|
|
return;
|
|
}
|
|
onUpdateNode(id, { position: { [axis]: val } });
|
|
}, [onUpdateNode, selectedPositionNodes]);
|
|
|
|
const updateRotation = useCallback((id, value, isPortNode = false) => {
|
|
const val = parseFloat(value);
|
|
if (isNaN(val)) return;
|
|
const clamped = Math.min(180, Math.max(-180, val));
|
|
const dataField = isPortNode || id === 'page-port' ? { angle: clamped } : { rotation: clamped };
|
|
onUpdateNode(id, { data: dataField });
|
|
}, [onUpdateNode]);
|
|
|
|
const toggleComponentTransform = useCallback((key) => {
|
|
if (!selectedNode) return;
|
|
onUpdateNode(selectedNode.id, { data: { [key]: !Boolean(selectedNode.data?.[key]) } });
|
|
}, [onUpdateNode, selectedNode]);
|
|
|
|
const formatPort = (port) => {
|
|
if (!port) return '-';
|
|
const description = port.description ? ` ${port.description}` : '';
|
|
return `(${port.x ?? '?'}, ${port.y ?? '?'}, ${port.a ?? '?'})${description}`;
|
|
};
|
|
|
|
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 selectedIsVirtualElement = selectedNode?.data?.elementType === 'port' || selectedNode?.data?.elementType === 'anchor';
|
|
const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0;
|
|
const forgeSelected = isForgeComponent(selectedComponentName);
|
|
const basicSelected = isBasicComponent(selectedComponentName);
|
|
const basicMetadata = basicSelected ? getBasicComponentMetadata(selectedComponentName, selectedNode?.data?.basicArguments) : null;
|
|
const basicArguments = basicSelected ? createBasicSettings(selectedComponentName, selectedNode?.data?.basicArguments) : {};
|
|
const forgeArguments = createForgeArguments(selectedNode?.data?.forgeArguments);
|
|
const selectedIsPort = selectedNode && (selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port');
|
|
const selectedIsAnchor = selectedNode?.data?.elementType === 'anchor';
|
|
const selectedNodeBoxSize = selectedNode?.data?.componentName && !selectedNode?.data?.elementType
|
|
? normalizeBoxSize({ box_size: selectedNode.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
|
|
: null;
|
|
|
|
const selectedRouteEdges = selectedEdges.length > 0 ? selectedEdges : (selectedEdge ? [selectedEdge] : []);
|
|
if (selectedRouteEdges.length > 0) {
|
|
const routes = selectedRouteEdges.map(edge => createRouteSettings(technologyManifest, edge.data?.route));
|
|
const selectedEdgeIds = selectedRouteEdges.map(edge => edge.id);
|
|
const firstRoute = routes[0];
|
|
const mixedValue = (key) => routes.every(route => String(route[key]) === String(firstRoute[key])) ? firstRoute[key] : '__mixed__';
|
|
const route = {
|
|
...firstRoute,
|
|
xsection: mixedValue('xsection'),
|
|
family: mixedValue('family'),
|
|
width: mixedValue('width'),
|
|
radius: mixedValue('radius'),
|
|
routing_type: mixedValue('routing_type')
|
|
};
|
|
const xsections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {});
|
|
const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend'];
|
|
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 }}>
|
|
<div className="right-block-header">Route Editor</div>
|
|
<div className="right-block-body">
|
|
<div className="route-chip" style={{ color: routeStyleForSettings(route, false).style.stroke }}>
|
|
{selectedRouteEdges.length} selected / {route.family === '__mixed__' ? '--' : route.family}
|
|
</div>
|
|
<label>XSection</label>
|
|
<select
|
|
value={route.xsection}
|
|
onChange={(event) => onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteXsection(currentRoute, event.target.value, technologyManifest))}
|
|
>
|
|
{route.xsection === '__mixed__' && <option value="__mixed__" disabled>--</option>}
|
|
{xsections.map(xsection => (
|
|
<option key={xsection} value={xsection}>{xsection}</option>
|
|
))}
|
|
</select>
|
|
<br /><br />
|
|
<label>Width</label>
|
|
<input
|
|
type="text"
|
|
step="0.01"
|
|
value={route.width === '__mixed__' ? '--' : route.width}
|
|
onChange={(event) => {
|
|
if (event.target.value === '--') return;
|
|
onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'width', event.target.value, technologyManifest));
|
|
}}
|
|
/>
|
|
<br /><br />
|
|
<label>Radius</label>
|
|
<input
|
|
type="text"
|
|
step="1"
|
|
value={route.radius === '__mixed__' ? '--' : route.radius}
|
|
onChange={(event) => {
|
|
if (event.target.value === '--') return;
|
|
onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'radius', event.target.value, technologyManifest));
|
|
}}
|
|
/>
|
|
<br /><br />
|
|
<label>Routing Type</label>
|
|
<select
|
|
value={route.routing_type}
|
|
onChange={(event) => onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'routing_type', event.target.value, technologyManifest))}
|
|
>
|
|
{route.routing_type === '__mixed__' && <option value="__mixed__" disabled>--</option>}
|
|
{routingTypes.map(type => (
|
|
<option key={type} value={type}>{type}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
const updateForgeArgument = (key, value, type) => {
|
|
if (!selectedNode) return;
|
|
let nextValue = value;
|
|
if (type === 'number') {
|
|
nextValue = value === '' ? '' : Number(value);
|
|
} else if (type === 'boolean') {
|
|
nextValue = Boolean(value);
|
|
}
|
|
onUpdateNode(selectedNode.id, {
|
|
data: {
|
|
forgeArguments: {
|
|
...forgeArguments,
|
|
[key]: nextValue
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const updateBasicArgument = (key, value) => {
|
|
if (!selectedNode || !basicSelected) return;
|
|
const numericValue = Number(value);
|
|
const nextArguments = {
|
|
...basicArguments,
|
|
[key]: Number.isFinite(numericValue) && value !== '' ? numericValue : value
|
|
};
|
|
const metadata = getBasicComponentMetadata(selectedComponentName, nextArguments);
|
|
onUpdateNode(selectedNode.id, {
|
|
data: {
|
|
basicArguments: nextArguments,
|
|
ports: metadata?.ports || {},
|
|
boxSize: metadata ? normalizeBoxSize(metadata) : selectedNode.data?.boxSize
|
|
}
|
|
});
|
|
};
|
|
|
|
const updatePortField = (key, value, type = 'text') => {
|
|
if (!selectedNode) return;
|
|
const nextValue = type === 'number' ? Number(value || 0) : value;
|
|
const dataUpdate = { [key]: nextValue };
|
|
if (key === 'portName') {
|
|
dataUpdate.componentDisplayName = value || selectedNode.data?.componentDisplayName;
|
|
dataUpdate.label = value || selectedNode.data?.label;
|
|
}
|
|
onUpdateNode(selectedNode.id, { data: dataUpdate });
|
|
};
|
|
|
|
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>
|
|
<div className="coordinate-grid">
|
|
<label>
|
|
<span>X</span>
|
|
<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();
|
|
}}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>Y</span>
|
|
<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();
|
|
}}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>Angle</span>
|
|
<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, selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port');
|
|
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();
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{selectedPositionNodes.length > 1 && (
|
|
<div className="route-chip" style={{ marginTop: 10 }}>
|
|
{selectedPositionNodes.length} selected
|
|
</div>
|
|
)}
|
|
{selectedNode?.data?.componentName && !selectedNode?.data?.elementType && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 12 }}>
|
|
<button
|
|
type="button"
|
|
className="tree-action-btn"
|
|
onClick={() => toggleComponentTransform('flip')}
|
|
>
|
|
{selectedNode.data?.flip ? 'Unflip X' : 'Flip X'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="tree-action-btn"
|
|
onClick={() => toggleComponentTransform('flop')}
|
|
>
|
|
{selectedNode.data?.flop ? 'Unmirror Y' : 'Mirror Y'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', textAlign: 'center' }}>Select a node to inspect</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedIsPort && (
|
|
<div className="right-block" style={{ flexShrink: 0 }}>
|
|
<div className="right-block-header">Port</div>
|
|
<div className="right-block-body">
|
|
<label>Name</label>
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data?.portName || selectedNode.data?.componentDisplayName || ''}
|
|
onChange={(event) => updatePortField('portName', event.target.value)}
|
|
/>
|
|
<br /><br />
|
|
<label>Description</label>
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data?.description || ''}
|
|
onChange={(event) => updatePortField('description', event.target.value)}
|
|
/>
|
|
<br /><br />
|
|
<label>Layer</label>
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data?.layer || 'WG_CORE'}
|
|
onChange={(event) => updatePortField('layer', event.target.value)}
|
|
/>
|
|
<br /><br />
|
|
<label>Width</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={selectedNode.data?.width ?? 0.5}
|
|
onChange={(event) => updatePortField('width', event.target.value, 'number')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIsAnchor && (
|
|
<div className="right-block" style={{ flexShrink: 0 }}>
|
|
<div className="right-block-header">Anchor</div>
|
|
<div className="right-block-body">
|
|
<label>Name</label>
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data?.componentDisplayName || ''}
|
|
onChange={(event) => onUpdateNode(selectedNode.id, { data: { componentDisplayName: event.target.value, label: event.target.value } })}
|
|
/>
|
|
<br /><br />
|
|
<label>Description</label>
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data?.description || ''}
|
|
onChange={(event) => onUpdateNode(selectedNode.id, { data: { description: event.target.value } })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedNode?.data?.componentName && !selectedNode?.data?.elementType && (
|
|
<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;
|
|
const forge = isForgeComponent(componentName);
|
|
onUpdateNode(selectedNode.id, {
|
|
data: {
|
|
componentName,
|
|
label: componentName,
|
|
ports: forge ? {} : undefined,
|
|
boxSize: forge ? DEFAULT_COMPONENT_BOX_SIZE : undefined,
|
|
forgeArguments: forge ? createForgeArguments(selectedNode.data.forgeArguments) : selectedNode.data.forgeArguments
|
|
}
|
|
});
|
|
}}
|
|
>
|
|
{availableComponents.map(componentName => (
|
|
<option key={componentName} value={componentName}>{componentName}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{selectedNodeBoxSize && (
|
|
<div>
|
|
<label>Box Size</label>
|
|
<div className="box-size-readout">
|
|
<div><span>W</span> {selectedNodeBoxSize.width.toFixed(3)} um</div>
|
|
<div><span>H</span> {selectedNodeBoxSize.height.toFixed(3)} um</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{basicSelected && basicMetadata ? (
|
|
<>
|
|
<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 || selectedComponentName}</span>
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Edit</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '8px' }}>Nazca Primitive:</p>
|
|
<div style={{ color: 'var(--text-muted)', marginBottom: '12px' }}>
|
|
{selectedComponentName} / {basicMetadata.process}
|
|
</div>
|
|
<div style={{ display: 'grid', gap: 10 }}>
|
|
{Object.entries(basicArguments).map(([key, value]) => (
|
|
<label key={key} style={{ display: 'grid', gap: 4 }}>
|
|
<span style={{ color: 'var(--text-muted)' }}>{key}</span>
|
|
<input
|
|
type={typeof value === 'number' ? 'number' : 'text'}
|
|
step="any"
|
|
value={value ?? ''}
|
|
onChange={(event) => updateBasicArgument(key, event.target.value)}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<p style={{ color: 'var(--text-main)', fontWeight: '500', margin: '14px 0 4px' }}>Ports:</p>
|
|
<ul className="port-info-list">
|
|
{Object.entries(basicMetadata.ports || {}).map(([portName, portInfo]) => (
|
|
<li key={portName}>
|
|
<span style={{ color: 'var(--accent)' }}>{portName}</span> : {formatPort(portInfo)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : forgeSelected ? (
|
|
<>
|
|
<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 || selectedComponentName}</span>
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Edit</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ color: 'var(--text-muted)', lineHeight: '1.6', marginBottom: '12px' }}>
|
|
<p style={{ margin: '0 0 8px 0', wordBreak: 'break-all' }}>
|
|
<strong style={{ color: 'var(--text-main)' }}>Cell:</strong> {FORGE_COMPONENT_TYPE}
|
|
</p>
|
|
<p style={{ margin: 0 }}>
|
|
<strong style={{ color: 'var(--text-main)' }}>Generator:</strong> mxpic_forge
|
|
</p>
|
|
</div>
|
|
|
|
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '8px' }}>Forge Arguments:</p>
|
|
<div style={{ display: 'grid', gap: 10 }}>
|
|
{Object.entries(forgeArguments).map(([key, value]) => {
|
|
const isBoolean = typeof value === 'boolean';
|
|
const isNumber = typeof value === 'number';
|
|
return (
|
|
<label key={key} style={{ display: 'grid', gap: 4 }}>
|
|
<span style={{ color: 'var(--text-muted)' }}>{key}</span>
|
|
{isBoolean ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(value)}
|
|
onChange={(event) => updateForgeArgument(key, event.target.checked, 'boolean')}
|
|
/>
|
|
) : (
|
|
<input
|
|
type={isNumber ? 'number' : 'text'}
|
|
step={isNumber ? 'any' : undefined}
|
|
value={value ?? ''}
|
|
onChange={(event) => updateForgeArgument(key, event.target.value, isNumber ? 'number' : 'text')}
|
|
/>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</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 className="port-info-list">
|
|
{componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
|
|
<li key={portName}>
|
|
<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?project=${encodeURIComponent(projectName || '')}`}
|
|
alt="Component layout"
|
|
loading="lazy"
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', cursor: 'pointer' }}
|
|
onClick={() => setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image?project=${encodeURIComponent(projectName || '')}`)}
|
|
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) {
|
|
function walk(obj, currentPath) {
|
|
if (obj && obj.__type__ === 'component' && obj.__name__ === compName) {
|
|
if (obj.__path__) {
|
|
return obj.__path__.split('/').filter(Boolean);
|
|
}
|
|
return currentPath;
|
|
}
|
|
if (typeof obj === 'object') {
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
const result = walk(val, [...currentPath, key]);
|
|
if (result) return result;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
return walk(lib, []) || [];
|
|
}
|
|
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 spaceRotateNodeIdRef = useRef(null);
|
|
|
|
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 [buildLayoutBusy, setBuildLayoutBusy] = useState(false);
|
|
const [buildGdsBusy, setBuildGdsBusy] = useState(false);
|
|
const [saveProjectBusy, setSaveProjectBusy] = useState(false);
|
|
const [buildProgress, setBuildProgress] = useState({ active: false, label: '', value: 0 });
|
|
const [rulerMode, setRulerMode] = useState(false);
|
|
const [rulerStartPoint, setRulerStartPoint] = useState(null);
|
|
const [rulerEndPoint, setRulerEndPoint] = useState(null);
|
|
const [rulerPreviewPoint, setRulerPreviewPoint] = useState(null);
|
|
const [projectTechnology, setProjectTechnology] = useState('');
|
|
const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
const [currentLinkXsection, setCurrentLinkXsection] = useState('strip');
|
|
|
|
const [clipboard, setClipboard] = useState({ nodes: [] });
|
|
|
|
const initializedRef = useRef(false);
|
|
const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []);
|
|
|
|
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
|
|
const currentNodes = activePage && Array.isArray(activePage.nodes) ? activePage.nodes : [];
|
|
const currentEdges = activePage && Array.isArray(activePage.edges) ? activePage.edges : [];
|
|
const activeCanvasSize = useMemo(() => normalizeCanvasSize(activePage?.canvasSize), [activePage?.canvasSize]);
|
|
const selectedEdges = useMemo(() => currentEdges.filter(edge => edge.selected), [currentEdges]);
|
|
const selectedEdge = selectedEdges[0] || null;
|
|
const selectedNodes = useMemo(() => currentNodes.filter(n => n.selected), [currentNodes]);
|
|
const selectedNode = selectedNodes[0] || null;
|
|
const linkXsectionChoices = useMemo(() => {
|
|
const manifestSections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {});
|
|
const preferred = ['strip', 'rib_low', 'metal_1', 'metal_2'];
|
|
const ordered = preferred.filter(xsection => manifestSections.includes(xsection));
|
|
manifestSections.forEach(xsection => {
|
|
if (!ordered.includes(xsection)) ordered.push(xsection);
|
|
});
|
|
return ordered.length ? ordered : preferred;
|
|
}, [technologyManifest]);
|
|
const currentLinkRoute = useMemo(
|
|
() => createRouteSettings(technologyManifest, { xsection: currentLinkXsection }),
|
|
[technologyManifest, currentLinkXsection]
|
|
);
|
|
useEffect(() => {
|
|
if (!linkXsectionChoices.includes(currentLinkXsection)) {
|
|
setCurrentLinkXsection(linkXsectionChoices[0] || 'strip');
|
|
}
|
|
}, [linkXsectionChoices, currentLinkXsection]);
|
|
const canvasNodeExtent = useMemo(() => [[0, 0], [activeCanvasSize.width, activeCanvasSize.height]], [activeCanvasSize.width, activeCanvasSize.height]);
|
|
const rulerActiveEndPoint = rulerEndPoint || rulerPreviewPoint;
|
|
const rulerMeasurement = useMemo(
|
|
() => createRulerMeasurement(rulerStartPoint, rulerActiveEndPoint),
|
|
[rulerStartPoint, rulerActiveEndPoint]
|
|
);
|
|
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
|
|
const compactRoutePoints = useCallback((points) => {
|
|
return (points || [])
|
|
.map(point => ({
|
|
x: Number(Number(point.x).toFixed(3)),
|
|
y: Number(Number(point.y).toFixed(3))
|
|
}))
|
|
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
|
|
.filter((point, index, list) => index === 0 || point.x !== list[index - 1].x || point.y !== list[index - 1].y);
|
|
}, []);
|
|
const routeEndpointNodeId = useCallback((edgeId, endpoint) => `__free_route_${edgeId}_${endpoint}__`, []);
|
|
const makeFreeRouteEdge = useCallback((edgeId, points, route, selected = false) => {
|
|
const view = routeStyleForSettings(route, selected);
|
|
return {
|
|
id: edgeId,
|
|
source: routeEndpointNodeId(edgeId, 'start'),
|
|
target: routeEndpointNodeId(edgeId, 'end'),
|
|
sourceHandle: 'route',
|
|
targetHandle: 'route',
|
|
type: 'parallelRoute',
|
|
selectable: true,
|
|
style: view.style,
|
|
data: { route, points: compactRoutePoints(points), freeRoute: true },
|
|
};
|
|
}, [compactRoutePoints, routeEndpointNodeId]);
|
|
const rulerNodes = useMemo(() => {
|
|
if (!activePage || activePage.type === 'layoutPreview' || !rulerStartPoint) return [];
|
|
const nodes = [{
|
|
id: '__ruler_start__',
|
|
type: 'rulerPointNode',
|
|
position: { x: rulerStartPoint.x - 6, y: rulerStartPoint.y - 6 },
|
|
data: { label: `Start (${rulerStartPoint.x.toFixed(3)}, ${rulerStartPoint.y.toFixed(3)})` },
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false
|
|
}];
|
|
if (rulerActiveEndPoint) {
|
|
const isPreviewPoint = !rulerEndPoint;
|
|
nodes.push({
|
|
id: isPreviewPoint ? '__ruler_preview__' : '__ruler_end__',
|
|
type: 'rulerPointNode',
|
|
position: { x: rulerActiveEndPoint.x - 6, y: rulerActiveEndPoint.y - 6 },
|
|
data: { label: `${isPreviewPoint ? 'Preview' : 'End'} (${rulerActiveEndPoint.x.toFixed(3)}, ${rulerActiveEndPoint.y.toFixed(3)})` },
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false
|
|
});
|
|
}
|
|
if (rulerMeasurement) {
|
|
nodes.push({
|
|
id: '__ruler_measurement__',
|
|
type: 'rulerMeasurementNode',
|
|
position: { x: rulerMeasurement.midpoint.x, y: rulerMeasurement.midpoint.y },
|
|
data: {
|
|
label: rulerMeasurement.label,
|
|
title: `Distance ${rulerMeasurement.distance.toFixed(3)} um; dx ${rulerMeasurement.dx.toFixed(3)}; dy ${rulerMeasurement.dy.toFixed(3)}`
|
|
},
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false
|
|
});
|
|
}
|
|
return nodes;
|
|
}, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]);
|
|
const rulerEdges = useMemo(() => {
|
|
if (!rulerMeasurement) return [];
|
|
return [{
|
|
id: '__ruler_edge__',
|
|
source: '__ruler_start__',
|
|
target: rulerPreviewMeasurement ? '__ruler_preview__' : '__ruler_end__',
|
|
type: 'straight',
|
|
selectable: false,
|
|
focusable: false,
|
|
data: { ruler: true },
|
|
style: {
|
|
stroke: '#2dd4bf',
|
|
strokeWidth: 2,
|
|
strokeDasharray: rulerPreviewMeasurement ? undefined : '8 6'
|
|
}
|
|
}];
|
|
}, [rulerMeasurement, rulerPreviewMeasurement]);
|
|
const freeRouteEndpointNodes = useMemo(() => {
|
|
if (!activePage || activePage.type === 'layoutPreview') return [];
|
|
return currentEdges.flatMap(edge => {
|
|
const points = edge.data?.freeRoute && Array.isArray(edge.data?.points) ? compactRoutePoints(edge.data.points) : [];
|
|
if (points.length < 2) return [];
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
return [
|
|
{
|
|
id: routeEndpointNodeId(edge.id, 'start'),
|
|
type: 'rulerPointNode',
|
|
position: { x: first.x - 6, y: first.y - 6 },
|
|
data: { label: 'route start' },
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false,
|
|
style: { opacity: 0.001, pointerEvents: 'none' }
|
|
},
|
|
{
|
|
id: routeEndpointNodeId(edge.id, 'end'),
|
|
type: 'rulerPointNode',
|
|
position: { x: last.x - 6, y: last.y - 6 },
|
|
data: { label: 'route end' },
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false,
|
|
style: { opacity: 0.001, pointerEvents: 'none' }
|
|
}
|
|
];
|
|
});
|
|
}, [activePage, currentEdges, compactRoutePoints, routeEndpointNodeId]);
|
|
const renderNodes = useMemo(() => {
|
|
if (!activePage || activePage.type === 'layoutPreview') return currentNodes;
|
|
return [{
|
|
id: '__canvas-boundary__',
|
|
type: 'canvasBoundaryNode',
|
|
position: { x: 0, y: 0 },
|
|
data: { size: activeCanvasSize },
|
|
draggable: false,
|
|
selectable: false,
|
|
deletable: false,
|
|
focusable: false,
|
|
style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' }
|
|
}, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes];
|
|
}, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]);
|
|
const renderEdges = useMemo(() => {
|
|
const groups = new Map();
|
|
currentEdges.forEach(edge => {
|
|
const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`;
|
|
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
|
|
const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(edge.id);
|
|
});
|
|
const separatedEdges = currentEdges.map(edge => {
|
|
const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`;
|
|
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
|
|
const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
|
|
const group = groups.get(key) || [];
|
|
if (group.length <= 1 && !(edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2)) return edge;
|
|
const index = group.indexOf(edge.id);
|
|
const offset = (index - (group.length - 1) / 2) * 18;
|
|
return {
|
|
...edge,
|
|
type: 'parallelRoute',
|
|
data: {
|
|
...(edge.data || {}),
|
|
parallelOffset: offset
|
|
}
|
|
};
|
|
});
|
|
return [...separatedEdges, ...rulerEdges];
|
|
}, [currentEdges, rulerEdges]);
|
|
|
|
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 }]);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!buildProgress.active || buildProgress.value >= 100) return;
|
|
const timer = window.setInterval(() => {
|
|
setBuildProgress(prev => {
|
|
if (!prev.active || prev.value >= 94) return prev;
|
|
return { ...prev, value: Math.min(94, prev.value + Math.max(1, Math.round((96 - prev.value) / 9))) };
|
|
});
|
|
}, 360);
|
|
return () => window.clearInterval(timer);
|
|
}, [buildProgress.active, buildProgress.value]);
|
|
|
|
const startBuildProgress = useCallback((label) => {
|
|
setBuildProgress({ active: true, label, value: 8 });
|
|
}, []);
|
|
|
|
const completeBuildProgress = useCallback((label) => {
|
|
setBuildProgress({ active: true, label, value: 100 });
|
|
window.setTimeout(() => {
|
|
setBuildProgress(prev => prev.value === 100 ? { active: false, label: '', value: 0 } : prev);
|
|
}, 900);
|
|
}, []);
|
|
|
|
const stopBuildProgress = useCallback(() => {
|
|
setBuildProgress({ active: false, label: '', value: 0 });
|
|
}, []);
|
|
|
|
const toBooleanFlag = useCallback((value) => (
|
|
value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true'
|
|
), []);
|
|
|
|
const normalizeRoutePoints = useCallback((points, usesGdsYUp = false) => (
|
|
(Array.isArray(points) ? points : [])
|
|
.map(point => ({
|
|
x: Number(point && point.x),
|
|
y: usesGdsYUp ? layoutToCanvasY(point && point.y) : Number(point && point.y)
|
|
}))
|
|
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
|
|
), []);
|
|
|
|
const loadTechnologyManifest = useCallback(async (technologyId) => {
|
|
if (!technologyId || !technologyId.includes('/')) {
|
|
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
return FALLBACK_TECHNOLOGY_MANIFEST;
|
|
}
|
|
const [foundry, technology] = technologyId.split('/');
|
|
try {
|
|
const response = await fetch(`/api/technologies/${encodeURIComponent(foundry)}/${encodeURIComponent(technology)}/manifest`);
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
addLog(data.error || 'Technology manifest not available; using fallback route defaults.');
|
|
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
return FALLBACK_TECHNOLOGY_MANIFEST;
|
|
}
|
|
const manifest = data.manifest || FALLBACK_TECHNOLOGY_MANIFEST;
|
|
setTechnologyManifest(manifest);
|
|
return manifest;
|
|
} catch (error) {
|
|
addLog('Technology manifest load failed: ' + error.message);
|
|
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
return FALLBACK_TECHNOLOGY_MANIFEST;
|
|
}
|
|
}, [addLog]);
|
|
|
|
const componentDataCacheRef = useRef(new Map());
|
|
|
|
const loadComponentMetadata = useCallback(async (componentName) => {
|
|
if (!componentName || isForgeComponent(componentName)) return null;
|
|
if (componentDataCacheRef.current.has(componentName)) {
|
|
return componentDataCacheRef.current.get(componentName);
|
|
}
|
|
const response = await fetch(`/api/component/${encodeURIComponent(componentName)}?project=${encodeURIComponent(currentProjectName)}`);
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
componentDataCacheRef.current.set(componentName, data);
|
|
return data;
|
|
}, [currentProjectName]);
|
|
|
|
const recordUserAction = useCallback((action, payload = {}) => {
|
|
fetch('/api/logs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action, ...payload })
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
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;
|
|
const relevantChanges = changes.filter(change => change.id !== '__canvas-boundary__');
|
|
if (relevantChanges.length === 0) return;
|
|
const removedNodeIds = new Set(relevantChanges.filter(change => change.type === 'remove').map(change => change.id));
|
|
if (removedNodeIds.size > 0 && activePage) {
|
|
releaseComponentDisplayNames(activePage.nodes.filter(node => removedNodeIds.has(node.id)));
|
|
}
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
const newNodes = applyNodeChanges(relevantChanges, p.nodes).map(node => {
|
|
if (!node.position || node.id === 'page-port') return node;
|
|
const boxSize = node.type === 'rotatableNode'
|
|
? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
|
|
: { width: 30, height: 30 };
|
|
return { ...node, position: clampPositionToCanvas(node.position, p.canvasSize || activeCanvasSize, boxSize) };
|
|
});
|
|
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;
|
|
const width = portNode.data?.width ?? p.port.width ?? 0.5;
|
|
if (x !== p.port.x || y !== p.port.y || angle !== p.port.a || width !== p.port.width) {
|
|
newPort = { x, y, a: angle, width };
|
|
}
|
|
}
|
|
return { ...p, nodes: newNodes, port: newPort };
|
|
}));
|
|
}, [activePageId, activePage, activeCanvasSize]);
|
|
|
|
const onEdgesChange = useCallback((changes) => {
|
|
if (!activePageId) return;
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
const styledEdges = applyEdgeChanges(changes, p.edges).map(edge => {
|
|
const route = createRouteSettings(technologyManifest, edge.data?.route);
|
|
const view = routeStyleForSettings(route, edge.selected);
|
|
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
|
|
return { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
|
|
});
|
|
return { ...p, edges: styledEdges };
|
|
}));
|
|
}, [activePageId, technologyManifest]);
|
|
|
|
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) {
|
|
const nextData = { ...n.data, ...update.data };
|
|
const nextPosition = update.position != null ? { ...n.position, ...update.position } : n.position;
|
|
const boxSize = n.type === 'rotatableNode'
|
|
? normalizeBoxSize({ box_size: nextData.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
|
|
: { width: 30, height: 30 };
|
|
return {
|
|
...n,
|
|
position: update.position != null ? clampPositionToCanvas(nextPosition, p.canvasSize || activeCanvasSize, boxSize) : nextPosition,
|
|
data: nextData
|
|
};
|
|
}
|
|
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, width: portNode.data?.width ?? 0.5 };
|
|
}
|
|
}
|
|
return { ...p, nodes: newNodes, port: newPort };
|
|
}));
|
|
}, [activePageId, activeCanvasSize]);
|
|
|
|
const handleCanvasSizeChange = useCallback((axis, value) => {
|
|
if (!activePageId) return;
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue) || numericValue <= 0) return;
|
|
setPages(prev => prev.map(page => {
|
|
if (page.id !== activePageId) return page;
|
|
const nextCanvasSize = normalizeCanvasSize({
|
|
...(page.canvasSize || DEFAULT_CANVAS_SIZE),
|
|
[axis]: numericValue
|
|
});
|
|
return {
|
|
...page,
|
|
canvasSize: nextCanvasSize,
|
|
nodes: page.nodes.map(node => {
|
|
if (!node.position || node.id === 'page-port') return node;
|
|
const boxSize = node.type === 'rotatableNode'
|
|
? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
|
|
: { width: 30, height: 30 };
|
|
return { ...node, position: clampPositionToCanvas(node.position, nextCanvasSize, boxSize) };
|
|
})
|
|
};
|
|
}));
|
|
}, [activePageId]);
|
|
|
|
const rotateComponentByNinety = useCallback((nodeId) => {
|
|
if (!activePageId || !nodeId) return;
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
return {
|
|
...p,
|
|
nodes: p.nodes.map(node => {
|
|
if (node.id !== nodeId || node.type !== 'rotatableNode' || node.data?.elementType) return node;
|
|
const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90);
|
|
return { ...node, data: { ...node.data, rotation } };
|
|
})
|
|
};
|
|
}));
|
|
}, [activePageId]);
|
|
|
|
const getSpaceRotationTarget = useCallback(() => {
|
|
if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current;
|
|
const selectedSpaceNode = selectedNode;
|
|
if (!selectedSpaceNode || selectedSpaceNode.type !== 'rotatableNode' || selectedSpaceNode.data?.elementType) return null;
|
|
return selectedSpaceNode.id;
|
|
}, [selectedNode]);
|
|
|
|
const onNodeMouseDown = useCallback((event, node) => {
|
|
if (event.button !== 0 || node.type !== 'rotatableNode' || node.data?.elementType) return;
|
|
spaceRotateNodeIdRef.current = node.id;
|
|
}, []);
|
|
|
|
const clearSpaceRotateNode = useCallback(() => {
|
|
spaceRotateNodeIdRef.current = null;
|
|
}, []);
|
|
|
|
const handleUpdateEdgeRoute = useCallback((edgeIds, routeUpdate) => {
|
|
if (!activePageId) return;
|
|
const targetEdgeIds = new Set(Array.isArray(edgeIds) ? edgeIds : [edgeIds]);
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node]));
|
|
let rejected = false;
|
|
const nextEdges = p.edges.map(edge => {
|
|
if (!targetEdgeIds.has(edge.id)) return edge;
|
|
const currentRoute = createRouteSettings(technologyManifest, edge.data?.route);
|
|
const route = createRouteSettings(
|
|
technologyManifest,
|
|
typeof routeUpdate === 'function' ? routeUpdate(currentRoute, edge) : routeUpdate
|
|
);
|
|
const view = routeStyleForSettings(route, edge.selected);
|
|
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
|
|
const candidate = { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
|
|
const conflict = findSameTypeRouteCrossing(candidate, p.edges, nodeMap, technologyManifest);
|
|
if (conflict) {
|
|
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
|
|
const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
|
|
addLog(`Route update rejected: ${route.xsection} route crosses ${source} to ${target}.`);
|
|
rejected = true;
|
|
return edge;
|
|
}
|
|
return candidate;
|
|
});
|
|
return rejected ? p : { ...p, edges: nextEdges };
|
|
}));
|
|
}, [activePageId, technologyManifest, addLog]);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!activePage) return;
|
|
const selectedNodes = activePage.nodes.filter(n => n.selected);
|
|
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);
|
|
if (selectedNodes.length > 0) {
|
|
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
|
|
releaseComponentDisplayNames(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 => {
|
|
const copyCategory = node.data?.libraryCategory && node.data.libraryCategory !== 'basic'
|
|
? node.data.libraryCategory
|
|
: (node.data?.category && node.data.category !== 'basic' ? node.data.category : '');
|
|
const copiedName = generateComponentDisplayName(
|
|
copyCategory || node.data?.componentName || node.data?.elementType,
|
|
{ singularize: Boolean(copyCategory), abbreviate: Boolean(copyCategory) }
|
|
);
|
|
const copiedData = {
|
|
...node.data,
|
|
componentDisplayName: copiedName
|
|
};
|
|
if (node.type === 'portNode' || node.data?.elementType === 'port') {
|
|
copiedData.portName = copiedName;
|
|
copiedData.label = copiedName;
|
|
copiedData.elementType = 'port';
|
|
}
|
|
return {
|
|
...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: copiedData
|
|
};
|
|
});
|
|
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 selectedNodes = activePage.nodes.filter(n => n.selected);
|
|
const selectedNodeIds = new Set(selectedNodes.map(n => n.id));
|
|
if (selectedNodeIds.size > 0) {
|
|
releaseComponentDisplayNames(selectedNodes);
|
|
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));
|
|
recordUserAction('instance.delete', {
|
|
project: currentProjectName,
|
|
cell: activePage.name,
|
|
detail: {
|
|
instances: selectedNodes.map(node => node.data?.componentDisplayName || node.id)
|
|
}
|
|
});
|
|
}
|
|
}, [activePage, currentProjectName, recordUserAction]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e) => {
|
|
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
|
|
return;
|
|
}
|
|
|
|
const cmdOrCtrl = e.ctrlKey || e.metaKey;
|
|
|
|
const spaceRotationTarget = getSpaceRotationTarget();
|
|
if ((e.code === 'Space' || e.key === ' ') && spaceRotationTarget) {
|
|
e.preventDefault();
|
|
rotateComponentByNinety(spaceRotationTarget);
|
|
} else 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);
|
|
window.addEventListener('mouseup', clearSpaceRotateNode);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('mouseup', clearSpaceRotateNode);
|
|
};
|
|
}, [handleCopy, handleCut, handlePaste, handleDelete, rotateComponentByNinety, getSpaceRotationTarget, clearSpaceRotateNode]);
|
|
|
|
const componentIndexesByPrefixRef = useRef({});
|
|
|
|
const COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS = {
|
|
directional_coupler: 'DC',
|
|
directional_couplers: 'DC',
|
|
multimode_interferometer: 'MMI',
|
|
multimode_interferometers: 'MMI',
|
|
photodetector: 'PD',
|
|
photodetectors: 'PD',
|
|
waveguide: 'WG',
|
|
waveguides: 'WG',
|
|
transition: 'TRX',
|
|
transitions: 'TRX',
|
|
transistion: 'TRX',
|
|
transistions: 'TRX',
|
|
Mach_Zender_Modulator: 'MZM',
|
|
Mach_Zender_Modulators: 'MZM',
|
|
Mach_Zender_modulator: 'MZM',
|
|
Mach_Zender_modulators: 'MZM',
|
|
mach_zender_modulator: 'MZM',
|
|
mach_zender_modulators: 'MZM',
|
|
bending: 'BD',
|
|
bendings: 'BD',
|
|
edge_coupler: 'EC',
|
|
edge_couplers: 'EC',
|
|
grating_coupler: 'GC',
|
|
grating_couplers: 'GC',
|
|
termination: 'TERM',
|
|
terminations: 'TERM'
|
|
};
|
|
|
|
function parseComponentDisplayName(displayName) {
|
|
const match = String(displayName || '').match(/^(.+)_(\d+)$/);
|
|
if (!match) return null;
|
|
const index = Number(match[2]);
|
|
if (!Number.isInteger(index) || index < 1) return null;
|
|
return { prefix: match[1], index };
|
|
}
|
|
|
|
function reserveComponentDisplayName(displayName) {
|
|
const parsed = parseComponentDisplayName(displayName);
|
|
if (!parsed) return;
|
|
const usedIndexes = componentIndexesByPrefixRef.current[parsed.prefix] || new Set();
|
|
usedIndexes.add(parsed.index);
|
|
componentIndexesByPrefixRef.current[parsed.prefix] = usedIndexes;
|
|
}
|
|
|
|
function releaseComponentDisplayName(displayName) {
|
|
const parsed = parseComponentDisplayName(displayName);
|
|
if (!parsed) return;
|
|
const usedIndexes = componentIndexesByPrefixRef.current[parsed.prefix];
|
|
if (!usedIndexes) return;
|
|
usedIndexes.delete(parsed.index);
|
|
if (usedIndexes.size === 0) {
|
|
delete componentIndexesByPrefixRef.current[parsed.prefix];
|
|
}
|
|
}
|
|
|
|
function releaseComponentDisplayNames(nodes = []) {
|
|
nodes.forEach(node => releaseComponentDisplayName(node?.data?.componentDisplayName));
|
|
}
|
|
|
|
function reserveComponentDisplayNamesFromPages() {
|
|
pages.forEach(page => {
|
|
(page.nodes || []).forEach(node => reserveComponentDisplayName(node?.data?.componentDisplayName));
|
|
});
|
|
}
|
|
|
|
const normalizeComponentDisplayNamePrefix = useCallback((prefixSource, options = {}) => {
|
|
const cleanedPrefix = String(prefixSource || 'element')
|
|
.trim()
|
|
.replace(/[\\/]+/g, '_')
|
|
.replace(/\s+/g, '_')
|
|
.replace(/[^A-Za-z0-9_]+/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
if (!cleanedPrefix) return 'element';
|
|
const abbreviation = options.abbreviate
|
|
? COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[cleanedPrefix] || COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[cleanedPrefix.toLowerCase()]
|
|
: '';
|
|
if (abbreviation) return abbreviation;
|
|
if (!options.singularize) return cleanedPrefix;
|
|
const parts = cleanedPrefix.split('_');
|
|
const lastIndex = parts.length - 1;
|
|
const last = parts[lastIndex];
|
|
if (last.length > 3 && last.endsWith('ies')) {
|
|
parts[lastIndex] = `${last.slice(0, -3)}y`;
|
|
} else if (last.length > 1 && last.endsWith('s')) {
|
|
parts[lastIndex] = last.slice(0, -1);
|
|
}
|
|
const singularPrefix = parts.join('_') || 'element';
|
|
if (options.abbreviate) {
|
|
return COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[singularPrefix] || COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[singularPrefix.toLowerCase()] || singularPrefix;
|
|
}
|
|
return singularPrefix;
|
|
}, []);
|
|
|
|
const generateComponentDisplayName = useCallback((prefixSource = 'element', options = {}) => {
|
|
const prefix = normalizeComponentDisplayNamePrefix(prefixSource, options);
|
|
reserveComponentDisplayNamesFromPages();
|
|
const usedIndexes = componentIndexesByPrefixRef.current[prefix] || new Set();
|
|
let nextIndex = 1;
|
|
while (usedIndexes.has(nextIndex)) nextIndex += 1;
|
|
const name = `${prefix}_${nextIndex}`;
|
|
usedIndexes.add(nextIndex);
|
|
componentIndexesByPrefixRef.current[prefix] = usedIndexes;
|
|
return name;
|
|
}, [normalizeComponentDisplayNamePrefix, pages]);
|
|
|
|
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
|
|
if (!activePageId) return;
|
|
const oldDisplayName = activePage?.nodes.find(node => node.id === nodeId)?.data?.componentDisplayName;
|
|
if (oldDisplayName !== newComponentDisplayName) {
|
|
releaseComponentDisplayName(oldDisplayName);
|
|
reserveComponentDisplayName(newComponentDisplayName);
|
|
}
|
|
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, activePage]);
|
|
|
|
const fetchLibrary = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/library?project=${encodeURIComponent(currentProjectName)}`);
|
|
const data = await res.json();
|
|
setLibrary(data);
|
|
} catch (err) {
|
|
console.error('Failed to fetch library', err);
|
|
}
|
|
}, [currentProjectName]);
|
|
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);
|
|
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
|
|
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();
|
|
const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName);
|
|
const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName);
|
|
const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName);
|
|
const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null;
|
|
let category = '';
|
|
|
|
if (!isProject && displayCompName && library && !instIsForge) {
|
|
const walk = (obj) => {
|
|
if (obj?.__type__ === 'component' && obj.__name__ === displayCompName) {
|
|
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: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0),
|
|
},
|
|
data: {
|
|
label: isProject ? instName : displayCompName,
|
|
componentName: isProject ? instName : displayCompName,
|
|
category: isProject ? '' : category,
|
|
rotation: parseFloat(inst.rotation) || 0,
|
|
flip: toBooleanFlag(inst.flip ?? inst.mirror),
|
|
flop: toBooleanFlag(inst.flop),
|
|
componentDisplayName: instName,
|
|
type: isProject ? 'composite' : undefined,
|
|
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined,
|
|
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
|
|
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
|
|
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
|
|
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!isProject) {
|
|
const links = doc.bundles?.output_bus?.links;
|
|
if (links) {
|
|
const linkArray = Array.isArray(links) ? links : [links];
|
|
linkArray.forEach(link => {
|
|
const route = createRouteSettings(technologyManifest, link);
|
|
const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up');
|
|
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) {
|
|
const view = routeStyleForSettings(route, false);
|
|
newEdges.push({
|
|
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
|
|
source: sourceId,
|
|
target: targetId,
|
|
sourceHandle: fromPort,
|
|
targetHandle: toPort,
|
|
type: view.type,
|
|
style: view.style,
|
|
data: { route, points: routePoints },
|
|
});
|
|
}
|
|
} else if (routePoints.length >= 2) {
|
|
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
|
newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
|
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
|
|
const importedPort = Array.isArray(doc.ports) && doc.ports[0]
|
|
? { x: Number(doc.ports[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.ports[0].y) : Number(doc.ports[0].y || 0), a: Number(doc.ports[0].angle ?? doc.ports[0].a ?? 0), width: Number(doc.ports[0].width || 0.5) }
|
|
: { x: 50, y: 150, a: 0, width: 0.5 };
|
|
const newPage = {
|
|
id: newPageId,
|
|
name: newPageName,
|
|
type: isProject ? 'project' : 'composite',
|
|
canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize),
|
|
nodes: isProject ? newNodes : [
|
|
{
|
|
id: 'page-port',
|
|
type: 'portNode',
|
|
position: { x: importedPort.x, y: importedPort.y },
|
|
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPort.a, width: importedPort.width || 0.5, layer: 'WG_CORE', description: '' },
|
|
draggable: true,
|
|
selectable: true,
|
|
deletable: false,
|
|
},
|
|
...newNodes,
|
|
],
|
|
edges: newEdges,
|
|
port: importedPort,
|
|
};
|
|
|
|
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 (isForgeComponent(compPath) || isForgeComponent(compName)) continue;
|
|
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, technologyManifest, makeFreeRouteEdge]);
|
|
|
|
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: [],
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
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, manifest) => {
|
|
const doc = jsyaml.load(content) || {};
|
|
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
|
|
const firstPort = Array.isArray(doc.ports) ? doc.ports[0] : null;
|
|
const pagePort = firstPort
|
|
? { x: Number(firstPort.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPort.y) : Number(firstPort.y || 0), a: Number(firstPort.angle ?? firstPort.a ?? 0), width: Number(firstPort.width || 0.5) }
|
|
: { x: 50, y: 150, a: 0, width: 0.5 };
|
|
const nodeNameMap = {};
|
|
const nodes = [
|
|
{
|
|
id: 'page-port',
|
|
type: 'portNode',
|
|
position: { x: pagePort.x, y: pagePort.y },
|
|
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: pagePort.a, width: pagePort.width || 0.5, layer: 'WG_CORE', description: '' },
|
|
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 instIsForge = isForgeComponent(compPath) || isForgeComponent(compName);
|
|
const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName);
|
|
const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName);
|
|
const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null;
|
|
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: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0),
|
|
},
|
|
data: {
|
|
label: displayCompName,
|
|
componentName: displayCompName,
|
|
category: instIsForge ? '' : findCategory(displayCompName),
|
|
rotation: parseFloat(inst.rotation) || 0,
|
|
flip: toBooleanFlag(inst.flip ?? inst.mirror),
|
|
flop: toBooleanFlag(inst.flop),
|
|
componentDisplayName: instName,
|
|
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined,
|
|
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
|
|
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
|
|
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
|
|
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
const links = doc.bundles?.output_bus?.links;
|
|
if (links) {
|
|
const linkArray = Array.isArray(links) ? links : [links];
|
|
linkArray.forEach(link => {
|
|
const route = createRouteSettings(manifest, link);
|
|
const routePoints = normalizeRoutePoints(link.points, usesGdsYUp);
|
|
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) return;
|
|
const view = routeStyleForSettings(route, false);
|
|
edges.push({
|
|
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
|
|
source: sourceId,
|
|
target: targetId,
|
|
sourceHandle: fromPort,
|
|
targetHandle: toPort,
|
|
type: view.type,
|
|
style: view.style,
|
|
data: { route, points: routePoints },
|
|
});
|
|
} else if (routePoints.length >= 2) {
|
|
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
|
edges.push(makeFreeRouteEdge(edgeId, routePoints, route));
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: `cell-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
|
name: doc.name || cellName,
|
|
type: doc.type === 'project' ? 'project' : 'composite',
|
|
canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize),
|
|
nodes,
|
|
edges,
|
|
port: pagePort
|
|
};
|
|
};
|
|
|
|
const loadProject = async () => {
|
|
const projectPage = makeProjectPage();
|
|
try {
|
|
const response = await fetch(`/api/projects/${encodeURIComponent(currentProjectName)}`);
|
|
if (!response.ok) {
|
|
setProjectTechnology('');
|
|
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
setPages([projectPage]);
|
|
setActivePageId(projectPage.id);
|
|
setProjectCompositeMap({ [currentProjectName]: [] });
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const technology = data.technology || '';
|
|
setProjectTechnology(technology);
|
|
const manifest = await loadTechnologyManifest(technology);
|
|
const cellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest));
|
|
const loadedProjectPage = cellPages.find(page => page.type === 'project' && page.name === currentProjectName);
|
|
const nonProjectPages = cellPages.filter(page => page !== loadedProjectPage);
|
|
const resolvedProjectPage = loadedProjectPage || projectPage;
|
|
setPages([resolvedProjectPage, ...nonProjectPages]);
|
|
setActivePageId(resolvedProjectPage.id);
|
|
setProjectCompositeMap({ [currentProjectName]: nonProjectPages.map(page => page.name) });
|
|
setStandaloneComposites([]);
|
|
const nextTrees = {};
|
|
nonProjectPages.forEach(page => {
|
|
nextTrees[page.name] = buildCompInstanceTree(page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library);
|
|
});
|
|
setCompositeTrees(nextTrees);
|
|
} catch (error) {
|
|
setProjectTechnology('');
|
|
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
|
|
setPages([projectPage]);
|
|
setActivePageId(projectPage.id);
|
|
setProjectCompositeMap({ [currentProjectName]: [] });
|
|
}
|
|
};
|
|
|
|
loadProject();
|
|
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge]);
|
|
|
|
useEffect(() => {
|
|
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
|
|
reactFlowInstance.fitBounds(
|
|
{ x: 0, y: 0, width: activeCanvasSize.width, height: activeCanvasSize.height },
|
|
{ padding: 0.12, duration: 0 }
|
|
);
|
|
}
|
|
}, [activePage?.id, activeCanvasSize.width, activeCanvasSize.height, reactFlowInstance]);
|
|
|
|
useEffect(() => {
|
|
setRulerStartPoint(null);
|
|
setRulerEndPoint(null);
|
|
setRulerPreviewPoint(null);
|
|
}, [activePageId]);
|
|
|
|
useEffect(() => {
|
|
if (!library) return;
|
|
syncAllCompositeTrees(pages, library);
|
|
}, [pages, library, syncAllCompositeTrees]);
|
|
|
|
useEffect(() => {
|
|
const compositePages = new Map(pages.filter(page => page.type === 'composite').map(page => [page.name, page]));
|
|
const portUpdates = new Map();
|
|
pages.forEach(page => {
|
|
page.nodes.forEach(node => {
|
|
const compPage = compositePages.get(node.data?.componentName);
|
|
if (!compPage) return;
|
|
const nextPorts = buildPageComponentPorts(compPage.port, compPage.nodes);
|
|
if (JSON.stringify(node.data?.ports || {}) !== JSON.stringify(nextPorts)) {
|
|
portUpdates.set(node.id, nextPorts);
|
|
}
|
|
});
|
|
});
|
|
if (portUpdates.size === 0) return;
|
|
setPages(prev => prev.map(page => ({
|
|
...page,
|
|
nodes: page.nodes.map(node => (
|
|
portUpdates.has(node.id)
|
|
? { ...node, data: { ...node.data, ports: portUpdates.get(node.id) } }
|
|
: node
|
|
))
|
|
})));
|
|
}, [pages]);
|
|
|
|
useEffect(() => {
|
|
const missingPortNodes = [];
|
|
pages.forEach(page => {
|
|
page.nodes.forEach(node => {
|
|
const componentName = node.data?.componentName;
|
|
if (node.data?.elementType || !componentName || isForgeComponent(componentName) || node.data?.type === 'composite') return;
|
|
if (isBasicComponent(componentName)) {
|
|
if (node.data?.ports && node.data?.boxSize) return;
|
|
const metadata = getBasicComponentMetadata(componentName, node.data?.basicArguments);
|
|
if (metadata) {
|
|
missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName, metadata });
|
|
}
|
|
return;
|
|
}
|
|
if (node.data?.ports && node.data?.boxSize) return;
|
|
missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName });
|
|
});
|
|
});
|
|
if (missingPortNodes.length === 0) return;
|
|
|
|
let cancelled = false;
|
|
Promise.all(missingPortNodes.map(async item => ({
|
|
...item,
|
|
metadata: item.metadata || await loadComponentMetadata(item.componentName)
|
|
}))).then(results => {
|
|
if (cancelled) return;
|
|
const metadataByNode = new Map(results.filter(item => item.metadata).map(item => [item.nodeId, item.metadata]));
|
|
if (metadataByNode.size === 0) return;
|
|
setPages(prev => prev.map(page => ({
|
|
...page,
|
|
nodes: page.nodes.map(node => {
|
|
const metadata = metadataByNode.get(node.id);
|
|
if (!metadata) return node;
|
|
const boxSize = normalizeBoxSize(metadata);
|
|
return {
|
|
...node,
|
|
position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, boxSize),
|
|
data: {
|
|
...node.data,
|
|
ports: metadata.ports || {},
|
|
boxSize,
|
|
foundry: metadata.foundry || '',
|
|
process: metadata.process || ''
|
|
}
|
|
};
|
|
})
|
|
})));
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [pages, loadComponentMetadata]);
|
|
|
|
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: [],
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
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,
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
nodes: [
|
|
{
|
|
id: 'page-port',
|
|
type: 'portNode',
|
|
position: { x: 50, y: 150 },
|
|
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: 0, width: 0.5, layer: 'WG_CORE', description: '' },
|
|
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,
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
nodes: [
|
|
{
|
|
id: 'page-port',
|
|
type: 'portNode',
|
|
position: { x: 50, y: 150 },
|
|
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: 0, width: 0.5, layer: 'WG_CORE', description: '' },
|
|
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]
|
|
}));
|
|
recordUserAction('canvas.create', { project: currentProjectName, cell: cellName });
|
|
}, [pages, currentProjectName, recordUserAction]);
|
|
|
|
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', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: newPort.a, width: newPort.width || 0.5, layer: 'WG_CORE', description: '' },
|
|
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 = clampPositionToCanvas(
|
|
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
|
activePage?.canvasSize || activeCanvasSize,
|
|
DEFAULT_COMPONENT_BOX_SIZE
|
|
);
|
|
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,
|
|
ports: parsedData.ports || {}
|
|
}
|
|
};
|
|
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]
|
|
};
|
|
});
|
|
}
|
|
recordUserAction('instance.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'standaloneComposite' }
|
|
});
|
|
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 = clampPositionToCanvas(
|
|
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
|
activePage?.canvasSize || activeCanvasSize,
|
|
DEFAULT_COMPONENT_BOX_SIZE
|
|
);
|
|
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,
|
|
ports: parsedData.ports || {}
|
|
}
|
|
};
|
|
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]
|
|
};
|
|
});
|
|
}
|
|
recordUserAction('instance.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'composite' }
|
|
});
|
|
return;
|
|
}
|
|
if (!activePageId) {
|
|
alert('Please open a composite page first.');
|
|
return;
|
|
}
|
|
const position = clampPositionToCanvas(
|
|
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
|
activePage?.canvasSize || activeCanvasSize,
|
|
parsedData.type === 'element' ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : DEFAULT_COMPONENT_BOX_SIZE
|
|
);
|
|
if (parsedData.type === 'basic') {
|
|
const componentName = parsedData.componentName || parsedData.name;
|
|
const basicArguments = createBasicSettings(componentName, parsedData.settings);
|
|
const metadata = getBasicComponentMetadata(componentName, basicArguments);
|
|
const componentDisplayName = generateComponentDisplayName(componentName);
|
|
const newNode = {
|
|
id: Date.now().toString(),
|
|
type: 'rotatableNode',
|
|
position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, normalizeBoxSize(metadata)),
|
|
data: {
|
|
label: componentName,
|
|
componentName,
|
|
componentDisplayName,
|
|
libraryCategory: 'basic',
|
|
category: 'basic',
|
|
rotation: 0,
|
|
ports: metadata?.ports || {},
|
|
boxSize: metadata ? normalizeBoxSize(metadata) : DEFAULT_COMPONENT_BOX_SIZE,
|
|
basicArguments
|
|
},
|
|
};
|
|
setPages(prev => prev.map(p => p.id === activePageId ? { ...p, nodes: p.nodes.concat(newNode) } : p));
|
|
recordUserAction('component.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { component: componentName, instance: componentDisplayName, category: 'basic' }
|
|
});
|
|
return;
|
|
}
|
|
if (parsedData.type === 'element') {
|
|
const elementName = generateComponentDisplayName(parsedData.elementType === 'anchor' ? 'anchor' : 'port');
|
|
const isPort = parsedData.elementType === 'port';
|
|
const newNode = isPort
|
|
? {
|
|
id: Date.now().toString(),
|
|
type: 'portNode',
|
|
position,
|
|
data: {
|
|
label: elementName,
|
|
componentDisplayName: elementName,
|
|
portName: elementName,
|
|
elementType: 'port',
|
|
angle: 0,
|
|
width: 0.5,
|
|
layer: 'WG_CORE',
|
|
description: ''
|
|
},
|
|
}
|
|
: {
|
|
id: Date.now().toString(),
|
|
type: 'anchorNode',
|
|
position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE }),
|
|
data: {
|
|
label: elementName,
|
|
componentName: 'Anchor',
|
|
componentDisplayName: elementName,
|
|
elementType: 'anchor',
|
|
category: null,
|
|
rotation: 0,
|
|
width: 0.5,
|
|
layer: 'WG_CORE',
|
|
description: '',
|
|
hideIcon: true,
|
|
boxSize: { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE },
|
|
ports: buildElementPorts('anchor')
|
|
},
|
|
};
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
return { ...p, nodes: p.nodes.concat(newNode) };
|
|
}));
|
|
recordUserAction('element.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { element: parsedData.elementType, name: elementName }
|
|
});
|
|
return;
|
|
}
|
|
if (parsedData.type === 'category') {
|
|
const availableComponents = Array.isArray(parsedData.components)
|
|
? parsedData.components
|
|
.map(component => typeof component === 'string' ? component : component?.name)
|
|
.filter(Boolean)
|
|
: [];
|
|
const selectedComponent = chooseCategoryComponent(parsedData.name, availableComponents, parsedData.category);
|
|
if (!selectedComponent) {
|
|
addLog('Skipped category placement: no components were found in this library category.');
|
|
return;
|
|
}
|
|
const selectedIsForge = isForgeComponent(selectedComponent);
|
|
const componentDisplayName = generateComponentDisplayName(parsedData.category || selectedComponent, {
|
|
singularize: Boolean(parsedData.category),
|
|
abbreviate: Boolean(parsedData.category)
|
|
});
|
|
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,
|
|
ports: selectedIsForge ? {} : undefined,
|
|
forgeArguments: selectedIsForge ? createForgeArguments() : undefined
|
|
},
|
|
};
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
return { ...p, nodes: p.nodes.concat(newNode) };
|
|
}));
|
|
recordUserAction('instance.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { component: selectedComponent, instance: componentDisplayName, category: parsedData.category }
|
|
});
|
|
return;
|
|
}
|
|
const componentDisplayName = generateComponentDisplayName(parsedData.category || parsedData.name, {
|
|
singularize: Boolean(parsedData.category),
|
|
abbreviate: Boolean(parsedData.category)
|
|
});
|
|
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) };
|
|
}));
|
|
recordUserAction('instance.create', {
|
|
project: currentProjectName,
|
|
cell: activePage?.name,
|
|
detail: { component: parsedData.name, instance: componentDisplayName, category: parsedData.category }
|
|
});
|
|
}, [activePageId, activePage, activeCanvasSize, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName, toBooleanFlag]);
|
|
|
|
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 toggleRulerMode = useCallback(() => {
|
|
setRulerMode(prev => {
|
|
const next = !prev;
|
|
if (!next) {
|
|
setRulerStartPoint(null);
|
|
setRulerEndPoint(null);
|
|
setRulerPreviewPoint(null);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const eventToRulerPoint = useCallback((event) => {
|
|
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
return {
|
|
x: Number(Math.min(activeCanvasSize.width, Math.max(0, rawPoint.x)).toFixed(3)),
|
|
y: Number(Math.min(activeCanvasSize.height, Math.max(0, rawPoint.y)).toFixed(3))
|
|
};
|
|
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
|
|
|
|
const handleRulerPaneClick = useCallback((event) => {
|
|
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const nextPoint = eventToRulerPoint(event);
|
|
if (!rulerStartPoint || rulerEndPoint) {
|
|
setRulerStartPoint(nextPoint);
|
|
setRulerEndPoint(null);
|
|
setRulerPreviewPoint(null);
|
|
addLog(`Ruler start: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`);
|
|
return;
|
|
}
|
|
const measurement = createRulerMeasurement(rulerStartPoint, nextPoint);
|
|
setRulerEndPoint(nextPoint);
|
|
setRulerPreviewPoint(null);
|
|
if (measurement) {
|
|
addLog(`Ruler distance: ${measurement.label}`);
|
|
}
|
|
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]);
|
|
|
|
const handleRulerMouseMove = useCallback((event) => {
|
|
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
|
|
if (!rulerStartPoint || rulerEndPoint) return;
|
|
setRulerPreviewPoint(eventToRulerPoint(event));
|
|
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]);
|
|
|
|
const selectEdgeById = useCallback((edgeId, additive = false) => {
|
|
if (!activePageId || !edgeId) return;
|
|
setPages(prev => prev.map(p => {
|
|
if (p.id !== activePageId) return p;
|
|
return {
|
|
...p,
|
|
nodes: additive ? p.nodes : p.nodes.map(node => ({ ...node, selected: false })),
|
|
edges: p.edges.map(edge => {
|
|
const route = createRouteSettings(technologyManifest, edge.data?.route);
|
|
const selected = edge.id === edgeId ? (additive ? !edge.selected : true) : (additive ? edge.selected : false);
|
|
const view = routeStyleForSettings(route, selected);
|
|
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
|
|
return { ...edge, selected, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
|
|
})
|
|
};
|
|
}));
|
|
}, [activePageId, technologyManifest]);
|
|
|
|
const handleBasicConnection = useCallback((connection) => {
|
|
if (!activePageId || !activePage || activePage.type === 'layoutPreview' || rulerMode) return;
|
|
if (!connection?.source || !connection?.target || !connection?.sourceHandle || !connection?.targetHandle) return;
|
|
if (connection.source === connection.target && connection.sourceHandle === connection.targetHandle) return;
|
|
const route = currentLinkRoute;
|
|
const view = routeStyleForSettings(route, false);
|
|
const edgeId = `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`;
|
|
const candidate = {
|
|
id: edgeId,
|
|
source: connection.source,
|
|
target: connection.target,
|
|
sourceHandle: connection.sourceHandle,
|
|
targetHandle: connection.targetHandle,
|
|
type: view.type,
|
|
selectable: true,
|
|
style: view.style,
|
|
data: { route }
|
|
};
|
|
const nodeMap = Object.fromEntries(activePage.nodes.map(node => [node.id, node]));
|
|
const conflict = findSameTypeRouteCrossing(candidate, activePage.edges, nodeMap, technologyManifest);
|
|
if (conflict) {
|
|
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
|
|
const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
|
|
addLog(`Connection rejected: ${route.xsection} route crosses ${source} to ${target}.`);
|
|
return;
|
|
}
|
|
setPages(prev => prev.map(p => (
|
|
p.id === activePageId
|
|
? { ...p, edges: addEdge(candidate, p.edges) }
|
|
: p
|
|
)));
|
|
addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`);
|
|
}, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog]);
|
|
|
|
const handleRouteEdgeMouseDown = useCallback((event) => {
|
|
if (rulerMode) return false;
|
|
const target = event.target?.closest?.('[data-route-edge-id]');
|
|
if (!target) return false;
|
|
const edgeId = target.getAttribute('data-route-edge-id');
|
|
if (!edgeId) return false;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
selectEdgeById(edgeId, event.shiftKey);
|
|
return true;
|
|
}, [rulerMode, selectEdgeById]);
|
|
|
|
const handleReactFlowEdgeMouseDown = useCallback((event, edge) => {
|
|
if (rulerMode || !edge || edge.data?.draft || edge.data?.ruler) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
selectEdgeById(edge.id, event.shiftKey);
|
|
}, [rulerMode, selectEdgeById]);
|
|
|
|
const handleCanvasMouseDown = useCallback((event) => {
|
|
handleRouteEdgeMouseDown(event);
|
|
}, [handleRouteEdgeMouseDown]);
|
|
|
|
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,
|
|
__ports__: buildPageComponentPorts(compPage.port, compPage.nodes)
|
|
};
|
|
}
|
|
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,
|
|
__ports__: compPage ? buildPageComponentPorts(compPage.port, compPage.nodes) : {}
|
|
};
|
|
});
|
|
items.push({
|
|
type: 'project',
|
|
name: project.name,
|
|
composites: [...projectNodeItems, ...unplacedCells]
|
|
});
|
|
});
|
|
standaloneComposites.forEach(name => {
|
|
const compPage = pages.find(p => p.name === name && p.type === 'composite');
|
|
items.push({
|
|
type: 'standaloneComposite',
|
|
name: name,
|
|
tree: compositeTrees[name] || {},
|
|
pageId: compPage?.id || name,
|
|
__ports__: buildPageComponentPorts(compPage?.port, compPage?.nodes)
|
|
});
|
|
});
|
|
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,
|
|
__ports__: buildPageComponentPorts(page.port, page.nodes)
|
|
};
|
|
});
|
|
const basicEntries = {
|
|
Port: {
|
|
__type__: 'component',
|
|
__name__: 'Port',
|
|
__category__: 'element',
|
|
__element__: true,
|
|
__elementType__: 'port',
|
|
__ports__: ELEMENT_COMPONENTS.Port.ports
|
|
},
|
|
Anchor: {
|
|
__type__: 'component',
|
|
__name__: 'Anchor',
|
|
__category__: 'element',
|
|
__element__: true,
|
|
__elementType__: 'anchor',
|
|
__ports__: ELEMENT_COMPONENTS.Anchor.ports
|
|
},
|
|
...Object.fromEntries(Object.entries(BASIC_COMPONENTS)
|
|
.filter(([, definition]) => !definition.hidden)
|
|
.map(([name, definition]) => ([
|
|
name,
|
|
{
|
|
__type__: 'component',
|
|
__name__: name,
|
|
__category__: 'basic',
|
|
__basic__: true,
|
|
__ports__: getBasicComponentMetadata(name, definition.settings)?.ports || {}
|
|
}
|
|
])))
|
|
};
|
|
return {
|
|
Cells: cellEntries,
|
|
Basic: basicEntries,
|
|
PDK: library || {}
|
|
};
|
|
}, [pages, library]);
|
|
|
|
const buildBundlesYaml = useCallback((page) => {
|
|
return buildRouteBundlesYaml(page, technologyManifest);
|
|
}, [technologyManifest]);
|
|
|
|
const validateRouteCrossings = useCallback((page) => {
|
|
if (!page || !Array.isArray(page.edges)) return true;
|
|
const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node]));
|
|
for (const edge of page.edges) {
|
|
const conflict = findSameTypeRouteCrossing(edge, page.edges, nodeMap, technologyManifest);
|
|
if (conflict) {
|
|
const route = createRouteSettings(technologyManifest, edge.data?.route);
|
|
const source = nodeMap[edge.source]?.data?.componentDisplayName || edge.source;
|
|
const target = nodeMap[edge.target]?.data?.componentDisplayName || edge.target;
|
|
const conflictSource = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
|
|
const conflictTarget = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
|
|
addLog(`Build blocked: ${route.xsection} route ${source} to ${target} crosses ${conflictSource} to ${conflictTarget}.`);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}, [technologyManifest, addLog]);
|
|
|
|
const buildYamlForPage = useCallback((page) => {
|
|
if (!page) return '';
|
|
const header = `# =============================================
|
|
# mxPIC Cell/Project Definition File
|
|
# =============================================
|
|
schema_version: "2.0.0"
|
|
kind: cell
|
|
coordinate_system: gds_y_up
|
|
canvas_size:
|
|
width: ${Number(page.canvasSize?.width || DEFAULT_CANVAS_SIZE.width)}
|
|
height: ${Number(page.canvasSize?.height || DEFAULT_CANVAS_SIZE.height)}
|
|
project: ${currentProjectName}
|
|
name: ${page.name}
|
|
type: ${page.type === 'project' ? 'project' : 'composite'}
|
|
version: "1.0.0"
|
|
|
|
# 1. External Ports (How this cell connects to the outside world)
|
|
${buildCanvasPortsYaml(page.nodes)}
|
|
|
|
# 2. Instances (The sub-components dropped onto this canvas)
|
|
instances:`;
|
|
|
|
const resolveComponentPath = (compName) => {
|
|
if (!library || !compName || isForgeComponent(compName) || isBasicComponent(compName)) return compName;
|
|
const pathArr = findComponentPath(library, compName);
|
|
return pathArr.length > 0 ? pathArr.join('/') : compName;
|
|
};
|
|
const instancesBlock = buildInstancesYaml({
|
|
nodes: page.nodes,
|
|
resolveComponentPath
|
|
});
|
|
const elementsBlock = buildElementsYaml(page.nodes);
|
|
const bundlesBlock = buildBundlesYaml(page);
|
|
return `${header}
|
|
${instancesBlock}
|
|
|
|
${elementsBlock}
|
|
|
|
${bundlesBlock}`;
|
|
}, [currentProjectName, library, buildBundlesYaml]);
|
|
|
|
const openLayoutPreview = useCallback((cellName, svgUrl, layoutBounds) => {
|
|
if (!cellName || !svgUrl) return;
|
|
const layoutTabId = `layout-${currentProjectName}-${cellName}`;
|
|
setPages(prev => {
|
|
const existing = prev.find(page => page.id === layoutTabId);
|
|
if (existing) {
|
|
return prev.map(page => page.id === layoutTabId
|
|
? { ...page, name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, layoutBounds, nodes: [], edges: [], isClosed: false }
|
|
: page
|
|
);
|
|
}
|
|
return prev.concat({
|
|
id: layoutTabId,
|
|
name: `${cellName}:layout`,
|
|
type: 'layoutPreview',
|
|
svgUrl,
|
|
layoutBounds,
|
|
nodes: [],
|
|
edges: [],
|
|
isClosed: false
|
|
});
|
|
});
|
|
setActivePageId(layoutTabId);
|
|
}, [currentProjectName]);
|
|
|
|
const handleBuildLayout = useCallback(async () => {
|
|
if (!activePage) return;
|
|
if (buildLayoutBusy) return;
|
|
if (!validateRouteCrossings(activePage)) return;
|
|
setBuildLayoutBusy(true);
|
|
startBuildProgress('Building layout');
|
|
const yamlContent = buildYamlForPage(activePage);
|
|
|
|
// 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');
|
|
stopBuildProgress();
|
|
return;
|
|
}
|
|
|
|
const result = await response.json();
|
|
addLog('Successfully saved: ' + result.path);
|
|
if (result.svg_url) {
|
|
completeBuildProgress('Layout ready');
|
|
openLayoutPreview(activePage.name, result.svg_url, calculateLayoutBounds(activePage));
|
|
} else {
|
|
completeBuildProgress('Layout saved');
|
|
}
|
|
} catch (err) {
|
|
addLog('Save error: ' + err.message);
|
|
stopBuildProgress();
|
|
} finally {
|
|
setBuildLayoutBusy(false);
|
|
}
|
|
}, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
|
|
|
|
const handleSaveProjectLayouts = useCallback(async () => {
|
|
if (saveProjectBusy) return;
|
|
const savePages = pages.filter(page => page.type !== 'layoutPreview');
|
|
if (savePages.length === 0) {
|
|
addLog('No canvas YAML to save.');
|
|
return;
|
|
}
|
|
setSaveProjectBusy(true);
|
|
try {
|
|
for (const page of savePages) {
|
|
const response = await fetch('/api/save-layout', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
project: currentProjectName,
|
|
cell: page.name,
|
|
content: buildYamlForPage(page),
|
|
preview: false,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const errData = await response.json().catch(() => ({}));
|
|
throw new Error(errData.error || `Save failed for ${page.name}`);
|
|
}
|
|
}
|
|
addLog(`Saved YAML for ${savePages.length} canvas${savePages.length === 1 ? '' : 'es'}.`);
|
|
} catch (err) {
|
|
addLog('Project save error: ' + err.message);
|
|
} finally {
|
|
setSaveProjectBusy(false);
|
|
}
|
|
}, [saveProjectBusy, pages, currentProjectName, buildYamlForPage, addLog]);
|
|
|
|
const handleBuildGds = useCallback(async () => {
|
|
if (buildGdsBusy) return;
|
|
const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page));
|
|
if (invalidPage) return;
|
|
setBuildGdsBusy(true);
|
|
startBuildProgress('Building GDS');
|
|
try {
|
|
const response = await fetch('/api/build-gds', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ project: currentProjectName }),
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const detail = result.error ? `: ${result.error}` : ` (HTTP ${response.status})`;
|
|
addLog(`Build GDS failed${detail}`);
|
|
stopBuildProgress();
|
|
return;
|
|
}
|
|
const warningText = result.warnings && result.warnings.length > 0
|
|
? ` (${result.warnings.length} warnings)`
|
|
: '';
|
|
if (result.download_url) {
|
|
const link = document.createElement('a');
|
|
link.href = result.download_url;
|
|
link.download = result.filename || `${currentProjectName}.gds`;
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
addLog(`GDS built with ${result.engine}: ${result.filename || result.path}${warningText}`);
|
|
completeBuildProgress('GDS ready');
|
|
} catch (err) {
|
|
addLog(`Build GDS network error: ${err.message}. Check that the Flask server is running from the same host and Python environment.`);
|
|
stopBuildProgress();
|
|
} finally {
|
|
setBuildGdsBusy(false);
|
|
}
|
|
}, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
|
|
|
|
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' }}>
|
|
<div className="site-nav-actions">
|
|
<button className="mini-btn" onClick={() => { window.location.href = '/dashboard'; }}>
|
|
Dashboard
|
|
</button>
|
|
<button className="mini-btn" onClick={() => { window.location.href = '/logout'; }}>
|
|
Logout
|
|
</button>
|
|
</div>
|
|
<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}
|
|
onBuildGds={handleBuildGds}
|
|
buildGdsBusy={buildGdsBusy}
|
|
onSaveProject={handleSaveProjectLayouts}
|
|
saveProjectBusy={saveProjectBusy}
|
|
projectExpanded={projectExpanded}
|
|
onProjectToggle={handleProjectToggle}
|
|
projectTreeRef={projectTreeContainerRef}
|
|
projectTreeKey={projectTreeKey}
|
|
canvasSize={activeCanvasSize}
|
|
onCanvasSizeChange={handleCanvasSizeChange}
|
|
/>
|
|
<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' }}
|
|
onMouseDownCapture={handleCanvasMouseDown}
|
|
>
|
|
<div className="canvas-toolbar">
|
|
<span className="grid-snap-label">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>
|
|
<details className="link-mode-tabs" title="Route type used for new links">
|
|
<summary className="link-mode-summary">
|
|
<span className="link-mode-label">Link</span>
|
|
<span className="link-mode-current" style={{ color: routeStyleForSettings(currentLinkRoute, false).style.stroke }}>
|
|
{currentLinkXsection}
|
|
</span>
|
|
</summary>
|
|
<div className="link-mode-menu">
|
|
{linkXsectionChoices.map(xsection => {
|
|
const route = createRouteSettings(technologyManifest, { xsection });
|
|
const color = routeStyleForSettings(route, false).style.stroke;
|
|
return (
|
|
<button
|
|
key={xsection}
|
|
type="button"
|
|
className={`link-mode-btn ${currentLinkXsection === xsection ? 'active' : ''}`}
|
|
style={{ color }}
|
|
onClick={(event) => {
|
|
setCurrentLinkXsection(xsection);
|
|
event.currentTarget.closest('details')?.removeAttribute('open');
|
|
}}
|
|
>
|
|
{xsection}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</details>
|
|
<button className="mini-btn" onClick={() => setThemeMode(themeMode === 'light' ? 'dark' : 'light')}>
|
|
{themeMode === 'light' ? 'Dark Mode' : 'Bright Mode'}
|
|
</button>
|
|
<button className="mini-btn" onClick={toggleRulerMode} aria-pressed={rulerMode ? 'true' : 'false'}>
|
|
{rulerMode ? 'Ruler On' : 'Ruler'}
|
|
</button>
|
|
</div>
|
|
|
|
{buildProgress.active && (
|
|
<div className="build-progress">
|
|
<div className="build-progress-row">
|
|
<span>{buildProgress.label}</span>
|
|
<span>{Math.round(buildProgress.value)}%</span>
|
|
</div>
|
|
<div className="build-progress-track">
|
|
<div className="build-progress-fill" style={{ width: `${buildProgress.value}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rulerMode && activePage?.type !== 'layoutPreview' && (
|
|
<div className="ruler-status">
|
|
{rulerMeasurement
|
|
? rulerMeasurement.label
|
|
: (rulerStartPoint && !rulerEndPoint ? 'Move mouse, click second point' : 'Click the first point')}
|
|
</div>
|
|
)}
|
|
|
|
{activePage && activePage.type !== 'layoutPreview' && (
|
|
<button
|
|
onClick={handleBuildLayout}
|
|
className="build-layout-btn"
|
|
disabled={buildLayoutBusy}
|
|
>
|
|
{buildLayoutBusy ? 'Building...' : 'Build Layout'}
|
|
</button>
|
|
)}
|
|
|
|
{activePage && activePage.type === 'layoutPreview' ? (
|
|
<LayoutSvgPreview page={activePage} />
|
|
) : (
|
|
<ReactFlow
|
|
nodes={renderNodes}
|
|
edges={renderEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={handleBasicConnection}
|
|
onPaneClick={handleRulerPaneClick}
|
|
onPaneMouseMove={handleRulerMouseMove}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
onNodeClick={handleRulerPaneClick}
|
|
onNodeMouseMove={handleRulerMouseMove}
|
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
onNodeMouseDown={onNodeMouseDown}
|
|
onEdgeMouseDown={handleReactFlowEdgeMouseDown}
|
|
onNodeMouseUp={clearSpaceRotateNode}
|
|
nodeTypes={{ rotatableNode: RotatableNode, portNode: PortNode, anchorNode: AnchorNode, canvasBoundaryNode: CanvasBoundaryNode, rulerPointNode: RulerPointNode, rulerMeasurementNode: RulerMeasurementNode }}
|
|
edgeTypes={edgeTypes}
|
|
nodeExtent={canvasNodeExtent}
|
|
snapToGrid={gridSnap}
|
|
snapGrid={[10, 10]}
|
|
nodesDraggable={true}
|
|
nodesConnectable={true}
|
|
elementsSelectable={true}
|
|
connectionMode="loose"
|
|
connectionRadius={50}
|
|
minZoom={0.02}
|
|
maxZoom={4}
|
|
defaultViewport={{ x: 80, y: 80, zoom: 0.12 }}
|
|
panOnDrag={false}
|
|
selectionOnDrag={true}
|
|
selectionMode={FULL_SELECTION_MODE}
|
|
multiSelectionKeyCode="Shift"
|
|
deleteKeyCode={['Backspace', 'Delete']}
|
|
>
|
|
<Controls style={{ bottom: 15, left: 15 }} />
|
|
<Background color="#334155" gap={10} 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}
|
|
selectedNodes={selectedNodes}
|
|
selectedEdge={selectedEdge}
|
|
selectedEdges={selectedEdges}
|
|
technologyManifest={technologyManifest}
|
|
projectName={currentProjectName}
|
|
width={rightWidth}
|
|
onRenameComponent={renameComponent}
|
|
onUpdateNode={handleUpdateNode}
|
|
onUpdateEdgeRoute={handleUpdateEdgeRoute}
|
|
/>
|
|
</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 %}
|