@principal-ai/principal-view-react 0.13.21 → 0.13.23
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/dist/components/GraphRenderer.d.ts +8 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +278 -30
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/contexts/GraphEditContext.d.ts +14 -0
- package/dist/contexts/GraphEditContext.d.ts.map +1 -0
- package/dist/contexts/GraphEditContext.js +8 -0
- package/dist/contexts/GraphEditContext.js.map +1 -0
- package/dist/hooks/useUndoRedo.d.ts +75 -0
- package/dist/hooks/useUndoRedo.d.ts.map +1 -0
- package/dist/hooks/useUndoRedo.js +70 -0
- package/dist/hooks/useUndoRedo.js.map +1 -0
- package/dist/nodes/CustomNode.d.ts.map +1 -1
- package/dist/nodes/CustomNode.js +22 -5
- package/dist/nodes/CustomNode.js.map +1 -1
- package/package.json +1 -1
- package/src/components/GraphRenderer.tsx +372 -38
- package/src/contexts/GraphEditContext.tsx +21 -0
- package/src/hooks/useUndoRedo.ts +138 -0
- package/src/nodes/CustomNode.tsx +35 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing undo/redo history in the graph editor
|
|
3
|
+
*
|
|
4
|
+
* Tracks position changes, dimension changes, edge operations, and node deletions
|
|
5
|
+
* Supports batch operations (e.g., moving multiple selected nodes = single undo)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef, useState, useCallback } from 'react';
|
|
9
|
+
import type { NodeState, EdgeState } from '@principal-ai/principal-view-core';
|
|
10
|
+
|
|
11
|
+
const MAX_HISTORY_SIZE = 50;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Individual history entry types
|
|
15
|
+
*/
|
|
16
|
+
export type HistoryEntry =
|
|
17
|
+
| {
|
|
18
|
+
type: 'position';
|
|
19
|
+
nodeId: string;
|
|
20
|
+
before: { x: number; y: number };
|
|
21
|
+
after: { x: number; y: number };
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
type: 'dimension';
|
|
25
|
+
nodeId: string;
|
|
26
|
+
before: { width: number; height: number };
|
|
27
|
+
after: { width: number; height: number };
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: 'nodeDelete';
|
|
31
|
+
nodeId: string;
|
|
32
|
+
nodeData: NodeState;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
type: 'edgeCreate';
|
|
36
|
+
edge: EdgeState & { sourceHandle?: string; targetHandle?: string };
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: 'edgeDelete';
|
|
40
|
+
edge: EdgeState & { sourceHandle?: string; targetHandle?: string };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A batch of history entries that should be undone/redone together
|
|
45
|
+
*/
|
|
46
|
+
export type HistoryBatch = HistoryEntry[];
|
|
47
|
+
|
|
48
|
+
export interface UseUndoRedoReturn {
|
|
49
|
+
/** Whether there are entries to undo */
|
|
50
|
+
canUndo: boolean;
|
|
51
|
+
/** Whether there are entries to redo */
|
|
52
|
+
canRedo: boolean;
|
|
53
|
+
/** Undo the last batch of changes, returns the entries to reverse */
|
|
54
|
+
undo: () => HistoryBatch | null;
|
|
55
|
+
/** Redo the last undone batch, returns the entries to re-apply */
|
|
56
|
+
redo: () => HistoryBatch | null;
|
|
57
|
+
/** Push a new batch of entries to the undo stack (clears redo stack) */
|
|
58
|
+
pushHistory: (entries: HistoryBatch) => void;
|
|
59
|
+
/** Clear all history (both undo and redo stacks) */
|
|
60
|
+
clearHistory: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hook for managing undo/redo history
|
|
65
|
+
*
|
|
66
|
+
* Uses refs for stacks to avoid re-renders on every push,
|
|
67
|
+
* with state for canUndo/canRedo to trigger UI updates
|
|
68
|
+
*/
|
|
69
|
+
export function useUndoRedo(): UseUndoRedoReturn {
|
|
70
|
+
// Use refs for stacks to avoid re-renders on every push
|
|
71
|
+
const undoStackRef = useRef<HistoryBatch[]>([]);
|
|
72
|
+
const redoStackRef = useRef<HistoryBatch[]>([]);
|
|
73
|
+
|
|
74
|
+
// State for UI updates (canUndo/canRedo)
|
|
75
|
+
const [canUndo, setCanUndo] = useState(false);
|
|
76
|
+
const [canRedo, setCanRedo] = useState(false);
|
|
77
|
+
|
|
78
|
+
const updateCanStates = useCallback(() => {
|
|
79
|
+
setCanUndo(undoStackRef.current.length > 0);
|
|
80
|
+
setCanRedo(redoStackRef.current.length > 0);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const pushHistory = useCallback(
|
|
84
|
+
(entries: HistoryBatch) => {
|
|
85
|
+
if (entries.length === 0) return;
|
|
86
|
+
|
|
87
|
+
undoStackRef.current.push(entries);
|
|
88
|
+
|
|
89
|
+
// Limit stack size
|
|
90
|
+
if (undoStackRef.current.length > MAX_HISTORY_SIZE) {
|
|
91
|
+
undoStackRef.current.shift();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Clear redo stack when new changes are made
|
|
95
|
+
redoStackRef.current = [];
|
|
96
|
+
|
|
97
|
+
updateCanStates();
|
|
98
|
+
},
|
|
99
|
+
[updateCanStates]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const undo = useCallback((): HistoryBatch | null => {
|
|
103
|
+
const batch = undoStackRef.current.pop();
|
|
104
|
+
if (!batch) return null;
|
|
105
|
+
|
|
106
|
+
// Push to redo stack
|
|
107
|
+
redoStackRef.current.push(batch);
|
|
108
|
+
|
|
109
|
+
updateCanStates();
|
|
110
|
+
return batch;
|
|
111
|
+
}, [updateCanStates]);
|
|
112
|
+
|
|
113
|
+
const redo = useCallback((): HistoryBatch | null => {
|
|
114
|
+
const batch = redoStackRef.current.pop();
|
|
115
|
+
if (!batch) return null;
|
|
116
|
+
|
|
117
|
+
// Push back to undo stack
|
|
118
|
+
undoStackRef.current.push(batch);
|
|
119
|
+
|
|
120
|
+
updateCanStates();
|
|
121
|
+
return batch;
|
|
122
|
+
}, [updateCanStates]);
|
|
123
|
+
|
|
124
|
+
const clearHistory = useCallback(() => {
|
|
125
|
+
undoStackRef.current = [];
|
|
126
|
+
redoStackRef.current = [];
|
|
127
|
+
updateCanStates();
|
|
128
|
+
}, [updateCanStates]);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
canUndo,
|
|
132
|
+
canRedo,
|
|
133
|
+
undo,
|
|
134
|
+
redo,
|
|
135
|
+
pushHistory,
|
|
136
|
+
clearHistory,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/nodes/CustomNode.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import React, { useState, useRef } from 'react';
|
|
2
|
-
import { Handle, Position, NodeResizer } from '@xyflow/react';
|
|
1
|
+
import React, { useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { Handle, Position, NodeResizer, useNodeId } from '@xyflow/react';
|
|
3
3
|
import type { NodeProps, Node } from '@xyflow/react';
|
|
4
4
|
import type { NodeTypeDefinition } from '@principal-ai/principal-view-core';
|
|
5
5
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
6
6
|
import { resolveIcon } from '../utils/iconResolver';
|
|
7
7
|
import { NodeTooltip } from '../components/NodeTooltip';
|
|
8
8
|
import type { OtelInfo } from '../components/NodeTooltip';
|
|
9
|
+
import { useGraphEdit } from '../contexts/GraphEditContext';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Converts a hex color to a lighter/tinted version (opaque, not transparent)
|
|
@@ -63,6 +64,8 @@ export interface CustomNodeData extends Record<string, unknown> {
|
|
|
63
64
|
*/
|
|
64
65
|
export const CustomNode: React.FC<NodeProps<Node<CustomNodeData>>> = ({ data, selected, dragging }) => {
|
|
65
66
|
const { theme } = useTheme();
|
|
67
|
+
const { onNodeResizeEnd } = useGraphEdit();
|
|
68
|
+
const nodeId = useNodeId();
|
|
66
69
|
const [isHovered, setIsHovered] = useState(false);
|
|
67
70
|
const nodeRef = useRef<HTMLDivElement>(null);
|
|
68
71
|
const nodeProps = data;
|
|
@@ -86,6 +89,19 @@ export const CustomNode: React.FC<NodeProps<Node<CustomNodeData>>> = ({ data, se
|
|
|
86
89
|
// Inactive nodes (scenario filtering) are dimmed to 0.1
|
|
87
90
|
const nodeOpacity = isHidden ? 0.4 : isActive ? 1 : 0.1;
|
|
88
91
|
|
|
92
|
+
// Handle resize end - notify parent to track the dimension change
|
|
93
|
+
const handleResizeEnd = useCallback(
|
|
94
|
+
(_event: unknown, params: { width: number; height: number }) => {
|
|
95
|
+
if (nodeId && onNodeResizeEnd && params.width && params.height) {
|
|
96
|
+
onNodeResizeEnd(nodeId, {
|
|
97
|
+
width: Math.round(params.width),
|
|
98
|
+
height: Math.round(params.height),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[nodeId, onNodeResizeEnd]
|
|
103
|
+
);
|
|
104
|
+
|
|
89
105
|
// DEBUG: Log ALL node data to understand structure
|
|
90
106
|
console.log('[CustomNode] Node data:', {
|
|
91
107
|
name: nodeProps.name,
|
|
@@ -406,6 +422,21 @@ export const CustomNode: React.FC<NodeProps<Node<CustomNodeData>>> = ({ data, se
|
|
|
406
422
|
// Show event name if it differs from display name
|
|
407
423
|
const showEventName = eventName && eventName !== displayName;
|
|
408
424
|
|
|
425
|
+
// Helper to render text with word break opportunities after dots
|
|
426
|
+
const renderWithDotBreaks = (text: string) => {
|
|
427
|
+
const parts = text.split('.');
|
|
428
|
+
return parts.map((part, i) => (
|
|
429
|
+
<span key={i}>
|
|
430
|
+
{part}
|
|
431
|
+
{i < parts.length - 1 && (
|
|
432
|
+
<>
|
|
433
|
+
.<wbr />
|
|
434
|
+
</>
|
|
435
|
+
)}
|
|
436
|
+
</span>
|
|
437
|
+
));
|
|
438
|
+
};
|
|
439
|
+
|
|
409
440
|
// Helper component for rendering name with optional event name
|
|
410
441
|
const renderNameWithEvent = (centered: boolean = true) => (
|
|
411
442
|
<div style={{ textAlign: centered ? 'center' : 'left', wordBreak: 'break-word' }}>
|
|
@@ -419,7 +450,7 @@ export const CustomNode: React.FC<NodeProps<Node<CustomNodeData>>> = ({ data, se
|
|
|
419
450
|
fontFamily: theme.fonts.monospace,
|
|
420
451
|
}}
|
|
421
452
|
>
|
|
422
|
-
{eventName}
|
|
453
|
+
{renderWithDotBreaks(eventName)}
|
|
423
454
|
</div>
|
|
424
455
|
)}
|
|
425
456
|
</div>
|
|
@@ -673,6 +704,7 @@ export const CustomNode: React.FC<NodeProps<Node<CustomNodeData>>> = ({ data, se
|
|
|
673
704
|
minWidth={minWidth}
|
|
674
705
|
minHeight={minHeight}
|
|
675
706
|
keepAspectRatio={keepAspectRatio}
|
|
707
|
+
onResizeEnd={handleResizeEnd}
|
|
676
708
|
handleStyle={{
|
|
677
709
|
width: 8,
|
|
678
710
|
height: 8,
|