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:
Ilya Ashikhmin 2026-07-02 23:32:27 +02:00
parent ab5a5e1a15
commit d495401bc1
26 changed files with 1897 additions and 493 deletions

View File

@ -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);
}
}

View File

@ -1,122 +1,30 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { useState } from "react";
import type { CanvasController } from "./canvas/CanvasController";
import { EditorContext } from "./ui/EditorContext";
import { CanvasView } from "./ui/CanvasView";
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() {
const [count, setCount] = useState(0)
export default function App() {
const [controller, setController] = useState<CanvasController | null>(null);
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
<EditorContext.Provider value={controller}>
<div className="editor-shell">
<Toolbar />
<div className="editor-body">
<NodePalette />
<main className="canvas-area">
<CanvasView onReady={setController} />
</main>
<RightPanel />
</div>
<div>
<h1>Get started</h1>
<p>
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>
</>
)
<StatusBar />
</div>
</EditorContext.Provider>
);
}
export default App

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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

View File

@ -9,29 +9,28 @@
*/
import { dia, shapes, elementTools, linkTools } from "@joint/core";
import {
PipelineNode,
createNode,
createLink,
restyleLink,
recolorPorts,
} from "./shapes";
import { PipelineNode, createLink, restyleLink, recolorPorts } from "./shapes";
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 {
RelationRegistry,
canConnect,
type PortDirection,
type PortRef,
type PortDirection,
} from "../model/mediums";
import { solveFlow, type FlowNetwork } from "../calc/flow";
import type { FlowNetwork } from "../calc/flow";
import type { EditorConfig, RouterType, Selection } from "../store/editorStore";
const CELL_NAMESPACE = {
...shapes,
pipeline: { Node: PipelineNode },
};
export interface ControllerHooks {
onSelect: (sel: Selection | null) => void;
onSelectionChanged: () => void;
@ -87,8 +86,8 @@ export class CanvasController {
linkPinning: false,
snapLinks: { radius: 20 },
markAvailable: true,
defaultRouter: this.routerConfig(config.router),
defaultConnector: this.connectorConfig(config.arcOnIntersection),
defaultRouter: this.routerCfg(config.router),
defaultConnector: this.connectorCfg(config.arcOnIntersection),
defaultLink: () =>
createLink({
source: { id: "", port: "" },
@ -106,21 +105,12 @@ export class CanvasController {
// ---- configuration -------------------------------------------------------
private routerConfig(router: RouterType) {
switch (router) {
case "orthogonal":
return { name: "orthogonal" };
case "normal":
return { name: "normal" };
default:
return { name: "manhattan", args: { padding: 12, step: this.config.gridSize } };
}
private routerCfg(router: RouterType) {
return routerConfig(router, this.config.gridSize);
}
private connectorConfig(arc: boolean) {
return arc
? { name: "jumpover", args: { size: 8, jump: "arc" } }
: { name: "rounded", args: { radius: 10 } };
private connectorCfg(arc: boolean) {
return connectorConfig(arc);
}
applyConfig(config: EditorConfig): void {
@ -128,16 +118,16 @@ export class CanvasController {
this.config = config;
this.scheme = getColorScheme(config.colorScheme);
this.paper.options.defaultRouter = this.routerConfig(config.router);
this.paper.options.defaultConnector = this.connectorConfig(config.arcOnIntersection);
this.paper.options.defaultRouter = this.routerCfg(config.router);
this.paper.options.defaultConnector = this.connectorCfg(config.arcOnIntersection);
this.paper.setGridSize(config.gridSize);
this.paper.setGrid(config.showGrid ? { name: "mesh", args: { color: this.scheme.grid } } : false);
this.paper.drawBackground({ color: this.scheme.background });
// Re-route + re-style every link, recolor ports/scheme if changed.
this.graph.getLinks().forEach((l) => {
l.router(this.routerConfig(config.router));
l.connector(this.connectorConfig(config.arcOnIntersection));
l.router(this.routerCfg(config.router));
l.connector(this.connectorCfg(config.arcOnIntersection));
restyleLink(l, this.scheme);
});
if (prevScheme !== config.colorScheme) {
@ -193,7 +183,21 @@ export class CanvasController {
p.on("link:pointerclick", (view, evt) => {
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();
});
p.on("blank:pointerdown", () => {
@ -309,6 +313,35 @@ export class CanvasController {
// ---- 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 {
const type = nodeType(typeId);
if (!type) return null;
@ -320,8 +353,8 @@ export class CanvasController {
x = Math.round(x / g) * g;
y = Math.round(y / g) * g;
}
const node = createNode({ type, position: { x, y }, scheme: this.scheme });
this.graph.addCell(node);
const node = addNodeToGraph(this.graph, this.scheme, typeId, x, y);
if (!node) return null;
this.commit();
this.selectCell(node, false);
return node;
@ -560,49 +593,11 @@ export class CanvasController {
// ---- flow calculation & animation ---------------------------------------
buildNetwork(): FlowNetwork {
const nodes = this.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 = 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 };
return buildNetwork(this.graph);
}
runFlow(): void {
const net = this.buildNetwork();
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);
}
}
const result = computeAndAnnotateFlow(this.graph);
this.hooks.onCalc({
totalSupply: result.totalSupply,
totalDemand: result.totalDemand,
@ -661,11 +656,8 @@ export class CanvasController {
this.rafId = null;
}
this.dashOffsets.clear();
for (const link of this.graph.getLinks()) {
link.prop("flow", 0);
link.prop("flowNorm", 0);
restyleLink(link, this.scheme);
}
clearFlow(this.graph);
for (const link of this.graph.getLinks()) restyleLink(link, this.scheme);
this.hooks.onCalc(null);
}

47
src/canvas/canvas.css Normal file
View 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
View 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();
}

View 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
View 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);
}
}

View File

@ -1,111 +1,38 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
--bg: #eef1f5;
--panel: #ffffff;
--panel-2: #f4f6f9;
--border: #d7dce3;
--text: #23272f;
--text-muted: #6b7280;
--accent: #2f6df6;
--accent-weak: #e6efff;
--danger: #e0402c;
--ok: #1f9d55;
--warn: #c9820a;
--toolbar-h: 48px;
--status-h: 28px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
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;
}
html,
body,
#root {
margin: 0;
height: 100%;
width: 100%;
}
body {
margin: 0;
background: var(--bg);
overflow: hidden;
}
h1,
h2 {
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);
button {
font-family: inherit;
}

111
src/io/document.ts Normal file
View 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" }));
}

View File

@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
);

73
src/ui/CanvasView.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;
}

View 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]);
}

View File

@ -1,10 +1,12 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// 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({
plugins: [react()],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
plugins: [react() as any],
clearScreen: false,
server: {
port: 1420,