@nanobpm/bojtos-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/Bojtos.d.ts +51 -0
- package/dist/Bojtos.js +154 -0
- package/dist/BpmnRuntimeView.d.ts +23 -0
- package/dist/BpmnRuntimeView.js +109 -0
- package/dist/examples/orderFulfillment.d.ts +17 -0
- package/dist/examples/orderFulfillment.js +88 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/useBojtos.d.ts +70 -0
- package/dist/useBojtos.js +177 -0
- package/package.json +47 -0
- package/src/Bojtos.tsx +263 -0
- package/src/BpmnRuntimeView.tsx +148 -0
- package/src/bpmn-js.d.ts +18 -0
- package/src/examples/orderFulfillment.tsx +98 -0
- package/src/index.ts +41 -0
- package/src/useBojtos.ts +295 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { createBojtosSession, dispatchRound, dispatchWorkers, } from "@nanobpm/bojtos-kit";
|
|
3
|
+
/**
|
|
4
|
+
* React binding over a headless {@link BojtosSession}: owns the engine's
|
|
5
|
+
* lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
|
|
6
|
+
* exposes the engine commands. The consuming component owns its own form state
|
|
7
|
+
* (selected process, seed vars, per-job output) and drives the visual contract
|
|
8
|
+
* (`<BpmnRuntimeView>` + the variable payload) off `snapshot`.
|
|
9
|
+
*
|
|
10
|
+
* This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
|
|
11
|
+
* test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
|
|
12
|
+
* test).
|
|
13
|
+
*/
|
|
14
|
+
export function useBojtos({ bpmn, wasm }) {
|
|
15
|
+
const sessionRef = useRef(null);
|
|
16
|
+
const [phase, setPhase] = useState("loading");
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
const [processIds, setProcessIds] = useState([]);
|
|
19
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
20
|
+
const [events, setEvents] = useState([]);
|
|
21
|
+
// The wasm source is an init-time concern (the first `ensureWasm` wins), so
|
|
22
|
+
// keep it in a ref rather than the mount effect's deps — a fresh URL/bytes
|
|
23
|
+
// identity each render must not re-create the session.
|
|
24
|
+
const wasmRef = useRef(wasm);
|
|
25
|
+
wasmRef.current = wasm;
|
|
26
|
+
const deployInto = useCallback((session) => {
|
|
27
|
+
const res = session.deploy(bpmn);
|
|
28
|
+
setProcessIds(res.processIds);
|
|
29
|
+
setSnapshot(null);
|
|
30
|
+
setEvents([]);
|
|
31
|
+
setError(null);
|
|
32
|
+
}, [bpmn]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
// A new diagram means a fresh engine: drop back to `loading` and clear the
|
|
36
|
+
// previous session's state — including `processIds` — so consumers never
|
|
37
|
+
// see `ready` (or a stale process list) against a freed session while the
|
|
38
|
+
// new one is still loading.
|
|
39
|
+
setPhase("loading");
|
|
40
|
+
setProcessIds([]);
|
|
41
|
+
setSnapshot(null);
|
|
42
|
+
setEvents([]);
|
|
43
|
+
setError(null);
|
|
44
|
+
createBojtosSession({ wasm: wasmRef.current })
|
|
45
|
+
.then((session) => {
|
|
46
|
+
if (cancelled) {
|
|
47
|
+
session.free();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
deployInto(session);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
// A failed deploy (e.g. invalid BPMN) must free the just-created
|
|
55
|
+
// engine rather than leak it until unmount, and must not be stored
|
|
56
|
+
// as the active session.
|
|
57
|
+
session.free();
|
|
58
|
+
setError(String(e));
|
|
59
|
+
setPhase("error");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
sessionRef.current = session;
|
|
63
|
+
setPhase("ready");
|
|
64
|
+
})
|
|
65
|
+
.catch((e) => {
|
|
66
|
+
if (cancelled)
|
|
67
|
+
return;
|
|
68
|
+
setError(String(e));
|
|
69
|
+
setPhase("error");
|
|
70
|
+
});
|
|
71
|
+
return () => {
|
|
72
|
+
cancelled = true;
|
|
73
|
+
sessionRef.current?.free();
|
|
74
|
+
sessionRef.current = null;
|
|
75
|
+
};
|
|
76
|
+
}, [deployInto]);
|
|
77
|
+
const run = useCallback((fn) => {
|
|
78
|
+
const session = sessionRef.current;
|
|
79
|
+
if (!session)
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
const snap = fn(session);
|
|
83
|
+
setSnapshot(snap);
|
|
84
|
+
setEvents(session.events());
|
|
85
|
+
setError(null);
|
|
86
|
+
return snap;
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
setError(String(e));
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
const createInstance = useCallback((processId, variablesJson) => run((s) => s.createInstance(processId, variablesJson)), [run]);
|
|
94
|
+
const completeJob = useCallback((jobKey, variablesJson) => run((s) => s.completeJob(jobKey, variablesJson)), [run]);
|
|
95
|
+
const failJob = useCallback((jobKey, retries, message) => run((s) => s.failJob(jobKey, retries, message)), [run]);
|
|
96
|
+
const advanceTime = useCallback((byMs) => run((s) => s.advanceTime(byMs)), [run]);
|
|
97
|
+
const correlateMessage = useCallback((messageName, correlationKey, variablesJson) => run((s) => s.correlateMessage(messageName, correlationKey, variablesJson)), [run]);
|
|
98
|
+
const runWorkers = useCallback(async (workers, opts) => {
|
|
99
|
+
const session = sessionRef.current;
|
|
100
|
+
if (!session)
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
const { snapshot: settled } = await dispatchWorkers(session, workers, opts);
|
|
104
|
+
// The session may have been torn down/replaced (bpmn change, unmount)
|
|
105
|
+
// while we awaited — don't publish stale state or read a freed session.
|
|
106
|
+
if (sessionRef.current !== session)
|
|
107
|
+
return null;
|
|
108
|
+
setSnapshot(settled);
|
|
109
|
+
setEvents(session.events());
|
|
110
|
+
setError(null);
|
|
111
|
+
return settled;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
if (sessionRef.current !== session)
|
|
115
|
+
return null;
|
|
116
|
+
// Reflect whatever state the engine reached before the drain aborted
|
|
117
|
+
// (e.g. the maxRounds guard) so the view isn't left stale.
|
|
118
|
+
setSnapshot(session.snapshot());
|
|
119
|
+
setEvents(session.events());
|
|
120
|
+
setError(String(e));
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}, []);
|
|
124
|
+
const stepWorkers = useCallback(async (workers, opts) => {
|
|
125
|
+
const session = sessionRef.current;
|
|
126
|
+
if (!session)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const round = await dispatchRound(session, workers, opts);
|
|
130
|
+
// Bail if the session was replaced/freed while we awaited the round.
|
|
131
|
+
if (sessionRef.current !== session)
|
|
132
|
+
return null;
|
|
133
|
+
setSnapshot(round.snapshot);
|
|
134
|
+
setEvents(session.events());
|
|
135
|
+
setError(null);
|
|
136
|
+
return round;
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (sessionRef.current !== session)
|
|
140
|
+
return null;
|
|
141
|
+
setSnapshot(session.snapshot());
|
|
142
|
+
setEvents(session.events());
|
|
143
|
+
setError(String(e));
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}, []);
|
|
147
|
+
const reset = useCallback(() => {
|
|
148
|
+
const session = sessionRef.current;
|
|
149
|
+
if (!session)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
// Wipe the engine to its pristine state, then redeploy the diagram, so a
|
|
153
|
+
// re-run starts from zero instances/completions rather than accumulating
|
|
154
|
+
// across runs (a plain redeploy leaves prior instances resident).
|
|
155
|
+
session.reset();
|
|
156
|
+
deployInto(session);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
setError(String(e));
|
|
160
|
+
}
|
|
161
|
+
}, [deployInto]);
|
|
162
|
+
return {
|
|
163
|
+
phase,
|
|
164
|
+
error,
|
|
165
|
+
processIds,
|
|
166
|
+
snapshot,
|
|
167
|
+
events,
|
|
168
|
+
createInstance,
|
|
169
|
+
completeJob,
|
|
170
|
+
failJob,
|
|
171
|
+
advanceTime,
|
|
172
|
+
correlateMessage,
|
|
173
|
+
runWorkers,
|
|
174
|
+
stepWorkers,
|
|
175
|
+
reset,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nanobpm/bojtos-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React binding for the Bojtos in-browser BPMN demo framework (ADR 0043): the useBojtos hook (owns the engine session + reactive snapshot/event state) and the <BpmnRuntimeView> live token/incident diagram. Built on @nanobpm/bojtos-kit; the console test-run panel is its first consumer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Magikcraft/nano-bpm",
|
|
10
|
+
"directory": "bojtos-react"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./source": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"prepack": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@nanobpm/bojtos-kit": "^0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"bpmn-js": ">=17",
|
|
37
|
+
"react": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^18.3.11",
|
|
41
|
+
"react": "^18.3.1",
|
|
42
|
+
"typescript": "^5.6.3"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/Bojtos.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { JobHandler, WasmEvent } from "@nanobpm/bojtos-kit";
|
|
3
|
+
import { BpmnRuntimeView } from "./BpmnRuntimeView.js";
|
|
4
|
+
import { useBojtos } from "./useBojtos.js";
|
|
5
|
+
|
|
6
|
+
/** A single engine event handed to `onTrace` as the simulation runs. */
|
|
7
|
+
export type TraceEvent = WasmEvent;
|
|
8
|
+
|
|
9
|
+
export interface BojtosProps {
|
|
10
|
+
/** The BPMN diagram XML to run. */
|
|
11
|
+
bpmn: string;
|
|
12
|
+
/**
|
|
13
|
+
* The in-browser workers, keyed by the model's job type (task definition
|
|
14
|
+
* type). Each handler receives the activated job (with the instance's live
|
|
15
|
+
* variables) and returns the variables to merge on completion — or throws to
|
|
16
|
+
* fail the job. This is the code a demo author edits to shape the run.
|
|
17
|
+
*/
|
|
18
|
+
workers: Record<string, JobHandler>;
|
|
19
|
+
/** Initial instance variables (the starting payload). Defaults to `{}`. */
|
|
20
|
+
seed?: Record<string, unknown>;
|
|
21
|
+
/** Start the instance and run the workers automatically once ready. */
|
|
22
|
+
autoplay?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Milliseconds between dispatch rounds while playing (default 700). The pause
|
|
25
|
+
* is what makes the token visibly hop task-to-task instead of settling
|
|
26
|
+
* instantly.
|
|
27
|
+
*/
|
|
28
|
+
stepDelayMs?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Which deployed process to start. Defaults to the first process in the
|
|
31
|
+
* diagram — set this only for a multi-process `.bpmn`.
|
|
32
|
+
*/
|
|
33
|
+
processId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Optional engine wasm URL for bundlers where the default `import.meta.url`
|
|
36
|
+
* loader can't resolve the binary (ADR 0043 §3).
|
|
37
|
+
*/
|
|
38
|
+
wasmUrl?: string;
|
|
39
|
+
/** Called for every engine event as the simulation advances. */
|
|
40
|
+
onTrace?: (event: TraceEvent) => void;
|
|
41
|
+
/** Optional class for the outer container. */
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
46
|
+
|
|
47
|
+
const MARKER_CSS = `
|
|
48
|
+
.bojtos-diagram .nano-active .djs-visual > :nth-child(1) {
|
|
49
|
+
stroke: #10b981 !important;
|
|
50
|
+
stroke-width: 3px !important;
|
|
51
|
+
}
|
|
52
|
+
.bojtos-diagram .nano-incident .djs-visual > :nth-child(1) {
|
|
53
|
+
stroke: #ef4444 !important;
|
|
54
|
+
stroke-width: 3px !important;
|
|
55
|
+
fill: #fee2e2 !important;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The turnkey Bojtos demo component (ADR 0043 §2): drop in a `bpmn` diagram and
|
|
61
|
+
* a map of in-browser `workers`, and it renders the live token/incident diagram
|
|
62
|
+
* beside the running variable payload, driving the "activate → handler →
|
|
63
|
+
* complete/fail" loop so you watch the token advance and the payload mutate as
|
|
64
|
+
* each worker runs.
|
|
65
|
+
*
|
|
66
|
+
* The consuming app must load bpmn-js's diagram CSS once
|
|
67
|
+
* (`bpmn-js/dist/assets/diagram-js.css` and
|
|
68
|
+
* `.../bpmn-font/css/bpmn-embedded.css`); the token/incident marker styles are
|
|
69
|
+
* injected here.
|
|
70
|
+
*/
|
|
71
|
+
export function Bojtos({
|
|
72
|
+
bpmn,
|
|
73
|
+
workers,
|
|
74
|
+
seed,
|
|
75
|
+
autoplay,
|
|
76
|
+
stepDelayMs = 700,
|
|
77
|
+
processId,
|
|
78
|
+
wasmUrl,
|
|
79
|
+
onTrace,
|
|
80
|
+
className,
|
|
81
|
+
}: BojtosProps) {
|
|
82
|
+
const {
|
|
83
|
+
phase,
|
|
84
|
+
error,
|
|
85
|
+
processIds,
|
|
86
|
+
snapshot,
|
|
87
|
+
events,
|
|
88
|
+
createInstance,
|
|
89
|
+
stepWorkers,
|
|
90
|
+
reset,
|
|
91
|
+
} = useBojtos({ bpmn, wasm: wasmUrl });
|
|
92
|
+
|
|
93
|
+
const [playing, setPlaying] = useState(false);
|
|
94
|
+
const playingRef = useRef(false);
|
|
95
|
+
const startedRef = useRef(false);
|
|
96
|
+
// Tracks whether the component is still mounted, so the async play loop (which
|
|
97
|
+
// can outlive an unmount while awaiting `stepWorkers()` / `delay()`) neither
|
|
98
|
+
// sets state on an unmounted component nor keeps driving a freed session.
|
|
99
|
+
const mountedRef = useRef(true);
|
|
100
|
+
useEffect(
|
|
101
|
+
() => () => {
|
|
102
|
+
mountedRef.current = false;
|
|
103
|
+
playingRef.current = false;
|
|
104
|
+
},
|
|
105
|
+
[],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Keep the object/function props in refs so the play loop and the autoplay
|
|
109
|
+
// effect don't churn (or re-fire) when a parent re-renders with fresh
|
|
110
|
+
// identities for `workers` / `seed` / `onTrace`.
|
|
111
|
+
const workersRef = useRef(workers);
|
|
112
|
+
workersRef.current = workers;
|
|
113
|
+
const seedRef = useRef(seed);
|
|
114
|
+
seedRef.current = seed;
|
|
115
|
+
const onTraceRef = useRef(onTrace);
|
|
116
|
+
onTraceRef.current = onTrace;
|
|
117
|
+
|
|
118
|
+
// A fresh engine (bpmn change / reset drops back to `loading`) clears the
|
|
119
|
+
// "instance created" latch and stops any in-flight play loop.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (phase === "loading") {
|
|
122
|
+
startedRef.current = false;
|
|
123
|
+
playingRef.current = false;
|
|
124
|
+
setPlaying(false);
|
|
125
|
+
}
|
|
126
|
+
}, [phase]);
|
|
127
|
+
|
|
128
|
+
// Forward every newly-appended engine event to `onTrace`.
|
|
129
|
+
const emittedRef = useRef(0);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const cb = onTraceRef.current;
|
|
132
|
+
if (cb) {
|
|
133
|
+
for (let i = emittedRef.current; i < events.length; i++) cb(events[i]);
|
|
134
|
+
}
|
|
135
|
+
emittedRef.current = events.length;
|
|
136
|
+
}, [events]);
|
|
137
|
+
|
|
138
|
+
const ensureStarted = useCallback((): boolean => {
|
|
139
|
+
if (startedRef.current) return true;
|
|
140
|
+
const target = processId ?? processIds[0];
|
|
141
|
+
if (!target) return false;
|
|
142
|
+
// Only latch once the instance actually started — a failed createInstance
|
|
143
|
+
// (returns null, e.g. an engine error) must stay retryable rather than
|
|
144
|
+
// wedging the demo in a non-started state.
|
|
145
|
+
if (!createInstance(target, JSON.stringify(seedRef.current ?? {}))) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
startedRef.current = true;
|
|
149
|
+
return true;
|
|
150
|
+
}, [createInstance, processId, processIds]);
|
|
151
|
+
|
|
152
|
+
const step = useCallback(async () => {
|
|
153
|
+
if (phase !== "ready") return;
|
|
154
|
+
if (!ensureStarted()) return;
|
|
155
|
+
await stepWorkers(workersRef.current);
|
|
156
|
+
}, [phase, ensureStarted, stepWorkers]);
|
|
157
|
+
|
|
158
|
+
const play = useCallback(async () => {
|
|
159
|
+
if (phase !== "ready" || playingRef.current) return;
|
|
160
|
+
if (!ensureStarted()) return;
|
|
161
|
+
playingRef.current = true;
|
|
162
|
+
setPlaying(true);
|
|
163
|
+
try {
|
|
164
|
+
while (playingRef.current) {
|
|
165
|
+
const round = await stepWorkers(workersRef.current);
|
|
166
|
+
if (!round || round.handled === 0) break;
|
|
167
|
+
await delay(stepDelayMs);
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
playingRef.current = false;
|
|
171
|
+
if (mountedRef.current) setPlaying(false);
|
|
172
|
+
}
|
|
173
|
+
}, [phase, ensureStarted, stepWorkers, stepDelayMs]);
|
|
174
|
+
|
|
175
|
+
const pause = useCallback(() => {
|
|
176
|
+
playingRef.current = false;
|
|
177
|
+
setPlaying(false);
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const restart = useCallback(() => {
|
|
181
|
+
playingRef.current = false;
|
|
182
|
+
setPlaying(false);
|
|
183
|
+
startedRef.current = false;
|
|
184
|
+
reset();
|
|
185
|
+
}, [reset]);
|
|
186
|
+
|
|
187
|
+
// Autoplay once, when the engine first becomes ready.
|
|
188
|
+
const autoplayedRef = useRef(false);
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (autoplay && phase === "ready" && !autoplayedRef.current) {
|
|
191
|
+
autoplayedRef.current = true;
|
|
192
|
+
void play();
|
|
193
|
+
}
|
|
194
|
+
if (phase === "loading") autoplayedRef.current = false;
|
|
195
|
+
}, [autoplay, phase, play]);
|
|
196
|
+
|
|
197
|
+
const ready = phase === "ready";
|
|
198
|
+
const instance = snapshot?.instances[0];
|
|
199
|
+
const variables = instance?.variables ?? {};
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div
|
|
203
|
+
className={className}
|
|
204
|
+
style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 320 }}
|
|
205
|
+
>
|
|
206
|
+
<style>{MARKER_CSS}</style>
|
|
207
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
208
|
+
<button type="button" onClick={play} disabled={!ready || playing}>
|
|
209
|
+
▶ Play
|
|
210
|
+
</button>
|
|
211
|
+
<button type="button" onClick={pause} disabled={!playing}>
|
|
212
|
+
⏸ Pause
|
|
213
|
+
</button>
|
|
214
|
+
<button type="button" onClick={step} disabled={!ready || playing}>
|
|
215
|
+
⏭ Step
|
|
216
|
+
</button>
|
|
217
|
+
<button type="button" onClick={restart} disabled={!ready}>
|
|
218
|
+
↺ Reset
|
|
219
|
+
</button>
|
|
220
|
+
<span style={{ marginLeft: "auto", fontSize: 12, opacity: 0.7 }}>
|
|
221
|
+
{error
|
|
222
|
+
? `error: ${error}`
|
|
223
|
+
: phase === "loading"
|
|
224
|
+
? "loading engine…"
|
|
225
|
+
: instance
|
|
226
|
+
? instance.completed
|
|
227
|
+
? "completed"
|
|
228
|
+
: "running"
|
|
229
|
+
: "ready"}
|
|
230
|
+
</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div style={{ display: "flex", gap: 8, flex: 1, minHeight: 280 }}>
|
|
233
|
+
<div
|
|
234
|
+
className="bojtos-diagram"
|
|
235
|
+
style={{ flex: 2, border: "1px solid #e5e7eb", borderRadius: 6 }}
|
|
236
|
+
>
|
|
237
|
+
<BpmnRuntimeView
|
|
238
|
+
xml={bpmn}
|
|
239
|
+
activeIds={snapshot?.activeElementIds ?? []}
|
|
240
|
+
incidentIds={snapshot?.incidentElementIds ?? []}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
<div
|
|
244
|
+
style={{
|
|
245
|
+
flex: 1,
|
|
246
|
+
minWidth: 200,
|
|
247
|
+
border: "1px solid #e5e7eb",
|
|
248
|
+
borderRadius: 6,
|
|
249
|
+
padding: 8,
|
|
250
|
+
overflow: "auto",
|
|
251
|
+
font: "12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
252
|
+
background: "#f9fafb",
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<div style={{ fontWeight: 600, marginBottom: 4 }}>Variables</div>
|
|
256
|
+
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
257
|
+
{JSON.stringify(variables, null, 2)}
|
|
258
|
+
</pre>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
|
|
3
|
+
|
|
4
|
+
interface Canvas {
|
|
5
|
+
zoom(mode: string): void;
|
|
6
|
+
addMarker(elementId: string, marker: string): void;
|
|
7
|
+
removeMarker(elementId: string, marker: string): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Overlays {
|
|
11
|
+
add(
|
|
12
|
+
elementId: string,
|
|
13
|
+
overlay: {
|
|
14
|
+
position: Record<string, number>;
|
|
15
|
+
html: string | HTMLElement;
|
|
16
|
+
},
|
|
17
|
+
): string;
|
|
18
|
+
remove(id: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BpmnRuntimeViewProps {
|
|
22
|
+
/** The diagram XML to render. */
|
|
23
|
+
xml: string;
|
|
24
|
+
/** Element ids to highlight as active (token) — marker class `nano-active`. */
|
|
25
|
+
activeIds: string[];
|
|
26
|
+
/** Element ids to highlight as incidents — marker class `nano-incident`. */
|
|
27
|
+
incidentIds: string[];
|
|
28
|
+
/** Optional class for the container element (it always fills its parent). */
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read-only diagram that imports the XML once and updates token/incident markers
|
|
34
|
+
* in place (no re-import, so the zoom/scroll position is preserved while
|
|
35
|
+
* stepping through the simulation). This is the token-movement half of the
|
|
36
|
+
* Bojtos visual contract (ADR 0043 §4): drive `activeIds` / `incidentIds` from a
|
|
37
|
+
* session snapshot's `activeElementIds` / `incidentElementIds`.
|
|
38
|
+
*
|
|
39
|
+
* The consumer must load bpmn-js's diagram CSS (`bpmn-js/dist/assets/
|
|
40
|
+
* diagram-js.css` and `.../bpmn-font/css/bpmn-embedded.css`) once in the app,
|
|
41
|
+
* and provide the `.nano-active` / `.nano-incident` marker styles plus a
|
|
42
|
+
* `.nano-token` style for the token badge overlaid on each active element.
|
|
43
|
+
*/
|
|
44
|
+
export function BpmnRuntimeView({
|
|
45
|
+
xml,
|
|
46
|
+
activeIds,
|
|
47
|
+
incidentIds,
|
|
48
|
+
className,
|
|
49
|
+
}: BpmnRuntimeViewProps) {
|
|
50
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
const viewerRef = useRef<NavigatedViewer | null>(null);
|
|
52
|
+
const importedRef = useRef(false);
|
|
53
|
+
const markedRef = useRef<{ id: string; cls: string }[]>([]);
|
|
54
|
+
const tokenOverlaysRef = useRef<string[]>([]);
|
|
55
|
+
// Track the latest ids in a ref so the post-import `applyMarkers()` (fired from
|
|
56
|
+
// the `[xml]` effect's async `.then`) uses current values, not the ids that
|
|
57
|
+
// were current when the import started — otherwise ids changing mid-import
|
|
58
|
+
// would leave the diagram unmarked until the next change.
|
|
59
|
+
const idsRef = useRef({ activeIds, incidentIds });
|
|
60
|
+
idsRef.current = { activeIds, incidentIds };
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!containerRef.current) return;
|
|
64
|
+
const viewer = new NavigatedViewer({ container: containerRef.current });
|
|
65
|
+
viewerRef.current = viewer;
|
|
66
|
+
importedRef.current = false;
|
|
67
|
+
viewer
|
|
68
|
+
.importXML(xml)
|
|
69
|
+
.then(() => {
|
|
70
|
+
viewer.get<Canvas>("canvas").zoom("fit-viewport");
|
|
71
|
+
importedRef.current = true;
|
|
72
|
+
applyMarkers();
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {
|
|
75
|
+
/* malformed XML — leave blank */
|
|
76
|
+
});
|
|
77
|
+
return () => {
|
|
78
|
+
viewer.destroy();
|
|
79
|
+
viewerRef.current = null;
|
|
80
|
+
};
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [xml]);
|
|
83
|
+
|
|
84
|
+
function applyMarkers() {
|
|
85
|
+
const viewer = viewerRef.current;
|
|
86
|
+
if (!viewer || !importedRef.current) return;
|
|
87
|
+
const canvas = viewer.get<Canvas>("canvas");
|
|
88
|
+
for (const { id, cls } of markedRef.current) {
|
|
89
|
+
try {
|
|
90
|
+
canvas.removeMarker(id, cls);
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const next: { id: string; cls: string }[] = [];
|
|
96
|
+
for (const id of idsRef.current.activeIds) next.push({ id, cls: "nano-active" });
|
|
97
|
+
for (const id of idsRef.current.incidentIds)
|
|
98
|
+
next.push({ id, cls: "nano-incident" });
|
|
99
|
+
for (const { id, cls } of next) {
|
|
100
|
+
try {
|
|
101
|
+
canvas.addMarker(id, cls);
|
|
102
|
+
} catch {
|
|
103
|
+
/* element not in this diagram */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
markedRef.current = next;
|
|
107
|
+
|
|
108
|
+
// A visible token badge on each active element: an explicit "token is here"
|
|
109
|
+
// marker so movement reads clearly even when a class-only highlight is too
|
|
110
|
+
// subtle. Overlays are removed/re-added each update so the token hops with
|
|
111
|
+
// the frontier.
|
|
112
|
+
const overlays = viewer.get<Overlays>("overlays");
|
|
113
|
+
for (const id of tokenOverlaysRef.current) {
|
|
114
|
+
try {
|
|
115
|
+
overlays.remove(id);
|
|
116
|
+
} catch {
|
|
117
|
+
/* ignore */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const nextOverlays: string[] = [];
|
|
121
|
+
for (const id of idsRef.current.activeIds) {
|
|
122
|
+
try {
|
|
123
|
+
nextOverlays.push(
|
|
124
|
+
overlays.add(id, {
|
|
125
|
+
position: { top: -12, left: -12 },
|
|
126
|
+
html: '<div class="nano-token" aria-hidden="true"></div>',
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
/* element not in this diagram */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
tokenOverlaysRef.current = nextOverlays;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
applyMarkers();
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [activeIds, incidentIds]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
ref={containerRef}
|
|
144
|
+
className={className}
|
|
145
|
+
style={{ width: "100%", height: "100%" }}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/bpmn-js.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// bpmn-js ships no types for the `NavigatedViewer` entry point; declare the
|
|
2
|
+
// minimal surface `BpmnRuntimeView` uses. This ambient declaration is a build-
|
|
3
|
+
// time input only — it is never emitted into `dist/`, so it cannot collide with
|
|
4
|
+
// a consumer's own bpmn-js typings.
|
|
5
|
+
declare module "bpmn-js/lib/NavigatedViewer" {
|
|
6
|
+
export interface ImportResult {
|
|
7
|
+
warnings: unknown[];
|
|
8
|
+
}
|
|
9
|
+
export default class NavigatedViewer {
|
|
10
|
+
constructor(options: { container: HTMLElement });
|
|
11
|
+
importXML(xml: string): Promise<ImportResult>;
|
|
12
|
+
get<T = unknown>(service: string): T;
|
|
13
|
+
destroy(): void;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare module "bpmn-js/dist/assets/diagram-js.css";
|
|
18
|
+
declare module "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
|