@principal-ai/principal-view-react 0.6.16 → 0.6.17

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,341 @@
1
+ import React, { useState } from 'react';
2
+ import type { NodeState, NodeTypeDefinition } from '@principal-ai/principal-view-core';
3
+ import { useTheme } from '@principal-ade/industry-theme';
4
+ import { resolveIcon } from '../utils/iconResolver';
5
+
6
+ export interface SelectionSidebarProps {
7
+ /** Set of selected node IDs */
8
+ selectedNodeIds: Set<string>;
9
+ /** All nodes in the graph */
10
+ nodes: NodeState[];
11
+ /** Node type definitions for icons and colors */
12
+ nodeTypeDefinitions: Record<string, NodeTypeDefinition>;
13
+ /** Callback when sidebar is closed */
14
+ onClose: () => void;
15
+ }
16
+
17
+ interface ExpandedState {
18
+ [nodeId: string]: boolean;
19
+ }
20
+
21
+ /**
22
+ * Sidebar that displays information about multiple selected nodes
23
+ * Shows name and description for each node, with expandable details
24
+ */
25
+ export const SelectionSidebar: React.FC<SelectionSidebarProps> = ({
26
+ selectedNodeIds,
27
+ nodes,
28
+ nodeTypeDefinitions,
29
+ onClose,
30
+ }) => {
31
+ const { theme } = useTheme();
32
+ const [expandedNodes, setExpandedNodes] = useState<ExpandedState>({});
33
+
34
+ // Get the selected nodes in order
35
+ const selectedNodes = nodes.filter((n) => selectedNodeIds.has(n.id));
36
+
37
+ const toggleExpanded = (nodeId: string) => {
38
+ setExpandedNodes((prev) => ({
39
+ ...prev,
40
+ [nodeId]: !prev[nodeId],
41
+ }));
42
+ };
43
+
44
+ // Internal fields that should not be displayed
45
+ const internalFields = [
46
+ 'icon',
47
+ 'name',
48
+ 'description',
49
+ 'sources',
50
+ 'color',
51
+ 'stroke',
52
+ 'width',
53
+ 'height',
54
+ 'canvasType',
55
+ 'text',
56
+ 'file',
57
+ 'url',
58
+ 'shape',
59
+ 'states',
60
+ 'actions',
61
+ 'nodeType',
62
+ ];
63
+
64
+ return (
65
+ <div
66
+ style={{
67
+ position: 'absolute',
68
+ top: '60px',
69
+ right: '20px',
70
+ backgroundColor: theme.colors.background,
71
+ color: theme.colors.text,
72
+ borderRadius: '8px',
73
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
74
+ padding: '16px',
75
+ minWidth: '280px',
76
+ maxWidth: '350px',
77
+ maxHeight: 'calc(100vh - 120px)',
78
+ overflowY: 'auto',
79
+ zIndex: 1000,
80
+ border: `1px solid ${theme.colors.border}`,
81
+ }}
82
+ >
83
+ {/* Header */}
84
+ <div
85
+ style={{
86
+ display: 'flex',
87
+ justifyContent: 'space-between',
88
+ alignItems: 'center',
89
+ marginBottom: '12px',
90
+ paddingBottom: '8px',
91
+ borderBottom: `2px solid ${theme.colors.primary}`,
92
+ }}
93
+ >
94
+ <div style={{ fontWeight: 'bold', fontSize: '14px', color: theme.colors.primary }}>
95
+ {selectedNodes.length} nodes selected
96
+ </div>
97
+ <button
98
+ onClick={onClose}
99
+ style={{
100
+ border: 'none',
101
+ background: 'none',
102
+ cursor: 'pointer',
103
+ fontSize: '18px',
104
+ color: theme.colors.textSecondary,
105
+ padding: '0',
106
+ width: '24px',
107
+ height: '24px',
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ justifyContent: 'center',
111
+ }}
112
+ >
113
+ x
114
+ </button>
115
+ </div>
116
+
117
+ {/* Node List */}
118
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
119
+ {selectedNodes.map((node) => {
120
+ const typeDefinition = nodeTypeDefinitions[node.type];
121
+ const nodeColor =
122
+ (node.data?.color as string) || typeDefinition?.color || theme.colors.secondary;
123
+ const icon = (node.data?.icon as string) || typeDefinition?.icon;
124
+ const isExpanded = expandedNodes[node.id] || false;
125
+ const description = node.data?.description as string | undefined;
126
+
127
+ // Get displayable data entries
128
+ const dataEntries = node.data
129
+ ? Object.entries(node.data).filter(([key]) => !internalFields.includes(key))
130
+ : [];
131
+
132
+ return (
133
+ <div
134
+ key={node.id}
135
+ style={{
136
+ backgroundColor: theme.colors.surface,
137
+ borderRadius: '6px',
138
+ border: `1px solid ${theme.colors.border}`,
139
+ overflow: 'hidden',
140
+ }}
141
+ >
142
+ {/* Node Header - Always visible */}
143
+ <button
144
+ onClick={() => toggleExpanded(node.id)}
145
+ style={{
146
+ width: '100%',
147
+ padding: '10px 12px',
148
+ backgroundColor: 'transparent',
149
+ border: 'none',
150
+ cursor: 'pointer',
151
+ display: 'flex',
152
+ alignItems: 'flex-start',
153
+ gap: '10px',
154
+ textAlign: 'left',
155
+ }}
156
+ >
157
+ {/* Icon */}
158
+ <div
159
+ style={{
160
+ width: '28px',
161
+ height: '28px',
162
+ borderRadius: '6px',
163
+ backgroundColor: nodeColor,
164
+ display: 'flex',
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ flexShrink: 0,
168
+ color: 'white',
169
+ }}
170
+ >
171
+ {icon ? resolveIcon(icon, 16) : null}
172
+ </div>
173
+
174
+ {/* Name and Description */}
175
+ <div style={{ flex: 1, minWidth: 0 }}>
176
+ <div
177
+ style={{
178
+ fontWeight: 600,
179
+ fontSize: '13px',
180
+ color: theme.colors.text,
181
+ marginBottom: description ? '2px' : 0,
182
+ }}
183
+ >
184
+ {node.name || node.id}
185
+ </div>
186
+ {description && (
187
+ <div
188
+ style={{
189
+ fontSize: '11px',
190
+ color: theme.colors.textSecondary,
191
+ overflow: 'hidden',
192
+ textOverflow: 'ellipsis',
193
+ display: '-webkit-box',
194
+ WebkitLineClamp: isExpanded ? 'unset' : 2,
195
+ WebkitBoxOrient: 'vertical',
196
+ }}
197
+ >
198
+ {description}
199
+ </div>
200
+ )}
201
+ </div>
202
+
203
+ {/* Expand/Collapse Indicator */}
204
+ <div
205
+ style={{
206
+ color: theme.colors.textSecondary,
207
+ fontSize: '10px',
208
+ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
209
+ transition: 'transform 0.2s',
210
+ flexShrink: 0,
211
+ }}
212
+ >
213
+
214
+ </div>
215
+ </button>
216
+
217
+ {/* Expanded Details */}
218
+ {isExpanded && (
219
+ <div
220
+ style={{
221
+ padding: '0 12px 10px 12px',
222
+ borderTop: `1px solid ${theme.colors.border}`,
223
+ marginTop: '0',
224
+ }}
225
+ >
226
+ {/* Node Type */}
227
+ <div style={{ marginTop: '10px' }}>
228
+ <div
229
+ style={{
230
+ fontSize: '10px',
231
+ color: theme.colors.textSecondary,
232
+ marginBottom: '4px',
233
+ }}
234
+ >
235
+ Type
236
+ </div>
237
+ <div
238
+ style={{
239
+ fontSize: '11px',
240
+ padding: '3px 8px',
241
+ backgroundColor: nodeColor,
242
+ color: 'white',
243
+ borderRadius: '4px',
244
+ display: 'inline-block',
245
+ }}
246
+ >
247
+ {node.type}
248
+ </div>
249
+ </div>
250
+
251
+ {/* Node State */}
252
+ {node.state && (
253
+ <div style={{ marginTop: '8px' }}>
254
+ <div
255
+ style={{
256
+ fontSize: '10px',
257
+ color: theme.colors.textSecondary,
258
+ marginBottom: '4px',
259
+ }}
260
+ >
261
+ State
262
+ </div>
263
+ <div
264
+ style={{
265
+ fontSize: '11px',
266
+ padding: '3px 8px',
267
+ backgroundColor:
268
+ typeDefinition?.states?.[node.state]?.color || theme.colors.secondary,
269
+ color: 'white',
270
+ borderRadius: '4px',
271
+ display: 'inline-block',
272
+ }}
273
+ >
274
+ {typeDefinition?.states?.[node.state]?.label || node.state}
275
+ </div>
276
+ </div>
277
+ )}
278
+
279
+ {/* Data Properties */}
280
+ {dataEntries.length > 0 && (
281
+ <div style={{ marginTop: '8px' }}>
282
+ <div
283
+ style={{
284
+ fontSize: '10px',
285
+ color: theme.colors.textSecondary,
286
+ marginBottom: '6px',
287
+ fontWeight: 'bold',
288
+ }}
289
+ >
290
+ Properties
291
+ </div>
292
+ {dataEntries.map(([key, value]) => (
293
+ <div key={key} style={{ marginBottom: '6px' }}>
294
+ <div
295
+ style={{
296
+ fontSize: '10px',
297
+ color: theme.colors.textSecondary,
298
+ marginBottom: '2px',
299
+ }}
300
+ >
301
+ {key}
302
+ </div>
303
+ <div
304
+ style={{
305
+ fontSize: '11px',
306
+ color: theme.colors.text,
307
+ wordBreak: 'break-word',
308
+ }}
309
+ >
310
+ {value !== undefined && value !== null
311
+ ? typeof value === 'object'
312
+ ? JSON.stringify(value, null, 2)
313
+ : String(value)
314
+ : '-'}
315
+ </div>
316
+ </div>
317
+ ))}
318
+ </div>
319
+ )}
320
+
321
+ {/* Node ID */}
322
+ <div
323
+ style={{
324
+ fontSize: '10px',
325
+ color: theme.colors.textMuted,
326
+ marginTop: '8px',
327
+ paddingTop: '6px',
328
+ borderTop: `1px solid ${theme.colors.border}`,
329
+ }}
330
+ >
331
+ ID: {node.id}
332
+ </div>
333
+ </div>
334
+ )}
335
+ </div>
336
+ );
337
+ })}
338
+ </div>
339
+ </div>
340
+ );
341
+ };
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Handle, Position } from '@xyflow/react';
2
+ import { Handle, Position, NodeResizer } from '@xyflow/react';
3
3
  import type { NodeProps } from '@xyflow/react';
