@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.
- package/LICENSE +21 -0
- package/README.md +125 -28
- package/README.standalone.md +14 -0
- package/dist/GraphEditorCanvasContext.d.ts +26 -0
- package/dist/GraphEditorCanvasContext.d.ts.map +1 -0
- package/dist/GraphEditorCanvasContext.js +20 -0
- package/dist/TreeSpecGraphEditor.d.ts +22 -1
- package/dist/TreeSpecGraphEditor.d.ts.map +1 -1
- package/dist/TreeSpecGraphEditor.js +133 -260
- package/dist/canvas/constants.d.ts +41 -0
- package/dist/canvas/constants.d.ts.map +1 -0
- package/dist/canvas/constants.js +40 -0
- package/dist/canvas/edgeBuilders.d.ts +6 -0
- package/dist/canvas/edgeBuilders.d.ts.map +1 -0
- package/dist/canvas/edgeBuilders.js +57 -0
- package/dist/canvas/edgeStyle.d.ts +16 -0
- package/dist/canvas/edgeStyle.d.ts.map +1 -0
- package/dist/canvas/edgeStyle.js +44 -0
- package/dist/canvas/focusChoice.d.ts +3 -0
- package/dist/canvas/focusChoice.d.ts.map +1 -0
- package/dist/canvas/focusChoice.js +12 -0
- package/dist/canvas/typeGuards.d.ts +3 -0
- package/dist/canvas/typeGuards.d.ts.map +1 -0
- package/dist/canvas/typeGuards.js +16 -0
- package/dist/contextMenu/GraphCanvasContextMenu.d.ts +10 -0
- package/dist/contextMenu/GraphCanvasContextMenu.d.ts.map +1 -0
- package/dist/contextMenu/GraphCanvasContextMenu.js +39 -0
- package/dist/contextMenu/types.d.ts +11 -0
- package/dist/contextMenu/types.d.ts.map +1 -0
- package/dist/contextMenu/types.js +1 -0
- package/dist/hooks/keyboardShortcutDispatch.d.ts +30 -0
- package/dist/hooks/keyboardShortcutDispatch.d.ts.map +1 -0
- package/dist/hooks/keyboardShortcutDispatch.js +88 -0
- package/dist/hooks/types.d.ts +32 -2
- package/dist/hooks/types.d.ts.map +1 -1
- package/dist/hooks/useCanvasContextMenu.d.ts +15 -0
- package/dist/hooks/useCanvasContextMenu.d.ts.map +1 -0
- package/dist/hooks/useCanvasContextMenu.js +50 -0
- package/dist/hooks/useCanvasGraphState.d.ts +29 -0
- package/dist/hooks/useCanvasGraphState.d.ts.map +1 -0
- package/dist/hooks/useCanvasGraphState.js +150 -0
- package/dist/hooks/useCanvasIssueIndex.d.ts +12 -0
- package/dist/hooks/useCanvasIssueIndex.d.ts.map +1 -0
- package/dist/hooks/useCanvasIssueIndex.js +32 -0
- package/dist/hooks/useCanvasNodeResize.d.ts +17 -0
- package/dist/hooks/useCanvasNodeResize.d.ts.map +1 -0
- package/dist/hooks/useCanvasNodeResize.js +53 -0
- package/dist/hooks/useCanvasViewport.d.ts +12 -0
- package/dist/hooks/useCanvasViewport.d.ts.map +1 -0
- package/dist/hooks/useCanvasViewport.js +84 -0
- package/dist/hooks/useChoiceDragDrop.d.ts +25 -0
- package/dist/hooks/useChoiceDragDrop.d.ts.map +1 -0
- package/dist/hooks/useChoiceDragDrop.js +40 -0
- package/dist/hooks/useEditorAdapter.d.ts +46 -0
- package/dist/hooks/useEditorAdapter.d.ts.map +1 -0
- package/dist/hooks/useEditorAdapter.js +281 -0
- package/dist/hooks/useEditorAutosave.d.ts +18 -0
- package/dist/hooks/useEditorAutosave.d.ts.map +1 -0
- package/dist/hooks/useEditorAutosave.js +37 -0
- package/dist/hooks/useEditorHistory.d.ts +16 -0
- package/dist/hooks/useEditorHistory.d.ts.map +1 -0
- package/dist/hooks/useEditorHistory.js +103 -0
- package/dist/hooks/useEditorSelection.d.ts +22 -0
- package/dist/hooks/useEditorSelection.d.ts.map +1 -0
- package/dist/hooks/useEditorSelection.js +75 -0
- package/dist/hooks/useGraphConnect.d.ts +22 -0
- package/dist/hooks/useGraphConnect.d.ts.map +1 -0
- package/dist/hooks/useGraphConnect.js +75 -0
- package/dist/hooks/useTreeSpecEditor.d.ts +1 -9
- package/dist/hooks/useTreeSpecEditor.d.ts.map +1 -1
- package/dist/hooks/useTreeSpecEditor.js +231 -462
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/nodes/ChoiceCanvasRow.d.ts +10 -0
- package/dist/nodes/ChoiceCanvasRow.d.ts.map +1 -0
- package/dist/nodes/ChoiceCanvasRow.js +55 -0
- package/dist/nodes/EndNode.d.ts +3 -0
- package/dist/nodes/EndNode.d.ts.map +1 -0
- package/dist/nodes/EndNode.js +7 -0
- package/dist/nodes/PromptNode.d.ts +6 -0
- package/dist/nodes/PromptNode.d.ts.map +1 -0
- package/dist/nodes/PromptNode.js +51 -0
- package/dist/nodes/PromptNodeChoicesList.d.ts +14 -0
- package/dist/nodes/PromptNodeChoicesList.d.ts.map +1 -0
- package/dist/nodes/PromptNodeChoicesList.js +24 -0
- package/dist/nodes/PromptNodeHeader.d.ts +10 -0
- package/dist/nodes/PromptNodeHeader.d.ts.map +1 -0
- package/dist/nodes/PromptNodeHeader.js +6 -0
- package/dist/nodes/PromptNodeIssueBadges.d.ts +7 -0
- package/dist/nodes/PromptNodeIssueBadges.d.ts.map +1 -0
- package/dist/nodes/PromptNodeIssueBadges.js +6 -0
- package/dist/nodes/PromptNodeToolbar.d.ts +6 -0
- package/dist/nodes/PromptNodeToolbar.d.ts.map +1 -0
- package/dist/nodes/PromptNodeToolbar.js +5 -0
- package/dist/nodes/types.d.ts +13 -0
- package/dist/nodes/types.d.ts.map +1 -0
- package/dist/nodes/types.js +1 -0
- package/dist/utils/joinClasses.d.ts +2 -0
- package/dist/utils/joinClasses.d.ts.map +1 -0
- package/dist/utils/joinClasses.js +3 -0
- package/package.json +36 -13
|
@@ -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,CA4EpF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
if (!tree.transitions.some((t) => t.id === next.id))
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const { focusNodeId: nextFocusNodeId, focusChoiceId: nextFocusChoiceId } = resolveGraphSelectionFocus(next, tree);
|
|
20
|
+
setFocusNodeId(nextFocusNodeId);
|
|
21
|
+
setFocusChoiceId(nextFocusChoiceId);
|
|
22
|
+
}, [tree]);
|
|
23
|
+
const selectedNode = useMemo(() => {
|
|
24
|
+
if (!tree || selection.kind !== GRAPH_SELECTION_KIND.NODE || !selection.id)
|
|
25
|
+
return null;
|
|
26
|
+
if (selection.id === END_NODE_ID)
|
|
27
|
+
return null;
|
|
28
|
+
return tree.nodes[selection.id] ?? null;
|
|
29
|
+
}, [tree, selection]);
|
|
30
|
+
const selectedEdge = useMemo(() => {
|
|
31
|
+
if (!tree || selection.kind !== GRAPH_SELECTION_KIND.EDGE || !selection.id)
|
|
32
|
+
return null;
|
|
33
|
+
return tree.transitions.find((t) => t.id === selection.id) ?? null;
|
|
34
|
+
}, [tree, selection]);
|
|
35
|
+
const inspectorNode = useMemo(() => {
|
|
36
|
+
if (selectedNode)
|
|
37
|
+
return selectedNode;
|
|
38
|
+
if (selectedEdge)
|
|
39
|
+
return tree?.nodes[selectedEdge.fromNodeId] ?? null;
|
|
40
|
+
return null;
|
|
41
|
+
}, [selectedNode, selectedEdge, tree]);
|
|
42
|
+
const triggerResetView = useCallback(() => setFitViewNonce((n) => n + 1), []);
|
|
43
|
+
const selectIssue = useCallback((issue) => {
|
|
44
|
+
if (!issue.node_id)
|
|
45
|
+
return;
|
|
46
|
+
setSelection({ kind: GRAPH_SELECTION_KIND.NODE, id: issue.node_id });
|
|
47
|
+
setFocusNodeId(issue.node_id);
|
|
48
|
+
setFocusChoiceId(issue.choice_id ?? null);
|
|
49
|
+
setFitViewNonce((n) => n + 1);
|
|
50
|
+
}, []);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!inspectorNode || !focusChoiceId)
|
|
53
|
+
return;
|
|
54
|
+
if (typeof document === 'undefined')
|
|
55
|
+
return;
|
|
56
|
+
const el = document.getElementById(`choice-editor-${inspectorNode.id}-${focusChoiceId}`);
|
|
57
|
+
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
58
|
+
}, [inspectorNode, focusChoiceId]);
|
|
59
|
+
return {
|
|
60
|
+
selection,
|
|
61
|
+
focusNodeId,
|
|
62
|
+
focusChoiceId,
|
|
63
|
+
fitViewNonce,
|
|
64
|
+
selectedNode,
|
|
65
|
+
selectedEdge,
|
|
66
|
+
inspectorNode,
|
|
67
|
+
selectChoice,
|
|
68
|
+
applySelection,
|
|
69
|
+
setFocusNodeId,
|
|
70
|
+
setFocusChoiceId,
|
|
71
|
+
triggerResetView,
|
|
72
|
+
selectIssue,
|
|
73
|
+
setSelection,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -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.js';
|
|
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
|
+
}
|
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
import type { UseTreeSpecEditorOptions, UseTreeSpecEditorResult } from './types';
|
|
1
|
+
import type { UseTreeSpecEditorOptions, UseTreeSpecEditorResult } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Headless React hook that owns the full stateful behavior of the SignalSafe
|
|
4
4
|
* TreeSpec graph editor — loading, autosave, validation, publish, snapshots,
|
|
5
5
|
* audit, clone-to-draft, selection, focus + fit-view, node/choice operations,
|
|
6
6
|
* and keyboard shortcuts. Consumers compose their own UI (toolbar, panels,
|
|
7
7
|
* modals, layout) on top of the returned state + actions.
|
|
8
|
-
*
|
|
9
|
-
* Boundary commitments:
|
|
10
|
-
* - **No router**: routing is host-injected via `onPreview` / `onCloneNavigate`.
|
|
11
|
-
* - **No UI library**: returns data only; never renders JSX.
|
|
12
|
-
* - **No simulator runtime**: hosts inject `computeRuntimeIssues` if needed.
|
|
13
|
-
*
|
|
14
|
-
* See {@link UseTreeSpecEditorOptions} for full option documentation and
|
|
15
|
-
* {@link UseTreeSpecEditorResult} for the returned shape.
|
|
16
8
|
*/
|
|
17
9
|
export declare function useTreeSpecEditor(options: UseTreeSpecEditorOptions): UseTreeSpecEditorResult;
|
|
18
10
|
//# sourceMappingURL=useTreeSpecEditor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useTreeSpecEditor.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeSpecEditor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useTreeSpecEditor.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeSpecEditor.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAER,wBAAwB,EACxB,uBAAuB,EAE1B,MAAM,SAAS,CAAC;AAcjB;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,uBAAuB,CA6f5F"}
|