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

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,14 +138,18 @@ 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
+ // Diamond uses wrapper approach for proper border - styles returned here are for inner fill
142
+ // The outer border wrapper is rendered separately in the JSX
141
143
  return {
142
144
  ...baseStyles,
143
- transform: 'rotate(45deg)',
144
- width: diamondSize,
145
- height: diamondSize,
146
- padding: '8px',
145
+ border: 'none', // Border handled by wrapper
146
+ clipPath: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
147
+ width: '100%',
148
+ height: '100%',
149
+ minWidth: 'unset',
150
+ minHeight: 'unset',
151
+ padding: '8px 16px',
152
+ boxShadow: 'none', // Shadow handled by wrapper
147
153
  };
148
154
  case 'rectangle':
149
155
  default:
@@ -156,6 +162,14 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
156
162
 
157
163
  const isDiamond = typeDefinition.shape === 'diamond';
158
164
  const isHexagon = typeDefinition.shape === 'hexagon';
165
+ const isCircle = typeDefinition.shape === 'circle';
166
+
167
+ // Determine if aspect ratio should be locked (circles should maintain square aspect)
168
+ const keepAspectRatio = isCircle;
169
+
170
+ // Minimum dimensions for resizing
171
+ const minWidth = typeDefinition.size?.width || 80;
172
+ const minHeight = typeDefinition.size?.height || (isCircle ? minWidth : 40);
159
173
 
160
174
  // Hexagon border wrapper styles (outer shape that acts as border)
161
175
  // Hexagon with gentle diagonals
@@ -166,10 +180,14 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
166
180
  position: 'relative',
167
181
  clipPath: hexagonClipPath,
168
182
  backgroundColor: hasViolations ? '#D0021B' : strokeColor,
169
- width: typeDefinition.size?.width || 120,
170
- height: typeDefinition.size?.height || 120,
183
+ // Use 100% to fill container for resizing support
184
+ width: '100%',
185
+ height: '100%',
186
+ minWidth: typeDefinition.size?.width || 120,
187
+ minHeight: typeDefinition.size?.height || 120,
171
188
  boxShadow: selected ? `0 0 0 2px ${strokeColor}` : '0 2px 4px rgba(0,0,0,0.1)',
172
- transition: 'all 0.2s ease',
189
+ transition: 'box-shadow 0.2s ease',
190
+ boxSizing: 'border-box',
173
191
  }
174
192
  : {};
175
193
 
@@ -194,6 +212,47 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
194
212
  }
195
213
  : {};
196
214
 
215
+ // Diamond clip-path
216
+ const diamondClipPath = 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)';
217
+ const diamondBorderWidth = 2;
218
+
219
+ // Diamond border wrapper styles (outer shape that acts as border)
220
+ const diamondBorderStyle: React.CSSProperties = isDiamond
221
+ ? {
222
+ position: 'relative',
223
+ clipPath: diamondClipPath,
224
+ backgroundColor: hasViolations ? '#D0021B' : strokeColor,
225
+ width: '100%',
226
+ height: '100%',
227
+ minWidth: typeDefinition.size?.width || 80,
228
+ minHeight: typeDefinition.size?.height || 80,
229
+ boxShadow: selected ? `0 0 0 2px ${strokeColor}` : '0 2px 4px rgba(0,0,0,0.1)',
230
+ transition: 'box-shadow 0.2s ease',
231
+ boxSizing: 'border-box',
232
+ }
233
+ : {};
234
+
235
+ // Diamond inner fill styles (white background inset from border)
236
+ const diamondInnerStyle: React.CSSProperties = isDiamond
237
+ ? {
238
+ position: 'absolute',
239
+ top: diamondBorderWidth,
240
+ left: diamondBorderWidth,
241
+ right: diamondBorderWidth,
242
+ bottom: diamondBorderWidth,
243
+ clipPath: diamondClipPath,
244
+ backgroundColor: 'white',
245
+ color: '#000',
246
+ display: 'flex',
247
+ flexDirection: 'column',
248
+ alignItems: 'center',
249
+ justifyContent: 'center',
250
+ fontSize: '12px',
251
+ fontWeight: 500,
252
+ gap: '4px',
253
+ }
254
+ : {};
255
+
197
256
  // Handle styles - larger and more visible in edit mode
198
257
  const baseHandleStyle = editable
199
258
  ? {
@@ -209,34 +268,13 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
209
268
  height: 8,
210
269
  };
211
270
 
