@signalsafe/tree-spec-editor-react 0.1.1 → 0.1.3

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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -28
  3. package/README.standalone.md +14 -0
  4. package/dist/GraphEditorCanvasContext.d.ts +26 -0
  5. package/dist/GraphEditorCanvasContext.d.ts.map +1 -0
  6. package/dist/GraphEditorCanvasContext.js +20 -0
  7. package/dist/TreeSpecGraphEditor.d.ts +22 -1
  8. package/dist/TreeSpecGraphEditor.d.ts.map +1 -1
  9. package/dist/TreeSpecGraphEditor.js +133 -260
  10. package/dist/canvas/constants.d.ts +41 -0
  11. package/dist/canvas/constants.d.ts.map +1 -0
  12. package/dist/canvas/constants.js +40 -0
  13. package/dist/canvas/edgeBuilders.d.ts +6 -0
  14. package/dist/canvas/edgeBuilders.d.ts.map +1 -0
  15. package/dist/canvas/edgeBuilders.js +57 -0
  16. package/dist/canvas/edgeStyle.d.ts +16 -0
  17. package/dist/canvas/edgeStyle.d.ts.map +1 -0
  18. package/dist/canvas/edgeStyle.js +44 -0
  19. package/dist/canvas/focusChoice.d.ts +3 -0
  20. package/dist/canvas/focusChoice.d.ts.map +1 -0
  21. package/dist/canvas/focusChoice.js +12 -0
  22. package/dist/canvas/typeGuards.d.ts +3 -0
  23. package/dist/canvas/typeGuards.d.ts.map +1 -0
  24. package/dist/canvas/typeGuards.js +16 -0
  25. package/dist/contextMenu/GraphCanvasContextMenu.d.ts +10 -0
  26. package/dist/contextMenu/GraphCanvasContextMenu.d.ts.map +1 -0
  27. package/dist/contextMenu/GraphCanvasContextMenu.js +39 -0
  28. package/dist/contextMenu/types.d.ts +11 -0
  29. package/dist/contextMenu/types.d.ts.map +1 -0
  30. package/dist/contextMenu/types.js +1 -0
  31. package/dist/hooks/keyboardShortcutDispatch.d.ts +30 -0
  32. package/dist/hooks/keyboardShortcutDispatch.d.ts.map +1 -0
  33. package/dist/hooks/keyboardShortcutDispatch.js +88 -0
  34. package/dist/hooks/types.d.ts +32 -2
  35. package/dist/hooks/types.d.ts.map +1 -1
  36. package/dist/hooks/useCanvasContextMenu.d.ts +15 -0
  37. package/dist/hooks/useCanvasContextMenu.d.ts.map +1 -0
  38. package/dist/hooks/useCanvasContextMenu.js +50 -0
  39. package/dist/hooks/useCanvasGraphState.d.ts +29 -0
  40. package/dist/hooks/useCanvasGraphState.d.ts.map +1 -0
  41. package/dist/hooks/useCanvasGraphState.js +150 -0
  42. package/dist/hooks/useCanvasIssueIndex.d.ts +12 -0
  43. package/dist/hooks/useCanvasIssueIndex.d.ts.map +1 -0
  44. package/dist/hooks/useCanvasIssueIndex.js +32 -0
  45. package/dist/hooks/useCanvasNodeResize.d.ts +17 -0
  46. package/dist/hooks/useCanvasNodeResize.d.ts.map +1 -0
  47. package/dist/hooks/useCanvasNodeResize.js +53 -0
  48. package/dist/hooks/useCanvasViewport.d.ts +12 -0
  49. package/dist/hooks/useCanvasViewport.d.ts.map +1 -0
  50. package/dist/hooks/useCanvasViewport.js +84 -0
  51. package/dist/hooks/useChoiceDragDrop.d.ts +25 -0
  52. package/dist/hooks/useChoiceDragDrop.d.ts.map +1 -0
  53. package/dist/hooks/useChoiceDragDrop.js +40 -0
  54. package/dist/hooks/useEditorAdapter.d.ts +46 -0
  55. package/dist/hooks/useEditorAdapter.d.ts.map +1 -0
  56. package/dist/hooks/useEditorAdapter.js +281 -0
  57. package/dist/hooks/useEditorAutosave.d.ts +18 -0
  58. package/dist/hooks/useEditorAutosave.d.ts.map +1 -0
  59. package/dist/hooks/useEditorAutosave.js +37 -0
  60. package/dist/hooks/useEditorHistory.d.ts +16 -0
  61. package/dist/hooks/useEditorHistory.d.ts.map +1 -0
  62. package/dist/hooks/useEditorHistory.js +103 -0
  63. package/dist/hooks/useEditorSelection.d.ts +22 -0
  64. package/dist/hooks/useEditorSelection.d.ts.map +1 -0
  65. package/dist/hooks/useEditorSelection.js +75 -0
  66. package/dist/hooks/useGraphConnect.d.ts +22 -0
  67. package/dist/hooks/useGraphConnect.d.ts.map +1 -0
  68. package/dist/hooks/useGraphConnect.js +75 -0
  69. package/dist/hooks/useTreeSpecEditor.d.ts +1 -9
  70. package/dist/hooks/useTreeSpecEditor.d.ts.map +1 -1
  71. package/dist/hooks/useTreeSpecEditor.js +231 -462
  72. package/dist/index.d.ts +4 -4
  73. package/dist/index.js +2 -2
  74. package/dist/nodes/ChoiceCanvasRow.d.ts +10 -0
  75. package/dist/nodes/ChoiceCanvasRow.d.ts.map +1 -0
  76. package/dist/nodes/ChoiceCanvasRow.js +55 -0
  77. package/dist/nodes/EndNode.d.ts +3 -0
  78. package/dist/nodes/EndNode.d.ts.map +1 -0
  79. package/dist/nodes/EndNode.js +7 -0
  80. package/dist/nodes/PromptNode.d.ts +6 -0
  81. package/dist/nodes/PromptNode.d.ts.map +1 -0
  82. package/dist/nodes/PromptNode.js +51 -0
  83. package/dist/nodes/PromptNodeChoicesList.d.ts +14 -0
  84. package/dist/nodes/PromptNodeChoicesList.d.ts.map +1 -0
  85. package/dist/nodes/PromptNodeChoicesList.js +24 -0
  86. package/dist/nodes/PromptNodeHeader.d.ts +10 -0
  87. package/dist/nodes/PromptNodeHeader.d.ts.map +1 -0
  88. package/dist/nodes/PromptNodeHeader.js +6 -0
  89. package/dist/nodes/PromptNodeIssueBadges.d.ts +7 -0
  90. package/dist/nodes/PromptNodeIssueBadges.d.ts.map +1 -0
  91. package/dist/nodes/PromptNodeIssueBadges.js +6 -0
  92. package/dist/nodes/PromptNodeToolbar.d.ts +6 -0
  93. package/dist/nodes/PromptNodeToolbar.d.ts.map +1 -0
  94. package/dist/nodes/PromptNodeToolbar.js +5 -0
  95. package/dist/nodes/types.d.ts +13 -0
  96. package/dist/nodes/types.d.ts.map +1 -0
  97. package/dist/nodes/types.js +1 -0
  98. package/dist/utils/joinClasses.d.ts +2 -0
  99. package/dist/utils/joinClasses.d.ts.map +1 -0
  100. package/dist/utils/joinClasses.js +3 -0
  101. package/package.json +36 -13
