feat(ui): React shell with stencil, property panel, toolbar and settings

Three-pane editor: searchable grouped symbol stencil with drag-drop,
typed/grouped property panel (string/int/float/enum/color/catalogue/list
editors), toolbar (file, undo/redo, edit, zoom, run-flow), settings dialog
(grid, snap, jumpover, color scheme, theme) and flow legend. Wires global
keyboard shortcuts and seeds a demo network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:24:32 +02:00
parent 4769412070
commit e4783c31f2
13 changed files with 1065 additions and 7 deletions

View File

@ -14,7 +14,7 @@ function createWindow(): void {
title: 'Pipeline Diagram Editor', title: 'Pipeline Diagram Editor',
backgroundColor: '#1e1f26', backgroundColor: '#1e1f26',
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.mjs'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: false sandbox: false

147
src/renderer/App.tsx Normal file
View File

@ -0,0 +1,147 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { DiagramController } from '@diagram/controller'
import { cellValues, schemaForCell } from '@diagram/shapes'
import { getSymbol } from '@diagram/symbols'
import type { DiagramSettings } from '@diagram/settings'
import { NodePanel } from './components/NodePanel'
import { PropertyPanel, type PropertyTarget } from './components/PropertyPanel'
import { CanvasView } from './components/CanvasView'
import { Toolbar } from './components/Toolbar'
import { SettingsDialog } from './components/SettingsDialog'
import { FlowLegend } from './components/FlowLegend'
export function App(): JSX.Element {
const [ctrl, setCtrl] = useState<DiagramController | null>(null)
const [, force] = useReducer((x: number) => x + 1, 0)
const [settingsOpen, setSettingsOpen] = useState(false)
// Subscribe to controller events → re-render.
useEffect(() => {
if (!ctrl) return
const offs = (['selection', 'change', 'settings', 'flow'] as const).map((e) => ctrl.on(e, force))
return () => offs.forEach((off) => off())
}, [ctrl])
// Global keyboard shortcuts.
useEffect(() => {
if (!ctrl) return
const handler = (e: KeyboardEvent): void => {
const t = e.target as HTMLElement
if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return
const mod = e.ctrlKey || e.metaKey
if (e.key === 'Delete' || e.key === 'Backspace') {
ctrl.deleteSelected()
e.preventDefault()
} else if (mod && e.key.toLowerCase() === 'z') {
e.shiftKey ? ctrl.redo() : ctrl.undo()
e.preventDefault()
} else if (mod && e.key.toLowerCase() === 'y') {
ctrl.redo()
e.preventDefault()
} else if (mod && e.key.toLowerCase() === 'c') {
ctrl.copySelected()
} else if (mod && e.key.toLowerCase() === 'v') {
ctrl.paste()
} else if (mod && e.key.toLowerCase() === 'd') {
ctrl.duplicateSelected()
e.preventDefault()
} else if (mod && e.key.toLowerCase() === 'a') {
ctrl.selectAll()
e.preventDefault()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [ctrl])
const selection = ctrl ? ctrl.getSelection() : []
const target: PropertyTarget | null = useMemo(() => {
if (!ctrl || selection.length !== 1) return null
const cell = selection[0]
const values = cellValues(cell)
const kind = cell.get('kind') === 'edge' ? 'edge' : 'node'
const sym = getSymbol(String(cell.get('symbolId')))
const title = String(values.title || sym?.label || (kind === 'edge' ? 'Connector' : 'Node'))
return { id: cell.id as string, kind, title, schema: schemaForCell(cell), values }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctrl, selection.map((c) => c.id).join(','), JSON.stringify(selection[0] ? cellValues(selection[0]) : {})])
const onAddCenter = useCallback(
(symbolId: string) => {
if (!ctrl) return
const box = ctrl.paper.el.getBoundingClientRect()
ctrl.addNodeAtClient(symbolId, box.left + box.width / 2, box.top + box.height / 2)
},
[ctrl]
)
const onSave = useCallback(async () => {
if (!ctrl) return
await window.diagramFile.save(ctrl.toJSON())
}, [ctrl])
const onOpen = useCallback(async () => {
if (!ctrl) return
const res = await window.diagramFile.open()
if (res.ok && res.json) ctrl.loadJSON(res.json)
}, [ctrl])
const onExport = useCallback(() => {
if (!ctrl) return
const svg = ctrl.paper.svg.cloneNode(true) as SVGSVGElement
const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'diagram.svg'
a.click()
URL.revokeObjectURL(url)
}, [ctrl])
const toggleFlow = useCallback(() => {
if (!ctrl) return
ctrl.isFlowing() ? ctrl.stopFlow() : ctrl.runFlow()
}, [ctrl])
return (
<div className="app">
<Toolbar
canUndo={ctrl?.canUndo() ?? false}
canRedo={ctrl?.canRedo() ?? false}
hasSelection={selection.length > 0}
flowing={ctrl?.isFlowing() ?? false}
onNew={() => ctrl?.newDiagram()}
onOpen={onOpen}
onSave={onSave}
onExport={onExport}
onUndo={() => ctrl?.undo()}
onRedo={() => ctrl?.redo()}
onDelete={() => ctrl?.deleteSelected()}
onDuplicate={() => ctrl?.duplicateSelected()}
onZoomIn={() => ctrl?.zoom(1.2)}
onZoomOut={() => ctrl?.zoom(1 / 1.2)}
onFit={() => ctrl?.fitContent()}
onToggleFlow={toggleFlow}
onSettings={() => setSettingsOpen(true)}
/>
<NodePanel onAdd={onAddCenter} />
<CanvasView onReady={setCtrl} empty={!!ctrl && ctrl.graph.getElements().length === 0}>
{ctrl && <FlowLegend solution={ctrl.getSolution()} />}
</CanvasView>
<PropertyPanel
target={target}
multi={selection.length}
onApply={(id, values) => ctrl?.applyValues(id, values)}
/>
{settingsOpen && ctrl && (
<SettingsDialog
settings={ctrl.settings}
onChange={(patch: Partial<DiagramSettings>) => ctrl.setSettings(patch)}
onClose={() => setSettingsOpen(false)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,42 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { DiagramController } from '@diagram/controller'
interface Props {
onReady: (ctrl: DiagramController) => void
empty: boolean
children?: ReactNode
}
export function CanvasView({ onReady, empty, children }: Props): JSX.Element {
const hostRef = useRef<HTMLDivElement>(null)
const ctrlRef = useRef<DiagramController | null>(null)
useEffect(() => {
if (!hostRef.current) return
const ctrl = new DiagramController(hostRef.current)
ctrlRef.current = ctrl
ctrl.seedSample()
onReady(ctrl)
return () => ctrl.dispose()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
className="canvas-wrap"
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}}
onDrop={(e) => {
e.preventDefault()
const id = e.dataTransfer.getData('application/x-symbol-id')
if (id && ctrlRef.current) ctrlRef.current.addNodeAtClient(id, e.clientX, e.clientY)
}}
>
<div ref={hostRef} className="canvas-host" />
{empty && <div className="empty-hint">Drag a symbol from the left panel to start</div>}
{children}
</div>
)
}

View File

@ -0,0 +1,20 @@
import type { FlowSolution } from '@diagram/flow'
export function FlowLegend({ solution }: { solution: FlowSolution | null }): JSX.Element | null {
if (!solution) return null
const max = solution.maxMagnitude
return (
<div className="flow-legend">
<div>Flow magnitude (m³/h)</div>
<div className="bar" />
<div className="scale">
<span>0</span>
<span>{(max / 2).toFixed(1)}</span>
<span>{max.toFixed(1)}</span>
</div>
<div style={{ color: 'var(--text-dim)', marginTop: 4 }}>
residual {solution.residual.toFixed(2)}
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
import { useMemo, useState } from 'react'
import { SYMBOLS, SYMBOL_CATEGORIES, symbolSvgMarkup, type SymbolDef } from '@diagram/symbols'
interface Props {
/** Add the symbol at canvas center (double-click / keyboard). */
onAdd: (symbolId: string) => void
}
export function NodePanel({ onAdd }: Props): JSX.Element {
const [query, setQuery] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const grouped = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (s: SymbolDef): boolean =>
!q || s.label.toLowerCase().includes(q) || s.category.toLowerCase().includes(q)
return SYMBOL_CATEGORIES.map((cat) => ({
cat,
items: SYMBOLS.filter((s) => s.category === cat && match(s))
})).filter((g) => g.items.length > 0)
}, [query])
return (
<aside className="stencil">
<input
className="search"
placeholder="Search symbols…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{grouped.map(({ cat, items }) => (
<div key={cat}>
<div
className="stencil-group-title"
onClick={() => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }))}
>
<span>{cat}</span>
<span>{collapsed[cat] ? '+' : ''}</span>
</div>
{!collapsed[cat] && (
<div className="stencil-grid">
{items.map((sym) => (
<div
key={sym.id}
className="stencil-item"
title={`${sym.label} — drag onto canvas`}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-symbol-id', sym.id)
e.dataTransfer.effectAllowed = 'copy'
}}
onDoubleClick={() => onAdd(sym.id)}
>
<span
// Inline SVG preview is trusted, generated from our registry.
dangerouslySetInnerHTML={{ __html: symbolSvgMarkup(sym, 40) }}
/>
<span className="name">{sym.label}</span>
</div>
))}
</div>
)}
</div>
))}
</aside>
)
}

