From 59cd3dc17489acfe0a2a9757e582f18d879f8b4e Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sun, 5 Jul 2026 11:40:50 +0530 Subject: [PATCH 1/6] Add new-from-scratch diagrams and multi-entity simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagram management (drop hardcoded list): - localStorage-backed user diagrams (storage.ts): save / delete / rename - + New blank diagram; Import/Export JSON; templates kept as starters - TopBar picker groups Templates + My diagrams; ToolPalette File section Simulation engine (sim.ts) — multiple concurrent entities: - entities/tokens with per-entity variables; spawn/remove/select - works for flowchart (branch choices) and state machine (events) - enabledFor/eventsFor/choices/fireEntity/fireTo/stepEntity/stepAll - single-entity API kept as active-entity wrappers; self-tests extended Simulation UX: - SimPanel: entity list, per-entity + global vars, numbered events/branches, Step / Step all / Reset, per-entity history - canvas renders a token per entity (active brighter), highlights all occupied - keyboard in simulate mode: 1-9 fire event/branch, Space step all, S step, N spawn, up/down switch token, R reset Fixes: - applySpec backfills node sizes so partial specs never yield NaN geometry - Fit/resetView already state-aware; loadDiagramState plays in-memory specs - drop non-existent 'checkout' template --- web/src/App.tsx | 195 ++++++++++++++++++++------ web/src/canvas.ts | 61 +++++++- web/src/components/SimPanel.tsx | 130 ++++++++++++++--- web/src/components/ToolPalette.tsx | 17 ++- web/src/components/TopBar.tsx | 36 +++-- web/src/renderer/sim.ts | 217 ++++++++++++++++++++++++++--- web/src/renderer/storage.ts | 69 +++++++++ 7 files changed, 624 insertions(+), 101 deletions(-) create mode 100644 web/src/renderer/storage.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index d702f59..ab6a26a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { PropertyEditor } from './components/PropertyEditor' import { SimPanel } from './components/SimPanel' import { Simulation } from './renderer/sim' import { layoutDiagram, nodeSize } from './renderer/layout' +import { listSaved, loadSaved, saveDiagram, deleteSaved, isTemplate, blankDiagram } from './renderer/storage' const KEY_MAP: Record = { ' ': 32, @@ -46,6 +47,8 @@ export function App() { const clipboardRef = useRef<{ nodes: NodeSpec[]; edges: EdgeSpec[] } | null>(null) const [selTick, setSelTick] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; kind: 'node' | 'edge' | 'canvas' } | null>(null) + const [savedList, setSavedList] = useState(() => listSaved()) + const fileInputRef = useRef(null) const editor = editorRef.current @@ -61,7 +64,7 @@ export function App() { r.onEdgeDoubleClick = handleEdgeDoubleClick r.onContextMenu = (x, y, kind) => setMenu({ x, y, kind }) r.init(canvas, themeName).then(() => { - r.loadDiagram(diagram) + loadTemplate(diagram) }) return () => { r.onDoubleClick = null @@ -81,6 +84,31 @@ export function App() { rendererRef.current?.handleKey(code) e.preventDefault() } + } else if (mode === 'simulate') { + const sim = simRef.current + if (!sim) return + const sm = editorRef.current?.state.type === 'statemachine' + // 1-9 pick the Nth event (state machine) or Nth branch (flowchart) + if (e.key >= '1' && e.key <= '9') { + const idx = +e.key - 1 + if (sm) { const ev = sim.eventsFor()[idx]; if (ev !== undefined) { sim.fire(ev); setSimTick((x) => x + 1) } } + else { const c = sim.choices()[idx]; if (c) { sim.fireTo(sim.activeEntityId, c.to); setSimTick((x) => x + 1) } } + e.preventDefault() + } else if (e.key === ' ') { + sim.stepAll(); setSimTick((x) => x + 1); e.preventDefault() + } else if (e.key === 's' || e.key === 'S') { + sim.step(); setSimTick((x) => x + 1); e.preventDefault() + } else if (e.key === 'r' || e.key === 'R') { + sim.reset(); setSimTick((x) => x + 1); e.preventDefault() + } else if (e.key === 'n' || e.key === 'N') { + sim.spawn(); setSimTick((x) => x + 1); e.preventDefault() + } else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && sim.entities.length > 1) { + // cycle the active entity + const ids = sim.entities.map((en) => en.id) + const cur = ids.indexOf(sim.activeEntityId) + const nx = e.key === 'ArrowDown' ? (cur + 1) % ids.length : (cur - 1 + ids.length) % ids.length + sim.selectEntity(ids[nx]); setSimTick((x) => x + 1); e.preventDefault() + } } } window.addEventListener('keydown', handler) @@ -166,43 +194,108 @@ export function App() { // load JSON string for the JSON editor const [jsonStr, setJsonStr] = useState('') - const loadJsonStr = useCallback(async (name: string) => { - try { - const resp = await fetch(`${import.meta.env.BASE_URL}examples/${name}.json`) - const text = await resp.text() - const spec = JSON.parse(text) as DiagramState - layoutDiagram(spec) - setJsonStr(JSON.stringify(spec, null, 2)) - if (editorRef.current) { - editorRef.current.load(spec) - } else { - const ed = new DiagramEditor(spec) - editorRef.current = ed - } - if (rendererRef.current) rendererRef.current.editor = editorRef.current - // rebuild the simulation if we loaded a new diagram while simulating - if (rendererRef.current?.simMode && editorRef.current) { + + // apply a diagram spec into editor + renderer, regardless of current mode. + // reads mode flags off the renderer (not React state) to stay closure-stable. + const applySpec = useCallback((spec: DiagramState, name: string, relayout = false) => { + if (!spec.nodes) spec.nodes = [] + if (relayout || spec.nodes.some((n) => n.x === undefined || n.y === undefined)) layoutDiagram(spec) + // backfill sizes so a partial spec (positions but no w/h) never yields NaN geometry + for (const n of spec.nodes) { + if (n.w === undefined || n.h === undefined) { const s = nodeSize(n.kind); n.w = n.w ?? s.w; n.h = n.h ?? s.h } + } + setJsonStr(JSON.stringify(spec, null, 2)) + if (editorRef.current) editorRef.current.load(spec) + else editorRef.current = new DiagramEditor(spec) + const r = rendererRef.current + if (r) { + r.editor = editorRef.current + if (r.editMode) { r.loadStateToWasm(editorRef.current.state); r.resetView() } + else r.loadDiagramState(editorRef.current.state) + if (r.simMode) { const sim = new Simulation(editorRef.current.state) simRef.current = sim - rendererRef.current.simulation = sim + r.simulation = sim setSimTick((t) => t + 1) } - } catch {} + } + setDiagram(name) + setSelectedId(null) + setError('') }, []) - useEffect(() => { - loadJsonStr(diagram) - }, [diagram, loadJsonStr]) + const loadTemplate = useCallback(async (name: string) => { + try { + const resp = await fetch(`${import.meta.env.BASE_URL}examples/${name}.json`) + const spec = JSON.parse(await resp.text()) as DiagramState + applySpec(spec, name, true) + } catch {} + }, [applySpec]) const handleDiagramChange = useCallback((name: string) => { + if (isTemplate(name)) { loadTemplate(name); return } + const s = loadSaved(name) + if (s) applySpec(s, name) + }, [loadTemplate, applySpec]) + + const handleNew = useCallback(() => { + const t = editorRef.current?.state.type ?? 'flowchart' + applySpec(blankDiagram(t), 'Untitled') + setMode('edit') + setSideOpen(true) + const r = rendererRef.current + if (r) { r.editMode = true; r.simMode = false } + }, [applySpec]) + + const handleSaveDiagram = useCallback(() => { + const ed = editorRef.current + if (!ed) return + const suggested = isTemplate(diagram) ? '' : diagram + const name = window.prompt('Save diagram as:', suggested)?.trim() + if (!name) return + saveDiagram(name, ed.state) + setSavedList(listSaved()) setDiagram(name) - setError('') - setSelectedId(null) - if (mode === 'view') { - rendererRef.current?.loadDiagram(name) + }, [diagram]) + + const handleDeleteDiagram = useCallback(() => { + if (isTemplate(diagram) || !loadSaved(diagram)) return + if (!window.confirm(`Delete saved diagram "${diagram}"?`)) return + deleteSaved(diagram) + setSavedList(listSaved()) + handleNew() + }, [diagram, handleNew]) + + const handleImport = useCallback(() => fileInputRef.current?.click(), []) + + const handleImportFile = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + const reader = new FileReader() + reader.onload = () => { + try { + const spec = JSON.parse(String(reader.result)) as DiagramState + applySpec(spec, file.name.replace(/\.json$/i, '') || 'Imported') + } catch { + setError('import failed: invalid JSON') + setTimeout(() => setError(''), 2500) + } } - loadJsonStr(name) - }, [mode, loadJsonStr]) + reader.readAsText(file) + }, [applySpec]) + + const handleExportJson = useCallback(() => { + const ed = editorRef.current + if (!ed) return + const blob = new Blob([ed.toJSON()], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${diagram || 'diagram'}.json` + a.click() + URL.revokeObjectURL(url) + }, [diagram]) const handleThemeChange = useCallback((name: string) => { setThemeName(name) @@ -233,20 +326,16 @@ export function App() { if (newMode === 'view') r.resetView() }, []) - const handleFire = useCallback((event: string) => { - simRef.current?.fire(event) - setSimTick((t) => t + 1) - }, []) - - const handleStep = useCallback(() => { - simRef.current?.step() - setSimTick((t) => t + 1) - }, []) + const bumpSim = useCallback(() => setSimTick((t) => t + 1), []) - const handleSimReset = useCallback(() => { - simRef.current?.reset() - setSimTick((t) => t + 1) - }, []) + const handleFire = useCallback((event: string) => { simRef.current?.fire(event); bumpSim() }, [bumpSim]) + const handleFireTo = useCallback((to: string) => { simRef.current?.fireTo(simRef.current.activeEntityId, to); bumpSim() }, [bumpSim]) + const handleStep = useCallback(() => { simRef.current?.step(); bumpSim() }, [bumpSim]) + const handleStepAll = useCallback(() => { simRef.current?.stepAll(); bumpSim() }, [bumpSim]) + const handleSimReset = useCallback(() => { simRef.current?.reset(); bumpSim() }, [bumpSim]) + const handleSpawn = useCallback(() => { simRef.current?.spawn(); bumpSim() }, [bumpSim]) + const handleRemoveEntity = useCallback((id: string) => { simRef.current?.removeEntity(id); bumpSim() }, [bumpSim]) + const handleSelectEntity = useCallback((id: string) => { simRef.current?.selectEntity(id); bumpSim() }, [bumpSim]) const handleZoomIn = useCallback(() => rendererRef.current?.zoomIn(), []) const handleZoomOut = useCallback(() => rendererRef.current?.zoomOut(), []) @@ -508,22 +597,25 @@ export function App() { const hint = mode === 'edit' ? 'drag empty = box-select · Shift-click multi · right-click menu · Ctrl+C/V copy · arrows nudge · Space-drag pan · dbl-click rename' : mode === 'simulate' - ? 'Fire events in the panel · Step auto-advance · Reset · scroll to zoom · drag to pan' + ? '19 fire event / branch · Space step all · S step · N spawn token · ↑↓ switch token · R reset' : '123 pick path · R restart · scroll to zoom · drag to pan' return ( <> + {mode === 'simulate' && simRef.current ? ( - + ) : ( <>
@@ -579,6 +683,11 @@ export function App() { onFontChange={handleGlobalFont} onRelayout={handleRelayout} dir={editor?.state.dir ?? 'TD'} + onSaveDiagram={handleSaveDiagram} + onImport={handleImport} + onExportJson={handleExportJson} + onDeleteDiagram={handleDeleteDiagram} + isSaved={!isTemplate(diagram) && savedList.includes(diagram)} /> )} diff --git a/web/src/canvas.ts b/web/src/canvas.ts index b4d013e..08e8190 100644 --- a/web/src/canvas.ts +++ b/web/src/canvas.ts @@ -268,6 +268,16 @@ export class CanvasRenderer implements DrawAPI { ;(this.instance.exports as any).loadJson(json.length) } + // load an in-memory diagram for VIEW-mode playback (saved/imported diagrams + // that aren't fetchable files). Mirrors loadDiagram minus the network fetch. + loadDiagramState(state: import('./renderer/types').DiagramState) { + if (!this.instance) return + this.trail = [] + this.loadStateToWasm(state) + this.startLoop() + this.resetView() + } + // arm click-to-connect from the selected node; returns false if nothing is selected armConnect(): boolean { const sel = this.editor?.selected @@ -518,7 +528,9 @@ export class CanvasRenderer implements DrawAPI { this.drawSubgraphFromState(sg) } - const simActive = this.simMode ? this.simulation?.active ?? null : null + const sim = this.simMode ? this.simulation : null + const simActive = sim?.active ?? null + const occupied = new Set(sim?.entities.map((e) => e.at) ?? []) if (!this.simMode && ed.selected && ed.getSelectionType() === 'subgraph') { const selSg = ed.getSelectedSubgraph() @@ -564,10 +576,12 @@ export class CanvasRenderer implements DrawAPI { } for (const n of state.nodes) { - const isSel = this.simMode ? n.id === simActive : ed.isNodeSelected(n.id) + const isSel = this.simMode ? occupied.has(n.id) : ed.isNodeSelected(n.id) this.drawNodeFromState(n, isSel) } + if (sim) this.drawSimTokens(sim, simActive) + if (!this.simMode && ed.selected && ed.getSelectionType() === 'node') { const primary = state.nodes.find((n) => n.id === ed.selected) // ports + resize handles only on the single primary node (not on a big multi-select) @@ -901,6 +915,49 @@ export class CanvasRenderer implements DrawAPI { } } + // draw a token per simulation entity at its node; multiple entities on the + // same node fan out. The active entity's token is brighter with a ring. + private drawSimTokens(sim: import('./renderer/sim').Simulation, activeId: string | null) { + const ctx = this.ctx + const c = this.theme + // group entities by node so co-located tokens don't overlap + const byNode = new Map() + for (const e of sim.entities) { + const arr = byNode.get(e.at) ?? [] + arr.push(e) + byNode.set(e.at, arr) + } + for (const [nid, ents] of byNode) { + const n = sim.state.nodes.find((x) => x.id === nid) + if (!n) continue + const cx = n.x! + n.w! / 2 + const cy = n.y! + n.h! / 2 + ents.forEach((e, i) => { + // fan tokens horizontally around the node center + const spread = (i - (ents.length - 1) / 2) * 20 + const tx = cx + spread + const ty = cy + const isActive = e.at === sim.active && sim.activeEntityId === e.id + ctx.save() + ctx.shadowColor = c.accent + ctx.shadowBlur = isActive ? 14 : 6 + ctx.fillStyle = c.accent + ctx.globalAlpha = isActive ? 1 : 0.55 + ctx.beginPath() + ctx.arc(tx, ty, isActive ? 9 : 7, 0, Math.PI * 2) + ctx.fill() + ctx.shadowColor = 'transparent' + // white highlight dot + ctx.globalAlpha = isActive ? 0.9 : 0.5 + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.arc(tx - 2, ty - 2, isActive ? 3 : 2.4, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + }) + } + } + private drawGuides() { const ctx = this.ctx const c = this.theme diff --git a/web/src/components/SimPanel.tsx b/web/src/components/SimPanel.tsx index 86b33b0..6d695d2 100644 --- a/web/src/components/SimPanel.tsx +++ b/web/src/components/SimPanel.tsx @@ -2,10 +2,16 @@ import type { Simulation } from '../renderer/sim' interface SimPanelProps { sim: Simulation - tick: number // forces re-read after imperative sim mutations - onFire: (event: string) => void - onStep: () => void + tick: number // bump forces re-read after imperative sim mutations + isStateMachine: boolean + onFire: (event: string) => void // fire an event on the active entity + onFireTo: (toNodeId: string) => void // flowchart: advance active entity along edge to this node + onStep: () => void // auto-step the active entity + onStepAll: () => void // advance every entity one auto-step onReset: () => void + onSpawn: () => void // spawn a new entity at the start node + onRemoveEntity: (id: string) => void + onSelectEntity: (id: string) => void } const heading: React.CSSProperties = { @@ -23,49 +29,133 @@ const flat: React.CSSProperties = { cursor: 'pointer', padding: '7px 12px', fontSize: 12, fontFamily: 'var(--font-display)', } -export function SimPanel({ sim, onFire, onStep, onReset }: SimPanelProps) { +function nodeLabel(sim: Simulation, id: string): string { + return sim.state.nodes.find((n) => n.id === id)?.label || id +} + +function VarRows({ entries }: { entries: [string, unknown][] }) { + return ( + <> + {entries.map(([k, v]) => ( +
+ {k} + {String(v)} +
+ ))} + + ) +} + +export function SimPanel(props: SimPanelProps) { + const { sim, isStateMachine, onFire, onFireTo, onStep, onStepAll, onReset, onSpawn, onRemoveEntity, onSelectEntity } = props + void props.tick // referenced so re-renders read fresh sim state + const activeNode = sim.state.nodes.find((n) => n.id === sim.active) - const events = sim.events() - const vars = Object.entries(sim.vars) - const canStep = sim.enabled().some((e) => !e.event) + const globalVars = Object.entries(sim.vars) + const entityVars = Object.entries(sim.activeEntity()?.vars ?? {}) + + const events = sim.eventsFor() + const choices = sim.choices() + const canStep = isStateMachine + ? sim.enabledFor().filter((e) => !e.event).length === 1 + : sim.choices().length === 1 return (
+ {/* --- Entities --- */} +
Entities
+
+ {sim.entities.length === 0 && ( +
no entities — Reset to start
+ )} + {sim.entities.map((ent) => { + const isActive = ent.id === sim.activeEntityId + return ( +
onSelectEntity(ent.id)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6, + cursor: 'pointer', borderRadius: 8, padding: '5px 8px', + border: isActive ? '1px solid var(--accent)' : '1px solid var(--line)', + background: isActive ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none', + }} + > + + {ent.id} + {nodeLabel(sim, ent.at)} + + {sim.entities.length > 1 && ( + + )} +
+ ) + })} +
+
+ +
+ + {/* --- Current state --- */}
Current state
{activeNode?.label || sim.active || '—'}
+ {/* --- Variables --- */}
Variables
- {vars.length === 0 &&
no variables
} - {vars.map(([k, v]) => ( -
- {k} - {String(v)} -
- ))} + {globalVars.length === 0 && entityVars.length === 0 && ( +
no variables
+ )} + {globalVars.length > 0 && } + {entityVars.length > 0 && ( + <> +
This entity
+ + + )}
-
Events
+ {/* --- Actions --- */} +
Actions
- {events.length === 0 &&
no enabled events from this state
} - {events.map((ev) => ( - - ))} + {isStateMachine ? ( + events.length === 0 + ?
no enabled events
+ : events.map((ev, i) => ( + + )) + ) : ( + choices.length === 0 + ?
end of flow — nothing to advance
+ : choices.map((c, i) => ( + + )) + )}
+
{sim.lastError &&
{sim.lastError}
} + {/* --- History --- */}
History
{sim.history.length === 0 &&
nothing fired yet
} {sim.history.map((h, i) => (
- {i + 1}. {h.from} —{h.event || 'auto'}→ {h.to} + {i + 1}.{' '} + {h.entity && {h.entity} } + {h.from} —{h.event || 'auto'}→ {h.to}
))}
diff --git a/web/src/components/ToolPalette.tsx b/web/src/components/ToolPalette.tsx index b8e47d2..2c57451 100644 --- a/web/src/components/ToolPalette.tsx +++ b/web/src/components/ToolPalette.tsx @@ -13,6 +13,11 @@ interface ToolPaletteProps { onFontChange: (px: number) => void onRelayout: (dir: 'TD' | 'BT' | 'LR' | 'RL') => void dir: 'TD' | 'BT' | 'LR' | 'RL' + onSaveDiagram: () => void + onImport: () => void + onExportJson: () => void + onDeleteDiagram: () => void + isSaved: boolean } const NODE_TYPES: { kind: string; label: string; shape: React.ReactNode }[] = [ @@ -60,9 +65,19 @@ const DIRS: { d: 'TD' | 'BT' | 'LR' | 'RL'; label: string }[] = [ { d: 'BT', label: '↑ Up' }, { d: 'RL', label: '← Left' }, ] -export function ToolPalette({ onAddNode, onConnect, onAddSubgraph, onDuplicate, onDelete, onClear, onUndo, onRedo, canUndo, canRedo, fontSize, onFontChange, onRelayout, dir }: ToolPaletteProps) { +export function ToolPalette({ onAddNode, onConnect, onAddSubgraph, onDuplicate, onDelete, onClear, onUndo, onRedo, canUndo, canRedo, fontSize, onFontChange, onRelayout, dir, onSaveDiagram, onImport, onExportJson, onDeleteDiagram, isSaved }: ToolPaletteProps) { return (
+
Diagram
+
+ + +
+
+ + +
+
Nodes
{NODE_TYPES.map((t) => ( diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx index 30a31d4..42d78f5 100644 --- a/web/src/components/TopBar.tsx +++ b/web/src/components/TopBar.tsx @@ -1,10 +1,4 @@ -const diagrams = [ - { value: 'fetch', label: 'Fetch state machine' }, - { value: 'traffic', label: 'Traffic light' }, - { value: 'approval', label: 'Approval flow' }, - { value: 'checkout', label: 'Order checkout' }, - { value: 'turnstile', label: 'Turnstile (state machine)' }, -] +import { TEMPLATES } from '../renderer/storage' const themes = [ { value: 'dark', label: 'Dark' }, @@ -19,25 +13,43 @@ interface TopBarProps { theme: string mode: Mode graphType: 'flowchart' | 'statemachine' + savedDiagrams: string[] onDiagramChange: (name: string) => void onThemeChange: (name: string) => void onModeChange: (mode: Mode) => void onTypeChange: (t: 'flowchart' | 'statemachine') => void + onNew: () => void } const MODES: Mode[] = ['view', 'edit', 'simulate'] -export function TopBar({ diagram, theme, mode, graphType, onDiagramChange, onThemeChange, onModeChange, onTypeChange }: TopBarProps) { +export function TopBar({ diagram, theme, mode, graphType, savedDiagrams, onDiagramChange, onThemeChange, onModeChange, onTypeChange, onNew }: TopBarProps) { return (
Flowplay
+
- t.id === diagram) ? diagram : ''} onChange={(e) => e.target.value && onDiagramChange(e.target.value)}> + {!savedDiagrams.includes(diagram) && !TEMPLATES.some((t) => t.id === diagram) && ( + + )} + + {TEMPLATES.map((d) => ( + + ))} + + {savedDiagrams.length > 0 && ( + + {savedDiagrams.map((n) => ( + + ))} + + )}
diff --git a/web/src/renderer/sim.ts b/web/src/renderer/sim.ts index 03b3d19..4542b47 100644 --- a/web/src/renderer/sim.ts +++ b/web/src/renderer/sim.ts @@ -1,7 +1,7 @@ // State-machine simulation: a tiny expression evaluator (no eval / no Function // on user input) plus a runner. Nodes are states, edges are transitions with an // optional event trigger, guard expression, and variable-mutating actions. -import type { DiagramState } from './types' +import type { DiagramState, EdgeSpec } from './types' type Val = number | string | boolean | undefined type Vars = Record @@ -138,56 +138,164 @@ export function runActions(src: string, vars: Vars): void { // ---- simulation runner ---- -export interface HistoryEntry { from: string; event: string; to: string } +// `entity` is optional so pre-existing single-entity call sites keep working. +export interface HistoryEntry { from: string; event: string; to: string; entity?: string } + +// A live token: it sits AT some node and carries its own per-entity variables. +export interface SimEntity { id: string; at: string; vars: Vars } export class Simulation { - active = '' - vars: Vars = {} + vars: Vars = {} // global variables (shared across entities) history: HistoryEntry[] = [] lastError = '' + entities: SimEntity[] = [] // the live tokens + activeEntityId = '' + private counter = 0 // for generating e1, e2, ... ids constructor(public state: DiagramState) { this.reset() } - reset(): void { + // Back-compat: the "active" node id is just the active entity's location. + get active(): string { return this.activeEntity()?.at ?? '' } + + activeEntity(): SimEntity | undefined { + return this.entities.find((e) => e.id === this.activeEntityId) + } + + selectEntity(id: string): void { + if (this.entities.some((e) => e.id === id)) this.activeEntityId = id + } + + private startNode(): string { const init = this.state.nodes.find((n) => n.role === 'initial') ?? this.state.nodes[0] - this.active = init?.id ?? '' + return init?.id ?? '' + } + + spawn(at?: string, vars: Vars = {}): string { + const id = 'e' + (++this.counter) + this.entities.push({ id, at: at ?? this.startNode(), vars: { ...vars } }) + return id + } + + removeEntity(id: string): void { + this.entities = this.entities.filter((e) => e.id !== id) + if (this.activeEntityId === id) this.activeEntityId = this.entities[0]?.id ?? '' + } + + reset(): void { + this.entities = [] + this.counter = 0 this.vars = JSON.parse(JSON.stringify(this.state.variables ?? {})) this.history = [] this.lastError = '' + // Spawn one entity at each 'initial' node; if none, a single one at nodes[0]. + const inits = this.state.nodes.filter((n) => n.role === 'initial') + if (inits.length) for (const n of inits) this.spawn(n.id) + else if (this.state.nodes[0]) this.spawn(this.state.nodes[0].id) + this.activeEntityId = this.entities[0]?.id ?? '' + } + + // Resolve an entity by id, defaulting to the active one. + private ent(entityId?: string): SimEntity | undefined { + return entityId ? this.entities.find((e) => e.id === entityId) : this.activeEntity() } - private outgoing() { return this.state.edges.filter((e) => e.from === this.active) } + private outgoing(at: string): EdgeSpec[] { return this.state.edges.filter((e) => e.from === at) } - private guardOk(guard?: string): boolean { + // Guards/actions evaluate against the MERGE of global vars overlaid by the + // entity's own vars (entity wins on read). + private scopeFor(ent: SimEntity): Vars { return { ...this.vars, ...ent.vars } } + + private guardOk(guard: string | undefined, ent: SimEntity): boolean { if (!guard) return true - try { return truthy(evalExpr(guard, this.vars)) } catch { return false } + try { return truthy(evalExpr(guard, this.scopeFor(ent))) } catch { return false } } - enabled() { return this.outgoing().filter((e) => this.guardOk(e.guard)) } + // Write rule: run actions against a merged scope, then split results back. + // Keys that ALREADY existed in the entity's own vars stay per-entity; every + // other key (global-originated, or newly created) is written to global vars. + private applyActions(actions: string, ent: SimEntity): void { + const scope = this.scopeFor(ent) + const entityKeys = new Set(Object.keys(ent.vars)) + runActions(actions, scope) + for (const k of Object.keys(scope)) { + if (entityKeys.has(k)) ent.vars[k] = scope[k] + else this.vars[k] = scope[k] + } + } - events(): string[] { + // ---- per-entity queries (entityId defaults to the active entity) ---- + + enabledFor(entityId?: string): EdgeSpec[] { + const ent = this.ent(entityId) + if (!ent) return [] + return this.outgoing(ent.at).filter((e) => this.guardOk(e.guard, ent)) + } + + eventsFor(entityId?: string): string[] { const s = new Set() - for (const e of this.enabled()) if (e.event) s.add(e.event) + for (const e of this.enabledFor(entityId)) if (e.event) s.add(e.event) return [...s] } - fire(event: string): boolean { - const cand = this.outgoing().filter((e) => (e.event ?? '') === event) - if (!cand.length) { this.lastError = `no transition on "${event}"`; return false } - const e = cand.find((x) => this.guardOk(x.guard)) - if (!e) { this.lastError = `guard blocked "${event}"`; return false } - if (e.actions) { try { runActions(e.actions, this.vars) } catch (err) { this.lastError = String(err); return false } } - this.history.push({ from: this.active, event, to: e.to }) - this.active = e.to + // The enabled transitions a UI can offer as a manual pick (flowchart branches). + choices(entityId?: string): EdgeSpec[] { return this.enabledFor(entityId) } + + private traverse(ent: SimEntity, e: EdgeSpec, event: string): boolean { + if (e.actions) { + try { this.applyActions(e.actions, ent) } catch (err) { this.lastError = String(err); return false } + } + this.history.push({ from: ent.at, event, to: e.to, entity: ent.id }) + ent.at = e.to this.lastError = '' return true } - step(): boolean { - const auto = this.enabled().filter((e) => !e.event) + fireEntity(entityId?: string, event = ''): boolean { + const ent = this.ent(entityId) + if (!ent) { this.lastError = 'no such entity'; return false } + const cand = this.outgoing(ent.at).filter((e) => (e.event ?? '') === event) + if (!cand.length) { this.lastError = `no transition on "${event}"`; return false } + const e = cand.find((x) => this.guardOk(x.guard, ent)) + if (!e) { this.lastError = `guard blocked "${event}"`; return false } + return this.traverse(ent, e, event) + } + + // Move along the edge to a specific target node (how flowchart choices resolve). + fireTo(entityId: string | undefined, toNodeId: string): boolean { + const ent = this.ent(entityId) + if (!ent) { this.lastError = 'no such entity'; return false } + const cand = this.outgoing(ent.at).filter((e) => e.to === toNodeId) + if (!cand.length) { this.lastError = `no edge to "${toNodeId}"`; return false } + const e = cand.find((x) => this.guardOk(x.guard, ent)) + if (!e) { this.lastError = `guard blocked to "${toNodeId}"`; return false } + return this.traverse(ent, e, e.event ?? '') + } + + stepEntity(entityId?: string): boolean { + const ent = this.ent(entityId) + if (!ent) { this.lastError = 'no such entity'; return false } + const auto = this.enabledFor(ent.id).filter((e) => !e.event) if (auto.length !== 1) { this.lastError = auto.length ? 'ambiguous auto-transition' : 'no auto transition'; return false } - return this.fire('') + return this.fireEntity(ent.id, '') } + + // Advance every entity by one unambiguous auto-step; return count advanced. + stepAll(): number { + let n = 0 + for (const ent of [...this.entities]) { + const node = this.state.nodes.find((x) => x.id === ent.at) + if (node?.role === 'final') continue + const auto = this.enabledFor(ent.id).filter((e) => !e.event) + if (auto.length === 1 && this.fireEntity(ent.id, '')) n++ + } + return n + } + + // ---- back-compat wrappers (operate on the active entity) ---- + enabled(): EdgeSpec[] { return this.enabledFor() } + events(): string[] { return this.eventsFor() } + fire(event: string): boolean { return this.fireEntity(this.activeEntityId, event) } + step(): boolean { return this.stepEntity(this.activeEntityId) } } // ---- self-check (runs once at import; logs, never throws in prod) ---- @@ -199,5 +307,68 @@ function selfTest() { const v: Vars = { coins: 5 } runActions('coins -= 2; ready = true', v) assert(v.coins === 3 && v.ready === true, 'actions') + + // -- back-compat single-entity flow -- + const single = new Simulation({ + nodes: [{ id: 'A', role: 'initial' }, { id: 'B' }], + edges: [{ from: 'A', to: 'B', event: 'go' }], + subgraphs: [], type: 'statemachine', variables: {}, + }) + assert(single.active === 'A', 'single active getter') + assert(single.fire('go') === true && single.active === 'B', 'single fire moves active') + + // -- multi-entity: two initial nodes each get a token -- + const multi = new Simulation({ + nodes: [ + { id: 'A', role: 'initial' }, { id: 'B', role: 'initial' }, + { id: 'C' }, { id: 'D' }, + ], + edges: [ + { from: 'A', to: 'C', event: 'go' }, + { from: 'B', to: 'D', event: 'go' }, + ], + subgraphs: [], type: 'statemachine', variables: {}, + }) + assert(multi.entities.length === 2, 'spawn at each initial') + const [eA, eB] = multi.entities + assert(eA.at === 'A' && eB.at === 'B', 'entities at their initial nodes') + // fireEntity moves one entity and not the other + assert(multi.fireEntity(eA.id, 'go') === true, 'fireEntity ok') + assert(eA.at === 'C' && eB.at === 'B', 'only fired entity moved') + + // -- per-entity var isolation + global read visible in guard -- + const iso = new Simulation({ + nodes: [{ id: 'S', role: 'initial' }, { id: 'T' }], + edges: [{ from: 'S', to: 'T', event: 'go', guard: 'g >= 0 && x >= 1', actions: 'x += 1; g += 5' }], + subgraphs: [], type: 'statemachine', variables: { x: 100, g: 0 }, + }) + const ie = iso.activeEntity()! + ie.vars.x = 1 // entity-local x shadows global x on read + assert(iso.fireEntity(ie.id, 'go') === true, 'guard sees entity x=1 and global g=0') + assert(ie.vars.x === 2, 'entity var x updated per-entity') + assert(iso.vars.x === 100, 'global x not clobbered by entity write') + assert(iso.vars.g === 5, 'global g written globally') + + // -- stepAll advances multiple entities -- + const autos = new Simulation({ + nodes: [ + { id: 'A', role: 'initial' }, { id: 'B', role: 'initial' }, + { id: 'C' }, { id: 'D' }, + ], + edges: [{ from: 'A', to: 'C' }, { from: 'B', to: 'D' }], + subgraphs: [], type: 'statemachine', variables: {}, + }) + assert(autos.stepAll() === 2, 'stepAll advances both entities') + assert(autos.entities[0].at === 'C' && autos.entities[1].at === 'D', 'stepAll targets') + + // -- flowchart choices + fireTo selecting a branch -- + const fc = new Simulation({ + nodes: [{ id: 'start', role: 'initial' }, { id: 'left' }, { id: 'right' }], + edges: [{ from: 'start', to: 'left' }, { from: 'start', to: 'right' }], + subgraphs: [], type: 'flowchart', variables: {}, + }) + assert(fc.choices().length === 2, 'flowchart offers both branches') + assert(fc.fireTo(fc.activeEntityId, 'right') === true && fc.active === 'right', 'fireTo selects branch') } try { selfTest() } catch (e) { console.error(e) } +console.log('sim selfTest passed') diff --git a/web/src/renderer/storage.ts b/web/src/renderer/storage.ts new file mode 100644 index 0000000..3ac35a3 --- /dev/null +++ b/web/src/renderer/storage.ts @@ -0,0 +1,69 @@ +// Local persistence for user-created diagrams. Built-in examples ship as +// read-only templates under /examples; anything the user saves lives here in +// localStorage so diagrams are no longer hardcoded into the app. +import type { DiagramState } from './types' + +const KEY = 'flowplay-diagrams' + +// built-in starter templates (fetched from /public/examples at load time) +export const TEMPLATES: { id: string; label: string }[] = [ + { id: 'fetch', label: 'Fetch state machine' }, + { id: 'traffic', label: 'Traffic light' }, + { id: 'approval', label: 'Approval flow' }, + { id: 'turnstile', label: 'Turnstile (state machine)' }, +] + +export const isTemplate = (id: string) => TEMPLATES.some((t) => t.id === id) + +type Store = Record + +function read(): Store { + try { + const raw = localStorage.getItem(KEY) + return raw ? (JSON.parse(raw) as Store) : {} + } catch { + return {} + } +} + +function write(s: Store): void { + localStorage.setItem(KEY, JSON.stringify(s)) +} + +export function listSaved(): string[] { + return Object.keys(read()).sort((a, b) => a.localeCompare(b)) +} + +export function loadSaved(name: string): DiagramState | null { + return read()[name] ?? null +} + +export function saveDiagram(name: string, state: DiagramState): void { + const s = read() + s[name] = JSON.parse(JSON.stringify(state)) + write(s) +} + +export function deleteSaved(name: string): void { + const s = read() + delete s[name] + write(s) +} + +export function renameSaved(oldName: string, newName: string): void { + if (oldName === newName) return + const s = read() + if (!(oldName in s)) return + s[newName] = s[oldName] + delete s[oldName] + write(s) +} + +export function existsSaved(name: string): boolean { + return name in read() +} + +// a fresh empty diagram to start from scratch +export function blankDiagram(type: 'flowchart' | 'statemachine' = 'flowchart'): DiagramState { + return { nodes: [], edges: [], subgraphs: [], type } +} From 4d8ace4848cc87f154541a4188bca7446673d1e3 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sun, 5 Jul 2026 11:43:42 +0530 Subject: [PATCH 2/6] Simulate mode: click a next-node to advance, click a token to select - Click an enabled target node to move the active entity there (fires the matching event or flowchart edge); click another token to make it active - Pointer cursor on hover over enabled next-nodes for discoverability --- web/src/App.tsx | 10 ++++++++++ web/src/canvas.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index ab6a26a..1001e38 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -63,6 +63,16 @@ export function App() { r.onDoubleClick = handleDoubleClick r.onEdgeDoubleClick = handleEdgeDoubleClick r.onContextMenu = (x, y, kind) => setMenu({ x, y, kind }) + r.onSimAdvance = (to) => { + const sim = simRef.current + if (!sim) return + const edge = sim.enabledFor().find((eg) => eg.to === to) + if (!edge) return + if (edge.event) sim.fire(edge.event) + else sim.fireTo(sim.activeEntityId, to) + setSimTick((x2) => x2 + 1) + } + r.onSimSelectEntity = (id) => { simRef.current?.selectEntity(id); setSimTick((x2) => x2 + 1) } r.init(canvas, themeName).then(() => { loadTemplate(diagram) }) diff --git a/web/src/canvas.ts b/web/src/canvas.ts index 08e8190..c55df8d 100644 --- a/web/src/canvas.ts +++ b/web/src/canvas.ts @@ -75,6 +75,8 @@ export class CanvasRenderer implements DrawAPI { onDoubleClick: ((nodeId: string, screenX: number, screenY: number) => void) | null = null onEdgeDoubleClick: ((from: string, to: string, screenX: number, screenY: number) => void) | null = null onContextMenu: ((screenX: number, screenY: number, kind: 'node' | 'edge' | 'canvas') => void) | null = null + onSimAdvance: ((toNodeId: string) => void) | null = null + onSimSelectEntity: ((entityId: string) => void) | null = null private boundResize: () => void private boundWheel: (e: WheelEvent) => void @@ -1323,7 +1325,24 @@ export class CanvasRenderer implements DrawAPI { private onEditMouseDown(e: MouseEvent) { if (this.simMode) { - // simulate mode: pan only, no editing + // simulate mode: click an enabled next-node to advance the active token, + // or click another token's node to make that entity active; else pan. + const sim = this.simulation + if (sim && !this.spaceDown && e.button !== 1) { + const rect = this.canvas.getBoundingClientRect() + const wx = this.cam.worldX(e.clientX - rect.left) + const wy = this.cam.worldY(e.clientY - rect.top) + for (let i = sim.state.nodes.length - 1; i >= 0; i--) { + const n = sim.state.nodes[i] + if (!this.nodeInBounds(n, wx, wy)) continue + const targets = sim.enabledFor().map((t) => t.to) + if (targets.includes(n.id)) { this.onSimAdvance?.(n.id); return } + const ent = sim.entities.find((x) => x.at === n.id) + if (ent) { this.onSimSelectEntity?.(ent.id); return } + break + } + } + // otherwise pan this.dragging = true this.last = { x: e.clientX, y: e.clientY } this.canvas.style.cursor = 'grabbing' @@ -1523,6 +1542,13 @@ export class CanvasRenderer implements DrawAPI { const wx = this.cam.worldX(mx) const wy = this.cam.worldY(my) + // simulate mode: pointer cursor over an enabled next-node (unless dragging/panning) + if (this.simMode && !this.dragging && this.simulation) { + const targets = new Set(this.simulation.enabledFor().map((t) => t.to)) + const hot = this.simulation.state.nodes.some((n) => targets.has(n.id) && this.nodeInBounds(n, wx, wy)) + this.canvas.style.cursor = hot ? 'pointer' : 'grab' + } + if (this.tempEdge) { this.tempEdge.toPt = { x: wx, y: wy } return From 8bb000a6acda6584530a9ded03feacc8a77afafc Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sun, 5 Jul 2026 11:57:59 +0530 Subject: [PATCH 3/6] Add diagram Variables editor (add N properties for simulation) Edit-mode panel section to add/edit/remove diagram-level variables. These seed the simulation and are read/written by edge guards and actions. Values are coerced to number/boolean/string. --- web/src/App.tsx | 12 +++++ web/src/components/VariablesEditor.tsx | 75 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 web/src/components/VariablesEditor.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 1001e38..e9641e9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { SidePanel } from './components/SidePanel' import { ToolPalette } from './components/ToolPalette' import { JsonEditor } from './components/JsonEditor' import { PropertyEditor } from './components/PropertyEditor' +import { VariablesEditor } from './components/VariablesEditor' import { SimPanel } from './components/SimPanel' import { Simulation } from './renderer/sim' import { layoutDiagram, nodeSize } from './renderer/layout' @@ -472,6 +473,11 @@ export function App() { emitState() }, [emitState]) + const handleVariablesChange = useCallback((vars: Record) => { + editorRef.current?.setVariables(vars) + emitState() + }, [emitState]) + const handleTypeChange = useCallback((t: 'flowchart' | 'statemachine') => { editorRef.current?.setType(t) emitState() @@ -701,6 +707,12 @@ export function App() { /> )} + {activeTab === 'palette' && ( +
+ +
+ )} + {activeTab === 'json' && ( )} diff --git a/web/src/components/VariablesEditor.tsx b/web/src/components/VariablesEditor.tsx new file mode 100644 index 0000000..0ffece5 --- /dev/null +++ b/web/src/components/VariablesEditor.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' + +type Val = number | string | boolean +type Vars = Record + +interface Props { + variables: Vars + onChange: (next: Vars) => void +} + +const FM = "'Geist Mono', ui-monospace, 'SFMono-Regular', monospace" +const heading: React.CSSProperties = { + fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', + color: 'var(--muted)', margin: '2px 0 8px', +} +const input: React.CSSProperties = { + flex: 1, minWidth: 0, background: 'var(--bg)', color: 'var(--text)', + border: '1px solid var(--line)', borderRadius: 6, padding: '5px 8px', + fontSize: 12, fontFamily: FM, outline: 'none', +} +const smallBtn: React.CSSProperties = { + background: 'none', border: '1px solid var(--line)', borderRadius: 6, + color: 'var(--muted)', cursor: 'pointer', padding: '4px 7px', fontSize: 12, +} + +// "true"/"false" → boolean, numeric string → number, otherwise string +function coerce(v: string): Val { + if (v === 'true') return true + if (v === 'false') return false + if (v !== '' && !isNaN(Number(v))) return Number(v) + return v +} + +// Diagram-level variables (properties). These seed the simulation and are +// readable/writable by edge guards and actions. Add as many as you like. +export function VariablesEditor({ variables, onChange }: Props) { + const entries = Object.entries(variables) + const [newKey, setNewKey] = useState('') + + const setVal = (k: string, raw: string) => onChange({ ...variables, [k]: coerce(raw) }) + const remove = (k: string) => { const n = { ...variables }; delete n[k]; onChange(n) } + const add = () => { + const k = newKey.trim() + if (!k || k in variables) return + onChange({ ...variables, [k]: 0 }) + setNewKey('') + } + + return ( +
+
Variables
+ {entries.length === 0 && ( +
+ none — add properties the simulation can read and mutate +
+ )} + {entries.map(([k, v]) => ( +
+
{k}
+ setVal(k, e.target.value)} /> + +
+ ))} +
+ setNewKey(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') add() }} /> + +
+
+ Use them in edge guards (e.g. coins >= 2) and actions (e.g. coins -= 2). +
+
+ ) +} From 3dfc010be6dc1629f56180417d78a8c2a1a292a1 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sun, 5 Jul 2026 12:02:28 +0530 Subject: [PATCH 4/6] Add text DSL for fast state-machine authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 'text' tab with a mermaid-flavoured DSL — type transitions instead of clicking each edge: [*] -> Locked Locked -> Unlocked : COIN [coins >= 0] / coins += 1 Unlocked -> [*] var coins = 0 parseDSL builds nodes/edges/roles/variables (type=statemachine); toDSL seeds the editor from the canvas for round-tripping. Apply (or Ctrl+Enter) lays it out and loads it. dsl.ts has an import-time round-trip self-test. --- web/src/App.tsx | 10 +- web/src/components/DslPanel.tsx | 75 +++++++++++++++ web/src/renderer/dsl.ts | 165 ++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 web/src/components/DslPanel.tsx create mode 100644 web/src/renderer/dsl.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index e9641e9..0b4b8d4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,8 @@ import { ToolPalette } from './components/ToolPalette' import { JsonEditor } from './components/JsonEditor' import { PropertyEditor } from './components/PropertyEditor' import { VariablesEditor } from './components/VariablesEditor' +import { DslPanel } from './components/DslPanel' +import { toDSL } from './renderer/dsl' import { SimPanel } from './components/SimPanel' import { Simulation } from './renderer/sim' import { layoutDiagram, nodeSize } from './renderer/layout' @@ -39,7 +41,7 @@ export function App() { const [error, setError] = useState('') const [mode, setMode] = useState<'view' | 'edit' | 'simulate'>('view') const [sideOpen, setSideOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'palette' | 'json' | 'props'>('palette') + const [activeTab, setActiveTab] = useState<'palette' | 'text' | 'json' | 'props'>('palette') const [selectedId, setSelectedId] = useState(null) const [jsonError, setJsonError] = useState(null) const [editingLabel, setEditingLabel] = useState<{ id: string; edge?: { from: string; to: string }; x: number; y: number; w: number; h: number; label: string } | null>(null) @@ -660,7 +662,7 @@ export function App() { ) : ( <>
- {(['palette', 'json', 'props'] as const).map((tab) => ( + {(['palette', 'text', 'json', 'props'] as const).map((tab) => (
)} + {activeTab === 'text' && ( + applySpec(spec, diagram, true)} /> + )} + {activeTab === 'json' && ( )} diff --git a/web/src/components/DslPanel.tsx b/web/src/components/DslPanel.tsx new file mode 100644 index 0000000..82a540b --- /dev/null +++ b/web/src/components/DslPanel.tsx @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react' +import type { DiagramState } from '../renderer/types' +import { parseDSL } from '../renderer/dsl' + +interface Props { + seed: string + onApply: (state: DiagramState) => void +} + +const FM = "'Geist Mono', ui-monospace, 'SFMono-Regular', monospace" +const heading: React.CSSProperties = { + fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', + color: 'var(--muted)', margin: '2px 0 8px', +} + +const PLACEHOLDER = `var coins = 0 +[*] -> Locked +Locked -> Unlocked : COIN +Unlocked -> Locked : PUSH +Unlocked -> [*]` + +// Text authoring for state machines. Type transitions, hit Apply (or Ctrl+Enter). +export function DslPanel({ seed, onApply }: Props) { + const [text, setText] = useState(seed) + const [err, setErr] = useState(null) + + // reseed when the underlying diagram changes (new diagram / switched away & back) + useEffect(() => { setText(seed); setErr(null) }, [seed]) + + const apply = () => { + const { state, error } = parseDSL(text) + if (error || !state) { setErr(error ?? 'parse failed'); return } + setErr(null) + onApply(state) + } + + // live validation feedback without applying + const liveErr = (() => { + if (!text.trim()) return null + return parseDSL(text).error ?? null + })() + + return ( +
+
State-machine text
+
+ A -> B : EVENT [guard] / action · [*] -> S initial · S -> [*] final · var x = 0 +
+