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