feat(ui): app shell — palette, property panel, toolbar, settings, canvas, store, persistence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:15:38 +02:00
parent 7aa1f9517b
commit 6585cfb32c
10 changed files with 1114 additions and 0 deletions

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './ui/App';
import './ui/styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

66
src/state/persistence.ts Normal file
View File

@ -0,0 +1,66 @@
/**
* Save/load/export. Uses the Tauri dialog + fs plugins when running inside
* Tauri; falls back to browser download / file-input in plain web dev mode.
*/
function inTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
export async function saveTextFile(
contents: string,
defaultName: string,
filterName: string,
extensions: string[],
): Promise<string | null> {
if (inTauri()) {
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
const path = await save({
defaultPath: defaultName,
filters: [{ name: filterName, extensions }],
});
if (!path) return null;
await writeTextFile(path, contents);
return path;
}
// Browser fallback: trigger a download.
const blob = new Blob([contents], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = defaultName;
a.click();
URL.revokeObjectURL(url);
return defaultName;
}
export async function openTextFile(
filterName: string,
extensions: string[],
): Promise<{ name: string; contents: string } | null> {
if (inTauri()) {
const { open } = await import('@tauri-apps/plugin-dialog');
const { readTextFile } = await import('@tauri-apps/plugin-fs');
const path = await open({
multiple: false,
filters: [{ name: filterName, extensions }],
});
if (typeof path !== 'string') return null;
const contents = await readTextFile(path);
return { name: path, contents };
}
// Browser fallback: hidden file input.
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = extensions.map((e) => `.${e}`).join(',');
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return resolve(null);
resolve({ name: file.name, contents: await file.text() });
};
input.oncancel = () => resolve(null);
input.click();
});
}

