feat(ui): full React editor UI, IO, demo seed, and tests
- graphOps: paper-independent graph logic (add/connect/network/flow/serialize) so core is unit-testable in jsdom; CanvasController delegates to it - App shell: toolbar, node palette (grouped/searchable/draggable), tabbed properties + configuration panels, status bar with calc summary - PropertyField editors for all 8 property types with validation - Keyboard shortcuts (undo/redo/copy/cut/paste/dup/select-all/delete/zoom) - Document IO (Tauri dialog+fs with browser download/upload fallback), SVG/PNG - Demo network seeded on first launch - Tests: 5 graphOps integration + 9 UI component (47 total) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab5a5e1a15
commit
d495401bc1
184
src/App.css
184
src/App.css
@ -1,184 +0,0 @@
|
|||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
142
src/App.tsx
142
src/App.tsx
@ -1,122 +1,30 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import reactLogo from './assets/react.svg'
|
import type { CanvasController } from "./canvas/CanvasController";
|
||||||
import viteLogo from './assets/vite.svg'
|
import { EditorContext } from "./ui/EditorContext";
|
||||||
import heroImg from './assets/hero.png'
|
import { CanvasView } from "./ui/CanvasView";
|
||||||
import './App.css'
|
import { NodePalette } from "./ui/NodePalette";
|
||||||
|
import { RightPanel } from "./ui/RightPanel";
|
||||||
|
import { Toolbar } from "./ui/Toolbar";
|
||||||
|
import { StatusBar } from "./ui/StatusBar";
|
||||||
|
import "./ui/editor.css";
|
||||||
|
import "./canvas/canvas.css";
|
||||||
|
|
||||||
function App() {
|
export default function App() {
|
||||||
const [count, setCount] = useState(0)
|
const [controller, setController] = useState<CanvasController | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EditorContext.Provider value={controller}>
|
||||||
<section id="center">
|
<div className="editor-shell">
|
||||||
<div className="hero">
|
<Toolbar />
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<div className="editor-body">
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
<NodePalette />
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
<main className="canvas-area">
|
||||||
|
<CanvasView onReady={setController} />
|
||||||
|
</main>
|
||||||
|
<RightPanel />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<StatusBar />
|
||||||
<h1>Get started</h1>
|
</div>
|
||||||
<p>
|
</EditorContext.Provider>
|
||||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
);
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="counter"
|
|
||||||
onClick={() => setCount((count) => count + 1)}
|
|
||||||
>
|
|
||||||
Count is {count}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img className="logo" src={viteLogo} alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://react.dev/" target="_blank">
|
|
||||||
<img className="button-icon" src={reactLogo} alt="" />
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#social-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#github-icon"></use>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vite.dev/" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#discord-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://x.com/vite_js" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#x-icon"></use>
|
|
||||||
</svg>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#bluesky-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@ -9,29 +9,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { dia, shapes, elementTools, linkTools } from "@joint/core";
|
import { dia, shapes, elementTools, linkTools } from "@joint/core";
|
||||||
import {
|
import { PipelineNode, createLink, restyleLink, recolorPorts } from "./shapes";
|
||||||
PipelineNode,
|
|
||||||
createNode,
|
|
||||||
createLink,
|
|
||||||
restyleLink,
|
|
||||||
recolorPorts,
|
|
||||||
} from "./shapes";
|
|
||||||
import { nodeType } from "../model/nodeTypes";
|
import { nodeType } from "../model/nodeTypes";
|
||||||
|
import {
|
||||||
|
CELL_NAMESPACE,
|
||||||
|
routerConfig,
|
||||||
|
connectorConfig,
|
||||||
|
addNodeToGraph,
|
||||||
|
connectInGraph,
|
||||||
|
buildNetwork,
|
||||||
|
computeAndAnnotateFlow,
|
||||||
|
clearFlow,
|
||||||
|
} from "./graphOps";
|
||||||
import { getColorScheme, type ColorScheme } from "../model/colorSchemes";
|
import { getColorScheme, type ColorScheme } from "../model/colorSchemes";
|
||||||
import {
|
import {
|
||||||
RelationRegistry,
|
RelationRegistry,
|
||||||
canConnect,
|
canConnect,
|
||||||
type PortDirection,
|
|
||||||
type PortRef,
|
type PortRef,
|
||||||
|
type PortDirection,
|
||||||
} from "../model/mediums";
|
} from "../model/mediums";
|
||||||
import { solveFlow, type FlowNetwork } from "../calc/flow";
|
import type { FlowNetwork } from "../calc/flow";
|
||||||
import type { EditorConfig, RouterType, Selection } from "../store/editorStore";
|
import type { EditorConfig, RouterType, Selection } from "../store/editorStore";
|
||||||
|
|
||||||
const CELL_NAMESPACE = {
|
|
||||||
...shapes,
|
|
||||||
pipeline: { Node: PipelineNode },
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ControllerHooks {
|
export interface ControllerHooks {
|
||||||
onSelect: (sel: Selection | null) => void;
|
onSelect: (sel: Selection | null) => void;
|
||||||
onSelectionChanged: () => void;
|
onSelectionChanged: () => void;
|
||||||
@ -87,8 +86,8 @@ export class CanvasController {
|
|||||||
linkPinning: false,
|
linkPinning: false,
|
||||||
snapLinks: { radius: 20 },
|
snapLinks: { radius: 20 },
|
||||||
markAvailable: true,
|
markAvailable: true,
|
||||||
defaultRouter: this.routerConfig(config.router),
|
defaultRouter: this.routerCfg(config.router),
|
||||||
defaultConnector: this.connectorConfig(config.arcOnIntersection),
|
defaultConnector: this.connectorCfg(config.arcOnIntersection),
|
||||||
defaultLink: () =>
|
defaultLink: () =>
|
||||||
createLink({
|
createLink({
|
||||||
source: { id: "", port: "" },
|
source: { id: "", port: "" },
|
||||||
@ -106,21 +105,12 @@ export class CanvasController {
|
|||||||
|
|
||||||
// ---- configuration -------------------------------------------------------
|
// ---- configuration -------------------------------------------------------
|
||||||
|
|
||||||
private routerConfig(router: RouterType) {
|
private routerCfg(router: RouterType) {
|
||||||
switch (router) {
|
return routerConfig(router, this.config.gridSize);
|
||||||
case "orthogonal":
|
|
||||||
return { name: "orthogonal" };
|
|
||||||
case "normal":
|
|
||||||
return { name: "normal" };
|
|
||||||
default:
|
|
||||||
return { name: "manhattan", args: { padding: 12, step: this.config.gridSize } };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectorConfig(arc: boolean) {
|
private connectorCfg(arc: boolean) {
|
||||||
return arc
|
return connectorConfig(arc);
|
||||||
? { name: "jumpover", args: { size: 8, jump: "arc" } }
|
|
||||||
: { name: "rounded", args: { radius: 10 } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyConfig(config: EditorConfig): void {
|
applyConfig(config: EditorConfig): void {
|
||||||
@ -128,16 +118,16 @@ export class CanvasController {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.scheme = getColorScheme(config.colorScheme);
|
this.scheme = getColorScheme(config.colorScheme);
|
||||||
|
|
||||||
this.paper.options.defaultRouter = this.routerConfig(config.router);
|
this.paper.options.defaultRouter = this.routerCfg(config.router);
|
||||||
this.paper.options.defaultConnector = this.connectorConfig(config.arcOnIntersection);
|
this.paper.options.defaultConnector = this.connectorCfg(config.arcOnIntersection);
|
||||||
this.paper.setGridSize(config.gridSize);
|
this.paper.setGridSize(config.gridSize);
|
||||||
this.paper.setGrid(config.showGrid ? { name: "mesh", args: { color: this.scheme.grid } } : false);
|
this.paper.setGrid(config.showGrid ? { name: "mesh", args: { color: this.scheme.grid } } : false);
|
||||||
this.paper.drawBackground({ color: this.scheme.background });
|
this.paper.drawBackground({ color: this.scheme.background });
|
||||||
|
|
||||||
// Re-route + re-style every link, recolor ports/scheme if changed.
|
// Re-route + re-style every link, recolor ports/scheme if changed.
|
||||||
this.graph.getLinks().forEach((l) => {
|
this.graph.getLinks().forEach((l) => {
|
||||||
l.router(this.routerConfig(config.router));
|
l.router(this.routerCfg(config.router));
|
||||||
l.connector(this.connectorConfig(config.arcOnIntersection));
|
l.connector(this.connectorCfg(config.arcOnIntersection));
|
||||||
restyleLink(l, this.scheme);
|
restyleLink(l, this.scheme);
|
||||||
});
|
});
|
||||||
if (prevScheme !== config.colorScheme) {
|
if (prevScheme !== config.colorScheme) {
|
||||||
@ -193,7 +183,21 @@ export class CanvasController {
|
|||||||
p.on("link:pointerclick", (view, evt) => {
|
p.on("link:pointerclick", (view, evt) => {
|
||||||
this.selectCell(view.model, evt.shiftKey === true);
|
this.selectCell(view.model, evt.shiftKey === true);
|
||||||
});
|
});
|
||||||
p.on("link:connect", () => {
|
p.on("link:connect", (linkView) => {
|
||||||
|
// Adopt the source port's medium so the new link colors correctly.
|
||||||
|
const link = linkView.model as dia.Link;
|
||||||
|
const src = link.getSourceElement();
|
||||||
|
const portId = link.source().port;
|
||||||
|
if (src && portId) {
|
||||||
|
const port = src.getPort(portId) as { medium?: string } | undefined;
|
||||||
|
if (port?.medium) {
|
||||||
|
const props = { ...((link.prop("props") ?? {}) as Record<string, unknown>) };
|
||||||
|
props.medium = port.medium;
|
||||||
|
link.prop("props", props);
|
||||||
|
restyleLink(link, this.scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.selectCell(link, false);
|
||||||
this.commit();
|
this.commit();
|
||||||
});
|
});
|
||||||
p.on("blank:pointerdown", () => {
|
p.on("blank:pointerdown", () => {
|
||||||
@ -309,6 +313,35 @@ export class CanvasController {
|
|||||||
|
|
||||||
// ---- creation ------------------------------------------------------------
|
// ---- creation ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Add a node at local (paper) coordinates. Used by the demo seed and tests. */
|
||||||
|
addNode(
|
||||||
|
typeId: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
props?: Record<string, unknown>,
|
||||||
|
): dia.Element | null {
|
||||||
|
return addNodeToGraph(this.graph, this.scheme, typeId, x, y, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect two ports programmatically; returns the link or null if invalid. */
|
||||||
|
connect(
|
||||||
|
sourceId: string,
|
||||||
|
sourcePort: string,
|
||||||
|
targetId: string,
|
||||||
|
targetPort: string,
|
||||||
|
props?: Record<string, unknown>,
|
||||||
|
): dia.Link | null {
|
||||||
|
return connectInGraph(this.graph, this.scheme, this.relations, {
|
||||||
|
sourceId,
|
||||||
|
sourcePort,
|
||||||
|
targetId,
|
||||||
|
targetPort,
|
||||||
|
router: this.routerCfg(this.config.router),
|
||||||
|
connector: this.connectorCfg(this.config.arcOnIntersection),
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addNodeAt(typeId: string, clientX: number, clientY: number): dia.Element | null {
|
addNodeAt(typeId: string, clientX: number, clientY: number): dia.Element | null {
|
||||||
const type = nodeType(typeId);
|
const type = nodeType(typeId);
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
@ -320,8 +353,8 @@ export class CanvasController {
|
|||||||
x = Math.round(x / g) * g;
|
x = Math.round(x / g) * g;
|
||||||
y = Math.round(y / g) * g;
|
y = Math.round(y / g) * g;
|
||||||
}
|
}
|
||||||
const node = createNode({ type, position: { x, y }, scheme: this.scheme });
|
const node = addNodeToGraph(this.graph, this.scheme, typeId, x, y);
|
||||||
this.graph.addCell(node);
|
if (!node) return null;
|
||||||
this.commit();
|
this.commit();
|
||||||
this.selectCell(node, false);
|
this.selectCell(node, false);
|
||||||
return node;
|
return node;
|
||||||
@ -560,49 +593,11 @@ export class CanvasController {
|
|||||||
// ---- flow calculation & animation ---------------------------------------
|
// ---- flow calculation & animation ---------------------------------------
|
||||||
|
|
||||||
buildNetwork(): FlowNetwork {
|
buildNetwork(): FlowNetwork {
|
||||||
const nodes = this.graph.getElements().map((el) => {
|
return buildNetwork(this.graph);
|
||||||
const props = (el.prop("props") ?? {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: String(el.id),
|
|
||||||
supply: typeof props.supply === "number" ? props.supply : undefined,
|
|
||||||
demand: typeof props.demand === "number" ? props.demand : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const edges = this.graph
|
|
||||||
.getLinks()
|
|
||||||
.filter((l) => l.getSourceElement() && l.getTargetElement())
|
|
||||||
.map((l) => ({
|
|
||||||
id: String(l.id),
|
|
||||||
source: String(l.getSourceElement()!.id),
|
|
||||||
target: String(l.getTargetElement()!.id),
|
|
||||||
}));
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runFlow(): void {
|
runFlow(): void {
|
||||||
const net = this.buildNetwork();
|
const result = computeAndAnnotateFlow(this.graph);
|
||||||
const result = solveFlow(net);
|
|
||||||
const max = result.maxFlow || 1;
|
|
||||||
|
|
||||||
for (const link of this.graph.getLinks()) {
|
|
||||||
const flow = result.edgeFlow[String(link.id)] ?? 0;
|
|
||||||
const norm = flow / max;
|
|
||||||
link.prop("flow", flow);
|
|
||||||
link.prop("flowNorm", norm);
|
|
||||||
const props = { ...((link.prop("props") ?? {}) as Record<string, unknown>) };
|
|
||||||
props.flowRate = Math.round(Math.abs(flow) * 100) / 100;
|
|
||||||
link.prop("props", props);
|
|
||||||
}
|
|
||||||
// Annotate meter readings.
|
|
||||||
for (const el of this.graph.getElements()) {
|
|
||||||
if (el.prop("nodeType") === "meter") {
|
|
||||||
const inflow = result.nodeInflow[String(el.id)] ?? 0;
|
|
||||||
const props = { ...((el.prop("props") ?? {}) as Record<string, unknown>) };
|
|
||||||
props.reading = Math.round(inflow * 100) / 100;
|
|
||||||
el.prop("props", props);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hooks.onCalc({
|
this.hooks.onCalc({
|
||||||
totalSupply: result.totalSupply,
|
totalSupply: result.totalSupply,
|
||||||
totalDemand: result.totalDemand,
|
totalDemand: result.totalDemand,
|
||||||
@ -661,11 +656,8 @@ export class CanvasController {
|
|||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
}
|
}
|
||||||
this.dashOffsets.clear();
|
this.dashOffsets.clear();
|
||||||
for (const link of this.graph.getLinks()) {
|
clearFlow(this.graph);
|
||||||
link.prop("flow", 0);
|
for (const link of this.graph.getLinks()) restyleLink(link, this.scheme);
|
||||||
link.prop("flowNorm", 0);
|
|
||||||
restyleLink(link, this.scheme);
|
|
||||||
}
|
|
||||||
this.hooks.onCalc(null);
|
this.hooks.onCalc(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/canvas/canvas.css
Normal file
47
src/canvas/canvas.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* JointJS canvas: port reveal-on-focus, selection glow, cursors. */
|
||||||
|
|
||||||
|
.canvas-host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-host .joint-paper {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ports are hidden until the node is hovered or focused/selected. */
|
||||||
|
.joint-element .joint-port {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.joint-element:hover .joint-port,
|
||||||
|
.joint-element.is-focused .joint-port,
|
||||||
|
.joint-element.is-selected .joint-port {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.joint-port-body {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection emphasis (works for both nodes and links). */
|
||||||
|
.joint-cell.is-selected {
|
||||||
|
filter: drop-shadow(0 0 3px rgba(47, 109, 246, 0.9));
|
||||||
|
}
|
||||||
|
.joint-element.is-selected [joint-selector="card"] {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight available magnets while drawing a link. */
|
||||||
|
.joint-port-body.available-magnet {
|
||||||
|
stroke: #1f9d55;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
.joint-highlight-stroke {
|
||||||
|
stroke: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.joint-link .connection-wrap {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
31
src/canvas/demo.ts
Normal file
31
src/canvas/demo.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Seed a demonstration pipeline network so the editor opens with something
|
||||||
|
* meaningful: a source feeding a pump and meter into a junction that splits to
|
||||||
|
* a storage tank and two demand outlets. Supplies/demands are set so the mock
|
||||||
|
* solver produces visible, balanced flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CanvasController } from "./CanvasController";
|
||||||
|
|
||||||
|
export function seedDemoNetwork(controller: CanvasController): void {
|
||||||
|
const source = controller.addNode("water-source", 60, 240, { title: "Reservoir", supply: 12 });
|
||||||
|
const pump = controller.addNode("pump", 220, 240, { title: "Booster pump", model: "p-medium" });
|
||||||
|
const meter = controller.addNode("meter", 380, 250, { title: "FT-01" });
|
||||||
|
const junction = controller.addNode("junction", 520, 252, { title: "J1" });
|
||||||
|
const tank = controller.addNode("tank", 500, 80, { title: "Buffer tank" });
|
||||||
|
const valve = controller.addNode("valve", 700, 120, { title: "V-01", state: "open" });
|
||||||
|
const sinkA = controller.addNode("water-sink", 860, 120, { title: "Zone A", demand: 5 });
|
||||||
|
const sinkB = controller.addNode("water-sink", 700, 300, { title: "Zone B", demand: 7 });
|
||||||
|
|
||||||
|
if (!source || !pump || !meter || !junction || !tank || !valve || !sinkA || !sinkB) return;
|
||||||
|
|
||||||
|
controller.connect(source.id as string, "out", pump.id as string, "in", { title: "Suction main" });
|
||||||
|
controller.connect(pump.id as string, "out", meter.id as string, "in");
|
||||||
|
controller.connect(meter.id as string, "out", junction.id as string, "p-left");
|
||||||
|
controller.connect(junction.id as string, "p-top", tank.id as string, "in");
|
||||||
|
controller.connect(tank.id as string, "out", valve.id as string, "in", { style: "bidirectional" });
|
||||||
|
controller.connect(valve.id as string, "out", sinkA.id as string, "in");
|
||||||
|
controller.connect(junction.id as string, "p-bottom", sinkB.id as string, "in", { style: "signal" });
|
||||||
|
|
||||||
|
controller.commit();
|
||||||
|
}
|
||||||
98
src/canvas/graphOps.test.ts
Normal file
98
src/canvas/graphOps.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
makeGraph,
|
||||||
|
addNodeToGraph,
|
||||||
|
connectInGraph,
|
||||||
|
buildNetwork,
|
||||||
|
computeAndAnnotateFlow,
|
||||||
|
routerConfig,
|
||||||
|
connectorConfig,
|
||||||
|
} from "./graphOps";
|
||||||
|
import { RelationRegistry } from "../model/mediums";
|
||||||
|
import { getColorScheme } from "../model/colorSchemes";
|
||||||
|
|
||||||
|
const scheme = getColorScheme("default");
|
||||||
|
const relations = new RelationRegistry();
|
||||||
|
|
||||||
|
function connect(graph: ReturnType<typeof makeGraph>, s: string, sp: string, t: string, tp: string) {
|
||||||
|
return connectInGraph(graph, scheme, relations, {
|
||||||
|
sourceId: s,
|
||||||
|
sourcePort: sp,
|
||||||
|
targetId: t,
|
||||||
|
targetPort: tp,
|
||||||
|
router: routerConfig("manhattan", 20),
|
||||||
|
connector: connectorConfig(true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("graphOps", () => {
|
||||||
|
it("adds nodes with the right ports and props", () => {
|
||||||
|
const g = makeGraph();
|
||||||
|
const pump = addNodeToGraph(g, scheme, "pump", 0, 0, { title: "P1", power: 5 });
|
||||||
|
expect(pump).toBeTruthy();
|
||||||
|
expect(pump!.prop("nodeType")).toBe("pump");
|
||||||
|
expect(pump!.getPorts().map((p) => p.id).sort()).toEqual(["in", "out"]);
|
||||||
|
expect((pump!.prop("props") as Record<string, unknown>).title).toBe("P1");
|
||||||
|
expect(g.getElements()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unknown node type", () => {
|
||||||
|
const g = makeGraph();
|
||||||
|
expect(addNodeToGraph(g, scheme, "does-not-exist", 0, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects compatible ports and rejects incompatible ones", () => {
|
||||||
|
const g = makeGraph();
|
||||||
|
const src = addNodeToGraph(g, scheme, "water-source", 0, 0)!;
|
||||||
|
const sink = addNodeToGraph(g, scheme, "water-sink", 200, 0)!;
|
||||||
|
const gas = addNodeToGraph(g, scheme, "compressor", 0, 200)!;
|
||||||
|
|
||||||
|
// valid: source.out(water) -> sink.in(water)
|
||||||
|
expect(connect(g, src.id as string, "out", sink.id as string, "in")).toBeTruthy();
|
||||||
|
// invalid medium: source.out(water) -> compressor.in(gas)
|
||||||
|
expect(connect(g, src.id as string, "out", gas.id as string, "in")).toBeNull();
|
||||||
|
// invalid direction: source.out -> source.out (same node anyway)
|
||||||
|
expect(connect(g, src.id as string, "out", src.id as string, "out")).toBeNull();
|
||||||
|
expect(g.getLinks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a flow network respecting drawn direction and runs the solver", () => {
|
||||||
|
const g = makeGraph();
|
||||||
|
const src = addNodeToGraph(g, scheme, "water-source", 0, 0, { supply: 10 })!;
|
||||||
|
const meter = addNodeToGraph(g, scheme, "meter", 200, 0)!;
|
||||||
|
const sink = addNodeToGraph(g, scheme, "water-sink", 400, 0, { demand: 10 })!;
|
||||||
|
connect(g, src.id as string, "out", meter.id as string, "in");
|
||||||
|
connect(g, meter.id as string, "out", sink.id as string, "in");
|
||||||
|
|
||||||
|
const net = buildNetwork(g);
|
||||||
|
expect(net.nodes).toHaveLength(3);
|
||||||
|
expect(net.edges).toHaveLength(2);
|
||||||
|
|
||||||
|
const result = computeAndAnnotateFlow(g);
|
||||||
|
expect(result.balanced).toBe(true);
|
||||||
|
// Links carry flow + flowRate; meter carries a reading.
|
||||||
|
for (const link of g.getLinks()) {
|
||||||
|
expect(Math.abs((link.prop("flow") as number) - 10)).toBeLessThan(1e-6);
|
||||||
|
expect((link.prop("props") as Record<string, unknown>).flowRate).toBe(10);
|
||||||
|
}
|
||||||
|
expect((meter.prop("props") as Record<string, unknown>).reading).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a JSON round-trip preserving nodes, links and props", () => {
|
||||||
|
const g = makeGraph();
|
||||||
|
const src = addNodeToGraph(g, scheme, "water-source", 40, 40, { supply: 7, title: "R" })!;
|
||||||
|
const sink = addNodeToGraph(g, scheme, "water-sink", 300, 40, { demand: 7 })!;
|
||||||
|
connect(g, src.id as string, "out", sink.id as string, "in");
|
||||||
|
|
||||||
|
const json = g.toJSON();
|
||||||
|
const g2 = makeGraph();
|
||||||
|
g2.fromJSON(json);
|
||||||
|
expect(g2.getElements()).toHaveLength(2);
|
||||||
|
expect(g2.getLinks()).toHaveLength(1);
|
||||||
|
const src2 = g2.getCell(src.id) ;
|
||||||
|
expect((src2.prop("props") as Record<string, unknown>).title).toBe("R");
|
||||||
|
// Ports (with custom medium/direction) survive serialization.
|
||||||
|
const port = (src2 as never as { getPort: (id: string) => { medium?: string } }).getPort("out");
|
||||||
|
expect(port.medium).toBe("water");
|
||||||
|
});
|
||||||
|
});
|
||||||
160
src/canvas/graphOps.ts
Normal file
160
src/canvas/graphOps.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Paper-independent graph operations. These work on a bare `dia.Graph` and so
|
||||||
|
* are unit-testable in jsdom (where `dia.Paper` cannot mount because jsdom has
|
||||||
|
* no SVG layout). The CanvasController delegates node/link creation, network
|
||||||
|
* extraction, flow annotation and (de)serialization here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dia, shapes } from "@joint/core";
|
||||||
|
import { PipelineNode, createNode, createLink } from "./shapes";
|
||||||
|
import { nodeType } from "../model/nodeTypes";
|
||||||
|
import type { ColorScheme } from "../model/colorSchemes";
|
||||||
|
import {
|
||||||
|
RelationRegistry,
|
||||||
|
canConnect,
|
||||||
|
type PortDirection,
|
||||||
|
type PortRef,
|
||||||
|
} from "../model/mediums";
|
||||||
|
import { solveFlow, type FlowNetwork, type FlowResult } from "../calc/flow";
|
||||||
|
import type { RouterType } from "../store/editorStore";
|
||||||
|
|
||||||
|
export const CELL_NAMESPACE = {
|
||||||
|
...shapes,
|
||||||
|
pipeline: { Node: PipelineNode },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeGraph(): dia.Graph {
|
||||||
|
return new dia.Graph({}, { cellNamespace: CELL_NAMESPACE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routerConfig(router: RouterType, step: number) {
|
||||||
|
switch (router) {
|
||||||
|
case "orthogonal":
|
||||||
|
return { name: "orthogonal" };
|
||||||
|
case "normal":
|
||||||
|
return { name: "normal" };
|
||||||
|
default:
|
||||||
|
return { name: "manhattan", args: { padding: 12, step } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectorConfig(arc: boolean) {
|
||||||
|
return arc
|
||||||
|
? { name: "jumpover", args: { size: 8, jump: "arc" } }
|
||||||
|
: { name: "rounded", args: { radius: 10 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addNodeToGraph(
|
||||||
|
graph: dia.Graph,
|
||||||
|
scheme: ColorScheme,
|
||||||
|
typeId: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
props?: Record<string, unknown>,
|
||||||
|
): dia.Element | null {
|
||||||
|
const type = nodeType(typeId);
|
||||||
|
if (!type) return null;
|
||||||
|
const node = createNode({ type, position: { x, y }, scheme, props });
|
||||||
|
graph.addCell(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portRefOf(el: dia.Element | undefined, portId: string): PortRef | null {
|
||||||
|
if (!el) return null;
|
||||||
|
const port = el.getPort(portId) as { medium?: string; direction?: PortDirection } | undefined;
|
||||||
|
return {
|
||||||
|
nodeId: String(el.id),
|
||||||
|
portId,
|
||||||
|
medium: port?.medium ?? "water",
|
||||||
|
direction: port?.direction ?? "inout",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectInGraph(
|
||||||
|
graph: dia.Graph,
|
||||||
|
scheme: ColorScheme,
|
||||||
|
relations: RelationRegistry,
|
||||||
|
opts: {
|
||||||
|
sourceId: string;
|
||||||
|
sourcePort: string;
|
||||||
|
targetId: string;
|
||||||
|
targetPort: string;
|
||||||
|
router: ReturnType<typeof routerConfig>;
|
||||||
|
connector: ReturnType<typeof connectorConfig>;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): dia.Link | null {
|
||||||
|
const s = graph.getCell(opts.sourceId) as dia.Element | undefined;
|
||||||
|
const t = graph.getCell(opts.targetId) as dia.Element | undefined;
|
||||||
|
const source = portRefOf(s, opts.sourcePort);
|
||||||
|
const target = portRefOf(t, opts.targetPort);
|
||||||
|
if (!source || !target) return null;
|
||||||
|
if (!canConnect(source, target, relations).ok) return null;
|
||||||
|
|
||||||
|
const link = createLink({
|
||||||
|
source: { id: opts.sourceId, port: opts.sourcePort },
|
||||||
|
target: { id: opts.targetId, port: opts.targetPort },
|
||||||
|
scheme,
|
||||||
|
props: opts.props,
|
||||||
|
});
|
||||||
|
link.router(opts.router);
|
||||||
|
link.connector(opts.connector);
|
||||||
|
graph.addCell(link);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the flow network (respecting drawn edge direction) from the graph. */
|
||||||
|
export function buildNetwork(graph: dia.Graph): FlowNetwork {
|
||||||
|
const nodes = graph.getElements().map((el) => {
|
||||||
|
const props = (el.prop("props") ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: String(el.id),
|
||||||
|
supply: typeof props.supply === "number" ? props.supply : undefined,
|
||||||
|
demand: typeof props.demand === "number" ? props.demand : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const edges = graph
|
||||||
|
.getLinks()
|
||||||
|
.filter((l) => l.getSourceElement() && l.getTargetElement())
|
||||||
|
.map((l) => ({
|
||||||
|
id: String(l.id),
|
||||||
|
source: String(l.getSourceElement()!.id),
|
||||||
|
target: String(l.getTargetElement()!.id),
|
||||||
|
}));
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve the network and write results back onto the graph: each link gets
|
||||||
|
* `flow`/`flowNorm` and a `flowRate` prop; meters get their `reading` prop.
|
||||||
|
* Returns the raw {@link FlowResult}.
|
||||||
|
*/
|
||||||
|
export function computeAndAnnotateFlow(graph: dia.Graph): FlowResult {
|
||||||
|
const result = solveFlow(buildNetwork(graph));
|
||||||
|
const max = result.maxFlow || 1;
|
||||||
|
|
||||||
|
for (const link of graph.getLinks()) {
|
||||||
|
const flow = result.edgeFlow[String(link.id)] ?? 0;
|
||||||
|
link.prop("flow", flow);
|
||||||
|
link.prop("flowNorm", flow / max);
|
||||||
|
const props = { ...((link.prop("props") ?? {}) as Record<string, unknown>) };
|
||||||
|
props.flowRate = Math.round(Math.abs(flow) * 100) / 100;
|
||||||
|
link.prop("props", props);
|
||||||
|
}
|
||||||
|
for (const el of graph.getElements()) {
|
||||||
|
if (el.prop("nodeType") === "meter") {
|
||||||
|
const inflow = result.nodeInflow[String(el.id)] ?? 0;
|
||||||
|
const props = { ...((el.prop("props") ?? {}) as Record<string, unknown>) };
|
||||||
|
props.reading = Math.round(inflow * 100) / 100;
|
||||||
|
el.prop("props", props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFlow(graph: dia.Graph): void {
|
||||||
|
for (const link of graph.getLinks()) {
|
||||||
|
link.prop("flow", 0);
|
||||||
|
link.prop("flowNorm", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/index.css
127
src/index.css
@ -1,111 +1,38 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
--bg: #eef1f5;
|
||||||
--text-h: #08060d;
|
--panel: #ffffff;
|
||||||
--bg: #fff;
|
--panel-2: #f4f6f9;
|
||||||
--border: #e5e4e7;
|
--border: #d7dce3;
|
||||||
--code-bg: #f4f3ec;
|
--text: #23272f;
|
||||||
--accent: #aa3bff;
|
--text-muted: #6b7280;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--accent: #2f6df6;
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
--accent-weak: #e6efff;
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--danger: #e0402c;
|
||||||
--shadow:
|
--ok: #1f9d55;
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
--warn: #c9820a;
|
||||||
|
--toolbar-h: 48px;
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--status-h: 28px;
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
button {
|
||||||
h2 {
|
font-family: inherit;
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/io/document.ts
Normal file
111
src/io/document.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Document IO: save/open the diagram JSON and export SVG/PNG. Runs natively
|
||||||
|
* through Tauri's dialog + fs plugins when available, and degrades gracefully to
|
||||||
|
* browser download / file-input when running in a plain browser (e.g. dev,
|
||||||
|
* tests). All Tauri modules are imported lazily so the browser build never
|
||||||
|
* touches them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramDocument {
|
||||||
|
version: number;
|
||||||
|
config: unknown;
|
||||||
|
graph: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(filename: string, content: Blob): void {
|
||||||
|
const url = URL.createObjectURL(content);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the document. Returns the chosen path (Tauri) or null (browser). */
|
||||||
|
export async function saveDocument(
|
||||||
|
doc: DiagramDocument,
|
||||||
|
suggestedName = "network.pipeline.json",
|
||||||
|
): Promise<string | null> {
|
||||||
|
const json = JSON.stringify(doc, null, 2);
|
||||||
|
if (isTauri()) {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: suggestedName,
|
||||||
|
filters: [{ name: "Pipeline network", extensions: ["json"] }],
|
||||||
|
});
|
||||||
|
if (!path) return null;
|
||||||
|
await writeTextFile(path, json);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
triggerDownload(suggestedName, new Blob([json], { type: "application/json" }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a document. Returns the parsed doc or null if cancelled. */
|
||||||
|
export async function openDocument(): Promise<DiagramDocument | null> {
|
||||||
|
if (isTauri()) {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const path = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: "Pipeline network", extensions: ["json"] }],
|
||||||
|
});
|
||||||
|
if (!path || typeof path !== "string") return null;
|
||||||
|
const text = await readTextFile(path);
|
||||||
|
return JSON.parse(text) as DiagramDocument;
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "application/json,.json";
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return resolve(null);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(String(reader.result)) as DiagramDocument);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSvgFile(svg: string, name = "network.svg"): Promise<void> {
|
||||||
|
if (isTauri()) {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const path = await save({ defaultPath: name, filters: [{ name: "SVG", extensions: ["svg"] }] });
|
||||||
|
if (path) await writeTextFile(path, svg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerDownload(name, new Blob([svg], { type: "image/svg+xml" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPngFile(dataUri: string, name = "network.png"): Promise<void> {
|
||||||
|
if (isTauri()) {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const path = await save({ defaultPath: name, filters: [{ name: "PNG", extensions: ["png"] }] });
|
||||||
|
if (path) {
|
||||||
|
const base64 = dataUri.split(",")[1] ?? "";
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||||
|
await writeFile(path, bytes);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base64 = dataUri.split(",")[1] ?? "";
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||||
|
triggerDownload(name, new Blob([bytes], { type: "image/png" }));
|
||||||
|
}
|
||||||
12
src/main.tsx
12
src/main.tsx
@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
73
src/ui/CanvasView.tsx
Normal file
73
src/ui/CanvasView.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { CanvasController } from "../canvas/CanvasController";
|
||||||
|
import { seedDemoNetwork } from "../canvas/demo";
|
||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onReady: (controller: CanvasController) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the JointJS paper and owns the CanvasController lifecycle. Bridges the
|
||||||
|
* imperative controller to the reactive store via hooks and keeps the canvas
|
||||||
|
* config in sync. Also handles drop-from-palette.
|
||||||
|
*/
|
||||||
|
export function CanvasView({ onReady }: Props) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const controllerRef = useRef<CanvasController | null>(null);
|
||||||
|
const store = useEditorStore;
|
||||||
|
|
||||||
|
useKeyboardShortcuts(controllerRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const s = store.getState();
|
||||||
|
const controller = new CanvasController(hostRef.current, s.config, {
|
||||||
|
onSelect: (sel) => {
|
||||||
|
store.getState().setSelection(sel);
|
||||||
|
store.getState().bumpSelection();
|
||||||
|
},
|
||||||
|
onSelectionChanged: () => store.getState().bumpSelection(),
|
||||||
|
onDirty: () => store.getState().setDirty(true),
|
||||||
|
onCalc: (calc) => store.getState().setCalc(calc),
|
||||||
|
});
|
||||||
|
controllerRef.current = controller;
|
||||||
|
|
||||||
|
if (controller.isEmpty()) {
|
||||||
|
seedDemoNetwork(controller);
|
||||||
|
controller.zoomToFit();
|
||||||
|
store.getState().setDirty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(controller);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.destroy();
|
||||||
|
controllerRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep canvas config in sync with the store.
|
||||||
|
const config = useEditorStore((st) => st.config);
|
||||||
|
useEffect(() => {
|
||||||
|
controllerRef.current?.applyConfig(config);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="canvas-host"
|
||||||
|
ref={hostRef}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const typeId = e.dataTransfer.getData("application/x-node-type");
|
||||||
|
if (typeId) controllerRef.current?.addNodeAt(typeId, e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/ui/ConfigPanel.tsx
Normal file
97
src/ui/ConfigPanel.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
import { COLOR_SCHEMES } from "../model/colorSchemes";
|
||||||
|
|
||||||
|
/** Editor configuration: color scheme, routing, grid/snap, animation. */
|
||||||
|
export function ConfigPanel() {
|
||||||
|
const config = useEditorStore((s) => s.config);
|
||||||
|
const setConfig = useEditorStore((s) => s.setConfig);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config">
|
||||||
|
<div className="panel-header">Configuration</div>
|
||||||
|
<div className="config-scroll">
|
||||||
|
<fieldset className="prop-group">
|
||||||
|
<legend>Appearance</legend>
|
||||||
|
<label className="prop-row">
|
||||||
|
<span className="prop-label">Color scheme</span>
|
||||||
|
<span className="prop-input">
|
||||||
|
<select value={config.colorScheme} onChange={(e) => setConfig({ colorScheme: e.target.value })}>
|
||||||
|
{COLOR_SCHEMES.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="prop-group">
|
||||||
|
<legend>Routing</legend>
|
||||||
|
<label className="prop-row">
|
||||||
|
<span className="prop-label">Router</span>
|
||||||
|
<span className="prop-input">
|
||||||
|
<select
|
||||||
|
value={config.router}
|
||||||
|
onChange={(e) => setConfig({ router: e.target.value as typeof config.router })}
|
||||||
|
>
|
||||||
|
<option value="manhattan">Manhattan (grid)</option>
|
||||||
|
<option value="orthogonal">Orthogonal</option>
|
||||||
|
<option value="normal">Straight</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="prop-row toggle">
|
||||||
|
<span className="prop-label">Arc on line intersection</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.arcOnIntersection}
|
||||||
|
onChange={(e) => setConfig({ arcOnIntersection: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="prop-group">
|
||||||
|
<legend>Grid</legend>
|
||||||
|
<label className="prop-row">
|
||||||
|
<span className="prop-label">Grid size</span>
|
||||||
|
<span className="prop-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={80}
|
||||||
|
value={config.gridSize}
|
||||||
|
onChange={(e) => setConfig({ gridSize: Math.max(5, Number(e.target.value) || 20) })}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="prop-row toggle">
|
||||||
|
<span className="prop-label">Show grid</span>
|
||||||
|
<input type="checkbox" checked={config.showGrid} onChange={(e) => setConfig({ showGrid: e.target.checked })} />
|
||||||
|
</label>
|
||||||
|
<label className="prop-row toggle">
|
||||||
|
<span className="prop-label">Snap to grid</span>
|
||||||
|
<input type="checkbox" checked={config.snapToGrid} onChange={(e) => setConfig({ snapToGrid: e.target.checked })} />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="prop-group">
|
||||||
|
<legend>Flow animation</legend>
|
||||||
|
<label className="prop-row">
|
||||||
|
<span className="prop-label">Speed ×{config.animationSpeed.toFixed(1)}</span>
|
||||||
|
<span className="prop-input">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
value={config.animationSpeed}
|
||||||
|
onChange={(e) => setConfig({ animationSpeed: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/ui/EditorContext.tsx
Normal file
9
src/ui/EditorContext.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { CanvasController } from "../canvas/CanvasController";
|
||||||
|
|
||||||
|
export const EditorContext = createContext<CanvasController | null>(null);
|
||||||
|
|
||||||
|
/** Access the CanvasController; null until the canvas has mounted. */
|
||||||
|
export function useController(): CanvasController | null {
|
||||||
|
return useContext(EditorContext);
|
||||||
|
}
|
||||||
92
src/ui/NodePalette.tsx
Normal file
92
src/ui/NodePalette.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { nodeTypesByGroup, type NodeTypeDef } from "../model/nodeTypes";
|
||||||
|
import { iconDataUri } from "../model/svgIcons";
|
||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
import { useController } from "./EditorContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left palette: node types grouped and collapsible, with a search filter.
|
||||||
|
* Items are draggable onto the canvas (HTML5 DnD) and also add-on-click as a
|
||||||
|
* fallback (dropped at the viewport center).
|
||||||
|
*/
|
||||||
|
export function NodePalette() {
|
||||||
|
const query = useEditorStore((s) => s.paletteQuery);
|
||||||
|
const setQuery = useEditorStore((s) => s.setPaletteQuery);
|
||||||
|
const controller = useController();
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return nodeTypesByGroup()
|
||||||
|
.map((g) => ({
|
||||||
|
group: g.group,
|
||||||
|
types: q
|
||||||
|
? g.types.filter(
|
||||||
|
(t) => t.label.toLowerCase().includes(q) || t.group.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: g.types,
|
||||||
|
}))
|
||||||
|
.filter((g) => g.types.length > 0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const addToCenter = (t: NodeTypeDef) => {
|
||||||
|
const host = document.querySelector(".canvas-host") as HTMLElement | null;
|
||||||
|
if (!host || !controller) return;
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
controller.addNodeAt(t.id, r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="palette panel">
|
||||||
|
<div className="panel-header">Components</div>
|
||||||
|
<div className="palette-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search components…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
aria-label="Search components"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="palette-scroll">
|
||||||
|
{groups.length === 0 && <div className="palette-empty">No components match “{query}”.</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.group} className="palette-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="palette-group-header"
|
||||||
|
onClick={() => setCollapsed((c) => ({ ...c, [g.group]: !c[g.group] }))}
|
||||||
|
aria-expanded={!collapsed[g.group]}
|
||||||
|
>
|
||||||
|
<span className={`chevron ${collapsed[g.group] ? "collapsed" : ""}`}>▾</span>
|
||||||
|
{g.group}
|
||||||
|
</button>
|
||||||
|
{!collapsed[g.group] && (
|
||||||
|
<div className="palette-items">
|
||||||
|
{g.types.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="palette-item"
|
||||||
|
draggable
|
||||||
|
title={`${t.label} — drag onto canvas`}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData("application/x-node-type", t.id);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => addToCenter(t)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<img src={iconDataUri(t.icon)} alt="" className="palette-icon" draggable={false} />
|
||||||
|
<span className="palette-label">{t.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="palette-hint">Drag onto the canvas, or double-click to add.</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/ui/PropertiesPanel.tsx
Normal file
121
src/ui/PropertiesPanel.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
import { useController } from "./EditorContext";
|
||||||
|
import { PropertyField } from "./editors/PropertyField";
|
||||||
|
import {
|
||||||
|
PROPERTY_GROUPS,
|
||||||
|
sortByGroup,
|
||||||
|
type PropertyDef,
|
||||||
|
type PropertyValue,
|
||||||
|
} from "../model/properties";
|
||||||
|
import { nodeType } from "../model/nodeTypes";
|
||||||
|
import { edgePropertyDefs } from "../model/edgeProperties";
|
||||||
|
import { cataloguesById } from "../model/catalogues";
|
||||||
|
import type { dia } from "@joint/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right panel showing grouped, editable properties for the current selection
|
||||||
|
* (node or edge). Reflects geometry live and writes edits back through the
|
||||||
|
* controller, which updates the canvas presentation.
|
||||||
|
*/
|
||||||
|
export function PropertiesPanel() {
|
||||||
|
const selection = useEditorStore((s) => s.selection);
|
||||||
|
const rev = useEditorStore((s) => s.selectionRev);
|
||||||
|
const catalogues = useEditorStore((s) => s.catalogues);
|
||||||
|
const controller = useController();
|
||||||
|
|
||||||
|
const cataloguesMap = useMemo(() => cataloguesById(catalogues), [catalogues]);
|
||||||
|
|
||||||
|
const cell = selection && controller ? controller.getPrimary() : null;
|
||||||
|
|
||||||
|
const model = useMemo(() => {
|
||||||
|
if (!cell || !selection) return null;
|
||||||
|
const props = (cell.prop("props") ?? {}) as Record<string, PropertyValue>;
|
||||||
|
if (selection.kind === "node") {
|
||||||
|
const el = cell as dia.Element;
|
||||||
|
const type = nodeType(el.prop("nodeType"));
|
||||||
|
const geom: PropertyDef[] = [
|
||||||
|
{ key: "__x", label: "X", type: "int", group: "geometry", unit: "px" },
|
||||||
|
{ key: "__y", label: "Y", type: "int", group: "geometry", unit: "px" },
|
||||||
|
{ key: "__w", label: "Width", type: "int", group: "geometry", unit: "px", min: 24 },
|
||||||
|
{ key: "__h", label: "Height", type: "int", group: "geometry", unit: "px", min: 24 },
|
||||||
|
{ key: "__angle", label: "Rotation", type: "int", group: "geometry", unit: "°" },
|
||||||
|
];
|
||||||
|
const pos = el.position();
|
||||||
|
const size = el.size();
|
||||||
|
const geomValues: Record<string, PropertyValue> = {
|
||||||
|
__x: Math.round(pos.x),
|
||||||
|
__y: Math.round(pos.y),
|
||||||
|
__w: Math.round(size.width),
|
||||||
|
__h: Math.round(size.height),
|
||||||
|
__angle: Math.round(el.angle()),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
title: type?.label ?? "Node",
|
||||||
|
defs: [...(type?.properties ?? []), ...geom],
|
||||||
|
values: { ...props, ...geomValues },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "Connector",
|
||||||
|
defs: edgePropertyDefs(),
|
||||||
|
values: props,
|
||||||
|
};
|
||||||
|
}, [cell, selection, rev]); // rev forces refresh when props change externally
|
||||||
|
|
||||||
|
if (!selection || !cell || !model) {
|
||||||
|
return (
|
||||||
|
<div className="properties empty">
|
||||||
|
<div className="panel-header">Properties</div>
|
||||||
|
<div className="empty-hint">Select a node or connector to edit its properties.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = (def: PropertyDef, value: PropertyValue) => {
|
||||||
|
if (!controller) return;
|
||||||
|
if (def.group === "geometry") {
|
||||||
|
const map: Record<string, "x" | "y" | "width" | "height" | "angle"> = {
|
||||||
|
__x: "x",
|
||||||
|
__y: "y",
|
||||||
|
__w: "width",
|
||||||
|
__h: "height",
|
||||||
|
__angle: "angle",
|
||||||
|
};
|
||||||
|
controller.setGeometry(String(cell.id), { [map[def.key]]: Number(value) });
|
||||||
|
useEditorStore.getState().bumpSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.setProp(String(cell.id), def.key, value);
|
||||||
|
useEditorStore.getState().bumpSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = PROPERTY_GROUPS.map((g) => ({
|
||||||
|
group: g,
|
||||||
|
defs: sortByGroup(model.defs).filter((d) => d.group === g.id),
|
||||||
|
})).filter((x) => x.defs.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="properties">
|
||||||
|
<div className="panel-header">
|
||||||
|
Properties<span className="panel-sub">{model.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="properties-scroll">
|
||||||
|
{grouped.map(({ group, defs }) => (
|
||||||
|
<fieldset key={group.id} className="prop-group">
|
||||||
|
<legend>{group.label}</legend>
|
||||||
|
{defs.map((def) => (
|
||||||
|
<PropertyField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
value={model.values[def.key] ?? ""}
|
||||||
|
catalogues={cataloguesMap}
|
||||||
|
onCommit={(v) => commit(def, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/ui/RightPanel.tsx
Normal file
21
src/ui/RightPanel.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { PropertiesPanel } from "./PropertiesPanel";
|
||||||
|
import { ConfigPanel } from "./ConfigPanel";
|
||||||
|
|
||||||
|
/** Right dock: switch between element Properties and editor Configuration. */
|
||||||
|
export function RightPanel() {
|
||||||
|
const [tab, setTab] = useState<"properties" | "config">("properties");
|
||||||
|
return (
|
||||||
|
<aside className="right-panel panel">
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={tab === "properties" ? "active" : ""} onClick={() => setTab("properties")}>
|
||||||
|
Properties
|
||||||
|
</button>
|
||||||
|
<button className={tab === "config" ? "active" : ""} onClick={() => setTab("config")}>
|
||||||
|
Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tab-body">{tab === "properties" ? <PropertiesPanel /> : <ConfigPanel />}</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/ui/StatusBar.tsx
Normal file
31
src/ui/StatusBar.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
|
||||||
|
/** Bottom status bar: selection info + flow simulation summary/warnings. */
|
||||||
|
export function StatusBar() {
|
||||||
|
const selection = useEditorStore((s) => s.selection);
|
||||||
|
const calc = useEditorStore((s) => s.calc);
|
||||||
|
const running = useEditorStore((s) => s.running);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="statusbar">
|
||||||
|
<span className="status-item">
|
||||||
|
{selection ? `Selected: ${selection.kind}` : "No selection"}
|
||||||
|
</span>
|
||||||
|
{running && calc && (
|
||||||
|
<>
|
||||||
|
<span className={`status-item ${calc.balanced ? "ok" : "warn"}`}>
|
||||||
|
Supply {calc.totalSupply.toFixed(1)} · Demand {calc.totalDemand.toFixed(1)} ·{" "}
|
||||||
|
{calc.balanced ? "balanced" : "unbalanced"}
|
||||||
|
</span>
|
||||||
|
{calc.warnings.length > 0 && (
|
||||||
|
<span className="status-item warn" title={calc.warnings.join("\n")}>
|
||||||
|
⚠ {calc.warnings.length} warning{calc.warnings.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="status-spacer" />
|
||||||
|
<span className="status-item muted">Drag from the palette · click a node to reveal ports · drag a port to connect</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/ui/Toolbar.tsx
Normal file
112
src/ui/Toolbar.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEditorStore } from "../store/editorStore";
|
||||||
|
import { useController } from "./EditorContext";
|
||||||
|
import {
|
||||||
|
saveDocument,
|
||||||
|
openDocument,
|
||||||
|
exportSvgFile,
|
||||||
|
exportPngFile,
|
||||||
|
type DiagramDocument,
|
||||||
|
} from "../io/document";
|
||||||
|
|
||||||
|
/** Top toolbar: document actions, history, zoom, and the flow simulation. */
|
||||||
|
export function Toolbar() {
|
||||||
|
const controller = useController();
|
||||||
|
const running = useEditorStore((s) => s.running);
|
||||||
|
const setRunning = useEditorStore((s) => s.setRunning);
|
||||||
|
const dirty = useEditorStore((s) => s.dirty);
|
||||||
|
const documentName = useEditorStore((s) => s.documentName);
|
||||||
|
const setDocumentName = useEditorStore((s) => s.setDocumentName);
|
||||||
|
const setDirty = useEditorStore((s) => s.setDirty);
|
||||||
|
|
||||||
|
const guard = <T,>(fn: (c: NonNullable<typeof controller>) => T) => () => {
|
||||||
|
if (controller) fn(controller);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNew = () => {
|
||||||
|
if (!controller) return;
|
||||||
|
if (dirty && !confirm("Discard unsaved changes and start a new network?")) return;
|
||||||
|
controller.clear();
|
||||||
|
setDocumentName("Untitled network");
|
||||||
|
setDirty(false);
|
||||||
|
setRunning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpen = async () => {
|
||||||
|
const doc = await openDocument();
|
||||||
|
if (doc && controller) {
|
||||||
|
controller.loadDocument(doc as unknown as { config?: never; graph: unknown });
|
||||||
|
setDirty(false);
|
||||||
|
setRunning(false);
|
||||||
|
controller.zoomToFit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!controller) return;
|
||||||
|
await saveDocument(controller.toDocument() as unknown as DiagramDocument, `${documentName}.json`);
|
||||||
|
setDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExportSvg = async () => {
|
||||||
|
if (!controller) return;
|
||||||
|
await exportSvgFile(controller.exportSVG(), `${documentName}.svg`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExportPng = async () => {
|
||||||
|
if (!controller) return;
|
||||||
|
await exportPngFile(await controller.exportPNG(), `${documentName}.png`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleRun = () => {
|
||||||
|
if (!controller) return;
|
||||||
|
if (running) {
|
||||||
|
controller.stopFlow();
|
||||||
|
setRunning(false);
|
||||||
|
} else {
|
||||||
|
controller.runFlow();
|
||||||
|
setRunning(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="toolbar">
|
||||||
|
<div className="toolbar-brand">◆ Pipeline Editor</div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button onClick={onNew} title="New (empty network)">New</button>
|
||||||
|
<button onClick={onOpen} title="Open a .json network">Open</button>
|
||||||
|
<button onClick={onSave} title="Save network as JSON">
|
||||||
|
Save{dirty ? " •" : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button onClick={guard((c) => c.undo())} title="Undo (Ctrl+Z)">↶</button>
|
||||||
|
<button onClick={guard((c) => c.redo())} title="Redo (Ctrl+Y)">↷</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button onClick={guard((c) => c.zoomOut())} title="Zoom out (Ctrl+-)">−</button>
|
||||||
|
<button onClick={guard((c) => c.zoomToFit())} title="Zoom to fit">Fit</button>
|
||||||
|
<button onClick={guard((c) => c.resetZoom())} title="Reset zoom">100%</button>
|
||||||
|
<button onClick={guard((c) => c.zoomIn())} title="Zoom in (Ctrl+=)">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button onClick={guard((c) => c.deleteSelection())} title="Delete selection (Del)">Delete</button>
|
||||||
|
<button onClick={guard((c) => c.duplicateSelection())} title="Duplicate (Ctrl+D)">Duplicate</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-group">
|
||||||
|
<button onClick={onExportSvg} title="Export SVG">SVG</button>
|
||||||
|
<button onClick={onExportPng} title="Export PNG">PNG</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-spacer" />
|
||||||
|
|
||||||
|
<button className={`run-btn ${running ? "running" : ""}`} onClick={onToggleRun}>
|
||||||
|
{running ? "■ Stop simulation" : "▶ Run simulation"}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/ui/components.test.tsx
Normal file
125
src/ui/components.test.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { NodePalette } from "./NodePalette";
|
||||||
|
import { ConfigPanel } from "./ConfigPanel";
|
||||||
|
import { PropertiesPanel } from "./PropertiesPanel";
|
||||||
|
import { EditorContext } from "./EditorContext";
|
||||||
|
import { useEditorStore, DEFAULT_CONFIG } from "../store/editorStore";
|
||||||
|
import type { CanvasController } from "../canvas/CanvasController";
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useEditorStore.setState({
|
||||||
|
config: { ...DEFAULT_CONFIG },
|
||||||
|
selection: null,
|
||||||
|
selectionRev: 0,
|
||||||
|
paletteQuery: "",
|
||||||
|
running: false,
|
||||||
|
calc: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithController(ui: React.ReactElement, controller: CanvasController | null = null) {
|
||||||
|
return render(<EditorContext.Provider value={controller}>{ui}</EditorContext.Provider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(resetStore);
|
||||||
|
|
||||||
|
describe("NodePalette", () => {
|
||||||
|
it("renders component groups and items", () => {
|
||||||
|
renderWithController(<NodePalette />);
|
||||||
|
expect(screen.getByText("Sources & sinks")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Active equipment")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Pump")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters items by search query", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithController(<NodePalette />);
|
||||||
|
await user.type(screen.getByLabelText("Search components"), "pump");
|
||||||
|
expect(screen.getByText("Pump")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Storage tank")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty message when nothing matches", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithController(<NodePalette />);
|
||||||
|
await user.type(screen.getByLabelText("Search components"), "zzzzz");
|
||||||
|
expect(screen.getByText(/No components match/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the node type id on drag start", () => {
|
||||||
|
renderWithController(<NodePalette />);
|
||||||
|
const item = screen.getByTitle(/Pump — drag onto canvas/);
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => (data[k] = v),
|
||||||
|
effectAllowed: "",
|
||||||
|
};
|
||||||
|
item.dispatchEvent(
|
||||||
|
Object.assign(new Event("dragstart", { bubbles: true }), { dataTransfer }),
|
||||||
|
);
|
||||||
|
expect(data["application/x-node-type"]).toBe("pump");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigPanel", () => {
|
||||||
|
it("toggles arc-on-intersection into the store", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithController(<ConfigPanel />);
|
||||||
|
expect(useEditorStore.getState().config.arcOnIntersection).toBe(true);
|
||||||
|
await user.click(screen.getByLabelText(/Arc on line intersection/i));
|
||||||
|
expect(useEditorStore.getState().config.arcOnIntersection).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes the color scheme", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithController(<ConfigPanel />);
|
||||||
|
await user.selectOptions(screen.getByDisplayValue("Default"), "dark");
|
||||||
|
expect(useEditorStore.getState().config.colorScheme).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the router", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithController(<ConfigPanel />);
|
||||||
|
await user.selectOptions(screen.getByDisplayValue("Manhattan (grid)"), "normal");
|
||||||
|
expect(useEditorStore.getState().config.router).toBe("normal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PropertiesPanel", () => {
|
||||||
|
it("shows an empty hint with no selection", () => {
|
||||||
|
renderWithController(<PropertiesPanel />);
|
||||||
|
expect(screen.getByText(/Select a node or connector/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders grouped fields for a selected node via a stub controller", () => {
|
||||||
|
// Minimal controller stub exposing getPrimary + setProp/setGeometry.
|
||||||
|
const props: Record<string, unknown> = { title: "Booster", power: 7.5, color: "#2f80ed", showLabel: "yes" };
|
||||||
|
const fakeCell = {
|
||||||
|
id: "n1",
|
||||||
|
prop: (k: string) => (k === "nodeType" ? "pump" : k === "props" ? props : undefined),
|
||||||
|
position: () => ({ x: 20, y: 40 }),
|
||||||
|
size: () => ({ width: 72, height: 72 }),
|
||||||
|
angle: () => 0,
|
||||||
|
};
|
||||||
|
const controller = {
|
||||||
|
getPrimary: () => fakeCell,
|
||||||
|
setProp: () => {},
|
||||||
|
setGeometry: () => {},
|
||||||
|
} as unknown as CanvasController;
|
||||||
|
|
||||||
|
useEditorStore.setState({ selection: { id: "n1", kind: "node" }, selectionRev: 1 });
|
||||||
|
renderWithController(<PropertiesPanel />, controller);
|
||||||
|
|
||||||
|
// Group legends present.
|
||||||
|
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Physics")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Geometry")).toBeInTheDocument();
|
||||||
|
// A physics field with its value.
|
||||||
|
const physics = screen.getByText("Physics").closest("fieldset")!;
|
||||||
|
expect(within(physics).getByText(/Power/)).toBeInTheDocument();
|
||||||
|
// Identity title value bound.
|
||||||
|
expect(screen.getByDisplayValue("Booster")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
373
src/ui/editor.css
Normal file
373
src/ui/editor.css
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
.editor-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Toolbar ---------- */
|
||||||
|
.toolbar {
|
||||||
|
height: var(--toolbar-h);
|
||||||
|
min-height: var(--toolbar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.toolbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.toolbar-group button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toolbar-group button:hover {
|
||||||
|
background: #e4e9f1;
|
||||||
|
}
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.run-btn {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.run-btn.running {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Body layout ---------- */
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.canvas-area {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.palette {
|
||||||
|
width: 230px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.right-panel {
|
||||||
|
width: 320px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.panel-sub {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Palette ---------- */
|
||||||
|
.palette-search {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.palette-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.palette-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.palette-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.palette-group {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.palette-group-header {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 6px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.chevron.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.palette-items {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.palette-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
cursor: grab;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.12s, transform 0.06s;
|
||||||
|
}
|
||||||
|
.palette-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-weak);
|
||||||
|
}
|
||||||
|
.palette-item:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.palette-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.palette-label {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.palette-hint {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tabs / right panel ---------- */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--panel-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tabs button.active {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.properties,
|
||||||
|
.config {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.properties-scroll,
|
||||||
|
.config-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
padding: 20px 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Property groups & fields ---------- */
|
||||||
|
.prop-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 6px 10px 10px;
|
||||||
|
}
|
||||||
|
.prop-group legend {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.prop-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.prop-row.toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.prop-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 0 0 44%;
|
||||||
|
}
|
||||||
|
.prop-unit {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.prop-input {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.prop-input input,
|
||||||
|
.prop-input select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.prop-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.color-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.color-input input[type="color"] {
|
||||||
|
width: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.checkbox-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 130px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.checkbox-row input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.list-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.add-btn {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Status bar ---------- */
|
||||||
|
.statusbar {
|
||||||
|
height: var(--status-h);
|
||||||
|
min-height: var(--status-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.status-item.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.status-item.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.status-item.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
210
src/ui/editors/PropertyField.tsx
Normal file
210
src/ui/editors/PropertyField.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
coerceValue,
|
||||||
|
type PropertyDef,
|
||||||
|
type PropertyValue,
|
||||||
|
} from "../../model/properties";
|
||||||
|
import type { Catalogue } from "../../model/catalogues";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
def: PropertyDef;
|
||||||
|
value: PropertyValue;
|
||||||
|
catalogues: Record<string, Catalogue>;
|
||||||
|
onCommit: (value: PropertyValue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders and validates a single property, dispatching per-type editors. */
|
||||||
|
export function PropertyField({ def, value, catalogues, onCommit }: Props) {
|
||||||
|
switch (def.type) {
|
||||||
|
case "string":
|
||||||
|
return <TextField def={def} value={value} onCommit={onCommit} />;
|
||||||
|
case "int":
|
||||||
|
case "float":
|
||||||
|
return <NumberField def={def} value={value} onCommit={onCommit} />;
|
||||||
|
case "enum":
|
||||||
|
return <EnumField def={def} value={value} onCommit={onCommit} />;
|
||||||
|
case "color":
|
||||||
|
return <ColorField def={def} value={value} onCommit={onCommit} />;
|
||||||
|
case "catalogueItem":
|
||||||
|
return <CatalogueItemField def={def} value={value} catalogues={catalogues} onCommit={onCommit} />;
|
||||||
|
case "catalogueItems":
|
||||||
|
return <CatalogueItemsField def={def} value={value} catalogues={catalogues} onCommit={onCommit} />;
|
||||||
|
case "list":
|
||||||
|
return <ListField def={def} value={value} onCommit={onCommit} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ def, children, error }: { def: PropertyDef; children: React.ReactNode; error?: string }) {
|
||||||
|
return (
|
||||||
|
<label className="prop-row" title={def.description}>
|
||||||
|
<span className="prop-label">
|
||||||
|
{def.label}
|
||||||
|
{def.unit ? <span className="prop-unit"> ({def.unit})</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="prop-input">
|
||||||
|
{children}
|
||||||
|
{error && <span className="prop-error">{error}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ def, value, onCommit }: Omit<Props, "catalogues">) {
|
||||||
|
const [text, setText] = useState(String(value ?? ""));
|
||||||
|
useEffect(() => setText(String(value ?? "")), [value]);
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
readOnly={def.readonly}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={() => onCommit(text)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ def, value, onCommit }: Omit<Props, "catalogues">) {
|
||||||
|
const [text, setText] = useState(String(value ?? 0));
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
useEffect(() => setText(String(value ?? 0)), [value]);
|
||||||
|
const commit = () => {
|
||||||
|
const res = coerceValue(def, text);
|
||||||
|
if (res.ok) {
|
||||||
|
setError(undefined);
|
||||||
|
setText(String(res.value));
|
||||||
|
onCommit(res.value!);
|
||||||
|
} else {
|
||||||
|
setError(res.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Row def={def} error={error}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={text}
|
||||||
|
readOnly={def.readonly}
|
||||||
|
min={def.min}
|
||||||
|
max={def.max}
|
||||||
|
step={def.step ?? (def.type === "int" ? 1 : "any")}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnumField({ def, value, onCommit }: Omit<Props, "catalogues">) {
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<select value={String(value ?? "")} disabled={def.readonly} onChange={(e) => onCommit(e.target.value)}>
|
||||||
|
{def.options?.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorField({ def, value, onCommit }: Omit<Props, "catalogues">) {
|
||||||
|
const v = String(value ?? "#888888");
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<span className="color-input">
|
||||||
|
<input type="color" value={/^#([0-9a-f]{6})$/i.test(v) ? v : "#888888"} onChange={(e) => onCommit(e.target.value)} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => {
|
||||||
|
const res = coerceValue(def, e.target.value);
|
||||||
|
if (res.ok) onCommit(res.value!);
|
||||||
|
else onCommit(e.target.value); // allow typing; canvas ignores invalid
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogueItemField({ def, value, catalogues, onCommit }: Props) {
|
||||||
|
const cat = def.catalogue ? catalogues[def.catalogue] : undefined;
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<select value={String(value ?? "")} disabled={def.readonly} onChange={(e) => onCommit(e.target.value)}>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{cat?.items.map((it) => (
|
||||||
|
<option key={it.id} value={it.id}>
|
||||||
|
{it.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogueItemsField({ def, value, catalogues, onCommit }: Props) {
|
||||||
|
const cat = def.catalogue ? catalogues[def.catalogue] : undefined;
|
||||||
|
const selected = new Set(Array.isArray(value) ? value.map(String) : []);
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
onCommit([...next]);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<div className="checkbox-list">
|
||||||
|
{cat?.items.map((it) => (
|
||||||
|
<label key={it.id} className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={selected.has(it.id)} onChange={() => toggle(it.id)} />
|
||||||
|
{it.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListField({ def, value, onCommit }: Omit<Props, "catalogues">) {
|
||||||
|
const items = Array.isArray(value) ? value.map(String) : [];
|
||||||
|
const update = (idx: number, v: string) => {
|
||||||
|
const next = [...items];
|
||||||
|
next[idx] = v;
|
||||||
|
onCommit(coerceList(def, next));
|
||||||
|
};
|
||||||
|
const add = () => onCommit(coerceList(def, [...items, def.itemType === "string" ? "" : "0"]));
|
||||||
|
const remove = (idx: number) => onCommit(coerceList(def, items.filter((_, i) => i !== idx)));
|
||||||
|
return (
|
||||||
|
<Row def={def}>
|
||||||
|
<div className="list-editor">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<div key={i} className="list-row">
|
||||||
|
<input
|
||||||
|
type={def.itemType === "string" ? "text" : "number"}
|
||||||
|
value={it}
|
||||||
|
onChange={(e) => update(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="icon-btn" onClick={() => remove(i)} aria-label="Remove">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="add-btn" onClick={add}>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceList(def: PropertyDef, raw: string[]): PropertyValue {
|
||||||
|
const res = coerceValue(def, raw);
|
||||||
|
return res.ok ? res.value! : raw;
|
||||||
|
}
|
||||||
50
src/ui/useKeyboardShortcuts.ts
Normal file
50
src/ui/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, type RefObject } from "react";
|
||||||
|
import type { CanvasController } from "../canvas/CanvasController";
|
||||||
|
|
||||||
|
/** Wire global editing shortcuts to controller actions. */
|
||||||
|
export function useKeyboardShortcuts(ref: RefObject<CanvasController | null>) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const c = ref.current;
|
||||||
|
if (!c) return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
// Don't hijack typing in form fields.
|
||||||
|
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
|
||||||
|
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (mod && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
c.undo();
|
||||||
|
} else if (mod && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
c.redo();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "c") {
|
||||||
|
c.copySelection();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "x") {
|
||||||
|
c.copySelection();
|
||||||
|
c.deleteSelection();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "v") {
|
||||||
|
c.paste();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "d") {
|
||||||
|
e.preventDefault();
|
||||||
|
c.duplicateSelection();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "a") {
|
||||||
|
e.preventDefault();
|
||||||
|
c.selectAll();
|
||||||
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
c.deleteSelection();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
c.clearSelection();
|
||||||
|
} else if (mod && e.key === "=") {
|
||||||
|
e.preventDefault();
|
||||||
|
c.zoomIn();
|
||||||
|
} else if (mod && e.key === "-") {
|
||||||
|
e.preventDefault();
|
||||||
|
c.zoomOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [ref]);
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
/// <reference types="vitest/config" />
|
import { defineConfig } from "vitest/config";
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// Tauri expects a fixed port; fail if unavailable.
|
// Tauri expects a fixed port; fail if unavailable.
|
||||||
|
// `react()` is typed against the top-level Vite while `vitest/config` bundles
|
||||||
|
// its own Vite copy; cast past that harmless version skew.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
plugins: [react() as any],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user