4
4
  import type { NodeTypeDefinition } from '@principal-ai/principal-view-core';
5
5
  import { resolveIcon } from '../utils/iconResolver';
@@ -100,6 +100,9 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
100
100
  border: `2px solid ${hasViolations ? '#D0021B' : strokeColor}`,
101
101
  fontSize: '12px',
102
102
  fontWeight: 500,
103
+ // Use 100% width/height to fill the node container (for resizing support)
104
+ width: '100%',
105
+ height: '100%',
103
106
  minWidth: typeDefinition.size?.width || 80,
104
107
  minHeight: typeDefinition.size?.height || 40,
105
108
  display: 'flex',
@@ -108,8 +111,9 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
108
111
  justifyContent: isGroup ? 'flex-start' : 'center',
109
112
  gap: '4px',
110
113
  boxShadow: selected ? `0 0 0 2px ${strokeColor}` : '0 2px 4px rgba(0,0,0,0.1)',
111
- transition: 'all 0.2s ease',
114
+ transition: 'box-shadow 0.2s ease',
112
115
  animationDuration: animationType ? `${animationDuration}ms` : undefined,
116
+ boxSizing: 'border-box' as const,
113
117
  };
114
118
 
115
119
  switch (typeDefinition.shape) {
@@ -117,8 +121,6 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
117
121
  return {
118
122
  ...baseStyles,
119
123
  borderRadius: '50%',
120
- width: typeDefinition.size?.width || 80,
121
- height: typeDefinition.size?.height || 80,
122
124
  padding: '8px',
123
125
  };
124
126
  case 'hexagon':
@@ -136,13 +138,12 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
136
138
  boxShadow: 'none', // Shadow handled by wrapper
137
139
  };
