@signalsafe/tree-spec-editor-react 0.1.0 → 0.1.2

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -7
  3. package/dist/GraphEditorCanvasContext.d.ts +26 -0
  4. package/dist/GraphEditorCanvasContext.d.ts.map +1 -0
  5. package/dist/GraphEditorCanvasContext.js +20 -0
  6. package/dist/TreeSpecGraphEditor.d.ts +22 -1
  7. package/dist/TreeSpecGraphEditor.d.ts.map +1 -1
  8. package/dist/TreeSpecGraphEditor.js +133 -257
  9. package/dist/canvas/constants.d.ts +41 -0
  10. package/dist/canvas/constants.d.ts.map +1 -0
  11. package/dist/canvas/constants.js +40 -0
  12. package/dist/canvas/edgeBuilders.d.ts +6 -0
  13. package/dist/canvas/edgeBuilders.d.ts.map +1 -0
  14. package/dist/canvas/edgeBuilders.js +57 -0
  15. package/dist/canvas/edgeStyle.d.ts +16 -0
  16. package/dist/canvas/edgeStyle.d.ts.map +1 -0
  17. package/dist/canvas/edgeStyle.js +44 -0
  18. package/dist/canvas/focusChoice.d.ts +3 -0
  19. package/dist/canvas/focusChoice.d.ts.map +1 -0
  20. package/dist/canvas/focusChoice.js +12 -0
  21. package/dist/canvas/typeGuards.d.ts +3 -0
  22. package/dist/canvas/typeGuards.d.ts.map +1 -0
  23. package/dist/canvas/typeGuards.js +16 -0
  24. package/dist/contextMenu/GraphCanvasContextMenu.d.ts +10 -0
  25. package/dist/contextMenu/GraphCanvasContextMenu.d.ts.map +1 -0
  26. package/dist/contextMenu/GraphCanvasContextMenu.js +39 -0
  27. package/dist/contextMenu/types.d.ts +11 -0
  28. package/dist/contextMenu/types.d.ts.map +1 -0
  29. package/dist/contextMenu/types.js +1 -0
  30. package/dist/hooks/keyboardShortcutDispatch.d.ts +30 -0
  31. package/dist/hooks/keyboardShortcutDispatch.d.ts.map +1 -0
  32. package/dist/hooks/keyboardShortcutDispatch.js +88 -0
  33. package/dist/hooks/types.d.ts +34 -4
  34. package/dist/hooks/types.d.ts.map +1 -1
  35. package/dist/hooks/useCanvasContextMenu.d.ts +15 -0
  36. package/dist/hooks/useCanvasContextMenu.d.ts.map +1 -0
  37. package/dist/hooks/useCanvasContextMenu.js +50 -0
  38. package/dist/hooks/useCanvasGraphState.d.ts +29 -0
  39. package/dist/hooks/useCanvasGraphState.d.ts.map +1 -0
  40. package/dist/hooks/useCanvasGraphState.js +150 -0
  41. package/dist/hooks/useCanvasIssueIndex.d.ts +12 -0
  42. package/dist/hooks/useCanvasIssueIndex.d.ts.map +1 -0
  43. package/dist/hooks/useCanvasIssueIndex.js +32 -0
  44. package/dist/hooks/useCanvasNodeResize.d.ts +17 -0
  45. package/dist/hooks/useCanvasNodeResize.d.ts.map +1 -0
  46. package/dist/hooks/useCanvasNodeResize.js +53 -0
  47. package/dist/hooks/useCanvasViewport.d.ts +12 -0
  48. package/dist/hooks/useCanvasViewport.d.ts.map +1 -0
  49. package/dist/hooks/useCanvasViewport.js +84 -0
  50. package/dist/hooks/useChoiceDragDrop.d.ts +25 -0
  51. package/dist/hooks/useChoiceDragDrop.d.ts.map +1 -0
  52. package/dist/hooks/useChoiceDragDrop.js +40 -0
  53. package/dist/hooks/useEditorAdapter.d.ts +46 -0
  54. package/dist/hooks/useEditorAdapter.d.ts.map +1 -0
  55. package/dist/hooks/useEditorAdapter.js +281 -0
  56. package/dist/hooks/useEditorAutosave.d.ts +18 -0
  57. package/dist/hooks/useEditorAutosave.d.ts.map +1 -0
  58. package/dist/hooks/useEditorAutosave.js +37 -0
  59. package/dist/hooks/useEditorHistory.d.ts +16 -0
  60. package/dist/hooks/useEditorHistory.d.ts.map +1 -0
  61. package/dist/hooks/useEditorHistory.js +103 -0
  62. package/dist/hooks/useEditorSelection.d.ts +22 -0
  63. package/dist/hooks/useEditorSelection.d.ts.map +1 -0
  64. package/dist/hooks/useEditorSelection.js +76 -0
  65. package/dist/hooks/useGraphConnect.d.ts +22 -0
  66. package/dist/hooks/useGraphConnect.d.ts.map +1 -0
  67. package/dist/hooks/useGraphConnect.js +75 -0
  68. package/dist/hooks/useTreeSpecEditor.d.ts +0 -8
  69. package/dist/hooks/useTreeSpecEditor.d.ts.map +1 -1
  70. package/dist/hooks/useTreeSpecEditor.js +231 -462
  71. package/dist/nodes/ChoiceCanvasRow.d.ts +10 -0
  72. package/dist/nodes/ChoiceCanvasRow.d.ts.map +1 -0
  73. package/dist/nodes/ChoiceCanvasRow.js +55 -0
  74. package/dist/nodes/EndNode.d.ts +3 -0
  75. package/dist/nodes/EndNode.d.ts.map +1 -0
  76. package/dist/nodes/EndNode.js +7 -0
  77. package/dist/nodes/PromptNode.d.ts +6 -0
  78. package/dist/nodes/PromptNode.d.ts.map +1 -0
  79. package/dist/nodes/PromptNode.js +51 -0
  80. package/dist/nodes/PromptNodeChoicesList.d.ts +14 -0
  81. package/dist/nodes/PromptNodeChoicesList.d.ts.map +1 -0
  82. package/dist/nodes/PromptNodeChoicesList.js +24 -0
  83. package/dist/nodes/PromptNodeHeader.d.ts +10 -0
  84. package/dist/nodes/PromptNodeHeader.d.ts.map +1 -0
  85. package/dist/nodes/PromptNodeHeader.js +6 -0
  86. package/dist/nodes/PromptNodeIssueBadges.d.ts +7 -0
  87. package/dist/nodes/PromptNodeIssueBadges.d.ts.map +1 -0
  88. package/dist/nodes/PromptNodeIssueBadges.js +6 -0
  89. package/dist/nodes/PromptNodeToolbar.d.ts +6 -0
  90. package/dist/nodes/PromptNodeToolbar.d.ts.map +1 -0
  91. package/dist/nodes/PromptNodeToolbar.js +5 -0
  92. package/dist/nodes/types.d.ts +13 -0
  93. package/dist/nodes/types.d.ts.map +1 -0
  94. package/dist/nodes/types.js +1 -0
  95. package/dist/utils/joinClasses.d.ts +2 -0
  96. package/dist/utils/joinClasses.d.ts.map +1 -0
  97. package/dist/utils/joinClasses.js +3 -0
  98. package/package.json +30 -12