97
src/state/store.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Minimal external store bridging the imperative EditorController and React
* (via useSyncExternalStore). Holds editor config, selection, history and
* simulation status.
*/
import { useSyncExternalStore } from 'react';
import type { EditorConfig } from '../editor/paper';
import { DEFAULT_CONFIG } from '../editor/paper';
import type { FlowResult } from '../sim/flow';
export interface AppState {
config: EditorConfig;
selection: string[];
canUndo: boolean;
canRedo: boolean;
simulationRunning: boolean;
simulationSummary: {
totalSupplied: number;
totalDemand: number;
unreachedConsumers: number;
} | null;
/** Bumped whenever the graph changes, so panels re-read cell props. */
graphVersion: number;
statusMessage: string;
}
const CONFIG_KEY = 'pipeline-editor-config';
function loadConfig(): EditorConfig {
try {
const raw = localStorage.getItem(CONFIG_KEY);
if (raw) return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
// Fall through to defaults.
}
return { ...DEFAULT_CONFIG };
}
let state: AppState = {
config: loadConfig(),
selection: [],
canUndo: false,
canRedo: false,
simulationRunning: false,
simulationSummary: null,
graphVersion: 0,
statusMessage: 'Ready',
};
const listeners = new Set<() => void>();
export function getState(): AppState {
return state;
}
export function setState(patch: Partial<AppState>): void {
state = { ...state, ...patch };
if (patch.config) {
try {
localStorage.setItem(CONFIG_KEY, JSON.stringify(patch.config));
} catch {
// Persisting config is best-effort.
}
}
for (const listener of listeners) listener();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useAppState(): AppState {
return useSyncExternalStore(subscribe, getState);
}
export function bumpGraphVersion(): void {
setState({ graphVersion: state.graphVersion + 1 });
}
export function setStatus(statusMessage: string): void {
setState({ statusMessage });
}
export function applySimulationResult(running: boolean, result: FlowResult | null): void {
setState({
simulationRunning: running,
simulationSummary: result
? {
totalSupplied: result.totalSupplied,
totalDemand: result.totalDemand,
unreachedConsumers: result.unreachedConsumers.length,
}
: null,
});
}

181
src/ui/App.tsx Normal file
View File

@ -0,0 +1,181 @@
/**
* Application shell: toolbar on top, palette left, canvas center,
* property panel right. Wires keyboard shortcuts and file operations.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { EditorController } from '../editor/paper';
import { Canvas } from './Canvas';
import { Palette } from './Palette';
import { PropertyPanel } from './PropertyPanel';
import { Toolbar } from './Toolbar';
import { SettingsDialog } from './SettingsDialog';
import { useAppState, setState, setStatus } from '../state/store';
import { saveTextFile, openTextFile } from '../state/persistence';
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
);
}
export function App() {
const state = useAppState();
const [controller, setController] = useState<EditorController | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const controllerRef = useRef<EditorController | null>(null);
controllerRef.current = controller;
const onReady = useCallback((c: EditorController) => setController(c), []);
// Keyboard shortcuts.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const c = controllerRef.current;
if (!c || isEditableTarget(e.target)) return;
const mod = e.ctrlKey || e.metaKey;
const grid = c.config.snapToGrid ? c.config.gridSize : 5;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault();
c.undo();
} else if ((mod && e.key.toLowerCase() === 'z' && e.shiftKey) || (mod && e.key.toLowerCase() === 'y')) {
e.preventDefault();
c.redo();
} else if (mod && e.key.toLowerCase() === 'c') {
c.copySelection();
setStatus('Copied');
} else if (mod && e.key.toLowerCase() === 'v') {
c.paste();
setStatus('Pasted');
} 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 (e.key.startsWith('Arrow')) {
e.preventDefault();
const d = {
ArrowLeft: [-grid, 0],
ArrowRight: [grid, 0],
ArrowUp: [0, -grid],
ArrowDown: [0, grid],
}[e.key as 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'];
if (d) c.nudgeSelection(d[0], d[1]);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const handleNew = () => {
if (!controller) return;
if (controller.graph.getCells().length > 0 && !window.confirm('Clear the current diagram?')) return;
controller.clearDocument();
setStatus('New diagram');
};
const handleSave = async () => {
if (!controller) return;
try {
const path = await saveTextFile(controller.serialize(), 'diagram.pipeline.json', 'Pipeline diagram', ['json']);
if (path) setStatus(`Saved: ${path}`);
} catch (err) {
setStatus(`Save failed: ${String(err)}`);
}
};
const handleOpen = async () => {
if (!controller) return;
try {
const file = await openTextFile('Pipeline diagram', ['json']);
if (!file) return;
controller.loadDocumentJSON(file.contents);
setStatus(`Loaded: ${file.name}`);
} catch (err) {
setStatus(`Open failed: ${String(err)}`);
}
};
const handleExportSvg = async () => {
if (!controller) return;
try {
const path = await saveTextFile(controller.exportSVG(), 'diagram.svg', 'SVG image', ['svg']);
if (path) setStatus(`Exported: ${path}`);
} catch (err) {
setStatus(`Export failed: ${String(err)}`);
}
};
const handleToggleSimulation = () => {
if (!controller) return;
if (controller.simulationRunning) {
controller.stopSimulation();
setStatus('Simulation stopped');
} else {
const result = controller.runSimulation();
setStatus(
result.totalDemand === 0
? 'Simulation: no consumers with demand found'
: `Simulation: ${result.totalSupplied.toFixed(1)} of ${result.totalDemand.toFixed(1)} m³/h delivered`,
);
}
};
const handleQuickAdd = (typeId: string) => {
if (!controller) return;
const host = document.querySelector('.pf-canvas');
const rect = host?.getBoundingClientRect();
const p = rect
? controller.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2)
: { x: 200, y: 200 };
controller.addNode(typeId, p.x, p.y);
};
return (
<div className="pf-app">
<Toolbar
canUndo={state.canUndo}
canRedo={state.canRedo}
simulationRunning={state.simulationRunning}
simulationSummary={state.simulationSummary}
statusMessage={state.statusMessage}
onNew={handleNew}
onOpen={handleOpen}
onSave={handleSave}
onExportSvg={handleExportSvg}
onUndo={() => controller?.undo()}
onRedo={() => controller?.redo()}
onDelete={() => controller?.deleteSelection()}
onZoomIn={() => controller?.zoom(1.2)}
onZoomOut={() => controller?.zoom(1 / 1.2)}
onZoomFit={() => controller?.zoomToFit()}
onZoomReset={() => controller?.resetZoom()}
onToggleSimulation={handleToggleSimulation}
onOpenSettings={() => setSettingsOpen(true)}
/>
<div className="pf-main">
<Palette schemeId={state.config.colorScheme} onQuickAdd={handleQuickAdd} />
<Canvas onReady={onReady} />
<PropertyPanel controller={controller} selection={state.selection} graphVersion={state.graphVersion} />
</div>
<SettingsDialog
open={settingsOpen}
config={state.config}
onChange={(config) => {
setState({ config });
controller?.applyConfig(config);
}}
onClose={() => setSettingsOpen(false)}
/>
</div>
);
}

58
src/ui/Canvas.tsx Normal file
View File

@ -0,0 +1,58 @@
/**
* Canvas host: mounts the JointJS paper via EditorController and accepts
* palette drops.
*/
import { useEffect, useRef } from 'react';
import { EditorController } from '../editor/paper';
import { PALETTE_MIME } from './Palette';
import { getState, setState, bumpGraphVersion, applySimulationResult } from '../state/store';
interface CanvasProps {
onReady(controller: EditorController): void;
}
export function Canvas({ onReady }: CanvasProps) {
const hostRef = useRef<HTMLDivElement>(null);
const controllerRef = useRef<EditorController | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host || controllerRef.current) return;
const controller = new EditorController(host, getState().config, {
onSelectionChange: (selection) => setState({ selection }),
onGraphChange: () => bumpGraphVersion(),
onHistoryChange: (canUndo, canRedo) => setState({ canUndo, canRedo }),
onSimulationChange: (running, result) => applySimulationResult(running, result),
});
controllerRef.current = controller;
onReady(controller);
return () => {
controller.dispose();
controllerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={hostRef}
className="pf-canvas"
data-testid="canvas"
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PALETTE_MIME)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDrop={(e) => {
const typeId = e.dataTransfer.getData(PALETTE_MIME);
const controller = controllerRef.current;
if (!typeId || !controller) return;
e.preventDefault();
const p = controller.clientToLocal(e.clientX, e.clientY);
controller.addNode(typeId, p.x, p.y);
}}
/>
);
}