138
140
  case 'diamond':
139
- // Rotated square - fixed dimensions for proper diamond shape
140
- const diamondSize = typeDefinition.size?.width || 70;
141
+ // Rotated square - use 100% to fill container
141
142
  return {
142
143
  ...baseStyles,
143
144
  transform: 'rotate(45deg)',
144
- width: diamondSize,
145
- height: diamondSize,
145
+ width: '100%',
146
+ height: '100%',
146
147
  padding: '8px',
147
148
  };
148
149
  case 'rectangle':
@@ -156,6 +157,14 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
156
157
 
157
158
  const isDiamond = typeDefinition.shape === 'diamond';
158
159
  const isHexagon = typeDefinition.shape === 'hexagon';
160
+ const isCircle = typeDefinition.shape === 'circle';
161
+
162
+ // Determine if aspect ratio should be locked (circles and diamonds should maintain square aspect)
163
+ const keepAspectRatio = isCircle || isDiamond;
164
+
165
+ // Minimum dimensions for resizing
166
+ const minWidth = typeDefinition.size?.width || 80;
167
+ const minHeight = typeDefinition.size?.height || (isCircle ? minWidth : 40);
159
168
 
160
169
  // Hexagon border wrapper styles (outer shape that acts as border)
161
170
  // Hexagon with gentle diagonals
