@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.
@@ -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
+ }
@@ -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,