82
src/ui/Palette.tsx Normal file
View File

@ -0,0 +1,82 @@
/**
* Left panel: node types grouped by category. Items are dragged onto the
* canvas (HTML5 drag & drop carrying the type id) or double-clicked to drop
* at the viewport center.
*/
import { useMemo, useState } from 'react';
import { nodeTypesByCategory, type NodeTypeDef } from '../model/nodeTypes';
import { svgDataUri } from '../editor/shapes';
import { getColorScheme } from '../editor/colorSchemes';
export const PALETTE_MIME = 'application/x-pipeline-node-type';
interface PaletteProps {
schemeId: string;
onQuickAdd(typeId: string): void;
}
function PaletteItem({ type, schemeId, onQuickAdd }: {
type: NodeTypeDef;
schemeId: string;
onQuickAdd(typeId: string): void;
}) {
const scheme = getColorScheme(schemeId);
return (
<div
className="pf-palette-item"
draggable
title={`${type.label} — drag onto canvas`}
data-testid={`palette-item-${type.id}`}
onDragStart={(e) => {
e.dataTransfer.setData(PALETTE_MIME, type.id);
e.dataTransfer.effectAllowed = 'copy';
}}
onDoubleClick={() => onQuickAdd(type.id)}
>
<img src={svgDataUri(type, scheme.nodeStroke)} alt={type.label} width={34} height={34} />
<span>{type.label}</span>
</div>
);
}
export function Palette({ schemeId, onQuickAdd }: PaletteProps) {
const [filter, setFilter] = useState('');
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const groups = useMemo(() => nodeTypesByCategory(), []);
const query = filter.trim().toLowerCase();
return (
<aside className="pf-palette" data-testid="palette">
<input
className="pf-palette-search"
placeholder="Search nodes…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{groups.map(({ category, types }) => {
const visible = query
? types.filter((t) => t.label.toLowerCase().includes(query))
: types;
if (visible.length === 0) return null;
const isCollapsed = collapsed[category.id] && !query;
return (
<section key={category.id} className="pf-palette-group">
<header
onClick={() => setCollapsed((c) => ({ ...c, [category.id]: !c[category.id] }))}
>
<span className="pf-caret">{isCollapsed ? '▸' : '▾'}</span> {category.label}
</header>
{!isCollapsed && (
<div className="pf-palette-items">
{visible.map((type) => (
<PaletteItem key={type.id} type={type} schemeId={schemeId} onQuickAdd={onQuickAdd} />
))}
</div>
)}
</section>
);
})}
</aside>
);
}

230
src/ui/PropertyPanel.tsx Normal file
View File