@@ -0,0 +1,281 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { compileTreeSpec, decompileTreeSpec, lintTreeSpecWire, TREE_SPEC_ISSUE_SEVERITY, } from '@signalsafe/tree-spec';
3
+ import { autoLayoutTree, AUTOSAVE_STATUS, coerceTreeSpecWireForEditor as defaultCoerceRawSpec, needsInitialLayout, lintEditorTree, parsePydanticOutcomeErrors as defaultParseServerErrorMessage, shouldQueueInitialValidation as defaultShouldQueueInitialValidation, } from '@signalsafe/tree-spec-editor-core';
4
+ export function useEditorAdapter(adapterOptions) {
5
+ const { options, tree, isPublished, setIsPublished, replaceTreeWithoutHistory, lastSavedKeyRef, setAutosaveStatus, } = adapterOptions;
6
+ const { adapter, entityId, coerceRawSpec = defaultCoerceRawSpec, onCloneNavigate, parseServerErrorMessage = defaultParseServerErrorMessage, shouldQueueInitialValidation = defaultShouldQueueInitialValidation, } = options;
7
+ const [loading, setLoading] = useState(true);
8
+ const [saving, setSaving] = useState(false);
9
+ const [publishing, setPublishing] = useState(false);
10
+ const [rawTreeSpec, setRawTreeSpec] = useState(null);
11
+ const [versionInfo, setVersionInfo] = useState(null);
12
+ const [localIssues, setLocalIssues] = useState([]);
13
+ const [serverIssues, setServerIssues] = useState([]);
14
+ const [lastValidatedAt, setLastValidatedAt] = useState(null);
15
+ const [showDraftHistory, setShowDraftHistory] = useState(false);
16
+ const [showAudit, setShowAudit] = useState(false);
17
+ const [showPublishModal, setShowPublishModal] = useState(false);
18
+ const [snapshots, setSnapshots] = useState([]);
19
+ const [auditEvents, setAuditEvents] = useState([]);
20
+ const [loadingSnapshots, setLoadingSnapshots] = useState(false);
21
+ const [loadingAudit, setLoadingAudit] = useState(false);
22
+ const [restoringSnapshotId, setRestoringSnapshotId] = useState(null);
23
+ const [creatingSnapshot, setCreatingSnapshot] = useState(false);
24
+ const [cloning, setCloning] = useState(false);
25
+ const validateRef = useRef(async () => undefined);
26
+ const saveDraftRef = useRef(async () => undefined);
27
+ const compiledTreeSpec = useMemo(() => (tree ? compileTreeSpec(tree) : null), [tree]);
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ async function load() {
31
+ if (!entityId)
32
+ return;
33
+ setLoading(true);
34
+ setVersionInfo(null);
35
+ try {
36
+ const raw = await adapter.getVersion(entityId);
37
+ if (cancelled)
38
+ return;
39
+ setIsPublished(Boolean(raw?.is_published));
40
+ setVersionInfo(raw?.info ?? null);
41
+ const rawSpec = raw?.tree_spec;
42
+ const spec = coerceRawSpec(rawSpec);
43
+ if (spec == null) {
44
+ setRawTreeSpec(null);
45
+ replaceTreeWithoutHistory(null);
46
+ setVersionInfo(raw?.info ?? null);
47
+ }
48
+ else {
49
+ setRawTreeSpec(spec);
50
+ let nextTree = decompileTreeSpec(spec);
51
+ if (needsInitialLayout(nextTree)) {
52
+ nextTree = autoLayoutTree(nextTree);
53
+ }
54
+ replaceTreeWithoutHistory(nextTree);
55
+ setLocalIssues([...lintTreeSpecWire(spec), ...lintEditorTree(nextTree)]);
56
+ if (shouldQueueInitialValidation(raw?.is_published)) {
57
+ queueMicrotask(() => {
58
+ void validateRef.current(spec);
59
+ });
60
+ }
61
+ lastSavedKeyRef.current = JSON.stringify(spec);
62
+ setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
63
+ }
64
+ }
65
+ catch {
66
+ if (cancelled)
67
+ return;
68
+ setRawTreeSpec(null);
69
+ replaceTreeWithoutHistory(null);
70
+ setVersionInfo(null);
71
+ }
72
+ finally {
73
+ if (!cancelled)
74
+ setLoading(false);
75
+ }
76
+ }
77
+ if (entityId)
78
+ void load();
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [
83
+ entityId,
84
+ adapter,
85
+ coerceRawSpec,
86
+ shouldQueueInitialValidation,
87
+ replaceTreeWithoutHistory,
88
+ lastSavedKeyRef,
89
+ setAutosaveStatus,
90
+ setIsPublished,
91
+ ]);
92
+ useEffect(() => {
93
+ if (!tree || !compiledTreeSpec)
94
+ return;
95
+ setLocalIssues([...lintTreeSpecWire(compiledTreeSpec), ...lintEditorTree(tree)]);
96
+ }, [tree, compiledTreeSpec]);
97
+ const validate = useCallback(async (specOverride) => {
98
+ if (!entityId)
99
+ return undefined;
100
+ const spec = specOverride ?? (tree ? compileTreeSpec(tree) : rawTreeSpec);
101
+ if (!spec)
102
+ return undefined;
103
+ if (!adapter.validate) {
104
+ setLastValidatedAt(new Date().toISOString());
105
+ return { valid: true };
106
+ }
107
+ try {
108
+ const payload = await adapter.validate(entityId, spec);
109
+ const nextIssues = (payload?.issues ?? []).map((i) => {
110
+ const severity = i.severity ?? i.level ?? TREE_SPEC_ISSUE_SEVERITY.ERROR;
111
+ return {
112
+ severity,
113
+ message: String(i.message ?? 'Issue'),
114
+ node_id: i.node_id ?? undefined,
115
+ choice_id: i.choice_id ?? undefined,
116
+ };
117
+ });
118
+ setServerIssues(nextIssues);
119
+ setLastValidatedAt(new Date().toISOString());
120
+ return payload;
121
+ }
122
+ catch (err) {
123
+ const ex = err;
124
+ const errData = ex?.response?.data;
125
+ const msg = errData?.detail ??
126
+ errData?.error ??
127
+ err?.message ??
128
+ 'Validation failed';
129
+ const parsed = parseServerErrorMessage(String(msg));
130
+ setServerIssues(parsed ?? [{ severity: TREE_SPEC_ISSUE_SEVERITY.ERROR, message: String(msg) }]);
131
+ setLastValidatedAt(new Date().toISOString());
132
+ return {
133
+ valid: false,
134
+ issues: [{ severity: TREE_SPEC_ISSUE_SEVERITY.ERROR, message: String(msg) }],
135
+ };
136
+ }
137
+ }, [adapter, entityId, parseServerErrorMessage, rawTreeSpec, tree]);
138
+ useEffect(() => {
139
+ validateRef.current = validate;
140
+ }, [validate]);
141
+ const saveDraft = useCallback(async () => {
142
+ if (!entityId || !tree)
143
+ return;
144
+ if (isPublished)
145
+ return;
146
+ setSaving(true);
147
+ setAutosaveStatus(AUTOSAVE_STATUS.SAVING);
148
+ try {
149
+ const compiled = compileTreeSpec(tree);
150
+ await adapter.updateVersion(entityId, { tree_spec: compiled });
151
+ setRawTreeSpec(compiled);
152
+ lastSavedKeyRef.current = JSON.stringify(compiled);
153
+ setAutosaveStatus(AUTOSAVE_STATUS.SAVED);
154
+ }
155
+ finally {
156
+ setSaving(false);
157
+ }
158
+ }, [adapter, entityId, isPublished, tree, lastSavedKeyRef, setAutosaveStatus]);
159
+ useEffect(() => {
160
+ saveDraftRef.current = saveDraft;
161
+ }, [saveDraft]);
162
+ useEffect(() => {
163
+ const listSnapshots = adapter.listSnapshots;
164
+ if (!listSnapshots || !showDraftHistory || !entityId)
165
+ return;
166
+ let cancelled = false;
167
+ setLoadingSnapshots(true);
168
+ void (async () => {
169
+ try {
170
+ const list = await listSnapshots(entityId);
171
+ if (!cancelled)
172
+ setSnapshots(list);
173
+ }
174
+ finally {
175
+ if (!cancelled)
176
+ setLoadingSnapshots(false);
177
+ }
178
+ })();
179
+ return () => {
180
+ cancelled = true;
181
+ };
182
+ }, [adapter, showDraftHistory, entityId]);
183
+ const createSnapshot = useCallback(async () => {
184
+ if (!entityId || !tree || !adapter.createSnapshot || !adapter.listSnapshots)
185
+ return;
186
+ setCreatingSnapshot(true);
187
+ try {
188
+ const compiled = compileTreeSpec(tree);
189
+ await adapter.createSnapshot(entityId, { label: '', tree_spec: compiled });
190
+ const list = await adapter.listSnapshots(entityId);
191
+ setSnapshots(list);
192
+ }
193
+ finally {
194
+ setCreatingSnapshot(false);
195
+ }
196
+ }, [adapter, entityId, tree]);
197
+ const restoreSnapshot = useCallback(async (snapshotId) => {
198
+ if (!entityId || !adapter.restoreSnapshot)
199
+ return;
200
+ setRestoringSnapshotId(snapshotId);
201
+ try {
202
+ const { tree_spec: rawSpec } = await adapter.restoreSnapshot(entityId, snapshotId);
203
+ const spec = coerceRawSpec(rawSpec);
204
+ if (spec != null) {
205
+ setRawTreeSpec(spec);
206
+ replaceTreeWithoutHistory(decompileTreeSpec(spec));
207
+ lastSavedKeyRef.current = JSON.stringify(spec);
208
+ setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
209
+ }
210
+ setShowDraftHistory(false);
211
+ }
212
+ finally {
213
+ setRestoringSnapshotId(null);
214
+ }
215
+ }, [adapter, coerceRawSpec, entityId, replaceTreeWithoutHistory, lastSavedKeyRef, setAutosaveStatus]);
216
+ useEffect(() => {
217
+ const listAudit = adapter.listAudit;
218
+ if (!listAudit || !showAudit || !entityId)
219
+ return;
220
+ let cancelled = false;
221
+ setLoadingAudit(true);
222
+ void (async () => {
223
+ try {
224
+ const list = await listAudit(entityId);
225
+ if (!cancelled)
226
+ setAuditEvents(list);
227
+ }
228
+ finally {
229
+ if (!cancelled)
230
+ setLoadingAudit(false);
231
+ }
232
+ })();
233
+ return () => {
234
+ cancelled = true;
235
+ };
236
+ }, [adapter, showAudit, entityId]);
237
+ const cloneToDraft = useCallback(async () => {
238
+ if (!entityId || !adapter.cloneToDraft)
239
+ return;
240
+ setCloning(true);
241
+ try {
242
+ const { id: newId } = await adapter.cloneToDraft(entityId);
243
+ if (newId)
244
+ onCloneNavigate?.(newId);
245
+ }
246
+ finally {
247
+ setCloning(false);
248
+ }
249
+ }, [adapter, entityId, onCloneNavigate]);
250
+ return {
251
+ loading,
252
+ saving,
253
+ publishing,
254
+ setPublishing,
255
+ creatingSnapshot,
256
+ cloning,
257
+ restoringSnapshotId,
258
+ lastValidatedAt,
259
+ rawTreeSpec,
260
+ versionInfo,
261
+ localIssues,
262
+ serverIssues,
263
+ snapshots,
264
+ auditEvents,
265
+ loadingSnapshots,
266
+ loadingAudit,
267
+ showDraftHistory,
268
+ setShowDraftHistory,
269
+ showAudit,
270
+ setShowAudit,
271
+ showPublishModal,
272
+ setShowPublishModal,
273
+ validate,
274
+ saveDraft,
275
+ createSnapshot,
276
+ restoreSnapshot,
277
+ cloneToDraft,
278
+ validateRef,
279
+ saveDraftRef,
280
+ };
281
+ }
@@ -0,0 +1,18 @@
1
+ import { type MutableRefObject, type RefObject } from 'react';
2
+ import { type AutosaveStatus, type EditorTree } from '@signalsafe/tree-spec-editor-core';
3
+ import type { UseTreeSpecEditorActions } from './types';
4
+ export type UseEditorAutosaveOptions = {
5
+ enableAutosave: boolean;
6
+ autosaveDebounceMs: number;
7
+ entityId: string | undefined;
8
+ tree: EditorTree | null;
9
+ isPublished: boolean;
10
+ saving: boolean;
11
+ publishing: boolean;
12
+ autosaveStatus: AutosaveStatus;
13
+ setAutosaveStatus: (status: AutosaveStatus) => void;
14
+ lastSavedKeyRef: MutableRefObject<string>;
15
+ saveDraftRef: RefObject<UseTreeSpecEditorActions['saveDraft']>;
16
+ };
17
+ export declare function useEditorAutosave(options: UseEditorAutosaveOptions): void;
18
+ //# sourceMappingURL=useEditorAutosave.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEditorAutosave.d.ts","sourceRoot":"","sources":["../../src/hooks/useEditorAutosave.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIjF,OAAO,EAAmB,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,mCAAmC,CAAC;AAE1G,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,MAAM,wBAAwB,GAAG;IACnC,cAAc,EAAE,OAAO,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,cAAc,EAAE,cAAc,CAAC;IAC/B,iBAAiB,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD,eAAe,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC1C,YAAY,EAAE,SAAS,CAAC,wBAAwB,CAAC,WAAW,CAAC,CAAC,CAAC;CAClE,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2CzE"}
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { compileTreeSpec } from '@signalsafe/tree-spec';
3
+ import { AUTOSAVE_STATUS } from '@signalsafe/tree-spec-editor-core';
4
+ export function useEditorAutosave(options) {
5
+ const { enableAutosave, autosaveDebounceMs, entityId, tree, isPublished, saving, publishing, autosaveStatus, setAutosaveStatus, lastSavedKeyRef, saveDraftRef, } = options;
6
+ const autosaveTimerRef = useRef(null);
7
+ useEffect(() => {
8
+ if (!enableAutosave)
9
+ return;
10
+ if (!entityId || !tree)
11
+ return;
12
+ if (isPublished)
13
+ return;
14
+ if (saving || publishing)
15
+ return;
16
+ const compiled = compileTreeSpec(tree);
17
+ const key = JSON.stringify(compiled);
18
+ if (key === lastSavedKeyRef.current) {
19
+ if (autosaveStatus !== AUTOSAVE_STATUS.SAVED)
20
+ setAutosaveStatus(AUTOSAVE_STATUS.IDLE);
21
+ return;
22
+ }
23
+ setAutosaveStatus(AUTOSAVE_STATUS.DIRTY);
24
+ autosaveTimerRef.current = globalThis.setTimeout(() => {
25
+ void saveDraftRef.current?.();
26
+ }, autosaveDebounceMs);
27
+ return () => {
28
+ if (autosaveTimerRef.current) {
29
+ globalThis.clearTimeout(autosaveTimerRef.current);
30
+ autosaveTimerRef.current = null;
31
+ }
32
+ };
33
+ // NOTE: autosaveStatus is intentionally NOT in deps. The dirty/idle
34
+ // bookkeeping above flips status, and re-running the effect on every
35
+ // status change would reset the debounce timer.
36
+ }, [tree, entityId, isPublished, saving, publishing, autosaveDebounceMs, enableAutosave]);
37
+ }
@@ -0,0 +1,16 @@
1
+ import { type EditorTree, type GraphSelection } from '@signalsafe/tree-spec-editor-core';
2
+ export type UseEditorHistoryResult = {
3
+ tree: EditorTree | null;
4
+ canUndo: boolean;
5
+ canRedo: boolean;
6
+ hasCopiedNode: boolean;
7
+ commitTree: (next: EditorTree | null) => void;
8
+ replaceTreeWithoutHistory: (next: EditorTree | null) => void;
9
+ undo: () => boolean;
10
+ redo: () => boolean;
11
+ copySelectedNode: (selection: GraphSelection) => boolean;
12
+ pasteCopiedNode: (setSelection: (next: GraphSelection) => void, setFocusNodeId: (id: string | null) => void) => boolean;
13
+ duplicateNodeById: (nodeId: string, setSelection: (next: GraphSelection) => void, setFocusNodeId: (id: string | null) => void) => string | undefined;
14
+ };
15
+ export declare function useEditorHistory(isPublished: boolean): UseEditorHistoryResult;
16
+ //# sourceMappingURL=useEditorHistory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEditorHistory.d.ts","sourceRoot":"","sources":["../../src/hooks/useEditorHistory.ts"],"names":[],"mappings":"AAEA,OAAO,EAaH,KAAK,UAAU,EACf,KAAK,cAAc,EACtB,MAAM,mCAAmC,CAAC;AAE3C,MAAM,MAAM,sBAAsB,GAAG;IACjC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,OAAO,CAAC;IACvB,UAAU,EAAE,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAC;IAC9C,yBAAyB,EAAE,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7D,IAAI,EAAE,MAAM,OAAO,CAAC;IACpB,IAAI,EAAE,MAAM,OAAO,CAAC;IACpB,gBAAgB,EAAE,CAAC,SAAS,EAAE,cAAc,KAAK,OAAO,CAAC;IACrD,eAAe,EAAE,CACjB,YAAY,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,EAC5C,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,KAC1C,OAAO,CAAC;IACb,iBAAiB,EAAE,CACf,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,EAC5C,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,KAC1C,MAAM,GAAG,SAAS,CAAC;CAC3B,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,OAAO,GAAG,sBAAsB,CAsH7E"}
@@ -0,0 +1,103 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import { canRedoEditorHistory, canUndoEditorHistory, clearEditorHistory, createEditorHistoryStack, duplicateNode, editorTreesEqual, END_NODE_ID, GRAPH_SELECTION_KIND, popEditorRedo, popEditorUndo, pushEditorHistory, } from '@signalsafe/tree-spec-editor-core';
3
+ export function useEditorHistory(isPublished) {
4
+ const [tree, setTree] = useState(null);
5
+ const historyRef = useRef(createEditorHistoryStack());
6
+ const copiedNodeIdRef = useRef(null);
7
+ const [canUndo, setCanUndo] = useState(false);
8
+ const [canRedo, setCanRedo] = useState(false);
9
+ const [hasCopiedNode, setHasCopiedNode] = useState(false);
10
+ const syncHistoryMeta = useCallback(() => {
11
+ const stack = historyRef.current;
12
+ setCanUndo(canUndoEditorHistory(stack));
13
+ setCanRedo(canRedoEditorHistory(stack));
14
+ }, []);
15
+ const resetEditorHistory = useCallback(() => {
16
+ clearEditorHistory(historyRef.current);
17
+ copiedNodeIdRef.current = null;
18
+ setHasCopiedNode(false);
19
+ syncHistoryMeta();
20
+ }, [syncHistoryMeta]);
21
+ const commitTree = useCallback((next) => {
22
+ setTree((prev) => {
23
+ if (prev && next && !editorTreesEqual(prev, next)) {
24
+ pushEditorHistory(historyRef.current, prev);
25
+ }
26
+ return next;
27
+ });
28
+ queueMicrotask(syncHistoryMeta);
29
+ }, [syncHistoryMeta]);
30
+ const replaceTreeWithoutHistory = useCallback((next) => {
31
+ resetEditorHistory();
32
+ setTree(next);
33
+ }, [resetEditorHistory]);
34
+ const undo = useCallback(() => {
35
+ if (!tree || isPublished)
36
+ return false;
37
+ const result = popEditorUndo(historyRef.current, tree);
38
+ if (!result)
39
+ return false;
40
+ historyRef.current.future.unshift(result.currentSnapshot);
41
+ setTree(result.nextTree);
42
+ queueMicrotask(syncHistoryMeta);
43
+ return true;
44
+ }, [tree, isPublished, syncHistoryMeta]);
45
+ const redo = useCallback(() => {
46
+ if (!tree || isPublished)
47
+ return false;
48
+ const result = popEditorRedo(historyRef.current, tree);
49
+ if (!result)
50
+ return false;
51
+ historyRef.current.past.push(result.currentSnapshot);
52
+ setTree(result.nextTree);
53
+ queueMicrotask(syncHistoryMeta);
54
+ return true;
55
+ }, [tree, isPublished, syncHistoryMeta]);
56
+ const copySelectedNode = useCallback((selection) => {
57
+ if (selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
58
+ return false;
59
+ if (selection.id === END_NODE_ID)
60
+ return false;
61
+ copiedNodeIdRef.current = selection.id;
62
+ setHasCopiedNode(true);
63
+ return true;
64
+ }, []);
65
+ const pasteCopiedNode = useCallback((setSelection, setFocusNodeId) => {
66
+ if (!tree || isPublished)
67
+ return false;
68
+ const sourceId = copiedNodeIdRef.current;
69
+ if (!sourceId)
70
+ return false;
71
+ const duplicated = duplicateNode(tree, sourceId);
72
+ if (!duplicated)
73
+ return false;
74
+ commitTree(duplicated.nextTree);
75
+ setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: duplicated.nextNodeId });
76
+ setFocusNodeId(duplicated.nextNodeId);
77
+ return true;
78
+ }, [tree, isPublished, commitTree]);
79
+ const duplicateNodeById = useCallback((nodeId, setSelection, setFocusNodeId) => {
80
+ if (!tree || isPublished)
81
+ return undefined;
82
+ const duplicated = duplicateNode(tree, nodeId);
83
+ if (!duplicated)
84
+ return undefined;
85
+ commitTree(duplicated.nextTree);
86
+ setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: duplicated.nextNodeId });
87
+ setFocusNodeId(duplicated.nextNodeId);
88
+ return duplicated.nextNodeId;
89
+ }, [tree, isPublished, commitTree]);
90
+ return {
91
+ tree,
92
+ canUndo,
93
+ canRedo,
94
+ hasCopiedNode,
95
+ commitTree,
96
+ replaceTreeWithoutHistory,
97
+ undo,
98
+ redo,
99
+ copySelectedNode,
100
+ pasteCopiedNode,
101
+ duplicateNodeById,
102
+ };
103
+ }
@@ -0,0 +1,22 @@
1
+ import { type EditorNode, type EditorTransition, type EditorTree, type GraphSelection } from '@signalsafe/tree-spec-editor-core';
2
+ export type UseEditorSelectionResult = {
3
+ selection: GraphSelection;
4
+ focusNodeId: string | null;
5
+ focusChoiceId: string | null;
6
+ fitViewNonce: number;
7
+ selectedNode: EditorNode | null;
8
+ selectedEdge: EditorTransition | null;
9
+ inspectorNode: EditorNode | null;
10
+ selectChoice: (nodeId: string, choiceId: string) => void;
11
+ applySelection: (next: GraphSelection) => void;
12
+ setFocusNodeId: (id: string | null) => void;
13
+ setFocusChoiceId: (id: string | null) => void;
14
+ triggerResetView: () => void;
15
+ selectIssue: (issue: {
16
+ node_id?: string;
17
+ choice_id?: string;
18
+ }) => void;
19
+ setSelection: (next: GraphSelection) => void;
20
+ };
21
+ export declare function useEditorSelection(tree: EditorTree | null): UseEditorSelectionResult;
22
+ //# sourceMappingURL=useEditorSelection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEditorSelection.d.ts","sourceRoot":"","sources":["../../src/hooks/useEditorSelection.ts"],"names":[],"mappings":"AAEA,OAAO,EAIH,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,cAAc,EACtB,MAAM,mCAAmC,CAAC;AAE3C,MAAM,MAAM,wBAAwB,GAAG;IACnC,SAAS,EAAE,cAAc,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC,YAAY,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACtC,aAAa,EAAE,UAAU,GAAG,IAAI,CAAC;IACjC,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,cAAc,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;IAC/C,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC5C,gBAAgB,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC9C,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,WAAW,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACvE,YAAY,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,wBAAwB,CA6EpF"}
@@ -0,0 +1,76 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { END_NODE_ID, GRAPH_SELECTION_KIND, resolveGraphSelectionFocus, } from '@signalsafe/tree-spec-editor-core';
3
+ export function useEditorSelection(tree) {
4
+ const [selection, setSelection] = useState({ kind: null, id: null });
5
+ const [focusNodeId, setFocusNodeId] = useState(null);
6
+ const [focusChoiceId, setFocusChoiceId] = useState(null);
7
+ const [fitViewNonce, setFitViewNonce] = useState(0);
8
+ const selectChoice = useCallback((nodeId, choiceId) => {
9
+ setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: nodeId });
10
+ setFocusChoiceId(choiceId);
11
+ setFocusNodeId(nodeId);
12
+ }, []);
13
+ const applySelection = useCallback((next) => {
14
+ setSelection(next);
15
+ if (next.kind === GRAPH_SELECTION_KIND.EDGE && next.id && tree) {
16
+ const edge = tree.transitions.find((t) => t.id === next.id);
17
+ if (!edge)
18
+ return;
19
+ }
20
+ const { focusNodeId: nextFocusNodeId, focusChoiceId: nextFocusChoiceId } = resolveGraphSelectionFocus(next, tree);
21
+ setFocusNodeId(nextFocusNodeId);
22
+ setFocusChoiceId(nextFocusChoiceId);
23
+ }, [tree]);
24
+ const selectedNode = useMemo(() => {
25
+ if (!tree || selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
26
+ return null;
27
+ if (selection.id === END_NODE_ID)
28
+ return null;
29
+ return tree.nodes[selection.id] ?? null;
30
+ }, [tree, selection]);
31
+ const selectedEdge = useMemo(() => {
32
+ if (!tree || selection.kind !== GRAPH_SELECTION_KIND.EDGE || !selection.id)
33
+ return null;
34
+ return tree.transitions.find((t) => t.id === selection.id) ?? null;
35
+ }, [tree, selection]);
36
+ const inspectorNode = useMemo(() => {
37
+ if (selectedNode)
38
+ return selectedNode;
39
+ if (selectedEdge)
40
+ return tree?.nodes[selectedEdge.fromNodeId] ?? null;
41
+ return null;
42
+ }, [selectedNode, selectedEdge, tree]);
43
+ const triggerResetView = useCallback(() => setFitViewNonce((n) => n + 1), []);
44
+ const selectIssue = useCallback((issue) => {
45
+ if (!issue.node_id)
46
+ return;
47
+ setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: issue.node_id });
48
+ setFocusNodeId(issue.node_id);
49
+ setFocusChoiceId(issue.choice_id ?? null);
50
+ setFitViewNonce((n) => n + 1);
51
+ }, []);
52
+ useEffect(() => {
53
+ if (!inspectorNode || !focusChoiceId)
54
+ return;
55
+ if (typeof document === 'undefined')
56
+ return;
57
+ const el = document.getElementById(`choice-editor-${inspectorNode.id}-${focusChoiceId}`);
58
+ el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
59
+ }, [inspectorNode, focusChoiceId]);
60
+ return {
61
+ selection,
62
+ focusNodeId,
63
+ focusChoiceId,
64
+ fitViewNonce,
65
+ selectedNode,
66
+ selectedEdge,
67
+ inspectorNode,
68
+ selectChoice,
69
+ applySelection,
70
+ setFocusNodeId,
71
+ setFocusChoiceId,
72
+ triggerResetView,
73
+ selectIssue,
74
+ setSelection,
75
+ };
76
+ }
@@ -0,0 +1,22 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { Connection, Edge } from 'reactflow';
3
+ import { type EditorTree, type GraphSelection } from '@signalsafe/tree-spec-editor-core';
4
+ export type UseGraphConnectOptions = {
5
+ treeRef: MutableRefObject<EditorTree>;
6
+ onChange: (next: EditorTree) => void;
7
+ onSelect?: (sel: GraphSelection) => void;
8
+ readOnly: boolean;
9
+ };
10
+ export type UseGraphConnectResult = {
11
+ onConnect: (conn: Connection) => void;
12
+ onConnectStart: (_event: unknown, params: {
13
+ nodeId: string | null;
14
+ handleId: string | null;
15
+ }) => void;
16
+ onConnectEnd: (event: MouseEvent | TouchEvent) => void;
17
+ onReconnect: (oldEdge: Edge, newConnection: Connection) => void;
18
+ onEdgesDelete: (deleted: Edge[]) => void;
19
+ isValidConnection: (conn: Connection) => boolean;
20
+ };
21
+ export declare function useGraphConnect(options: UseGraphConnectOptions): UseGraphConnectResult;
22
+ //# sourceMappingURL=useGraphConnect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGraphConnect.d.ts","sourceRoot":"","sources":["../../src/hooks/useGraphConnect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,OAAO,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAMH,KAAK,UAAU,EACf,KAAK,cAAc,EACtB,MAAM,mCAAmC,CAAC;AAI3C,MAAM,MAAM,sBAAsB,GAAG;IACjC,OAAO,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAC;IACtC,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,QAAQ,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAChC,SAAS,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC,cAAc,EAAE,CACZ,MAAM,EAAE,OAAO,EACf,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,KACzD,IAAI,CAAC;IACV,YAAY,EAAE,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,KAAK,IAAI,CAAC;IACvD,WAAW,EAAE,CAAC,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,KAAK,IAAI,CAAC;IAChE,aAAa,EAAE,CAAC,OAAO,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACzC,iBAAiB,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,CAAC;CACpD,CAAC;AAEF,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAmGtF"}
@@ -0,0 +1,75 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { useReactFlow } from 'reactflow';
3
+ import { GRAPH_SELECTION_KIND, applyEditorConnect, applyEditorConnectOnDrop, applyEditorReconnect, isValidEditorConnection, } from '@signalsafe/tree-spec-editor-core';
4
+ import { isReactFlowPaneTarget } from '../canvas/typeGuards';
5
+ export function useGraphConnect(options) {
6
+ const { treeRef, onChange, onSelect, readOnly } = options;
7
+ const rf = useReactFlow();
8
+ const connectCompletedRef = useRef(false);
9
+ const pendingConnectRef = useRef(null);
10
+ const onConnect = useCallback((conn) => {
11
+ connectCompletedRef.current = true;
12
+ const nextTree = applyEditorConnect(treeRef.current, conn);
13
+ if (!nextTree)
14
+ return;
15
+ onChange(nextTree);
16
+ }, [onChange, treeRef]);
17
+ const onConnectStart = useCallback((_event, params) => {
18
+ connectCompletedRef.current = false;
19
+ if (!params.nodeId || !params.handleId) {
20
+ pendingConnectRef.current = null;
21
+ return;
22
+ }
23
+ pendingConnectRef.current = {
24
+ source: params.nodeId,
25
+ sourceHandle: params.handleId,
26
+ };
27
+ }, []);
28
+ const onConnectEnd = useCallback((event) => {
29
+ if (readOnly || connectCompletedRef.current) {
30
+ pendingConnectRef.current = null;
31
+ return;
32
+ }
33
+ const pending = pendingConnectRef.current;
34
+ pendingConnectRef.current = null;
35
+ if (!pending)
36
+ return;
37
+ if (!isReactFlowPaneTarget(event.target)) {
38
+ return;
39
+ }
40
+ const clientX = 'clientX' in event ? event.clientX : event.changedTouches?.[0]?.clientX;
41
+ const clientY = 'clientY' in event ? event.clientY : event.changedTouches?.[0]?.clientY;
42
+ if (clientX == null || clientY == null)
43
+ return;
44
+ const dropPosition = rf.screenToFlowPosition({ x: clientX, y: clientY });
45
+ const result = applyEditorConnectOnDrop(treeRef.current, pending.source, pending.sourceHandle, dropPosition);
46
+ if (!result)
47
+ return;
48
+ onChange(result.nextTree);
49
+ onSelect?.({ kind: GRAPH_SELECTION_KIND.NODE, id: result.nextNodeId });
50
+ }, [onChange, onSelect, readOnly, rf, treeRef]);
51
+ const isValidConnection = useCallback((conn) => isValidEditorConnection(treeRef.current, conn), [treeRef]);
52
+ const onReconnect = useCallback((oldEdge, newConnection) => {
53
+ const nextTree = applyEditorReconnect(treeRef.current, oldEdge, newConnection);
54
+ if (!nextTree)
55
+ return;
56
+ onChange(nextTree);
57
+ }, [onChange, treeRef]);
58
+ const onEdgesDelete = useCallback((deleted) => {
59
+ const deletedIds = new Set(deleted.map((edge) => String(edge.id)));
60
+ if (deletedIds.size === 0)
61
+ return;
62
+ onChange({
63
+ ...treeRef.current,
64
+ transitions: treeRef.current.transitions.filter((t) => !deletedIds.has(t.id)),
65
+ });
66
+ }, [onChange, treeRef]);
67
+ return {
68
+ onConnect,
69
+ onConnectStart,
70
+ onConnectEnd,
71
+ onReconnect,
72
+ onEdgesDelete,
73
+ isValidConnection,
74
+ };
75
+ }