@signalsafe/tree-spec-editor-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 ADDED
@@ -0,0 +1,43 @@
1
+ # @signalsafe/tree-spec-editor-react
2
+
3
+ Headless React layer for the SignalSafe TreeSpec graph editor. Renders the
4
+ React Flow canvas, owns React state plumbing, and exposes the editor as a
5
+ single React component — without depending on any specific UI library.
6
+
7
+ This is the React-specific sibling to **[`@signalsafe/tree-spec-editor-core`](../tree-spec-editor-core/README.md)**. The core package owns model + helpers (zero UI deps); this package owns React rendering. UI shells (e.g. `@signalsafe/tree-spec-editor` for React + Bootstrap, planned `@signalsafe/tree-spec-editor-react-mui` for React + Material) layer on top.
8
+
9
+ ## What this package owns
10
+
11
+ - **`TreeSpecGraphEditor`** — the React Flow canvas (Background, Controls, MiniMap, custom node renderer, transition edges, selection wiring, focus/fit-view).
12
+ - **`TreeSpecGraphEditorProps`** — props type.
13
+ - Future: framework-shaped headless hooks (e.g. `useTreeSpecEditorState`,
14
+ `useGraphSelection`) that wrap the framework-agnostic helpers from
15
+ `tree-spec-editor-core`.
16
+
17
+ ## What lives elsewhere
18
+
19
+ | Concern | Package |
20
+ |--------|---------|
21
+ | Editor model, tree operations, layout, autosave/keyboard helpers, constants | `@signalsafe/tree-spec-editor-core` |
22
+ | Sidebar panels, inspector, modals, toolbar (Bootstrap-styled) | `@signalsafe/tree-spec-editor` |
23
+ | Material-styled UI shells (planned) | `@signalsafe/tree-spec-editor-react-mui` |
24
+ | Angular implementation (planned) | `@signalsafe/tree-spec-editor-angular` |
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install @signalsafe/tree-spec-editor-react react react-dom reactflow
30
+ ```
31
+
32
+ `reactflow` is a peer dependency; you must install it (and ship its CSS,
33
+ e.g. `import 'reactflow/dist/style.css';`) in the consuming app. The
34
+ package itself imports the CSS file from its source, so bundlers that
35
+ resolve module references will pick it up automatically.
36
+
37
+ ## Why a separate package?
38
+
39
+ This layer is React-specific but **UI-library-agnostic**. Hosts that want
40
+ to ship a Material-styled editor only need to publish their own UI shell
41
+ (panels, modals, toolbar) — they reuse the canvas and the editor model
42
+ unchanged. This also keeps `@signalsafe/tree-spec-editor` (the
43
+ Bootstrap variant) from being the sole React entry point.
@@ -0,0 +1,16 @@
1
+ import 'reactflow/dist/style.css';
2
+ import { type EditorTree, type GraphSelection, type GraphEditorIssue } from '@signalsafe/tree-spec-editor-core';
3
+ export type TreeSpecGraphEditorProps = {
4
+ tree: EditorTree;
5
+ onChange: (next: EditorTree) => void;
6
+ issues?: GraphEditorIssue[];
7
+ showMiniMap?: boolean;
8
+ selected?: GraphSelection;
9
+ onSelect?: (sel: GraphSelection) => void;
10
+ focusNodeId?: string | null;
11
+ fitViewNonce?: number;
12
+ /** Optional class for the outer container (default includes h-70vh border rounded). */
13
+ className?: string;
14
+ };
15
+ export default function TreeSpecGraphEditor(props: Readonly<TreeSpecGraphEditorProps>): import("react/jsx-runtime").JSX.Element;
16
+ //# sourceMappingURL=TreeSpecGraphEditor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TreeSpecGraphEditor.d.ts","sourceRoot":"","sources":["../src/TreeSpecGraphEditor.tsx"],"names":[],"mappings":"AAkBA,OAAO,0BAA0B,CAAC;AAIlC,OAAO,EAKH,KAAK,UAAU,EAEf,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAGxB,MAAM,mCAAmC,CAAC;AAE3C,MAAM,MAAM,wBAAwB,GAAG;IACnC,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAkZF,MAAM,CAAC,OAAO,UAAU,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,wBAAwB,CAAC,2CAMpF"}
@@ -0,0 +1,274 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
3
+ import ReactFlow, { Background, Controls, Handle, MiniMap, Position, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, } from 'reactflow';
4
+ import 'reactflow/dist/style.css';
5
+ import { TERMINAL_OUTCOME, TREE_SPEC_ISSUE_SEVERITY } from '@signalsafe/tree-spec';
6
+ import { END_NODE_ID, GRAPH_SELECTION_KIND, safeUUID, } from '@signalsafe/tree-spec-editor-core';
7
+ /** Background highlight when the node matches editor selection (sidebar / issues / canvas). */
8
+ const CANVAS_NODE_SELECTED_CLASS = 'bg-primary-subtle';
9
+ function getPromptNodeBorderClass(hasErrors, warningCount) {
10
+ if (hasErrors) {
11
+ return 'border-danger';
12
+ }
13
+ if (warningCount > 0) {
14
+ return 'border-warning';
15
+ }
16
+ return '';
17
+ }
18
+ function getIssueEdgeStyle(style, hasIssue) {
19
+ if (!hasIssue) {
20
+ return style;
21
+ }
22
+ return style ? { ...style, strokeWidth: 2, strokeDasharray: '6 4' } : { strokeWidth: 2, strokeDasharray: '6 4' };
23
+ }
24
+ function PromptNode({ data, selected }) {
25
+ const n = data.node;
26
+ const choices = n.choices ?? [];
27
+ const hasErrors = data.issuesErrors > 0;
28
+ const borderClass = getPromptNodeBorderClass(hasErrors, data.issuesWarnings);
29
+ return (_jsxs("div", { className: `card card-min-width-280 ${borderClass}${selected ? ` ${CANVAS_NODE_SELECTED_CLASS}` : ''}`, children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-default" }), _jsxs("div", { className: "card-body p-2", children: [_jsxs("div", { className: "d-flex justify-content-between align-items-center", children: [_jsxs("div", { children: [_jsxs("div", { className: "fw-bold font-size-13", children: [data.isStart ? '▶ ' : '', n.type, data.issuesTotal > 0 ? (_jsxs("span", { className: "ms-2", title: `${data.issuesErrors} errors, ${data.issuesWarnings} warnings, ${data.issuesInfo} info`, children: [data.issuesErrors > 0 ? _jsx("span", { className: "badge bg-danger me-1", children: data.issuesErrors }) : null, data.issuesWarnings > 0 ? (_jsx("span", { className: "badge bg-warning text-dark me-1", children: data.issuesWarnings })) : null, data.issuesInfo > 0 ? _jsx("span", { className: "badge bg-info text-dark", children: data.issuesInfo }) : null] })) : null] }), _jsx("div", { className: "font-size-12 opacity-90 text-truncate", children: n.prompt || _jsx("em", { className: "text-muted", children: "(empty prompt)" }) })] }), _jsx("div", { className: "text-muted font-size-11", children: n.id.slice(0, 8) })] }), _jsx("hr", { className: "my-2" }), _jsx("div", { className: "font-size-12", children: choices.length === 0 ? (_jsx("div", { className: "text-muted", children: _jsx("em", { children: "No choices" }) })) : (_jsx("ul", { className: "list-unstyled mb-0", children: choices.map((c) => (_jsxs("li", { className: "d-flex justify-content-between align-items-center position-relative pr-18", children: [_jsx("span", { className: "text-truncate max-w-210", children: c.label }), _jsx("span", { className: "badge bg-light text-dark", children: c.id }), _jsx(Handle, { type: "source", position: Position.Right, id: `choice:${c.id}`, className: "graph-editor-handle", style: {
30
+ position: 'absolute',
31
+ right: -6,
32
+ top: '50%',
33
+ transform: 'translateY(-50%)',
34
+ } })] }, c.id))) })) })] })] }));
35
+ }
36
+ function EndNode({ selected }) {
37
+ return (_jsxs("div", { className: `card border-danger w-180${selected ? ` ${CANVAS_NODE_SELECTED_CLASS}` : ''}`, children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-danger" }), _jsxs("div", { className: "card-body p-2 text-center", children: [_jsx("div", { className: "fw-bold text-danger", children: "END" }), _jsx("div", { className: "text-muted font-size-12", children: "Outcome required" })] })] }));
38
+ }
39
+ function choiceIdFromHandle(h) {
40
+ if (!h)
41
+ return '';
42
+ if (h.startsWith('choice:'))
43
+ return h.slice('choice:'.length);
44
+ return h;
45
+ }
46
+ function edgeLabelForTransition(tree, t) {
47
+ const node = tree.nodes[t.fromNodeId];
48
+ const choice = node?.choices?.find((c) => c.id === t.fromChoiceId);
49
+ const base = choice?.label || t.fromChoiceId;
50
+ if (t.toNodeId === END_NODE_ID) {
51
+ const oc = t.outcome ?? TERMINAL_OUTCOME.AT_RISK;
52
+ return `${base} → END (${oc})`;
53
+ }
54
+ return base;
55
+ }
56
+ function buildEdgesFromTransitions(tree) {
57
+ return tree.transitions.map((t) => ({
58
+ id: t.id,
59
+ source: t.fromNodeId,
60
+ target: t.toNodeId,
61
+ sourceHandle: `choice:${t.fromChoiceId}`,
62
+ label: edgeLabelForTransition(tree, t),
63
+ }));
64
+ }
65
+ function buildTransitionsFromEdges(edges, existing) {
66
+ const existingById = new Map(existing.map((t) => [t.id, t]));
67
+ return edges
68
+ .filter((e) => e.source && e.target && e.source !== END_NODE_ID)
69
+ .map((e) => {
70
+ const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
71
+ const prior = existingById.get(String(e.id));
72
+ return {
73
+ id: String(e.id),
74
+ fromNodeId: String(e.source),
75
+ fromChoiceId: fromChoiceId,
76
+ toNodeId: String(e.target),
77
+ outcome: String(e.target) === END_NODE_ID ? (prior?.outcome ?? TERMINAL_OUTCOME.AT_RISK) : undefined,
78
+ };
79
+ })
80
+ .filter((t) => t.fromChoiceId.length > 0);
81
+ }
82
+ function TreeSpecGraphInner({ tree, onChange, issues = [], selected, onSelect, focusNodeId, showMiniMap = true, fitViewNonce, className = 'h-70vh border rounded', }) {
83
+ const rf = useReactFlow();
84
+ const isDraggingRef = useRef(false);
85
+ const nodesRef = useRef([]);
86
+ const edgesRef = useRef([]);
87
+ const issuesByNode = useMemo(() => {
88
+ const m = new Map();
89
+ for (const i of issues) {
90
+ if (!i.node_id)
91
+ continue;
92
+ const cur = m.get(i.node_id) ?? { total: 0, errors: 0, warnings: 0, info: 0 };
93
+ cur.total += 1;
94
+ const sev = String(i.severity ?? '').toLowerCase();
95
+ if (sev === TREE_SPEC_ISSUE_SEVERITY.WARNING)
96
+ cur.warnings += 1;
97
+ else if (sev === TREE_SPEC_ISSUE_SEVERITY.INFO)
98
+ cur.info += 1;
99
+ else
100
+ cur.errors += 1;
101
+ m.set(i.node_id, cur);
102
+ }
103
+ return m;
104
+ }, [issues]);
105
+ const issueKeySet = useMemo(() => {
106
+ const s = new Set();
107
+ for (const i of issues) {
108
+ if (!i.node_id || !i.choice_id)
109
+ continue;
110
+ s.add(`${i.node_id}::${i.choice_id}`);
111
+ }
112
+ return s;
113
+ }, [issues]);
114
+ const initialNodes = useMemo(() => {
115
+ const arr = [];
116
+ for (const n of Object.values(tree.nodes)) {
117
+ arr.push({
118
+ id: n.id,
119
+ type: 'promptNode',
120
+ position: n.position ?? { x: 0, y: 0 },
121
+ data: {
122
+ node: n,
123
+ isStart: tree.start_node === n.id,
124
+ issuesTotal: issuesByNode.get(n.id)?.total ?? 0,
125
+ issuesErrors: issuesByNode.get(n.id)?.errors ?? 0,
126
+ issuesWarnings: issuesByNode.get(n.id)?.warnings ?? 0,
127
+ issuesInfo: issuesByNode.get(n.id)?.info ?? 0,
128
+ },
129
+ });
130
+ }
131
+ arr.push({
132
+ id: END_NODE_ID,
133
+ type: 'endNode',
134
+ position: { x: 700, y: 0 },
135
+ data: {},
136
+ selectable: true,
137
+ draggable: true,
138
+ });
139
+ return arr;
140
+ }, [tree.nodes, tree.start_node, issuesByNode]);
141
+ const initialEdges = useMemo(() => buildEdgesFromTransitions(tree), [tree]);
142
+ const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
143
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
144
+ useEffect(() => {
145
+ nodesRef.current = nodes;
146
+ }, [nodes]);
147
+ useEffect(() => {
148
+ edgesRef.current = edges;
149
+ }, [edges]);
150
+ const lastTreeRef = useRef('');
151
+ useEffect(() => {
152
+ const nextKey = JSON.stringify({ tree, issues: issues.length });
153
+ if (nextKey === lastTreeRef.current)
154
+ return;
155
+ lastTreeRef.current = nextKey;
156
+ setNodes(initialNodes);
157
+ setEdges(initialEdges);
158
+ }, [tree, issues.length, initialNodes, initialEdges, setNodes, setEdges]);
159
+ useEffect(() => {
160
+ if (!focusNodeId)
161
+ return;
162
+ try {
163
+ const n = rf.getNode(focusNodeId);
164
+ if (!n)
165
+ return;
166
+ rf.setCenter(n.position.x + 150, n.position.y + 60, { zoom: 1, duration: 300 });
167
+ }
168
+ catch {
169
+ // ignore
170
+ }
171
+ }, [focusNodeId, rf]);
172
+ useEffect(() => {
173
+ if (!fitViewNonce)
174
+ return;
175
+ const id = setTimeout(() => {
176
+ try {
177
+ rf.fitView({ padding: 0.2, duration: 300 });
178
+ }
179
+ catch {
180
+ // ignore
181
+ }
182
+ }, 100);
183
+ return () => clearTimeout(id);
184
+ }, [fitViewNonce, rf]);
185
+ const nodeTypes = useMemo(() => ({
186
+ promptNode: PromptNode,
187
+ endNode: EndNode,
188
+ }), []);
189
+ const commit = useCallback((nextNodes, nextEdges) => {
190
+ if (isDraggingRef.current)
191
+ return;
192
+ const updatedNodes = { ...tree.nodes };
193
+ for (const n of nextNodes) {
194
+ if (n.id === END_NODE_ID)
195
+ continue;
196
+ const existing = updatedNodes[n.id];
197
+ if (!existing)
198
+ continue;
199
+ updatedNodes[n.id] = {
200
+ ...existing,
201
+ position: { x: n.position.x, y: n.position.y },
202
+ };
203
+ }
204
+ const transitions = buildTransitionsFromEdges(nextEdges, tree.transitions);
205
+ onChange({
206
+ ...tree,
207
+ nodes: updatedNodes,
208
+ transitions,
209
+ });
210
+ }, [tree, onChange]);
211
+ const onConnect = useCallback((conn) => {
212
+ const choiceId = choiceIdFromHandle(conn.sourceHandle);
213
+ if (!conn.source || !conn.target || !choiceId)
214
+ return;
215
+ const newEdge = {
216
+ id: safeUUID(),
217
+ source: conn.source,
218
+ target: conn.target,
219
+ sourceHandle: conn.sourceHandle,
220
+ label: choiceId,
221
+ };
222
+ setEdges((eds) => {
223
+ const next = addEdge(newEdge, eds);
224
+ commit(nodesRef.current, next);
225
+ return next;
226
+ });
227
+ }, [setEdges, commit]);
228
+ const onNodesChangeWrapped = useCallback((changes) => {
229
+ onNodesChange(changes);
230
+ const shouldCommit = changes.some((c) => {
231
+ if (c?.type === 'select' || c?.type === 'dimensions')
232
+ return false;
233
+ if (c?.type === 'position' && c?.dragging)
234
+ return false;
235
+ return true;
236
+ });
237
+ if (shouldCommit)
238
+ queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
239
+ }, [onNodesChange, commit]);
240
+ const onEdgesChangeWrapped = useCallback((changes) => {
241
+ onEdgesChange(changes);
242
+ const shouldCommit = changes.some((c) => c?.type !== 'select');
243
+ if (shouldCommit)
244
+ queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
245
+ }, [onEdgesChange, commit]);
246
+ const onNodeDragStart = useCallback(() => {
247
+ isDraggingRef.current = true;
248
+ }, []);
249
+ const onNodeDragStop = useCallback(() => {
250
+ isDraggingRef.current = false;
251
+ commit(nodesRef.current, edgesRef.current);
252
+ }, [commit]);
253
+ const onNodeClick = useCallback((_evt, node) => {
254
+ onSelect?.({ kind: GRAPH_SELECTION_KIND.NODE, id: node.id });
255
+ }, [onSelect]);
256
+ const onEdgeClick = useCallback((_evt, edge) => {
257
+ onSelect?.({ kind: GRAPH_SELECTION_KIND.EDGE, id: edge.id });
258
+ }, [onSelect]);
259
+ return (_jsx("div", { className: className, children: _jsxs(ReactFlow, { nodes: nodes.map((n) => ({
260
+ ...n,
261
+ selected: selected?.kind === GRAPH_SELECTION_KIND.NODE && selected.id === n.id,
262
+ })), edges: edges.map((e) => {
263
+ const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
264
+ const hasIssue = fromChoiceId ? issueKeySet.has(`${e.source}::${fromChoiceId}`) : false;
265
+ return {
266
+ ...e,
267
+ selected: selected?.kind === GRAPH_SELECTION_KIND.EDGE && selected.id === e.id,
268
+ style: getIssueEdgeStyle(e.style, hasIssue),
269
+ };
270
+ }), onNodesChange: onNodesChangeWrapped, onEdgesChange: onEdgesChangeWrapped, onConnect: onConnect, onNodeDragStart: onNodeDragStart, onNodeDragStop: onNodeDragStop, onNodeClick: onNodeClick, onEdgeClick: onEdgeClick, nodeTypes: nodeTypes, children: [showMiniMap ? _jsx(MiniMap, {}) : null, _jsx(Controls, {}), _jsx(Background, {})] }) }));
271
+ }
272
+ export default function TreeSpecGraphEditor(props) {
273
+ return (_jsx(ReactFlowProvider, { children: _jsx(TreeSpecGraphInner, { ...props }) }));
274
+ }
@@ -0,0 +1,337 @@
1
+ import type { AutosaveStatus, EditorChoice, EditorNode, EditorTransition, EditorTree, GraphSelection, KeyboardShortcutAction, TreeSpecAuditEventItem, TreeSpecIssue, TreeSpecSnapshotItem, TreeSpecWire, TreeTemplateSpec } from '@signalsafe/tree-spec-editor-core';
2
+ /**
3
+ * Optional metadata returned with {@link TreeSpecEditorAdapter.getVersion} for
4
+ * host info panels (e.g. scenario id, version label, timestamps).
5
+ */
6
+ export interface GraphEditorVersionInfo {
7
+ scenarioId: string;
8
+ versionId: string;
9
+ /** Display name (e.g. scenario title or version label). */
10
+ name: string;
11
+ createdAt: string | null;
12
+ updatedAt: string | null;
13
+ }
14
+ /**
15
+ * Data-only adapter consumed by {@link useTreeSpecEditor}. Hosts pass an
16
+ * object satisfying this contract; the hook never reads or writes routing,
17
+ * URLs, or page chrome through it. Host pages may extend this adapter with
18
+ * extra methods (e.g. `getPreviewUrl`, `getBackUrl`) for their own use —
19
+ * those methods are not part of the hook contract.
20
+ *
21
+ * Every method except `getVersion` and `updateVersion` is optional; the hook
22
+ * gates the corresponding feature (Validate / Publish / Snapshots / Audit /
23
+ * Clone) on its presence.
24
+ */
25
+ export interface TreeSpecEditorAdapter {
26
+ /**
27
+ * Load the current TreeSpec wire and published flag for an entity.
28
+ * Returning `null` makes the hook surface a "failed to load" state.
29
+ */
30
+ getVersion: (entityId: string) => Promise<{
31
+ tree_spec: Record<string, unknown>;
32
+ is_published: boolean;
33
+ info?: GraphEditorVersionInfo;
34
+ } | null>;
35
+ /** Persist the current TreeSpec wire (called by Save Draft + autosave). */
36
+ updateVersion: (entityId: string, payload: {
37
+ tree_spec: Record<string, unknown>;
38
+ }) => Promise<void>;
39
+ /**
40
+ * Optional server-side validation. When absent, Validate becomes a local
41
+ * (lint-only) operation and the toolbar should hide the Validate button.
42
+ */
43
+ validate?: (entityId: string, tree_spec: Record<string, unknown>) => Promise<{
44
+ valid: boolean;
45
+ issues?: AdapterValidationIssue[];
46
+ }>;
47
+ /** Optional publish. When absent, Publish is unavailable. */
48
+ publish?: (entityId: string) => Promise<void>;
49
+ /** Optional snapshot listing. When absent, Draft history is unavailable. */
50
+ listSnapshots?: (entityId: string) => Promise<TreeSpecSnapshotItem[]>;
51
+ /** Optional snapshot restore. */
52
+ restoreSnapshot?: (entityId: string, snapshotId: string) => Promise<{
53
+ tree_spec: Record<string, unknown>;
54
+ }>;
55
+ /** Optional manual snapshot creation. */
56
+ createSnapshot?: (entityId: string, payload: {
57
+ label?: string;
58
+ tree_spec: Record<string, unknown>;
59
+ }) => Promise<void>;
60
+ /**
61
+ * Optional clone-to-draft. Returns the id of the newly created draft.
62
+ * When absent, Clone is unavailable.
63
+ */
64
+ cloneToDraft?: (entityId: string) => Promise<{
65
+ id: string;
66
+ }>;
67
+ /** Optional audit listing. When absent, Audit is unavailable. */
68
+ listAudit?: (entityId: string) => Promise<TreeSpecAuditEventItem[]>;
69
+ }
70
+ /**
71
+ * Adapter-shaped validation issue. Hosts that surface server validation should
72
+ * normalize their backend payload to this shape inside the adapter — the hook
73
+ * then maps it into the standard {@link TreeSpecIssue}.
74
+ */
75
+ export interface AdapterValidationIssue {
76
+ severity?: 'error' | 'warning' | 'info';
77
+ level?: string;
78
+ message?: string;
79
+ node_id?: string;
80
+ choice_id?: string;
81
+ }
82
+ /**
83
+ * Input options for {@link useTreeSpecEditor}. Only `adapter` and `entityId`
84
+ * are required; everything else is opt-in.
85
+ */
86
+ export interface UseTreeSpecEditorOptions {
87
+ /** Data adapter for the editor (see {@link TreeSpecEditorAdapter}). */
88
+ adapter: TreeSpecEditorAdapter;
89
+ /**
90
+ * Stable identifier for the entity being edited (host extracts from its
91
+ * router, e.g. `useParams().scenarioVersionId`). `undefined` puts the
92
+ * hook in a loading-without-target state until a value is supplied.
93
+ */
94
+ entityId: string | undefined;
95
+ /**
96
+ * Autosave debounce delay in milliseconds. Default `2500`.
97
+ * Ignored when {@link enableAutosave} is `false`.
98
+ */
99
+ autosaveDebounceMs?: number;
100
+ /** Enable/disable autosave entirely. Default `true`. */
101
+ enableAutosave?: boolean;
102
+ /** Enable/disable the global keyboard shortcut listener. Default `true`. */
103
+ enableKeyboardShortcuts?: boolean;
104
+ /**
105
+ * Coerce the raw `tree_spec` payload returned by the adapter into a
106
+ * `TreeSpecWire`. Defaults to {@link coerceTreeSpecWireForEditor} from
107
+ * `@signalsafe/tree-spec-editor-core`, which passes valid wires through
108
+ * and bootstraps a starter graph for `null`/`undefined`/empty payloads.
109
+ * Override to change the bootstrap behavior (e.g. ship a richer template,
110
+ * or refuse to bootstrap).
111
+ */
112
+ coerceRawSpec?: (raw: unknown) => TreeSpecWire | null;
113
+ /**
114
+ * Optional computation of runtime issues from the compiled TreeSpec.
115
+ * Hosts that integrate with the simulator pass
116
+ * `treeSpecRuntimeIssues` from `@signalsafe/simulator-core`. Hosts that
117
+ * don't can omit this; runtime issues will be an empty array.
118
+ * The hook itself never imports the simulator package.
119
+ */
120
+ computeRuntimeIssues?: (compiled: Record<string, unknown>) => TreeSpecIssue[];
121
+ /**
122
+ * When `true`, the runtime-issue category is folded into the developer
123
+ * tools surface rather than appearing in the dedup'd `issues` list. Host
124
+ * supplies this from its debug-mode toggle. Default `false`.
125
+ */
126
+ debugMode?: boolean;
127
+ /**
128
+ * Invoked when the user triggers the Preview action (toolbar button or
129
+ * `Ctrl/Cmd+P` keyboard shortcut). Hosts wire their router here.
130
+ */
131
+ onPreview?: () => void;
132
+ /**
133
+ * Invoked after a successful `cloneToDraft` with the new draft id. Hosts
134
+ * wire their router here.
135
+ */
136
+ onCloneNavigate?: (newDraftId: string) => void;
137
+ /**
138
+ * Optional parser for backend validation errors that arrive as a single
139
+ * detail string (e.g. Pydantic outcome errors). Defaults to
140
+ * {@link parsePydanticOutcomeErrors} from
141
+ * `@signalsafe/tree-spec-editor-core`.
142
+ */
143
+ parseServerErrorMessage?: (message: string) => TreeSpecIssue[] | null;
144
+ /**
145
+ * Decide whether the hook should auto-validate on initial load. Defaults
146
+ * to {@link shouldQueueInitialValidation} from
147
+ * `@signalsafe/tree-spec-editor-core` (validates drafts, skips published).
148
+ */
149
+ shouldQueueInitialValidation?: (isPublished: boolean | undefined) => boolean;
150
+ }
151
+ /** Mutable editor state surfaced by {@link useTreeSpecEditor}. */
152
+ export interface UseTreeSpecEditorState {
153
+ /** Loading the entity from the adapter. `true` until first load resolves or rejects. */
154
+ loading: boolean;
155
+ /** A `Save Draft` round-trip is in flight. */
156
+ saving: boolean;
157
+ /** A `Publish` round-trip is in flight. */
158
+ publishing: boolean;
159
+ /** A `Snapshot` round-trip is in flight. */
160
+ creatingSnapshot: boolean;
161
+ /** A `Clone to Draft` round-trip is in flight. */
162
+ cloning: boolean;
163
+ /** Id of the snapshot currently being restored, or `null`. */
164
+ restoringSnapshotId: string | null;
165
+ /** Autosave state machine snapshot. */
166
+ autosaveStatus: AutosaveStatus;
167
+ /** ISO timestamp of the most recent validation, or `null`. */
168
+ lastValidatedAt: string | null;
169
+ /** The most recent raw wire from the adapter (or the last successful save). */
170
+ rawTreeSpec: TreeSpecWire | null;
171
+ /** Editor-shaped tree derived from `rawTreeSpec` plus user edits. */
172
+ tree: EditorTree | null;
173
+ /**
174
+ * Decompiled `rawTreeSpec` — used by the Publish modal to diff against the
175
+ * current `tree`. `null` until the initial load resolves.
176
+ */
177
+ baselineTree: EditorTree | null;
178
+ /** Compiled `tree`, or `null` when `tree` is `null`. Memoized. */
179
+ compiledTreeSpec: Record<string, unknown> | null;
180
+ /** Whether the loaded entity is published (read-only). */
181
+ isPublished: boolean;
182
+ /**
183
+ * Optional version/scenario metadata from {@link TreeSpecEditorAdapter.getVersion}
184
+ * (e.g. for an info panel). `null` when absent or not yet loaded.
185
+ */
186
+ versionInfo: GraphEditorVersionInfo | null;
187
+ /** Convenience: `Boolean(tree)`. */
188
+ hasTree: boolean;
189
+ /** Local lint issues (editor + wire) — recomputed on every tree change. */
190
+ localIssues: TreeSpecIssue[];
191
+ /** Server validation issues — populated by `actions.validate`. */
192
+ serverIssues: TreeSpecIssue[];
193
+ /**
194
+ * Runtime issues from {@link UseTreeSpecEditorOptions.computeRuntimeIssues}
195
+ * (empty when no callback is wired).
196
+ */
197
+ runtimeIssues: TreeSpecIssue[];
198
+ /** Dedup'd union of `localIssues`, `serverIssues`, and `runtimeIssues`. */
199
+ issues: TreeSpecIssue[];
200
+ /** `true` when `issues` contains no error-severity entries. */
201
+ canPublish: boolean;
202
+ /** Current graph selection (node, edge, or none). */
203
+ selection: GraphSelection;
204
+ /** Node the canvas should focus into view (e.g. after add/duplicate). */
205
+ focusNodeId: string | null;
206
+ /** Choice within the focused node to scroll into view. */
207
+ focusChoiceId: string | null;
208
+ /** Bump-counter the canvas watches to trigger `fitView`. */
209
+ fitViewNonce: number;
210
+ /** Node object for `selection` when it's a NODE selection; `null` otherwise. */
211
+ selectedNode: EditorNode | null;
212
+ /** Transition object for `selection` when it's an EDGE selection; `null` otherwise. */
213
+ selectedEdge: EditorTransition | null;
214
+ /** Search filter for the Nodes panel. */
215
+ nodeSearch: string;
216
+ /** Search filter for the Issues panel. */
217
+ issueSearch: string;
218
+ /** Whether the canvas mini-map is visible. */
219
+ showMiniMap: boolean;
220
+ /** Snapshot list (loaded when `showDraftHistory` opens). */
221
+ snapshots: TreeSpecSnapshotItem[];
222
+ /** Audit event list (loaded when `showAudit` opens). */
223
+ auditEvents: TreeSpecAuditEventItem[];
224
+ /** Fetching the snapshot list. */
225
+ loadingSnapshots: boolean;
226
+ /** Fetching the audit event list. */
227
+ loadingAudit: boolean;
228
+ /** Draft-history modal visibility. */
229
+ showDraftHistory: boolean;
230
+ /** Audit modal visibility. */
231
+ showAudit: boolean;
232
+ /** Publish-review modal visibility. */
233
+ showPublishModal: boolean;
234
+ }
235
+ /** Actions surfaced by {@link useTreeSpecEditor}. */
236
+ export interface UseTreeSpecEditorActions {
237
+ /** Replace the entire tree (e.g. after the canvas emits a change). */
238
+ setTree: (next: EditorTree | null) => void;
239
+ /** Replace the current selection. */
240
+ setSelection: (next: GraphSelection) => void;
241
+ /** Replace the node-focus marker. */
242
+ setFocusNodeId: (id: string | null) => void;
243
+ /** Replace the choice-focus marker. */
244
+ setFocusChoiceId: (id: string | null) => void;
245
+ /** Bump the fitView nonce (e.g. for a "Reset view" button). */
246
+ triggerResetView: () => void;
247
+ /** Update the Nodes-panel search filter. */
248
+ setNodeSearch: (next: string) => void;
249
+ /** Update the Issues-panel search filter. */
250
+ setIssueSearch: (next: string) => void;
251
+ /** Toggle the canvas mini-map. */
252
+ setShowMiniMap: (next: boolean) => void;
253
+ /** Show/hide the draft-history modal. */
254
+ setShowDraftHistory: (next: boolean) => void;
255
+ /** Show/hide the audit modal. */
256
+ setShowAudit: (next: boolean) => void;
257
+ /** Show/hide the publish-review modal. */
258
+ setShowPublishModal: (next: boolean) => void;
259
+ /**
260
+ * Spawn a new node of the supplied type (host owns the type vocabulary).
261
+ * Returns the id of the spawned node, or `undefined` when no tree is loaded.
262
+ */
263
+ addNodeOfType: (type: string, patch?: Partial<EditorNode>) => string | undefined;
264
+ /**
265
+ * Remove the currently selected node (same semantics as the Delete keyboard shortcut).
266
+ * Returns `true` when a node was removed. No-op (returns `false`) when there is no tree,
267
+ * nothing is selected, the selection is not a node, the version is published, or delete is
268
+ * not allowed for that node.
269
+ */
270
+ deleteSelectedNode: () => boolean;
271
+ /**
272
+ * Remove a node by id (clears selection / focus when they pointed at that node).
273
+ * Returns `true` when the node was removed. No-op when there is no tree, the version is
274
+ * published, or `deleteNode` refuses (e.g. invalid id).
275
+ */
276
+ deleteNodeById: (nodeId: string) => boolean;
277
+ /** Run the auto-layout helper and bump the fitView nonce. */
278
+ autoLayout: () => void;
279
+ /** Apply a starter template (host owns the template vocabulary). */
280
+ insertTemplate: (spec: TreeTemplateSpec) => void;
281
+ /**
282
+ * Run server-side validation if the adapter supports it; otherwise just
283
+ * stamp `lastValidatedAt`. Pass an explicit wire to validate something
284
+ * other than the current `tree`. Returns the adapter's payload, or
285
+ * `{ valid: false, issues }` when the call rejected.
286
+ */
287
+ validate: (specOverride?: TreeSpecWire) => Promise<{
288
+ valid: boolean;
289
+ issues?: AdapterValidationIssue[];
290
+ } | undefined>;
291
+ /** Save the current draft via the adapter. No-op when published or loading. */
292
+ saveDraft: () => Promise<void>;
293
+ /**
294
+ * Validate then publish. Surfaces validation errors via `serverIssues`
295
+ * and refuses to publish when `canPublish` is `false`.
296
+ */
297
+ publish: () => Promise<void>;
298
+ /** Create a snapshot of the current tree via the adapter. */
299
+ createSnapshot: () => Promise<void>;
300
+ /** Restore the tree to a snapshot via the adapter; clears local edits. */
301
+ restoreSnapshot: (snapshotId: string) => Promise<void>;
302
+ /**
303
+ * Clone the current entity to a new draft via the adapter and invoke
304
+ * {@link UseTreeSpecEditorOptions.onCloneNavigate} when the new id arrives.
305
+ */
306
+ cloneToDraft: () => Promise<void>;
307
+ /** Patch the currently-selected node. No-op when no node is selected. */
308
+ updateSelectedNode: (patch: Partial<EditorNode>) => void;
309
+ /** Append a new (empty) choice to the currently-selected node. */
310
+ addChoice: () => void;
311
+ /** Remove a choice from the selected node (and its outgoing transitions). */
312
+ deleteChoice: (choiceId: string) => void;
313
+ /** Re-target a choice's transition (creates the transition if missing). */
314
+ setChoiceTarget: (choiceId: string, targetNodeId: string) => void;
315
+ /** Update the terminal outcome of a choice targeting the END node. */
316
+ setChoiceOutcome: (choiceId: string, outcome: string) => void;
317
+ /**
318
+ * Click an issue from the Issues panel: focuses the issue's node + choice,
319
+ * selects the node, and bumps the fitView nonce.
320
+ */
321
+ selectIssue: (issue: {
322
+ node_id?: string;
323
+ choice_id?: string;
324
+ }) => void;
325
+ }
326
+ /** Return value of {@link useTreeSpecEditor}. */
327
+ export interface UseTreeSpecEditorResult extends UseTreeSpecEditorState {
328
+ actions: UseTreeSpecEditorActions;
329
+ }
330
+ /**
331
+ * Map of keyboard shortcut actions the hook handled most recently. Test-only
332
+ * surface; not part of the public API.
333
+ * @internal
334
+ */
335
+ export type DispatchedKeyboardAction = KeyboardShortcutAction | null;
336
+ export type { EditorChoice, EditorNode, EditorTransition, EditorTree };
337
+ //# sourceMappingURL=types.d.ts.map