@@ -1,6 +1,11 @@
1
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';
2
+ import { compileTreeSpec, decompileTreeSpec, TERMINAL_OUTCOME, TREE_SPEC_ISSUE_SEVERITY, } from '@signalsafe/tree-spec';
3
+ import { applyTreeTemplate, autoLayoutTree, AUTOSAVE_STATUS, deleteNode, deleteTransitionsForChoice, END_NODE_ID, getNextSpawnPosition, getTransition, GRAPH_SELECTION_KIND, moveChoiceInTree, moveNodeChoice, patchChoiceEdgeHints, patchGraphEditorMeta, renameNodeChoiceId, safeUUID, upsertTransition, } from '@signalsafe/tree-spec-editor-core';
4
+ import { useEditorAdapter } from './useEditorAdapter.js';
5
+ import { useEditorAutosave } from './useEditorAutosave.js';
6
+ import { useEditorHistory } from './useEditorHistory.js';
7
+ import { useEditorSelection } from './useEditorSelection.js';
8
+ import { dispatchEditorKeyboardShortcut, resolveEditorKeyboardShortcutAction } from './keyboardShortcutDispatch.js';
4
9
  const DEFAULT_AUTOSAVE_DEBOUNCE_MS = 2500;
5
10
  function isTextFieldTarget(target) {
6
11
  const tag = target instanceof HTMLElement ? target.tagName.toLowerCase() : '';
@@ -14,69 +19,56 @@ function isTextFieldTarget(target) {
14
19
  * audit, clone-to-draft, selection, focus + fit-view, node/choice operations,
15
20
  * and keyboard shortcuts. Consumers compose their own UI (toolbar, panels,
16
21
  * 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
22
  */
26
23
  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);
24
+ const { adapter, entityId, autosaveDebounceMs = DEFAULT_AUTOSAVE_DEBOUNCE_MS, enableAutosave = true, enableKeyboardShortcuts = true, computeRuntimeIssues, debugMode = false, onPreview, } = options;
25
+ const lastSavedKeyRef = useRef('');
34
26
  const [autosaveStatus, setAutosaveStatus] = useState(AUTOSAVE_STATUS.IDLE);
35
- const [rawTreeSpec, setRawTreeSpec] = useState(null);
36
- const [tree, setTree] = useState(null);
37
27
  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
- // -----------------------------------------------------------------------
28
+ const history = useEditorHistory(isPublished);
29
+ const { tree, canUndo, canRedo, hasCopiedNode, commitTree, replaceTreeWithoutHistory, undo, redo, copySelectedNode: copySelectedNodeInternal, pasteCopiedNode: pasteCopiedNodeInternal, duplicateNodeById: duplicateNodeByIdInternal, } = history;
30
+ const adapterState = useEditorAdapter({
31
+ options,
32
+ tree,
33
+ isPublished,
34
+ setIsPublished,
35
+ replaceTreeWithoutHistory,
36
+ lastSavedKeyRef,
37
+ setAutosaveStatus,
38
+ });
39
+ const selectionState = useEditorSelection(tree);
40
+ const { selection, focusNodeId, focusChoiceId, fitViewNonce, selectedNode, selectedEdge, inspectorNode, selectChoice, applySelection, setFocusNodeId, setFocusChoiceId, triggerResetView, selectIssue, setSelection, } = selectionState;
41
+ useEditorAutosave({
42
+ enableAutosave,
43
+ autosaveDebounceMs,
44
+ entityId,
45
+ tree,
46
+ isPublished,
47
+ saving: adapterState.saving,
48
+ publishing: adapterState.publishing,
49
+ autosaveStatus,
50
+ setAutosaveStatus,
51
+ lastSavedKeyRef,
52
+ saveDraftRef: adapterState.saveDraftRef,
53
+ });
64
54
  const baselineTree = useMemo(() => {
65
- if (!rawTreeSpec)
55
+ if (!adapterState.rawTreeSpec)
66
56
  return null;
67
57
  try {
68
- return decompileTreeSpec(rawTreeSpec);
58
+ return decompileTreeSpec(adapterState.rawTreeSpec);
69
59
  }
70
60
  catch {
71
61
  return null;
72
62
  }
73
- }, [rawTreeSpec]);
63
+ }, [adapterState.rawTreeSpec]);
74
64
  const compiledTreeSpec = useMemo(() => (tree ? compileTreeSpec(tree) : null), [tree]);