@ -0,0 +1,230 @@
/**
* Right panel: grouped, typed property editors for the focused node or edge.
* Renders one editor per property type; commits values through
* EditorController.setCellProp after validation.
*/
import { useMemo, useState } from 'react';
import type { PropertyDef, PropertyValue } from '../model/properties';
import { groupDefs, validateValue } from '../model/properties';
import { getCatalogue } from '../model/catalog';
import { getNodeType } from '../model/nodeTypes';
import { EDGE_PROPERTIES } from '../model/graph';
import type { EditorController } from '../editor/paper';
interface PropertyPanelProps {
controller: EditorController | null;
selection: string[];
/** Changes on every graph mutation so the panel re-reads values. */
graphVersion: number;
}
function EditorRow({ def, value, onCommit }: {
def: PropertyDef;
value: PropertyValue;
onCommit(value: PropertyValue): void;
}) {
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<string | null>(null);
const commit = (raw: PropertyValue) => {
const result = validateValue(def, raw);
if (!result.ok) {
setError(result.error ?? 'Invalid value');
return;
}
setError(null);
setDraft(null);
onCommit(result.value);
};
const control = (() => {
if (def.readOnly) {
const text = Array.isArray(value) ? value.join(', ') : String(value ?? '—');
return <span className="pf-prop-readonly" data-testid={`prop-${def.key}`}>{text}</span>;
}
switch (def.type) {
case 'string':
return (
<input
data-testid={`prop-${def.key}`}
value={draft ?? String(value ?? '')}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => draft !== null && commit(draft)}
onKeyDown={(e) => e.key === 'Enter' && draft !== null && commit(draft)}
/>
);
case 'int':
case 'float':
return (
<input
data-testid={`prop-${def.key}`}
type="number"
step={def.type === 'int' ? 1 : 'any'}
min={def.min}
max={def.max}
value={draft ?? String(value ?? '')}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => draft !== null && commit(draft)}
onKeyDown={(e) => e.key === 'Enter' && draft !== null && commit(draft)}
/>
);
case 'enum':
return (
<select
data-testid={`prop-${def.key}`}
value={String(value ?? '')}
onChange={(e) => commit(e.target.value)}
>
{(def.options ?? []).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
case 'color':
return (
<input
data-testid={`prop-${def.key}`}
type="color"
value={String(value ?? '#000000')}
onChange={(e) => commit(e.target.value)}
/>
);
case 'catalogueItem': {
const cat = def.catalogueId ? getCatalogue(def.catalogueId) : undefined;
return (
<select
data-testid={`prop-${def.key}`}
value={String(value ?? '')}
onChange={(e) => commit(e.target.value === '' ? null : e.target.value)}
>
<option value=""> none </option>
{(cat?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
);
}
case 'catalogueItems': {
const cat = def.catalogueId ? getCatalogue(def.catalogueId) : undefined;
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<div className="pf-multi" data-testid={`prop-${def.key}`}>
{(cat?.items ?? []).map((item) => (
<label key={item.id} className="pf-multi-item">
<input
type="checkbox"
checked={selected.includes(item.id)}
onChange={(e) => {
const next = e.target.checked
? [...selected, item.id]
: selected.filter((id) => id !== item.id);
commit(next);
}}
/>
{item.label}
</label>
))}
</div>
);
}
case 'listOfValues': {
const list = Array.isArray(value) ? value : [];
const text = draft ?? list.join(', ');
return (
<input
data-testid={`prop-${def.key}`}
value={text}
placeholder="comma, separated, values"
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft === null) return;
const items = draft.split(',').map((s) => s.trim()).filter((s) => s !== '');
commit(items as PropertyValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && draft !== null) {
const items = draft.split(',').map((s) => s.trim()).filter((s) => s !== '');
commit(items as PropertyValue);
}
}}
/>
);
}
}
})();
return (
<div className="pf-prop-row">
<label title={def.description}>{def.label}{def.unit ? <em> ({def.unit})</em> : null}</label>
<div className="pf-prop-control">
{control}
{error && <div className="pf-prop-error">{error}</div>}
</div>
</div>
);
}
export function PropertyPanel({ controller, selection, graphVersion }: PropertyPanelProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
void graphVersion; // dependency to re-read props after graph changes
const target = useMemo(() => {
if (!controller || selection.length !== 1) return null;
const cellId = selection[0];
const kind = controller.cellKind(cellId);
if (!kind) return null;
const props = controller.getCellProps(cellId);
if (!props) return null;
if (kind === 'node') {
const typeId = controller.cellTypeId(cellId);
const type = typeId ? getNodeType(typeId) : undefined;
if (!type) return null;
return { cellId, kind, defs: type.properties, heading: type.label, props };
}
return { cellId, kind, defs: EDGE_PROPERTIES, heading: 'Pipe / line', props };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, selection, graphVersion]);
if (!controller || selection.length === 0) {
return (
<aside className="pf-props" data-testid="property-panel">
<div className="pf-props-empty">Select a node or an edge to inspect its properties.</div>
</aside>
);
}
if (selection.length > 1) {
return (
<aside className="pf-props" data-testid="property-panel">
<div className="pf-props-empty">{selection.length} items selected.</div>
</aside>
);
}
if (!target) return <aside className="pf-props" data-testid="property-panel" />;
const groups = groupDefs(target.defs);
return (
<aside className="pf-props" data-testid="property-panel">
<h2>{target.heading}</h2>
{groups.map(({ group, defs }) => {
const isCollapsed = collapsed[group.id];
return (
<section key={group.id} className="pf-prop-group" data-testid={`prop-group-${group.id}`}>
<header onClick={() => setCollapsed((c) => ({ ...c, [group.id]: !c[group.id] }))}>
<span className="pf-caret">{isCollapsed ? '▸' : '▾'}</span> {group.label}
</header>
{!isCollapsed &&
defs.map((def) => (
<EditorRow
key={`${target.cellId}:${def.key}:${String(target.props[def.key])}`}
def={def}
value={target.props[def.key] ?? null}
onCommit={(value) => controller.setCellProp(target.cellId, def.key, value)}
/>
))}
</section>
);
})}
</aside>
);
}