212
- // Diamond handles need to be offset to reach the tips of the rotated shape
213
- // A 45° rotated square has tips at ~41% beyond the original edges
214
- const diamondOffset = isDiamond ? '21%' : '0';
215
-
216
- const getHandleStyle = (position: 'top' | 'bottom' | 'left' | 'right') => {
271
+ const getHandleStyle = (_position: 'top' | 'bottom' | 'left' | 'right') => {
217
272
  if (!isDiamond && !isHexagon) return baseHandleStyle;
218
273
 
219
274
  const offsetStyle: React.CSSProperties = { ...baseHandleStyle };
220
275
 
221
- if (isDiamond) {
222
- switch (position) {
223
- case 'top':
224
- offsetStyle.top = `-${diamondOffset}`;
225
- break;
226
- case 'bottom':
227
- offsetStyle.bottom = `-${diamondOffset}`;
228
- break;
229
- case 'left':
230
- offsetStyle.left = `-${diamondOffset}`;
231
- break;
232
- case 'right':
233
- offsetStyle.right = `-${diamondOffset}`;
234
- break;
235
- }
236
- }
237
-
238
- if (isHexagon) {
239
- // Bring handles above the hexagon layers
276
+ // Bring handles above the clip-path layers for hexagon and diamond
277
+ if (isHexagon || isDiamond) {
240
278
  offsetStyle.zIndex = 10;
241
279
  }
242
280
 
@@ -245,13 +283,32 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
245
283
 
246
284
  return (
247
285
  <>
286
+ {/* Node Resizer - only shown in edit mode */}
287
+ {editable && (
288
+ <NodeResizer
289
+ color={strokeColor}
290
+ isVisible={selected}
291
+ minWidth={minWidth}
292
+ minHeight={minHeight}
293
+ keepAspectRatio={keepAspectRatio}
294
+ handleStyle={{
295
+ width: 8,
296
+ height: 8,
297
+ borderRadius: 2,
298
+ }}
299
+ lineStyle={{
300
+ borderWidth: 1,
301
+ }}
302
+ />
303
+ )}
304
+
248
305
  {/* Input handles - all 4 sides for incoming connections */}
249
306
  <Handle type="target" position={Position.Top} id="top" style={getHandleStyle('top')} />
250
307
  <Handle type="target" position={Position.Bottom} id="bottom" style={getHandleStyle('bottom')} />
251
308
  <Handle type="target" position={Position.Left} id="left" style={getHandleStyle('left')} />
252
309
  <Handle type="target" position={Position.Right} id="right" style={getHandleStyle('right')} />
253
310
 
254
- {/* Hexagon needs a wrapper for proper border rendering */}
311
+ {/* Hexagon and Diamond need a wrapper for proper border rendering */}
255
312
  {isHexagon ? (
256
313
  <div style={hexagonBorderStyle} className={animationClass}>
257
314
  <div style={hexagonInnerStyle}>
@@ -288,12 +345,47 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
288
345
  )}
289
346
  </div>
290
347
  </div>
348
+ ) : isDiamond ? (
349
+ <div style={diamondBorderStyle} className={animationClass}>
350
+ <div style={diamondInnerStyle}>
351
+ {icon && (
352
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
353
+ {resolveIcon(icon, 20)}
354
+ </div>
355
+ )}
356
+ <div style={{ textAlign: 'center', wordBreak: 'break-word' }}>{displayName}</div>
357
+ {state && (
358
+ <div
359
+ style={{
360
+ fontSize: '10px',
361
+ backgroundColor: color,
362
+ color: 'white',
363
+ padding: '2px 6px',
364
+ borderRadius: '4px',
365
+ textAlign: 'center',
366
+ }}
367
+ >
368
+ {nodeDataStates?.[state]?.label || typeDefinition.states?.[state]?.label || state}
369
+ </div>
370
+ )}
371
+ {hasViolations && (
372
+ <div
373
+ style={{
374
+ fontSize: '10px',
375
+ color: '#D0021B',
376
+ fontWeight: 'bold',
377
+ }}
378
+ >
379
+ ⚠️
380
+ </div>
381
+ )}
382
+ </div>
383
+ </div>
291
384
  ) : (
292
385
  <div style={getShapeStyles()} className={animationClass}>
293
- {/* Inner content (rotated back if diamond) */}
386
+ {/* Inner content */}
294
387
  <div
295
388
  style={{
296
- ...(isDiamond ? { transform: 'rotate(-45deg)' } : {}),
297
389
  ...(isGroup ? { width: '100%' } : {}),
298
390
  }}
299
391
  >
@@ -437,22 +529,6 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected }) => {
437
529
  }
438
530
  }
439
531
 
440
- /* Special handling for diamond shape with shake */
441
- .node-shake[style*="rotate(45deg)"] {
442
- animation: node-shake-diamond ease-in-out;
443
- }
444
-
445
- @keyframes node-shake-diamond {
446
- 0%, 100% {
447
- transform: rotate(45deg) translateX(0);
448
- }
449
- 10%, 30%, 50%, 70%, 90% {
450
- transform: rotate(45deg) translateX(-4px);
451
- }
452
- 20%, 40%, 60%, 80% {
453
- transform: rotate(45deg) translateX(4px);
454
- }
455
- }
456
532
  `}</style>
457
533
  </>
458
534
  );