@@ -166,10 +175,14 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
166
175
  position: 'relative',
167
176
  clipPath: hexagonClipPath,
168
177
  backgroundColor: hasViolations ? '#D0021B' : strokeColor,
169
- width: typeDefinition.size?.width || 120,
170
- height: typeDefinition.size?.height || 120,
178
+ // Use 100% to fill container for resizing support
179
+ width: '100%',
180
+ height: '100%',
181
+ minWidth: typeDefinition.size?.width || 120,
182
+ minHeight: typeDefinition.size?.height || 120,
171
183
  boxShadow: selected ? `0 0 0 2px ${strokeColor}` : '0 2px 4px rgba(0,0,0,0.1)',
172
- transition: 'all 0.2s ease',
184
+ transition: 'box-shadow 0.2s ease',
185
+ boxSizing: 'border-box',
173
186
  }
174
187
  : {};
175
188
 
@@ -245,6 +258,25 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
245
258
 
246
259
  return (
247
260
  <>
261
+ {/* Node Resizer - only shown in edit mode */}
262
+ {editable && (
263
+ <NodeResizer
264
+ color={strokeColor}
265
+ isVisible={selected}
266
+ minWidth={minWidth}
267
+ minHeight={minHeight}
268
+ keepAspectRatio={keepAspectRatio}
269
+ handleStyle={{
270
+ width: 8,
271
+ height: 8,
272
+ borderRadius: 2,
273
+ }}
274
+ lineStyle={{
275
+ borderWidth: 1,
276
+ }}
277
+ />
278
+ )}
279
+
248
280
  {/* Input handles - all 4 sides for incoming connections */}
249
281
  <Handle type="target" position={Position.Top} id="top" style={getHandleStyle('top')} />
250
282
  <Handle type="target" position={Position.Bottom} id="bottom" style={getHandleStyle('bottom')} />