112
src/ui/SettingsDialog.tsx Normal file
View File

@ -0,0 +1,112 @@
/**
* Editor configuration dialog: grid, routing, arc-on-intersection,
* color scheme, animation.
*/
import type { EditorConfig } from '../editor/paper';
import { COLOR_SCHEMES } from '../editor/colorSchemes';
interface SettingsDialogProps {
open: boolean;
config: EditorConfig;
onChange(config: EditorConfig): void;
onClose(): void;
}
export function SettingsDialog({ open, config, onChange, onClose }: SettingsDialogProps) {
if (!open) return null;
const patch = (p: Partial<EditorConfig>) => onChange({ ...config, ...p });
return (
<div className="pf-modal-backdrop" onClick={onClose}>
<div className="pf-modal" data-testid="settings-dialog" onClick={(e) => e.stopPropagation()}>
<h2>Editor settings</h2>
<div className="pf-setting-row">
<label htmlFor="set-scheme">Color scheme</label>
<select
id="set-scheme"
value={config.colorScheme}
onChange={(e) => patch({ colorScheme: e.target.value })}
>
{COLOR_SCHEMES.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<div className="pf-setting-row">
<label htmlFor="set-router">Edge routing</label>
<select
id="set-router"
value={config.router}
onChange={(e) => patch({ router: e.target.value as EditorConfig['router'] })}
>
<option value="manhattan">Manhattan (grid, avoids obstacles)</option>
<option value="orthogonal">Orthogonal (grid)</option>
<option value="rightAngle">Right angle</option>
<option value="normal">Straight</option>
</select>
</div>
<div className="pf-setting-row">
<label htmlFor="set-arc">Arc on line intersections</label>
<input
id="set-arc"
type="checkbox"
data-testid="setting-arc"
checked={config.arcOnIntersections}
onChange={(e) => patch({ arcOnIntersections: e.target.checked })}
/>
</div>
<div className="pf-setting-row">
<label htmlFor="set-grid-size">Grid size</label>
<select
id="set-grid-size"
value={String(config.gridSize)}
onChange={(e) => patch({ gridSize: Number(e.target.value) })}
>
{[10, 20, 25, 40, 50].map((s) => (
<option key={s} value={s}>{s} px</option>
))}
</select>
</div>
<div className="pf-setting-row">
<label htmlFor="set-show-grid">Show grid</label>
<input
id="set-show-grid"
type="checkbox"
checked={config.showGrid}
onChange={(e) => patch({ showGrid: e.target.checked })}
/>
</div>
<div className="pf-setting-row">
<label htmlFor="set-snap">Snap to grid</label>
<input
id="set-snap"
type="checkbox"
checked={config.snapToGrid}
onChange={(e) => patch({ snapToGrid: e.target.checked })}
/>
</div>
<div className="pf-setting-row">
<label htmlFor="set-anim">Animate flow during simulation</label>
<input
id="set-anim"
type="checkbox"
checked={config.animateFlow}
onChange={(e) => patch({ animateFlow: e.target.checked })}
/>
</div>
<div className="pf-modal-actions">
<button onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

68
src/ui/Toolbar.tsx Normal file
View File

@ -0,0 +1,68 @@
/**
* Top toolbar: file operations, undo/redo, zoom, simulation toggle, settings.
*/
interface ToolbarProps {
canUndo: boolean;
canRedo: boolean;
simulationRunning: boolean;
simulationSummary: { totalSupplied: number; totalDemand: number; unreachedConsumers: number } | null;
statusMessage: string;
onNew(): void;
onOpen(): void;
onSave(): void;
onExportSvg(): void;
onUndo(): void;
onRedo(): void;
onDelete(): void;
onZoomIn(): void;
onZoomOut(): void;
onZoomFit(): void;
onZoomReset(): void;
onToggleSimulation(): void;
onOpenSettings(): void;
}
export function Toolbar(p: ToolbarProps) {
return (
<div className="pf-toolbar" data-testid="toolbar">
<div className="pf-toolbar-group">
<button onClick={p.onNew} title="New diagram">New</button>
<button onClick={p.onOpen} title="Open diagram (JSON)">Open</button>
<button onClick={p.onSave} title="Save diagram (JSON)">Save</button>
<button onClick={p.onExportSvg} title="Export as SVG">Export SVG</button>
</div>
<div className="pf-toolbar-group">
<button onClick={p.onUndo} disabled={!p.canUndo} title="Undo (Ctrl+Z)"> Undo</button>
<button onClick={p.onRedo} disabled={!p.canRedo} title="Redo (Ctrl+Shift+Z)"> Redo</button>
<button onClick={p.onDelete} title="Delete selection (Del)">🗑</button>
</div>
<div className="pf-toolbar-group">
<button onClick={p.onZoomOut} title="Zoom out"></button>
<button onClick={p.onZoomIn} title="Zoom in">+</button>
<button onClick={p.onZoomFit} title="Zoom to fit">Fit</button>
<button onClick={p.onZoomReset} title="Reset zoom">1:1</button>
</div>
<div className="pf-toolbar-group">
<button
className={p.simulationRunning ? 'pf-sim-on' : ''}
onClick={p.onToggleSimulation}
data-testid="simulate-button"
title="Run/stop mock flow simulation"
>
{p.simulationRunning ? '■ Stop flow' : '▶ Simulate flow'}
</button>
{p.simulationSummary && (
<span className="pf-sim-summary" data-testid="sim-summary">
{p.simulationSummary.totalSupplied.toFixed(1)} / {p.simulationSummary.totalDemand.toFixed(1)} m³/h
{p.simulationSummary.unreachedConsumers > 0 &&
` · ${p.simulationSummary.unreachedConsumers} unreached`}
</span>
)}
</div>
<div className="pf-toolbar-spacer" />
<span className="pf-status">{p.statusMessage}</span>
<button onClick={p.onOpenSettings} title="Editor settings"> Settings</button>
</div>
);
}

210
src/ui/styles.css Normal file
View File

@ -0,0 +1,210 @@
:root {
--pf-border: #d5dbe3;
--pf-panel-bg: #ffffff;
--pf-text: #1e293b;
--pf-accent: #0ea5e9;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
color: var(--pf-text);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
.pf-app {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ---------------------------------------------------------------- toolbar */
.pf-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-bottom: 1px solid var(--pf-border);
background: var(--pf-panel-bg);
flex-wrap: wrap;
}
.pf-toolbar-group { display: flex; gap: 4px; align-items: center; }
.pf-toolbar-spacer { flex: 1; }
.pf-toolbar button {
padding: 5px 10px;
border: 1px solid var(--pf-border);
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.pf-toolbar button:hover:not(:disabled) { background: #f1f5f9; }
.pf-toolbar button:disabled { opacity: 0.45; cursor: default; }
.pf-toolbar .pf-sim-on { background: #dcfce7; border-color: #22c55e; }
.pf-sim-summary { font-size: 12px; color: #475569; font-family: monospace; }
.pf-status { font-size: 12px; color: #64748b; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ------------------------------------------------------------------- main */
.pf-main {
display: flex;
flex: 1;
min-height: 0;
}
/* ---------------------------------------------------------------- palette */
.pf-palette {
width: 210px;
overflow-y: auto;
border-right: 1px solid var(--pf-border);
background: var(--pf-panel-bg);
padding: 8px;
}
.pf-palette-search {
width: 100%;
padding: 6px 8px;
margin-bottom: 8px;
border: 1px solid var(--pf-border);
border-radius: 6px;
}
.pf-palette-group header {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
padding: 6px 2px;
cursor: pointer;
user-select: none;
}
.pf-palette-items { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-bottom: 6px; }
.pf-palette-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid transparent;
border-radius: 8px;
cursor: grab;
text-align: center;
font-size: 11px;
}
.pf-palette-item:hover { border-color: var(--pf-accent); background: #f0f9ff; }
.pf-caret { display: inline-block; width: 12px; }
/* ----------------------------------------------------------------- canvas */
.pf-canvas {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
}
.pf-canvas svg { user-select: none; }
.pf-rubber-band {
position: absolute;
border: 1px dashed var(--pf-accent);
background: rgba(14, 165, 233, 0.08);
pointer-events: none;
z-index: 10;
}
/* Ports: hidden until the node is focused/hovered or a compatible target. */
.pf-port-body { opacity: 0; pointer-events: none; transition: opacity 120ms; }
.pf-port-label { opacity: 0; transition: opacity 120ms; font-family: sans-serif; pointer-events: none; }
.joint-element:hover .pf-port-body,
.joint-element.pf-focused .pf-port-body,
.pf-magnet-available.pf-port-body,
.pf-element-available .pf-port-body {
opacity: 1;
pointer-events: all;
}
.joint-element.pf-focused .pf-port-label,
.joint-element:hover .pf-port-label { opacity: 1; }
.pf-magnet-available.pf-port-body { stroke: #22c55e; stroke-width: 3; }
/* ---------------------------------------------------------------- props */
.pf-props {
width: 280px;
overflow-y: auto;
border-left: 1px solid var(--pf-border);
background: var(--pf-panel-bg);
padding: 10px;
}
.pf-props h2 { font-size: 15px; margin: 2px 0 10px; }
.pf-props-empty { color: #94a3b8; font-size: 13px; padding: 12px 4px; }
.pf-prop-group { margin-bottom: 8px; }
.pf-prop-group > header {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
padding: 6px 2px;
border-bottom: 1px solid var(--pf-border);
cursor: pointer;
user-select: none;
}
.pf-prop-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 5px 2px;
}
.pf-prop-row > label { flex: 0 0 110px; font-size: 12px; padding-top: 5px; color: #334155; }
.pf-prop-row > label em { color: #94a3b8; font-style: normal; }
.pf-prop-control { flex: 1; min-width: 0; }
.pf-prop-control input:not([type='checkbox']):not([type='color']),
.pf-prop-control select {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--pf-border);
border-radius: 5px;
font-size: 13px;
}
.pf-prop-control input[type='color'] { width: 100%; height: 28px; padding: 1px; border: 1px solid var(--pf-border); border-radius: 5px; }
.pf-prop-readonly { display: inline-block; padding: 5px 2px; color: #64748b; font-family: monospace; font-size: 12px; }
.pf-prop-error { color: #dc2626; font-size: 11px; margin-top: 2px; }
.pf-multi { display: flex; flex-direction: column; gap: 2px; max-height: 140px; overflow-y: auto; border: 1px solid var(--pf-border); border-radius: 5px; padding: 4px; }
.pf-multi-item { display: flex; gap: 6px; font-size: 12px; align-items: center; }
/* ----------------------------------------------------------------- modal */
.pf-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.pf-modal {
background: #fff;
border-radius: 10px;
padding: 18px 20px;
width: 420px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
}
.pf-modal h2 { margin: 0 0 14px; font-size: 16px; }
.pf-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
}
.pf-setting-row label { font-size: 13px; }
.pf-setting-row select { min-width: 180px; padding: 4px 6px; }
.pf-modal-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
.pf-modal-actions button {
padding: 6px 14px;
border: 1px solid var(--pf-border);
border-radius: 6px;
background: #fff;
cursor: pointer;
}
/* JointJS link tools sizing */
.joint-link .marker-vertices circle { fill: var(--pf-accent); }