75
65
  const runtimeIssues = useMemo(() => (compiledTreeSpec && computeRuntimeIssues ? computeRuntimeIssues(compiledTreeSpec) : []), [compiledTreeSpec, computeRuntimeIssues]);
76
66
  const issues = useMemo(() => {
77
67
  const seen = new Set();
78
68
  const out = [];
79
- const groups = debugMode ? [localIssues, serverIssues] : [localIssues, runtimeIssues, serverIssues];
69
+ const groups = debugMode
70
+ ? [adapterState.localIssues, adapterState.serverIssues]
71
+ : [adapterState.localIssues, runtimeIssues, adapterState.serverIssues];
80
72
  for (const arr of groups) {
81
73
  for (const i of arr) {
82
74
  const key = `${i.severity}|${i.message}|${i.node_id ?? ''}|${i.choice_id ?? ''}`;
@@ -87,201 +79,15 @@ export function useTreeSpecEditor(options) {
87
79
  }
88
80
  }
89
81
  return out;
90
- }, [localIssues, serverIssues, runtimeIssues, debugMode]);
82
+ }, [adapterState.localIssues, adapterState.serverIssues, runtimeIssues, debugMode]);
91
83
  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
84
  const publish = useCallback(async () => {
279
85
  if (!entityId || !tree || !adapter.publish)
280
86
  return;
281
- setPublishing(true);
87
+ adapterState.setPublishing(true);
282
88
  try {
283
89
  const compiled = compileTreeSpec(tree);
284
- const vr = await validate(compiled);
90
+ const vr = await adapterState.validate(compiled);
285
91
  if (vr?.valid === false)
286
92
  return;
287
93
  if (!canPublish)
@@ -290,110 +96,12 @@ export function useTreeSpecEditor(options) {
290
96
  setIsPublished(true);
291
97
  }
292
98
  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);
99
+ adapterState.setPublishing(false);
391
100
  }
392
- }, [adapter, entityId, onCloneNavigate]);
393
- // -----------------------------------------------------------------------
394
- // Node + tree operations.
395
- // -----------------------------------------------------------------------
396
- const triggerResetView = useCallback(() => setFitViewNonce((n) => n + 1), []);
101
+ }, [adapter, adapterState, canPublish, entityId, tree]);
102
+ const [nodeSearch, setNodeSearch] = useState('');
103
+ const [issueSearch, setIssueSearch] = useState('');
104
+ const [showMiniMap, setShowMiniMap] = useState(true);
397
105
  const addNodeOfType = useCallback((type, patch) => {
398
106
  if (!tree)
399
107
  return undefined;
@@ -406,34 +114,34 @@ export function useTreeSpecEditor(options) {
406
114
  choices: patch?.choices ?? [],
407
115
  position: patch?.position ?? p,
408
116
  };
409
- setTree({ ...tree, nodes: { ...tree.nodes, [id]: nextNode } });
117
+ commitTree({ ...tree, nodes: { ...tree.nodes, [id]: nextNode } });
410
118
  setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id });