View File

@ -0,0 +1,246 @@
import { useEffect, useState } from 'react'
import type { PropertyDef, PropertySchema, PropertyValue } from '@diagram/properties'
import { coerce, validate } from '@diagram/properties'
import { getCatalogue, seedsFromItem } from '@diagram/catalogue'
export interface PropertyTarget {
id: string
kind: 'node' | 'edge'
title: string
schema: PropertySchema
values: Record<string, PropertyValue>
}
interface Props {
target: PropertyTarget | null
multi: number
onApply: (id: string, values: Record<string, PropertyValue>) => void
}
export function PropertyPanel({ target, multi, onApply }: Props): JSX.Element {
const [draft, setDraft] = useState<Record<string, PropertyValue>>({})
useEffect(() => {
setDraft(target ? { ...target.values } : {})
}, [target?.id])
if (multi > 1) {
return (
<aside className="properties">
<div className="prop-empty">{multi} items selected.<br />Select a single item to edit its properties.</div>
</aside>
)
}
if (!target) {
return (
<aside className="properties">
<div className="prop-empty">Nothing selected.<br />Click a node or edge to edit.</div>
</aside>
)
}
const errors = validate(target.schema, draft).errors
const commit = (next: Record<string, PropertyValue>): void => {
setDraft(next)
onApply(target.id, next)
}
const setField = (def: PropertyDef, raw: unknown): void => {
const next = { ...draft, [def.key]: coerce(def, raw) }
// Catalogue selection seeds physics defaults.
if (def.type === 'catalogueItem' && def.catalogue) {
const seeds = seedsFromItem(def.catalogue, String(raw))
Object.assign(next, seeds)
}
commit(next)
}
return (
<aside className="properties">
<div className="prop-header">{target.title || '(untitled)'}</div>
<div className="prop-sub">{target.kind === 'edge' ? 'Connector' : 'Node'} · {target.id.slice(0, 8)}</div>
{target.schema.map((group) => (
<div key={group.id} className="prop-group">
<div className="prop-group-title">{group.label}</div>
{group.defs.map((def) => (
<Field
key={def.key}
def={def}
value={draft[def.key]}
error={errors[def.key]}
onChange={(raw) => setField(def, raw)}
/>
))}
</div>
))}
</aside>
)
}
function Field({
def,
value,
error,
onChange
}: {
def: PropertyDef
value: PropertyValue
error?: string
onChange: (raw: unknown) => void
}): JSX.Element {
return (
<>
<div className="prop-row">
<label title={def.description}>
{def.label}
{def.unit ? <span className="unit"> ({def.unit})</span> : null}
</label>
<Editor def={def} value={value} error={error} onChange={onChange} />
{error ? <div className="prop-error">{error}</div> : null}
</div>
</>
)
}
function Editor({
def,
value,
error,
onChange
}: {
def: PropertyDef
value: PropertyValue
error?: string
onChange: (raw: unknown) => void
}): JSX.Element {
const invalid = error ? 'invalid' : ''
switch (def.type) {
case 'int':
case 'float':
return (
<input
className={invalid}
type="number"
value={value === '' || value == null ? '' : Number(value)}
min={def.min}
max={def.max}
step={def.step ?? (def.type === 'int' ? 1 : 0.1)}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'color':
return (
<input type="color" value={String(value ?? '#888888')} onChange={(e) => onChange(e.target.value)} />
)
case 'enum':
return (
<select value={String(value ?? '')} onChange={(e) => onChange(e.target.value)}>
{def.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)
case 'catalogueItem': {
const cat = def.catalogue ? getCatalogue(def.catalogue) : undefined
return (
<select value={String(value ?? '')} onChange={(e) => onChange(e.target.value)}>
<option value=""></option>
{cat?.items.map((it) => (
<option key={it.id} value={it.id}>
{it.label}
</option>
))}
</select>
)
}
case 'catalogueItems':
return <CatalogueItemsEditor def={def} value={(value as string[]) ?? []} onChange={onChange} />
case 'listOfValues':
return <ListEditor def={def} value={(value as (string | number)[]) ?? []} onChange={onChange} />
case 'string':
default:
return (
<input
className={invalid}
type="text"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
/>
)
}
}
function CatalogueItemsEditor({
def,
value,
onChange
}: {
def: PropertyDef
value: string[]
onChange: (raw: unknown) => void
}): JSX.Element {
const cat = def.catalogue ? getCatalogue(def.catalogue) : undefined
const remaining = cat?.items.filter((i) => !value.includes(i.id)) ?? []
return (
<div>
<div className="chips">
{value.map((id) => {
const item = cat?.items.find((i) => i.id === id)
return (
<span key={id} className="chip">
{item?.label ?? id}
<button onClick={() => onChange(value.filter((v) => v !== id))}>×</button>
</span>
)
})}
</div>
{remaining.length > 0 && (
<select
value=""
onChange={(e) => e.target.value && onChange([...value, e.target.value])}
>
<option value="">+ add</option>
{remaining.map((it) => (
<option key={it.id} value={it.id}>
{it.label}
</option>
))}
</select>
)}
</div>
)
}
function ListEditor({
def,
value,
onChange
}: {
def: PropertyDef
value: (string | number)[]
onChange: (raw: unknown) => void
}): JSX.Element {
const numeric = def.itemType && def.itemType !== 'string'
return (
<div className="list-editor">
{value.map((v, i) => (
<div key={i} className="chip" style={{ justifyContent: 'space-between' }}>
<input
type={numeric ? 'number' : 'text'}
value={v as string}
onChange={(e) => {
const next = [...value]
next[i] = e.target.value
onChange(next)
}}
/>
<button onClick={() => onChange(value.filter((_, k) => k !== i))}>×</button>
</div>
))}
<button onClick={() => onChange([...value, numeric ? 0 : ''])}>+ add value</button>
</div>
)
}

View File

@ -0,0 +1,81 @@
import type { DiagramSettings } from '@diagram/settings'
import { COLOR_SCHEMES } from '@diagram/relations'
interface Props {
settings: DiagramSettings
onChange: (patch: Partial<DiagramSettings>) => void
onClose: () => void
}
export function SettingsDialog({ settings, onChange, onClose }: Props): JSX.Element {
return (
<div className="dialog-backdrop" onClick={onClose}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Diagram settings</h3>
<div className="prop-row">
<label>Grid size (px)</label>
<input
type="number"
min={4}
max={64}
step={2}
value={settings.gridSize}
onChange={(e) => onChange({ gridSize: Number(e.target.value) || 16 })}
/>
</div>
<div className="prop-row">
<label>Snap to grid</label>
<input
type="checkbox"
checked={settings.snapToGrid}
onChange={(e) => onChange({ snapToGrid: e.target.checked })}
/>
</div>
<div className="prop-row">
<label>Show grid</label>
<input
type="checkbox"
checked={settings.showGrid}
onChange={(e) => onChange({ showGrid: e.target.checked })}
/>
</div>
<div className="prop-row">
<label>Arc on line crossings</label>
<input
type="checkbox"
checked={settings.jumpover}
onChange={(e) => onChange({ jumpover: e.target.checked })}
/>
</div>
<div className="prop-row">
<label>Color scheme</label>
<select
value={settings.colorScheme}
onChange={(e) => onChange({ colorScheme: e.target.value })}
>
{COLOR_SCHEMES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div className="prop-row">
<label>Theme</label>
<select
value={settings.theme}
onChange={(e) => onChange({ theme: e.target.value as DiagramSettings['theme'] })}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div className="actions">
<button className="primary" onClick={onClose}>Done</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,53 @@
interface Props {
canUndo: boolean
canRedo: boolean
hasSelection: boolean
flowing: boolean
onNew: () => void
onOpen: () => void
onSave: () => void
onExport: () => void
onUndo: () => void
onRedo: () => void
onDelete: () => void
onDuplicate: () => void
onZoomIn: () => void
onZoomOut: () => void
onFit: () => void
onToggleFlow: () => void
onSettings: () => void
}
export function Toolbar(p: Props): JSX.Element {
return (
<header className="toolbar">
<span className="title"> Pipeline Editor</span>
<div className="group">
<button onClick={p.onNew} title="New (Ctrl+N)">New</button>
<button onClick={p.onOpen} title="Open (Ctrl+O)">Open</button>
<button onClick={p.onSave} title="Save (Ctrl+S)">Save</button>
<button onClick={p.onExport} title="Export SVG">Export</button>
</div>
<div className="group">
<button onClick={p.onUndo} disabled={!p.canUndo} title="Undo (Ctrl+Z)"></button>
<button onClick={p.onRedo} disabled={!p.canRedo} title="Redo (Ctrl+Y)"></button>
</div>
<div className="group">
<button onClick={p.onDuplicate} disabled={!p.hasSelection} title="Duplicate (Ctrl+D)">Duplicate</button>
<button className="danger" onClick={p.onDelete} disabled={!p.hasSelection} title="Delete (Del)">Delete</button>
</div>
<div className="group">
<button onClick={p.onZoomOut} title="Zoom out"></button>
<button onClick={p.onZoomIn} title="Zoom in">+</button>
<button onClick={p.onFit} title="Fit to content">Fit</button>
</div>
<div className="spacer" />
<div className="group">
<button className={p.flowing ? 'active' : 'primary'} onClick={p.onToggleFlow}>
{p.flowing ? '■ Stop flow' : '▶ Run flow'}
</button>
<button onClick={p.onSettings} title="Settings"></button>
</div>
</header>
)
}

View File

@ -87,10 +87,10 @@ export class DiagramController {
private bindEvents(): void { private bindEvents(): void {
this.paper.on('element:pointerclick', (view, evt) => { this.paper.on('element:pointerclick', (view, evt) => {
this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
}) })
this.paper.on('link:pointerclick', (view, evt) => { this.paper.on('link:pointerclick', (view, evt) => {
this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
}) })
this.paper.on('blank:pointerclick', () => this.clearSelection()) this.paper.on('blank:pointerclick', () => this.clearSelection())
@ -339,13 +339,18 @@ export class DiagramController {
setSettings(patch: Partial<DiagramSettings>): void { setSettings(patch: Partial<DiagramSettings>): void {
this.settings = { ...this.settings, ...patch } this.settings = { ...this.settings, ...patch }
const s = this.settings const s = this.settings
// Some grid runtime methods are undeclared in the shipped d.ts.
const paper = this.paper as unknown as {
drawGrid: (opt: unknown) => void
clearGrid: () => void
}
this.paper.options.gridSize = s.gridSize this.paper.options.gridSize = s.gridSize
this.paper.setGridSize(s.gridSize) this.paper.setGridSize(s.gridSize)
this.paper.drawGrid(s.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false) if (s.showGrid) paper.drawGrid({ name: 'mesh', args: { color: '#2a2c36' } })
if (!s.showGrid) this.paper.clearGrid() else paper.clearGrid()
this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' }) this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' })
this.paper.options.defaultRouter = routerConfig(s) as dia.Path.Segment[] | never this.paper.options.defaultRouter = routerConfig(s)
this.paper.options.defaultConnector = connectorConfig(s) as never this.paper.options.defaultConnector = connectorConfig(s)
for (const link of this.graph.getLinks()) { for (const link of this.graph.getLinks()) {
link.router(routerConfig(s).name, routerConfig(s).args) link.router(routerConfig(s).name, routerConfig(s).args)
link.connector(connectorConfig(s).name, connectorConfig(s).args) link.connector(connectorConfig(s).name, connectorConfig(s).args)
@ -457,6 +462,36 @@ export class DiagramController {
return defaultsFor(edgeSchema()) return defaultsFor(edgeSchema())
} }
/** Build a small demo network so the canvas isn't empty on first launch. */
seedSample(): void {
this.restoring = true
const s = this.settings.colorScheme
const add = (sym: string, x: number, y: number): dia.Element => {
const n = createNode(sym, x, y, s)
n.addTo(this.graph)
return n
}
const reservoir = add('reservoir', 64, 208)
const pump = add('pump', 240, 214)
const valve = add('valve', 416, 220)
const tee = add('tee', 576, 222)
const outletA = add('outlet', 736, 128)
const outletB = add('outlet', 736, 320)
const link = (a: dia.Element, ap: string, b: dia.Element, bp: string): void => {
createLink({ id: a.id as string, port: ap }, { id: b.id as string, port: bp }).addTo(this.graph)
}
link(reservoir, 'out', pump, 'in')
link(pump, 'out', valve, 'a')
link(valve, 'b', tee, 'a')
link(tee, 'b', outletA, 'in')
link(tee, 'c', outletB, 'in')
for (const n of this.graph.getElements()) this.setPortsVisible(n, false)
this.restoring = false
this.history.reset(JSON.stringify(this.graph.toJSON()))
this.setSettings({})
this.fire('change')
}
dispose(): void { dispose(): void {
this.animator.stop() this.animator.stop()
this.paper.remove() this.paper.remove()

9
src/renderer/global.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import type { DiagramFileApi } from '../preload'
declare global {
interface Window {
diagramFile: DiagramFileApi
}
}
export {}

13
src/renderer/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pipeline Diagram Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

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

335
src/renderer/styles/app.css Normal file
View File

@ -0,0 +1,335 @@
:root {
--bg: #181920;
--panel: #1e1f26;
--panel-2: #23252e;
--border: #2f313c;
--text: #d7dae0;
--text-dim: #8a8f99;
--accent: #3f8ae0;
--accent-2: #f0c000;
--danger: #e0562f;
font-family: Inter, system-ui, -apple-system, sans-serif;
font-size: 13px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
overflow: hidden;
user-select: none;
}
.app {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 240px 1fr 320px;
grid-template-areas:
'toolbar toolbar toolbar'
'stencil canvas properties';
height: 100%;
}
/* ---- Toolbar ---- */
.toolbar {
grid-area: toolbar;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.toolbar .title {
font-weight: 600;
margin-right: 12px;
}
.toolbar .spacer {
flex: 1;
}
.toolbar .group {
display: flex;
gap: 2px;
padding: 0 6px;
border-right: 1px solid var(--border);
}
.toolbar .group:last-child {
border-right: none;
}
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 5px;
padding: 5px 9px;
cursor: pointer;
font-size: 12px;
}
button:hover:not(:disabled) {
background: #2c2e38;
border-color: #3d3f4b;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
button.active {
background: var(--accent-2);
border-color: var(--accent-2);
color: #1a1a1a;
}
/* ---- Stencil ---- */
.stencil {
grid-area: stencil;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
}
.stencil .search {
width: 100%;
padding: 6px 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
margin-bottom: 8px;
}
.stencil-group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin: 10px 4px 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.stencil-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.stencil-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
cursor: grab;
}
.stencil-item:hover {
border-color: var(--accent);
}
.stencil-item svg {
width: 40px;
height: 40px;
}
.stencil-item .name {
font-size: 10px;
color: var(--text-dim);
text-align: center;
line-height: 1.2;
}
/* ---- Canvas ---- */
.canvas-wrap {
grid-area: canvas;
position: relative;
overflow: hidden;
background: var(--bg);
}
.canvas-host {
width: 100%;
height: 100%;
}
.canvas-host .joint-paper {
background: transparent !important;
}
.empty-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
pointer-events: none;
font-size: 14px;
}
/* ---- Properties ---- */
.properties {
grid-area: properties;
background: var(--panel);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 10px;
}
.prop-empty {
color: var(--text-dim);
padding: 20px 6px;
text-align: center;
}
.prop-header {
font-weight: 600;
margin-bottom: 4px;
}
.prop-sub {
color: var(--text-dim);
font-size: 11px;
margin-bottom: 10px;
}
.prop-group {
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
}
.prop-group-title {
background: var(--panel-2);
padding: 6px 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.prop-row {
display: grid;
grid-template-columns: 44% 56%;
align-items: center;
gap: 6px;
padding: 5px 10px;
}
.prop-row label {
color: var(--text-dim);
font-size: 12px;
}
.prop-row .unit {
color: var(--text-dim);
font-size: 10px;
}
.prop-row input[type='text'],
.prop-row input[type='number'],
.prop-row select {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 4px 6px;
font-size: 12px;
}
.prop-row input.invalid {
border-color: var(--danger);
}
.prop-error {
grid-column: 1 / 3;
color: var(--danger);
font-size: 10px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 2px 8px;
font-size: 11px;
display: flex;
gap: 4px;
align-items: center;
}
.chip button {
border: none;
background: none;
padding: 0;
color: var(--text-dim);
}
.list-editor input {
margin-bottom: 4px;
}
/* ---- Dialog ---- */
.dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.dialog {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
width: 380px;
}
.dialog h3 {
margin: 0 0 14px;
}
.dialog .prop-row {
padding: 6px 0;
}
.dialog .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
/* ---- Flow legend ---- */
.flow-legend {
position: absolute;
bottom: 12px;
left: 12px;
background: rgba(30, 31, 38, 0.9);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
}
.flow-legend .bar {
width: 160px;
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #3a6ea5, #3fb6c0, #e0b62f, #e0562f);
margin: 4px 0;
}
.flow-legend .scale {
display: flex;
justify-content: space-between;
color: var(--text-dim);
}
.zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
gap: 4px;
}