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:
parent
4769412070
commit
e4783c31f2
@ -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
147
src/renderer/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/renderer/components/CanvasView.tsx
Normal file
42
src/renderer/components/CanvasView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/renderer/components/FlowLegend.tsx
Normal file
20
src/renderer/components/FlowLegend.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/renderer/components/NodePanel.tsx
Normal file
67
src/renderer/components/NodePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
246
src/renderer/components/PropertyPanel.tsx
Normal file
246
src/renderer/components/PropertyPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/renderer/components/SettingsDialog.tsx
Normal file
81
src/renderer/components/SettingsDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/renderer/components/Toolbar.tsx
Normal file
53
src/renderer/components/Toolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
9
src/renderer/global.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { DiagramFileApi } from '../preload'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
diagramFile: DiagramFileApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
13
src/renderer/index.html
Normal file
13
src/renderer/index.html
Normal 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
10
src/renderer/main.tsx
Normal 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
335
src/renderer/styles/app.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user