411
119
  setFocusNodeId(id);
412
120
  return id;
413
- }, [tree]);
121
+ }, [tree, commitTree, setSelection, setFocusNodeId]);
414
122
  const autoLayout = useCallback(() => {
415
123
  if (!tree)
416
124
  return;
417
- setTree(autoLayoutTree(tree));
418
- setFitViewNonce((n) => n + 1);
419
- }, [tree]);
125
+ commitTree(autoLayoutTree(tree));
126
+ triggerResetView();
127
+ }, [tree, commitTree, triggerResetView]);
420
128
  const insertTemplate = useCallback((spec) => {
421
129
  if (!tree)
422
130
  return;
423
131
  const { nextTree, focusNodeId: spawnedFocusNodeId } = applyTreeTemplate(tree, spec);
424
- setTree(nextTree);
132
+ commitTree(nextTree);
425
133
  setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: spawnedFocusNodeId });
426
134
  setFocusNodeId(spawnedFocusNodeId);
427
- }, [tree]);
428
- // -----------------------------------------------------------------------
429
- // Choice operations (when a node is selected).
430
- // -----------------------------------------------------------------------
135
+ }, [tree, commitTree, setSelection, setFocusNodeId]);
136
+ const copySelectedNode = useCallback(() => copySelectedNodeInternal(selection), [copySelectedNodeInternal, selection]);
137
+ const pasteCopiedNode = useCallback(() => pasteCopiedNodeInternal(setSelection, setFocusNodeId), [pasteCopiedNodeInternal, setSelection, setFocusNodeId]);
138
+ const duplicateNodeById = useCallback((nodeId) => duplicateNodeByIdInternal(nodeId, setSelection, setFocusNodeId), [duplicateNodeByIdInternal, setSelection, setFocusNodeId]);
431
139
  const updateSelectedNode = useCallback((patch) => {
432
140
  if (!tree || !selectedNode)
433
141
  return;
434
142
  const next = { ...selectedNode, ...patch };
435
- setTree({ ...tree, nodes: { ...tree.nodes, [selectedNode.id]: next } });
436
- }, [tree, selectedNode]);
143
+ commitTree({ ...tree, nodes: { ...tree.nodes, [selectedNode.id]: next } });
144
+ }, [tree, selectedNode, commitTree]);
437
145
  const addChoice = useCallback(() => {
438
146
  if (!tree || !selectedNode)
439
147
  return;
@@ -441,19 +149,67 @@ export function useTreeSpecEditor(options) {
441
149
  const nextChoice = { id: choiceId, label: 'New choice' };
442
150
  updateSelectedNode({ choices: [...(selectedNode.choices ?? []), nextChoice] });
443
151
  }, [tree, selectedNode, updateSelectedNode]);
152
+ const setChoiceType = useCallback((choiceId, typeId, defaultLabel) => {
153
+ if (!tree || !selectedNode)
154
+ return;
155
+ const next = renameNodeChoiceId(tree, selectedNode.id, choiceId, typeId);
156
+ if (!next)
157
+ return;
158
+ if (defaultLabel) {
159
+ const node = next.nodes[selectedNode.id];
160
+ if (node) {
161
+ const choices = (node.choices ?? []).map((choice) => {
162
+ if (choice.id !== typeId)
163
+ return choice;
164
+ if (choice.label === 'New choice' || !choice.label.trim()) {
165
+ return { ...choice, label: defaultLabel };
166
+ }
167
+ return choice;
168
+ });
169
+ next.nodes[selectedNode.id] = { ...node, choices };
170
+ }
171
+ }
172
+ commitTree(next);
173
+ if (focusChoiceId === choiceId) {
174
+ selectChoice(selectedNode.id, typeId);
175
+ }
176
+ }, [tree, selectedNode, commitTree, focusChoiceId, selectChoice]);
444
177
  const deleteChoice = useCallback((choiceId) => {
445
178
  if (!tree || !selectedNode)
446
179
  return;
447
180
  const nextChoices = (selectedNode.choices ?? []).filter((c) => c.id !== choiceId);
448
181
  const nextTree = deleteTransitionsForChoice(tree, selectedNode.id, choiceId);
449
- setTree({
182
+ commitTree({
450
183
  ...nextTree,
451
184
  nodes: {
452
185
  ...nextTree.nodes,
453
186
  [selectedNode.id]: { ...selectedNode, choices: nextChoices },
454
187
  },
455
188
  });
456
- }, [tree, selectedNode]);
189
+ }, [tree, selectedNode, commitTree]);
190
+ const moveChoice = useCallback((choiceId, direction) => {
191
+ if (!tree || !selectedNode)
192
+ return;
193
+ const nextChoices = moveNodeChoice(selectedNode.choices ?? [], choiceId, direction);
194
+ if (!nextChoices)
195
+ return;
196
+ commitTree({
197
+ ...tree,
198
+ nodes: {
199
+ ...tree.nodes,
200
+ [selectedNode.id]: { ...selectedNode, choices: nextChoices },
201
+ },
202
+ });
203
+ }, [tree, selectedNode, commitTree]);
204
+ const repositionChoice = useCallback((fromNodeId, choiceId, toNodeId, toIndex) => {
205
+ if (!tree)
206
+ return;
207
+ const next = moveChoiceInTree(tree, fromNodeId, choiceId, toNodeId, toIndex);
208
+ if (!next)
209
+ return;
210
+ commitTree(next);
211
+ selectChoice(toNodeId, choiceId);
212
+ }, [tree, commitTree, selectChoice]);
457
213
  const setChoiceTarget = useCallback((choiceId, targetNodeId) => {
458
214
  if (!tree || !selectedNode)
459
215
  return;
@@ -467,8 +223,8 @@ export function useTreeSpecEditor(options) {
467
223
  ? (existing?.outcome ?? TERMINAL_OUTCOME.AT_RISK)
468
224
  : undefined,
469
225
  };
470
- setTree(upsertTransition(tree, next));
471
- }, [tree, selectedNode]);
226
+ commitTree(upsertTransition(tree, next));
227
+ }, [tree, selectedNode, commitTree]);
472
228
  const setChoiceOutcome = useCallback((choiceId, outcome) => {
473
229
  if (!tree || !selectedNode)
474
230
  return;
@@ -481,26 +237,35 @@ export function useTreeSpecEditor(options) {
481
237
  outcome !== TERMINAL_OUTCOME.AT_RISK &&
482
238
  outcome !== TERMINAL_OUTCOME.COMPROMISED)
483
239
  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)
240
+ commitTree(upsertTransition(tree, { ...existing, outcome }));
241
+ }, [tree, selectedNode, commitTree]);
242
+ const updateChoiceEdgeHints = useCallback((nodeId, choiceId, patch) => {
243
+ if (!tree)
244
+ return;
245
+ const node = tree.nodes[nodeId];
246
+ if (!node)
491
247
  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
- }, []);
248
+ const choices = (node.choices ?? []).map((choice) => choice.id === choiceId ? patchChoiceEdgeHints(choice, patch) : choice);
249
+ commitTree({
250
+ ...tree,
251
+ nodes: {
252
+ ...tree.nodes,
253
+ [nodeId]: { ...node, choices },
254
+ },
255
+ });
256
+ }, [tree, commitTree]);
257
+ const setDefaultEdgeType = useCallback((edgeType) => {
258
+ if (!tree)
259
+ return;
260
+ commitTree(patchGraphEditorMeta(tree, { default_edge_type: edgeType }));
261
+ }, [tree, commitTree]);
497
262
  const deleteNodeById = useCallback((nodeId) => {
498
263
  if (!tree || isPublished)
499
264
  return false;
500
265
  const nextTree = deleteNode(tree, nodeId);
501
266
  if (!nextTree)
502
267
  return false;
503
- setTree(nextTree);
268
+ commitTree(nextTree);
504
269
  const wasSelected = selection.kind === GRAPH_SELECTION_KIND.NODE && selection.id === nodeId;
505
270
  if (wasSelected) {
506
271
  setSelection({ kind: null, id: null });
@@ -510,26 +275,21 @@ export function useTreeSpecEditor(options) {
510
275
  setFocusNodeId(null);
511
276
  }
512
277
  return true;
513
- }, [tree, isPublished, selection, focusNodeId, setTree, setSelection, setFocusNodeId, setFocusChoiceId]);
278
+ }, [
279
+ tree,
280
+ isPublished,
281
+ selection,
282
+ focusNodeId,
283
+ commitTree,
284
+ setSelection,
285
+ setFocusNodeId,
286
+ setFocusChoiceId,
287
+ ]);
514
288
  const deleteSelectedNode = useCallback(() => {
515
289
  if (selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
516
290
  return false;
517
291
  return deleteNodeById(selection.id);
518
292
  }, [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
293
  useEffect(() => {
534
294
  if (!enableKeyboardShortcuts)
535
295
  return;
@@ -538,77 +298,71 @@ export function useTreeSpecEditor(options) {
538
298
  const onKeyDown = (e) => {
539
299
  if (isTextFieldTarget(e.target))
540
300
  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),
301
+ const shortcutAction = resolveEditorKeyboardShortcutAction(e, {
302
+ tree,
303
+ selection,
304
+ canUndo,
305
+ canRedo,
306
+ hasCopiedNode,
307
+ isPublished,
308
+ });
309
+ dispatchEditorKeyboardShortcut(shortcutAction, e, {
310
+ tree,
311
+ selection,
312
+ isPublished,
313
+ onPreview,
314
+ undo,
315
+ redo,
316
+ copySelectedNode,
317
+ pasteCopiedNode,
318
+ deleteSelectedNode,
319
+ saveDraft: () => void adapterState.saveDraftRef.current?.(),
320
+ validate: () => void adapterState.validateRef.current?.(),
321
+ commitTree,
322
+ setSelection: applySelection,
323
+ setFocusNodeId,
548
324
  });
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
325
  };
588
326
  globalThis.addEventListener('keydown', onKeyDown);
589
327
  return () => globalThis.removeEventListener('keydown', onKeyDown);
590
- }, [enableKeyboardShortcuts, onPreview, tree, selection, deleteSelectedNode]);
591
- // -----------------------------------------------------------------------
592
- // Compose the public surface.
593
- // -----------------------------------------------------------------------
328
+ }, [
329
+ enableKeyboardShortcuts,
330
+ onPreview,
331
+ tree,
332
+ selection,
333
+ deleteSelectedNode,
334
+ canUndo,
335
+ canRedo,
336
+ hasCopiedNode,
337
+ isPublished,
338
+ undo,
339
+ redo,
340
+ copySelectedNode,
341
+ pasteCopiedNode,
342
+ commitTree,
343
+ applySelection,
344
+ setFocusNodeId,
345
+ adapterState.saveDraftRef,
346
+ adapterState.validateRef,
347
+ ]);
594
348
  const state = {
595
- loading,
596
- saving,
597
- publishing,
598
- creatingSnapshot,
599
- cloning,
600
- restoringSnapshotId,
349
+ loading: adapterState.loading,
350
+ saving: adapterState.saving,
351
+ publishing: adapterState.publishing,
352
+ creatingSnapshot: adapterState.creatingSnapshot,
353
+ cloning: adapterState.cloning,
354
+ restoringSnapshotId: adapterState.restoringSnapshotId,
601
355
  autosaveStatus,
602
- lastValidatedAt,
603
- rawTreeSpec,
356
+ lastValidatedAt: adapterState.lastValidatedAt,
357
+ rawTreeSpec: adapterState.rawTreeSpec,
604
358
  tree,
605
359
  baselineTree,
606
360
  compiledTreeSpec,
607
361
  isPublished,
608
- versionInfo,
362
+ versionInfo: adapterState.versionInfo,
609
363
  hasTree: Boolean(tree),
610
- localIssues,
611
- serverIssues,
364
+ localIssues: adapterState.localIssues,
365
+ serverIssues: adapterState.serverIssues,
612
366
  runtimeIssues,
613
367
  issues,
614
368
  canPublish,
@@ -618,46 +372,61 @@ export function useTreeSpecEditor(options) {
618
372
  fitViewNonce,
619
373
  selectedNode,
620
374
  selectedEdge,
375
+ inspectorNode,
621
376
  nodeSearch,
622
377
  issueSearch,
623
378
  showMiniMap,
624
- snapshots,
625
- auditEvents,
626
- loadingSnapshots,
627
- loadingAudit,
628
- showDraftHistory,
629
- showAudit,
630
- showPublishModal,
379
+ snapshots: adapterState.snapshots,
380
+ auditEvents: adapterState.auditEvents,
381
+ loadingSnapshots: adapterState.loadingSnapshots,
382
+ loadingAudit: adapterState.loadingAudit,
383
+ showDraftHistory: adapterState.showDraftHistory,
384
+ showAudit: adapterState.showAudit,
385
+ showPublishModal: adapterState.showPublishModal,
386
+ canUndo,
387
+ canRedo,
388
+ hasCopiedNode,
631
389
  };
632
390
  const actions = {
633
- setTree,
634
- setSelection,
391
+ setTree: commitTree,
392
+ setSelection: applySelection,
393
+ selectChoice,
635
394
  setFocusNodeId,
636
395
  setFocusChoiceId,
637
396
  triggerResetView,
638
397
  setNodeSearch,
639
398
  setIssueSearch,
640
399
  setShowMiniMap,
641
- setShowDraftHistory,
642
- setShowAudit,
643
- setShowPublishModal,
400
+ setShowDraftHistory: adapterState.setShowDraftHistory,
401
+ setShowAudit: adapterState.setShowAudit,
402
+ setShowPublishModal: adapterState.setShowPublishModal,
644
403
  addNodeOfType,
645
404
  deleteSelectedNode,
646
405
  deleteNodeById,
647
406
  autoLayout,
648
407
  insertTemplate,
649
- validate,
650
- saveDraft,
408
+ validate: adapterState.validate,
409
+ saveDraft: adapterState.saveDraft,
651
410
  publish,
652
- createSnapshot,
653
- restoreSnapshot,
654
- cloneToDraft,
411
+ createSnapshot: adapterState.createSnapshot,
412
+ restoreSnapshot: adapterState.restoreSnapshot,
413
+ cloneToDraft: adapterState.cloneToDraft,
655
414
  updateSelectedNode,
656
415
  addChoice,
416
+ setChoiceType,
657
417
  deleteChoice,
418
+ moveChoice,
419
+ repositionChoice,
658
420
  setChoiceTarget,
659
421
  setChoiceOutcome,
422
+ updateChoiceEdgeHints,
423
+ setDefaultEdgeType,
660
424
  selectIssue,
425
+ undo,
426
+ redo,
427
+ copySelectedNode,
428
+ pasteCopiedNode,
429
+ duplicateNodeById,
661
430
  };
662
431
  return { ...state, actions };
663
432
  }