@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 +43 -0
- package/dist/TreeSpecGraphEditor.d.ts +16 -0
- package/dist/TreeSpecGraphEditor.d.ts.map +1 -0
- package/dist/TreeSpecGraphEditor.js +274 -0
- package/dist/hooks/types.d.ts +337 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +1 -0
- package/dist/hooks/useTreeSpecEditor.d.ts +18 -0
- package/dist/hooks/useTreeSpecEditor.d.ts.map +1 -0
- package/dist/hooks/useTreeSpecEditor.js +663 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/package.json +58 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { compileTreeSpec, decompileTreeSpec, lintTreeSpecWire, TERMINAL_OUTCOME, TREE_SPEC_ISSUE_SEVERITY } from '@signalsafe/tree-spec';
|
|
3
|
+
import { applyTreeTemplate, autoLayoutTree, AUTOSAVE_STATUS, coerceTreeSpecWireForEditor as defaultCoerceRawSpec, deleteNode, deleteTransitionsForChoice, duplicateNode, END_NODE_ID, getKeyboardShortcutAction, getNextSpawnPosition, getTransition, GRAPH_SELECTION_KIND, KEYBOARD_SHORTCUT_ACTION, lintEditorTree, parsePydanticOutcomeErrors as defaultParseServerErrorMessage, safeUUID, shouldQueueInitialValidation as defaultShouldQueueInitialValidation, upsertTransition, } from '@signalsafe/tree-spec-editor-core';
|
|
4
|
+
const DEFAULT_AUTOSAVE_DEBOUNCE_MS = 2500;
|
|
5
|
+
function isTextFieldTarget(target) {
|
|
6
|
+
const tag = target instanceof HTMLElement ? target.tagName.toLowerCase() : '';
|
|
7
|
+
return (tag === 'input' ||
|
|
8
|
+
tag === 'textarea' ||
|
|
9
|
+
(target instanceof HTMLElement && target.getAttribute('contenteditable') === 'true'));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Headless React hook that owns the full stateful behavior of the SignalSafe
|
|
13
|
+
* TreeSpec graph editor — loading, autosave, validation, publish, snapshots,
|
|
14
|
+
* audit, clone-to-draft, selection, focus + fit-view, node/choice operations,
|
|
15
|
+
* and keyboard shortcuts. Consumers compose their own UI (toolbar, panels,
|
|
16
|
+
* modals, layout) on top of the returned state + actions.
|
|
17
|
+
*
|
|
18
|
+
* Boundary commitments:
|
|
19
|
+
* - **No router**: routing is host-injected via `onPreview` / `onCloneNavigate`.
|
|
20
|
+
* - **No UI library**: returns data only; never renders JSX.
|
|
21
|
+
* - **No simulator runtime**: hosts inject `computeRuntimeIssues` if needed.
|
|
22
|
+
*
|
|
23
|
+
* See {@link UseTreeSpecEditorOptions} for full option documentation and
|
|
24
|
+
* {@link UseTreeSpecEditorResult} for the returned shape.
|
|
25
|
+
*/
|
|
26
|
+
export function useTreeSpecEditor(options) {
|
|
27
|
+
const { adapter, entityId, autosaveDebounceMs = DEFAULT_AUTOSAVE_DEBOUNCE_MS, enableAutosave = true, enableKeyboardShortcuts = true, coerceRawSpec = defaultCoerceRawSpec, computeRuntimeIssues, debugMode = false, onPreview, onCloneNavigate, parseServerErrorMessage = defaultParseServerErrorMessage, shouldQueueInitialValidation = defaultShouldQueueInitialValidation, } = options;
|
|
28
|
+
// -----------------------------------------------------------------------
|
|
29
|
+
// Core editor state.
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [saving, setSaving] = useState(false);
|
|
33
|
+
const [publishing, setPublishing] = useState(false);
|
|
34
|
+
const [autosaveStatus, setAutosaveStatus] = useState(AUTOSAVE_STATUS.IDLE);
|
|
35
|
+
const [rawTreeSpec, setRawTreeSpec] = useState(null);
|
|
36
|
+
const [tree, setTree] = useState(null);
|
|
37
|
+
const [isPublished, setIsPublished] = useState(false);
|
|
38
|
+
const [versionInfo, setVersionInfo] = useState(null);
|
|
39
|
+
const [localIssues, setLocalIssues] = useState([]);
|
|
40
|
+
const [serverIssues, setServerIssues] = useState([]);
|
|
41
|
+
const [lastValidatedAt, setLastValidatedAt] = useState(null);
|
|
42
|
+
const [selection, setSelection] = useState({ kind: null, id: null });
|
|
43
|
+
const [focusNodeId, setFocusNodeId] = useState(null);
|
|
44
|
+
const [focusChoiceId, setFocusChoiceId] = useState(null);
|
|
45
|
+
const [fitViewNonce, setFitViewNonce] = useState(0);
|
|
46
|
+
const [nodeSearch, setNodeSearch] = useState('');
|
|
47
|
+
const [issueSearch, setIssueSearch] = useState('');
|
|
48
|
+
const [showMiniMap, setShowMiniMap] = useState(true);
|
|
49
|
+
const [showDraftHistory, setShowDraftHistory] = useState(false);
|
|
50
|
+
const [showAudit, setShowAudit] = useState(false);
|
|
51
|
+
const [showPublishModal, setShowPublishModal] = useState(false);
|
|
52
|
+
const [snapshots, setSnapshots] = useState([]);
|
|
53
|
+
const [auditEvents, setAuditEvents] = useState([]);
|
|
54
|
+
const [loadingSnapshots, setLoadingSnapshots] = useState(false);
|
|
55
|
+
const [loadingAudit, setLoadingAudit] = useState(false);
|
|
56
|
+
const [restoringSnapshotId, setRestoringSnapshotId] = useState(null);
|
|
57
|
+
const [creatingSnapshot, setCreatingSnapshot] = useState(false);
|
|
58
|
+
const [cloning, setCloning] = useState(false);
|
|
59
|
+
const lastSavedKeyRef = useRef('');
|
|
60
|
+
const autosaveTimerRef = useRef(null);
|
|
61
|
+
// -----------------------------------------------------------------------
|
|
62
|
+
// Derived state.
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
const baselineTree = useMemo(() => {
|
|
65
|
+
if (!rawTreeSpec)
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
return decompileTreeSpec(rawTreeSpec);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}, [rawTreeSpec]);
|
|
74
|
+
const compiledTreeSpec = useMemo(() => (tree ? compileTreeSpec(tree) : null), [tree]);
|
|
75
|
+
const runtimeIssues = useMemo(() => (compiledTreeSpec && computeRuntimeIssues ? computeRuntimeIssues(compiledTreeSpec) : []), [compiledTreeSpec, computeRuntimeIssues]);
|
|
76
|
+
const issues = useMemo(() => {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
const groups = debugMode ? [localIssues, serverIssues] : [localIssues, runtimeIssues, serverIssues];
|
|
80
|
+
for (const arr of groups) {
|
|
81
|
+
for (const i of arr) {
|
|
82
|
+
const key = `${i.severity}|${i.message}|${i.node_id ?? ''}|${i.choice_id ?? ''}`;
|
|
83
|
+
if (seen.has(key))
|
|
84
|
+
continue;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
out.push(i);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}, [localIssues, serverIssues, runtimeIssues, debugMode]);
|
|
91
|
+
const canPublish = useMemo(() => !issues.some((i) => i.severity === TREE_SPEC_ISSUE_SEVERITY.ERROR), [issues]);
|
|
92
|
+
const selectedNode = useMemo(() => {
|
|
93
|
+
if (!tree || selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
|
|
94
|
+
return null;
|
|
95
|
+
if (selection.id === END_NODE_ID)
|
|
96
|
+
return null;
|
|
97
|
+
return tree.nodes[selection.id] ?? null;
|
|
98
|
+
}, [tree, selection]);
|
|
99
|
+
const selectedEdge = useMemo(() => {
|
|
100
|
+
if (!tree || selection.kind !== GRAPH_SELECTION_KIND.EDGE || !selection.id)
|
|
101
|
+
return null;
|
|
102
|
+
return tree.transitions.find((t) => t.id === selection.id) ?? null;
|
|
103
|
+
}, [tree, selection]);
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
// Initial load + local lint recomputation.
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// Stash latest validate in a ref so the keyboard effect can call it
|
|
108
|
+
// without re-subscribing on every tree change. Populated below.
|
|
109
|
+
const validateRef = useRef(async () => undefined);
|
|
110
|
+
const saveDraftRef = useRef(async () => undefined);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
async function load() {
|
|
114
|
+
if (!entityId)
|
|
115
|
+
return;
|
|
116
|
+
setLoading(true);
|
|
117
|
+
setVersionInfo(null);
|
|
118
|
+
try {
|
|
119
|
+
const raw = await adapter.getVersion(entityId);
|
|
120
|
+
if (cancelled)
|
|
121
|
+
return;
|
|
122
|
+
setIsPublished(Boolean(raw?.is_published));
|
|
123
|
+
setVersionInfo(raw?.info ?? null);
|
|
124
|
+
const rawSpec = raw?.tree_spec;
|
|
125
|
+
const spec = coerceRawSpec(rawSpec);
|
|
126
|
+
if (spec == null) {
|
|
127
|
+
setRawTreeSpec(null);
|
|
128
|
+
setTree(null);
|
|
129
|
+
setVersionInfo(raw?.info ?? null);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
setRawTreeSpec(spec);
|
|
133
|
+
const nextTree = decompileTreeSpec(spec);
|
|
134
|
+
setTree(nextTree);
|
|
135
|
+
setLocalIssues([...lintTreeSpecWire(spec), ...lintEditorTree(nextTree)]);
|
|
136
|
+
if (shouldQueueInitialValidation(raw?.is_published)) {
|
|
137
|
+
queueMicrotask(() => {
|
|
138
|
+
void validateRef.current(spec);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
lastSavedKeyRef.current = JSON.stringify(spec);
|
|
142
|
+
setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
if (cancelled)
|
|
147
|
+
return;
|
|
148
|
+
setRawTreeSpec(null);
|
|
149
|
+
setTree(null);
|
|
150
|
+
setVersionInfo(null);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
if (!cancelled)
|
|
154
|
+
setLoading(false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (entityId)
|
|
158
|
+
void load();
|
|
159
|
+
return () => {
|
|
160
|
+
cancelled = true;
|
|
161
|
+
};
|
|
162
|
+
}, [entityId, adapter, coerceRawSpec, shouldQueueInitialValidation]);
|
|
163
|
+
// Local issue list stays focused on structural/editor checks; recomputed
|
|
164
|
+
// every time the compiled spec changes (which happens on every tree edit).
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!tree || !compiledTreeSpec)
|
|
167
|
+
return;
|
|
168
|
+
setLocalIssues([...lintTreeSpecWire(compiledTreeSpec), ...lintEditorTree(tree)]);
|
|
169
|
+
}, [tree, compiledTreeSpec]);
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Validation.
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
const validate = useCallback(async (specOverride) => {
|
|
174
|
+
if (!entityId)
|
|
175
|
+
return undefined;
|
|
176
|
+
const spec = specOverride ?? (tree ? compileTreeSpec(tree) : rawTreeSpec);
|
|
177
|
+
if (!spec)
|
|
178
|
+
return undefined;
|
|
179
|
+
if (!adapter.validate) {
|
|
180
|
+
setLastValidatedAt(new Date().toISOString());
|
|
181
|
+
return { valid: true };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const payload = await adapter.validate(entityId, spec);
|
|
185
|
+
const nextIssues = (payload?.issues ?? []).map((i) => {
|
|
186
|
+
const severity = i.severity ?? i.level ?? TREE_SPEC_ISSUE_SEVERITY.ERROR;
|
|
187
|
+
return {
|
|
188
|
+
severity,
|
|
189
|
+
message: String(i.message ?? 'Issue'),
|
|
190
|
+
node_id: i.node_id ?? undefined,
|
|
191
|
+
choice_id: i.choice_id ?? undefined,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
setServerIssues(nextIssues);
|
|
195
|
+
setLastValidatedAt(new Date().toISOString());
|
|
196
|
+
return payload;
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
const ex = err;
|
|
200
|
+
const errData = ex?.response?.data;
|
|
201
|
+
const msg = errData?.detail ??
|
|
202
|
+
errData?.error ??
|
|
203
|
+
err?.message ??
|
|
204
|
+
'Validation failed';
|
|
205
|
+
const parsed = parseServerErrorMessage(String(msg));
|
|
206
|
+
setServerIssues(parsed ?? [{ severity: TREE_SPEC_ISSUE_SEVERITY.ERROR, message: String(msg) }]);
|
|
207
|
+
setLastValidatedAt(new Date().toISOString());
|
|
208
|
+
return {
|
|
209
|
+
valid: false,
|
|
210
|
+
issues: [{ severity: TREE_SPEC_ISSUE_SEVERITY.ERROR, message: String(msg) }],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}, [adapter, entityId, parseServerErrorMessage, rawTreeSpec, tree]);
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
validateRef.current = validate;
|
|
216
|
+
}, [validate]);
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// Save draft + autosave.
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
const saveDraft = useCallback(async () => {
|
|
221
|
+
if (!entityId || !tree)
|
|
222
|
+
return;
|
|
223
|
+
if (isPublished)
|
|
224
|
+
return;
|
|
225
|
+
setSaving(true);
|
|
226
|
+
setAutosaveStatus(AUTOSAVE_STATUS.SAVING);
|
|
227
|
+
try {
|
|
228
|
+
const compiled = compileTreeSpec(tree);
|
|
229
|
+
await adapter.updateVersion(entityId, { tree_spec: compiled });
|
|
230
|
+
setRawTreeSpec(compiled);
|
|
231
|
+
lastSavedKeyRef.current = JSON.stringify(compiled);
|
|
232
|
+
setAutosaveStatus(AUTOSAVE_STATUS.SAVED);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
setSaving(false);
|
|
236
|
+
}
|
|
237
|
+
}, [adapter, entityId, isPublished, tree]);
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
saveDraftRef.current = saveDraft;
|
|
240
|
+
}, [saveDraft]);
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!enableAutosave)
|
|
243
|
+
return;
|
|
244
|
+
if (!entityId || !tree)
|
|
245
|
+
return;
|
|
246
|
+
if (isPublished)
|
|
247
|
+
return;
|
|
248
|
+
if (saving || publishing)
|
|
249
|
+
return;
|
|
250
|
+
const compiled = compileTreeSpec(tree);
|
|
251
|
+
const key = JSON.stringify(compiled);
|
|
252
|
+
if (key === lastSavedKeyRef.current) {
|
|
253
|
+
if (autosaveStatus !== AUTOSAVE_STATUS.SAVED)
|
|
254
|
+
setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
setAutosaveStatus(AUTOSAVE_STATUS.DIRTY);
|
|
258
|
+
if (autosaveTimerRef.current) {
|
|
259
|
+
globalThis.clearTimeout(autosaveTimerRef.current);
|
|
260
|
+
autosaveTimerRef.current = null;
|
|
261
|
+
}
|
|
262
|
+
autosaveTimerRef.current = globalThis.setTimeout(() => {
|
|
263
|
+
void saveDraftRef.current();
|
|
264
|
+
}, autosaveDebounceMs);
|
|
265
|
+
return () => {
|
|
266
|
+
if (autosaveTimerRef.current) {
|
|
267
|
+
globalThis.clearTimeout(autosaveTimerRef.current);
|
|
268
|
+
autosaveTimerRef.current = null;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
// NOTE: autosaveStatus is intentionally NOT in deps. The dirty/idle
|
|
272
|
+
// bookkeeping above flips status, and re-running the effect on every
|
|
273
|
+
// status change would reset the debounce timer.
|
|
274
|
+
}, [tree, entityId, isPublished, saving, publishing, autosaveDebounceMs, enableAutosave]);
|
|
275
|
+
// -----------------------------------------------------------------------
|
|
276
|
+
// Publish.
|
|
277
|
+
// -----------------------------------------------------------------------
|
|
278
|
+
const publish = useCallback(async () => {
|
|
279
|
+
if (!entityId || !tree || !adapter.publish)
|
|
280
|
+
return;
|
|
281
|
+
setPublishing(true);
|
|
282
|
+
try {
|
|
283
|
+
const compiled = compileTreeSpec(tree);
|
|
284
|
+
const vr = await validate(compiled);
|
|
285
|
+
if (vr?.valid === false)
|
|
286
|
+
return;
|
|
287
|
+
if (!canPublish)
|
|
288
|
+
return;
|
|
289
|
+
await adapter.publish(entityId);
|
|
290
|
+
setIsPublished(true);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
setPublishing(false);
|
|
294
|
+
}
|
|
295
|
+
}, [adapter, canPublish, entityId, tree, validate]);
|
|
296
|
+
// -----------------------------------------------------------------------
|
|
297
|
+
// Snapshots: list (when modal opens), create, restore.
|
|
298
|
+
// -----------------------------------------------------------------------
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
const listSnapshots = adapter.listSnapshots;
|
|
301
|
+
if (!listSnapshots || !showDraftHistory || !entityId)
|
|
302
|
+
return;
|
|
303
|
+
let cancelled = false;
|
|
304
|
+
setLoadingSnapshots(true);
|
|
305
|
+
void (async () => {
|
|
306
|
+
try {
|
|
307
|
+
const list = await listSnapshots(entityId);
|
|
308
|
+
if (!cancelled)
|
|
309
|
+
setSnapshots(list);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (!cancelled)
|
|
313
|
+
setLoadingSnapshots(false);
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
return () => {
|
|
317
|
+
cancelled = true;
|
|
318
|
+
};
|
|
319
|
+
}, [adapter, showDraftHistory, entityId]);
|
|
320
|
+
const createSnapshot = useCallback(async () => {
|
|
321
|
+
if (!entityId || !tree || !adapter.createSnapshot || !adapter.listSnapshots)
|
|
322
|
+
return;
|
|
323
|
+
setCreatingSnapshot(true);
|
|
324
|
+
try {
|
|
325
|
+
const compiled = compileTreeSpec(tree);
|
|
326
|
+
await adapter.createSnapshot(entityId, { label: '', tree_spec: compiled });
|
|
327
|
+
const list = await adapter.listSnapshots(entityId);
|
|
328
|
+
setSnapshots(list);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
setCreatingSnapshot(false);
|
|
332
|
+
}
|
|
333
|
+
}, [adapter, entityId, tree]);
|
|
334
|
+
const restoreSnapshot = useCallback(async (snapshotId) => {
|
|
335
|
+
if (!entityId || !adapter.restoreSnapshot)
|
|
336
|
+
return;
|
|
337
|
+
setRestoringSnapshotId(snapshotId);
|
|
338
|
+
try {
|
|
339
|
+
const { tree_spec: rawSpec } = await adapter.restoreSnapshot(entityId, snapshotId);
|
|
340
|
+
const spec = coerceRawSpec(rawSpec);
|
|
341
|
+
if (spec != null) {
|
|
342
|
+
setRawTreeSpec(spec);
|
|
343
|
+
setTree(decompileTreeSpec(spec));
|
|
344
|
+
lastSavedKeyRef.current = JSON.stringify(spec);
|
|
345
|
+
setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
|
|
346
|
+
}
|
|
347
|
+
setShowDraftHistory(false);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
setRestoringSnapshotId(null);
|
|
351
|
+
}
|
|
352
|
+
}, [adapter, coerceRawSpec, entityId]);
|
|
353
|
+
// -----------------------------------------------------------------------
|
|
354
|
+
// Audit: list (when modal opens).
|
|
355
|
+
// -----------------------------------------------------------------------
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
const listAudit = adapter.listAudit;
|
|
358
|
+
if (!listAudit || !showAudit || !entityId)
|
|
359
|
+
return;
|
|
360
|
+
let cancelled = false;
|
|
361
|
+
setLoadingAudit(true);
|
|
362
|
+
void (async () => {
|
|
363
|
+
try {
|
|
364
|
+
const list = await listAudit(entityId);
|
|
365
|
+
if (!cancelled)
|
|
366
|
+
setAuditEvents(list);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
if (!cancelled)
|
|
370
|
+
setLoadingAudit(false);
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
return () => {
|
|
374
|
+
cancelled = true;
|
|
375
|
+
};
|
|
376
|
+
}, [adapter, showAudit, entityId]);
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// Clone to Draft.
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
const cloneToDraft = useCallback(async () => {
|
|
381
|
+
if (!entityId || !adapter.cloneToDraft)
|
|
382
|
+
return;
|
|
383
|
+
setCloning(true);
|
|
384
|
+
try {
|
|
385
|
+
const { id: newId } = await adapter.cloneToDraft(entityId);
|
|
386
|
+
if (newId)
|
|
387
|
+
onCloneNavigate?.(newId);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
setCloning(false);
|
|
391
|
+
}
|
|
392
|
+
}, [adapter, entityId, onCloneNavigate]);
|
|
393
|
+
// -----------------------------------------------------------------------
|
|
394
|
+
// Node + tree operations.
|
|
395
|
+
// -----------------------------------------------------------------------
|
|
396
|
+
const triggerResetView = useCallback(() => setFitViewNonce((n) => n + 1), []);
|
|
397
|
+
const addNodeOfType = useCallback((type, patch) => {
|
|
398
|
+
if (!tree)
|
|
399
|
+
return undefined;
|
|
400
|
+
const id = `n_${safeUUID().slice(0, 8)}`;
|
|
401
|
+
const p = getNextSpawnPosition(tree);
|
|
402
|
+
const nextNode = {
|
|
403
|
+
id,
|
|
404
|
+
type,
|
|
405
|
+
prompt: patch?.prompt ?? '',
|
|
406
|
+
choices: patch?.choices ?? [],
|
|
407
|
+
position: patch?.position ?? p,
|
|
408
|
+
};
|
|
409
|
+
setTree({ ...tree, nodes: { ...tree.nodes, [id]: nextNode } });
|
|
410
|
+
setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id });
|
|
411
|
+
setFocusNodeId(id);
|
|
412
|
+
return id;
|
|
413
|
+
}, [tree]);
|
|
414
|
+
const autoLayout = useCallback(() => {
|
|
415
|
+
if (!tree)
|
|
416
|
+
return;
|
|
417
|
+
setTree(autoLayoutTree(tree));
|
|
418
|
+
setFitViewNonce((n) => n + 1);
|
|
419
|
+
}, [tree]);
|
|
420
|
+
const insertTemplate = useCallback((spec) => {
|
|
421
|
+
if (!tree)
|
|
422
|
+
return;
|
|
423
|
+
const { nextTree, focusNodeId: spawnedFocusNodeId } = applyTreeTemplate(tree, spec);
|
|
424
|
+
setTree(nextTree);
|
|
425
|
+
setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: spawnedFocusNodeId });
|
|
426
|
+
setFocusNodeId(spawnedFocusNodeId);
|
|
427
|
+
}, [tree]);
|
|
428
|
+
// -----------------------------------------------------------------------
|
|
429
|
+
// Choice operations (when a node is selected).
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
const updateSelectedNode = useCallback((patch) => {
|
|
432
|
+
if (!tree || !selectedNode)
|
|
433
|
+
return;
|
|
434
|
+
const next = { ...selectedNode, ...patch };
|
|
435
|
+
setTree({ ...tree, nodes: { ...tree.nodes, [selectedNode.id]: next } });
|
|
436
|
+
}, [tree, selectedNode]);
|
|
437
|
+
const addChoice = useCallback(() => {
|
|
438
|
+
if (!tree || !selectedNode)
|
|
439
|
+
return;
|
|
440
|
+
const choiceId = `c_${safeUUID().slice(0, 6)}`;
|
|
441
|
+
const nextChoice = { id: choiceId, label: 'New choice' };
|
|
442
|
+
updateSelectedNode({ choices: [...(selectedNode.choices ?? []), nextChoice] });
|
|
443
|
+
}, [tree, selectedNode, updateSelectedNode]);
|
|
444
|
+
const deleteChoice = useCallback((choiceId) => {
|
|
445
|
+
if (!tree || !selectedNode)
|
|
446
|
+
return;
|
|
447
|
+
const nextChoices = (selectedNode.choices ?? []).filter((c) => c.id !== choiceId);
|
|
448
|
+
const nextTree = deleteTransitionsForChoice(tree, selectedNode.id, choiceId);
|
|
449
|
+
setTree({
|
|
450
|
+
...nextTree,
|
|
451
|
+
nodes: {
|
|
452
|
+
...nextTree.nodes,
|
|
453
|
+
[selectedNode.id]: { ...selectedNode, choices: nextChoices },
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}, [tree, selectedNode]);
|
|
457
|
+
const setChoiceTarget = useCallback((choiceId, targetNodeId) => {
|
|
458
|
+
if (!tree || !selectedNode)
|
|
459
|
+
return;
|
|
460
|
+
const existing = getTransition(tree, selectedNode.id, choiceId);
|
|
461
|
+
const next = {
|
|
462
|
+
id: existing?.id ?? safeUUID(),
|
|
463
|
+
fromNodeId: selectedNode.id,
|
|
464
|
+
fromChoiceId: choiceId,
|
|
465
|
+
toNodeId: targetNodeId,
|
|
466
|
+
outcome: targetNodeId === END_NODE_ID
|
|
467
|
+
? (existing?.outcome ?? TERMINAL_OUTCOME.AT_RISK)
|
|
468
|
+
: undefined,
|
|
469
|
+
};
|
|
470
|
+
setTree(upsertTransition(tree, next));
|
|
471
|
+
}, [tree, selectedNode]);
|
|
472
|
+
const setChoiceOutcome = useCallback((choiceId, outcome) => {
|
|
473
|
+
if (!tree || !selectedNode)
|
|
474
|
+
return;
|
|
475
|
+
const existing = getTransition(tree, selectedNode.id, choiceId);
|
|
476
|
+
if (!existing)
|
|
477
|
+
return;
|
|
478
|
+
if (existing.toNodeId !== END_NODE_ID)
|
|
479
|
+
return;
|
|
480
|
+
if (outcome !== TERMINAL_OUTCOME.SAFE &&
|
|
481
|
+
outcome !== TERMINAL_OUTCOME.AT_RISK &&
|
|
482
|
+
outcome !== TERMINAL_OUTCOME.COMPROMISED)
|
|
483
|
+
return;
|
|
484
|
+
setTree(upsertTransition(tree, { ...existing, outcome }));
|
|
485
|
+
}, [tree, selectedNode]);
|
|
486
|
+
// -----------------------------------------------------------------------
|
|
487
|
+
// Issue selection helper (used by an Issues panel).
|
|
488
|
+
// -----------------------------------------------------------------------
|
|
489
|
+
const selectIssue = useCallback((issue) => {
|
|
490
|
+
if (!issue.node_id)
|
|
491
|
+
return;
|
|
492
|
+
setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: issue.node_id });
|
|
493
|
+
setFocusNodeId(issue.node_id);
|
|
494
|
+
setFocusChoiceId(issue.choice_id ?? null);
|
|
495
|
+
setFitViewNonce((n) => n + 1);
|
|
496
|
+
}, []);
|
|
497
|
+
const deleteNodeById = useCallback((nodeId) => {
|
|
498
|
+
if (!tree || isPublished)
|
|
499
|
+
return false;
|
|
500
|
+
const nextTree = deleteNode(tree, nodeId);
|
|
501
|
+
if (!nextTree)
|
|
502
|
+
return false;
|
|
503
|
+
setTree(nextTree);
|
|
504
|
+
const wasSelected = selection.kind === GRAPH_SELECTION_KIND.NODE && selection.id === nodeId;
|
|
505
|
+
if (wasSelected) {
|
|
506
|
+
setSelection({ kind: null, id: null });
|
|
507
|
+
setFocusChoiceId(null);
|
|
508
|
+
}
|
|
509
|
+
if (focusNodeId === nodeId) {
|
|
510
|
+
setFocusNodeId(null);
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}, [tree, isPublished, selection, focusNodeId, setTree, setSelection, setFocusNodeId, setFocusChoiceId]);
|
|
514
|
+
const deleteSelectedNode = useCallback(() => {
|
|
515
|
+
if (selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
|
|
516
|
+
return false;
|
|
517
|
+
return deleteNodeById(selection.id);
|
|
518
|
+
}, [deleteNodeById, selection]);
|
|
519
|
+
// -----------------------------------------------------------------------
|
|
520
|
+
// Scroll the focused choice into view when it changes.
|
|
521
|
+
// -----------------------------------------------------------------------
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (!selectedNode || !focusChoiceId)
|
|
524
|
+
return;
|
|
525
|
+
if (typeof document === 'undefined')
|
|
526
|
+
return;
|
|
527
|
+
const el = document.getElementById(`choice-editor-${selectedNode.id}-${focusChoiceId}`);
|
|
528
|
+
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
529
|
+
}, [selectedNode, focusChoiceId]);
|
|
530
|
+
// -----------------------------------------------------------------------
|
|
531
|
+
// Keyboard shortcuts (Save/Validate/Preview/Duplicate/Delete).
|
|
532
|
+
// -----------------------------------------------------------------------
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (!enableKeyboardShortcuts)
|
|
535
|
+
return;
|
|
536
|
+
if (typeof globalThis.addEventListener !== 'function')
|
|
537
|
+
return;
|
|
538
|
+
const onKeyDown = (e) => {
|
|
539
|
+
if (isTextFieldTarget(e.target))
|
|
540
|
+
return;
|
|
541
|
+
const selectedNodeId = selection.kind === GRAPH_SELECTION_KIND.NODE ? selection.id : null;
|
|
542
|
+
const shortcutAction = getKeyboardShortcutAction({
|
|
543
|
+
ctrlKey: e.ctrlKey,
|
|
544
|
+
metaKey: e.metaKey,
|
|
545
|
+
shiftKey: e.shiftKey,
|
|
546
|
+
key: e.key,
|
|
547
|
+
hasSelectedNode: Boolean(tree && selectedNodeId),
|
|
548
|
+
});
|
|
549
|
+
switch (shortcutAction) {
|
|
550
|
+
case KEYBOARD_SHORTCUT_ACTION.SAVE:
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
void saveDraftRef.current();
|
|
553
|
+
return;
|
|
554
|
+
case KEYBOARD_SHORTCUT_ACTION.VALIDATE:
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
void validateRef.current();
|
|
557
|
+
return;
|
|
558
|
+
case KEYBOARD_SHORTCUT_ACTION.PREVIEW:
|
|
559
|
+
if (!onPreview)
|
|
560
|
+
return;
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
onPreview();
|
|
563
|
+
return;
|
|
564
|
+
case KEYBOARD_SHORTCUT_ACTION.DUPLICATE: {
|
|
565
|
+
if (!tree || !selectedNodeId)
|
|
566
|
+
return;
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
const duplicated = duplicateNode(tree, selectedNodeId);
|
|
569
|
+
if (!duplicated)
|
|
570
|
+
return;
|
|
571
|
+
setTree(duplicated.nextTree);
|
|
572
|
+
setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: duplicated.nextNodeId });
|
|
573
|
+
setFocusNodeId(duplicated.nextNodeId);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
case KEYBOARD_SHORTCUT_ACTION.DELETE: {
|
|
577
|
+
if (!tree || !selectedNodeId)
|
|
578
|
+
return;
|
|
579
|
+
if (deleteSelectedNode()) {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
default:
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
globalThis.addEventListener('keydown', onKeyDown);
|
|
589
|
+
return () => globalThis.removeEventListener('keydown', onKeyDown);
|
|
590
|
+
}, [enableKeyboardShortcuts, onPreview, tree, selection, deleteSelectedNode]);
|
|
591
|
+
// -----------------------------------------------------------------------
|
|
592
|
+
// Compose the public surface.
|
|
593
|
+
// -----------------------------------------------------------------------
|
|
594
|
+
const state = {
|
|
595
|
+
loading,
|
|
596
|
+
saving,
|
|
597
|
+
publishing,
|
|
598
|
+
creatingSnapshot,
|
|
599
|
+
cloning,
|
|
600
|
+
restoringSnapshotId,
|
|
601
|
+
autosaveStatus,
|
|
602
|
+
lastValidatedAt,
|
|
603
|
+
rawTreeSpec,
|
|
604
|
+
tree,
|
|
605
|
+
baselineTree,
|
|
606
|
+
compiledTreeSpec,
|
|
607
|
+
isPublished,
|
|
608
|
+
versionInfo,
|
|
609
|
+
hasTree: Boolean(tree),
|
|
610
|
+
localIssues,
|
|
611
|
+
serverIssues,
|
|
612
|
+
runtimeIssues,
|
|
613
|
+
issues,
|
|
614
|
+
canPublish,
|
|
615
|
+
selection,
|
|
616
|
+
focusNodeId,
|
|
617
|
+
focusChoiceId,
|
|
618
|
+
fitViewNonce,
|
|
619
|
+
selectedNode,
|
|
620
|
+
selectedEdge,
|
|
621
|
+
nodeSearch,
|
|
622
|
+
issueSearch,
|
|
623
|
+
showMiniMap,
|
|
624
|
+
snapshots,
|
|
625
|
+
auditEvents,
|
|
626
|
+
loadingSnapshots,
|
|
627
|
+
loadingAudit,
|
|
628
|
+
showDraftHistory,
|
|
629
|
+
showAudit,
|
|
630
|
+
showPublishModal,
|
|
631
|
+
};
|
|
632
|
+
const actions = {
|
|
633
|
+
setTree,
|
|
634
|
+
setSelection,
|
|
635
|
+
setFocusNodeId,
|
|
636
|
+
setFocusChoiceId,
|
|
637
|
+
triggerResetView,
|
|
638
|
+
setNodeSearch,
|
|
639
|
+
setIssueSearch,
|
|
640
|
+
setShowMiniMap,
|
|
641
|
+
setShowDraftHistory,
|
|
642
|
+
setShowAudit,
|
|
643
|
+
setShowPublishModal,
|
|
644
|
+
addNodeOfType,
|
|
645
|
+
deleteSelectedNode,
|
|
646
|
+
deleteNodeById,
|
|
647
|
+
autoLayout,
|
|
648
|
+
insertTemplate,
|
|
649
|
+
validate,
|
|
650
|
+
saveDraft,
|
|
651
|
+
publish,
|
|
652
|
+
createSnapshot,
|
|
653
|
+
restoreSnapshot,
|
|
654
|
+
cloneToDraft,
|
|
655
|
+
updateSelectedNode,
|
|
656
|
+
addChoice,
|
|
657
|
+
deleteChoice,
|
|
658
|
+
setChoiceTarget,
|
|
659
|
+
setChoiceOutcome,
|
|
660
|
+
selectIssue,
|
|
661
|
+
};
|
|
662
|
+
return { ...state, actions };
|
|
663
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* Headless React layer for the SignalSafe TreeSpec graph editor.
|
|
4
|
+
*
|
|
5
|
+
* This package owns the React Flow canvas and the framework-shaped
|
|
6
|
+
* orchestration hook ({@link useTreeSpecEditor}). It depends on `react`,
|
|
7
|
+
* `react-dom`, `reactflow`, and `@signalsafe/tree-spec-editor-core`, but is
|
|
8
|
+
* intentionally free of any UI library (no `react-bootstrap`, no Material,
|
|
9
|
+
* no Tailwind) and any router (no `react-router-dom`, no Next.js router).
|
|
10
|
+
* UI shells layer on top of this package; routing is host-injected via hook
|
|
11
|
+
* callbacks.
|
|
12
|
+
*/
|
|
13
|
+
export { default } from './TreeSpecGraphEditor';
|
|
14
|
+
export type { TreeSpecGraphEditorProps } from './TreeSpecGraphEditor';
|
|
15
|
+
export { useTreeSpecEditor } from './hooks/useTreeSpecEditor';
|
|
16
|
+
export type { AdapterValidationIssue, GraphEditorVersionInfo, TreeSpecEditorAdapter, UseTreeSpecEditorActions, UseTreeSpecEditorOptions, UseTreeSpecEditorResult, UseTreeSpecEditorState, } from './hooks/types';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,YAAY,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAC9D,YAAY,EACR,sBAAsB,EACtB,sBAAsB,EACtB,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,uBAAuB,EACvB,sBAAsB,GACzB,MAAM,eAAe,CAAC"}
|