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:
parent
7aa1f9517b
commit
6585cfb32c
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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
66
src/state/persistence.ts
Normal 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
97
src/state/store.ts
Normal 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
181
src/ui/App.tsx
Normal 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
58
src/ui/Canvas.tsx
Normal 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
82
src/ui/Palette.tsx
Normal 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
230
src/ui/PropertyPanel.tsx
Normal 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
112
src/ui/SettingsDialog.tsx
Normal 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
68
src/ui/Toolbar.tsx
Normal 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
210
src/ui/styles.css
Normal 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); }
|
||||
Loading…
x
Reference in New Issue
Block a user