@@ -84,6 +84,8 @@ function ensureFlowSpec(spec: Record<string, any> | undefined): FlowSpec {
8484 subtitle : typeof node . subtitle === 'string' ? node . subtitle : '' ,
8585 shape : MOCK_SHAPE_OPTIONS . includes ( node . shape ) ? node . shape : 'box' ,
8686 color : typeof node . color === 'string' && node . color . trim ( ) ? node . color : '#64748B' ,
87+ width : typeof node . width === 'number' ? node . width : undefined ,
88+ height : typeof node . height === 'number' ? node . height : undefined ,
8789 position,
8890 } ;
8991 }
@@ -204,36 +206,44 @@ function getNodeDimensions(node: FlowNode): { width: number; height: number } {
204206
205207 switch ( node . shape ) {
206208 case 'pill' : {
207- const width = clamp ( 188 + Math . max ( 0 , longestText - 12 ) * 7 , 188 , 340 ) ;
209+ const baseWidth = clamp ( 188 + Math . max ( 0 , longestText - 12 ) * 7 , 188 , 340 ) ;
210+ const width = clamp ( Math . max ( node . width || 0 , baseWidth ) , 188 , MAX_NODE_WIDTH ) ;
208211 const charsPerLine = Math . max ( 12 , Math . floor ( ( width - 44 ) / 8 ) ) ;
209212 const titleLines = estimateWrappedLines ( title , charsPerLine ) ;
210213 const subtitleLines = estimateWrappedLines ( subtitle , charsPerLine ) ;
211- const height = Math . max ( 84 , 48 + titleLines * 18 + subtitleLines * 16 + 18 ) ;
214+ const baseHeight = Math . max ( 84 , 48 + titleLines * 18 + subtitleLines * 16 + 18 ) ;
215+ const height = Math . max ( node . height || 0 , baseHeight ) ;
212216 return { width, height } ;
213217 }
214218 case 'diamond' : {
215- const width = clamp ( 272 + Math . max ( 0 , longestText - 10 ) * 10 , 272 , MAX_NODE_WIDTH ) ;
219+ const baseWidth = clamp ( 272 + Math . max ( 0 , longestText - 10 ) * 10 , 272 , MAX_NODE_WIDTH ) ;
220+ const width = clamp ( Math . max ( node . width || 0 , baseWidth ) , 272 , MAX_NODE_WIDTH ) ;
216221 const charsPerLine = Math . max ( 9 , Math . floor ( ( width * 0.42 ) / 8 ) ) ;
217222 const titleLines = estimateWrappedLines ( title , charsPerLine ) ;
218223 const subtitleLines = estimateWrappedLines ( subtitle , charsPerLine ) ;
219- const height = clamp ( 120 + Math . max ( 0 , titleLines - 1 ) * 22 + subtitleLines * 20 , 120 , 220 ) ;
224+ const baseHeight = clamp ( 120 + Math . max ( 0 , titleLines - 1 ) * 22 + subtitleLines * 20 , 120 , 220 ) ;
225+ const height = Math . max ( node . height || 0 , baseHeight ) ;
220226 return { width, height } ;
221227 }
222228 case 'note' : {
223- const width = clamp ( 196 + Math . max ( 0 , longestText - 14 ) * 7 , 196 , 360 ) ;
229+ const baseWidth = clamp ( 196 + Math . max ( 0 , longestText - 14 ) * 7 , 196 , 360 ) ;
230+ const width = clamp ( Math . max ( node . width || 0 , baseWidth ) , 196 , MAX_NODE_WIDTH ) ;
224231 const charsPerLine = Math . max ( 13 , Math . floor ( ( width - 52 ) / 8 ) ) ;
225232 const titleLines = estimateWrappedLines ( title , charsPerLine ) ;
226233 const subtitleLines = estimateWrappedLines ( subtitle , charsPerLine ) ;
227- const height = Math . max ( MIN_NODE_HEIGHT , 56 + titleLines * 18 + subtitleLines * 16 + 24 ) ;
234+ const baseHeight = Math . max ( MIN_NODE_HEIGHT , 56 + titleLines * 18 + subtitleLines * 16 + 24 ) ;
235+ const height = Math . max ( node . height || 0 , baseHeight ) ;
228236 return { width, height } ;
229237 }
230238 case 'box' :
231239 default : {
232- const width = clamp ( 180 + Math . max ( 0 , longestText - 14 ) * 6 , 180 , 320 ) ;
240+ const baseWidth = clamp ( 180 + Math . max ( 0 , longestText - 14 ) * 6 , 180 , 320 ) ;
241+ const width = clamp ( Math . max ( node . width || 0 , baseWidth ) , 180 , MAX_NODE_WIDTH ) ;
233242 const charsPerLine = Math . max ( 14 , Math . floor ( ( width - 36 ) / 8 ) ) ;
234243 const titleLines = estimateWrappedLines ( title , charsPerLine ) ;
235244 const subtitleLines = estimateWrappedLines ( subtitle , charsPerLine ) ;
236- const height = Math . max ( MIN_NODE_HEIGHT , 48 + titleLines * 18 + subtitleLines * 16 + 18 ) ;
245+ const baseHeight = Math . max ( MIN_NODE_HEIGHT , 48 + titleLines * 18 + subtitleLines * 16 + 18 ) ;
246+ const height = Math . max ( node . height || 0 , baseHeight ) ;
237247 return { width, height } ;
238248 }
239249 }
@@ -459,6 +469,7 @@ export default function Flow() {
459469 const [ notice , setNotice ] = useState ( '' ) ;
460470 const [ dirty , setDirty ] = useState ( false ) ;
461471 const [ dragging , setDragging ] = useState < { nodeId : string ; offsetX : number ; offsetY : number } | null > ( null ) ;
472+ const [ resizing , setResizing ] = useState < { nodeId : string ; startClientX : number ; startClientY : number ; startWidth : number ; startHeight : number } | null > ( null ) ;
462473 const [ panning , setPanning ] = useState < { startX : number ; startY : number ; originX : number ; originY : number } | null > ( null ) ;
463474 const [ canvasZoom , setCanvasZoom ] = useState ( 1 ) ;
464475 const [ zoomMode , setZoomMode ] = useState < 'fit' | 'manual' > ( 'fit' ) ;
@@ -469,7 +480,7 @@ export default function Flow() {
469480 const requestedMode = searchParams . get ( 'mode' ) === 'edit' ? 'edit' : 'view' ;
470481 const flowBounds = getFlowBounds ( flowSpec ) ;
471482 const [ renderBounds , setRenderBounds ] = useState ( flowBounds ) ;
472- const activeBounds = dragging ? renderBounds : flowBounds ;
483+ const activeBounds = dragging || resizing ? renderBounds : flowBounds ;
473484 const stageMinX = Math . min ( 0 , activeBounds . minX ) ;
474485 const stageMinY = Math . min ( 0 , activeBounds . minY ) ;
475486 const stageMaxX = Math . max ( CANVAS_WIDTH , activeBounds . maxX ) ;
@@ -496,8 +507,27 @@ export default function Flow() {
496507 }
497508
498509 function applyManualZoom ( nextZoom : number ) {
510+ const { width, height } = getViewportDimensions ( ) ;
511+ applyZoomAtClientPoint ( nextZoom , ( viewportRef . current ?. getBoundingClientRect ( ) . left || 0 ) + width / 2 , ( viewportRef . current ?. getBoundingClientRect ( ) . top || 0 ) + height / 2 ) ;
512+ }
513+
514+ function applyZoomAtClientPoint ( nextZoom : number , clientX : number , clientY : number ) {
515+ const zoom = clamp ( nextZoom , MIN_ZOOM , MAX_ZOOM ) ;
516+ const viewport = viewportRef . current ;
517+ if ( ! viewport ) {
518+ setZoomMode ( 'manual' ) ;
519+ setCanvasZoom ( zoom ) ;
520+ return ;
521+ }
522+ const rect = viewport . getBoundingClientRect ( ) ;
523+ const stageX = ( clientX - rect . left - panOffset . x ) / canvasZoom ;
524+ const stageY = ( clientY - rect . top - panOffset . y ) / canvasZoom ;
499525 setZoomMode ( 'manual' ) ;
500- setCanvasZoom ( clamp ( nextZoom , MIN_ZOOM , MAX_ZOOM ) ) ;
526+ setCanvasZoom ( zoom ) ;
527+ setPanOffset ( {
528+ x : clientX - rect . left - stageX * zoom ,
529+ y : clientY - rect . top - stageY * zoom ,
530+ } ) ;
501531 }
502532
503533 function fitCanvas ( ) {
@@ -518,9 +548,9 @@ export default function Flow() {
518548 }
519549
520550 useEffect ( ( ) => {
521- if ( dragging ) return ;
551+ if ( dragging || resizing ) return ;
522552 setRenderBounds ( flowBounds ) ;
523- } , [ dragging , flowBounds ] ) ;
553+ } , [ dragging , resizing , flowBounds ] ) ;
524554
525555 useEffect ( ( ) => {
526556 let active = true ;
@@ -608,6 +638,42 @@ export default function Flow() {
608638 } ;
609639 } , [ dragging , canvasZoom ] ) ;
610640
641+ useEffect ( ( ) => {
642+ if ( ! resizing ) return ;
643+ const currentResize = resizing ;
644+
645+ function handleMove ( event : MouseEvent ) {
646+ const nextWidth = currentResize . startWidth + ( event . clientX - currentResize . startClientX ) / canvasZoom ;
647+ const nextHeight = currentResize . startHeight + ( event . clientY - currentResize . startClientY ) / canvasZoom ;
648+
649+ setFlowSpec ( ( prev ) => ( {
650+ ...prev ,
651+ nodes : prev . nodes . map ( ( node ) => (
652+ node . id === currentResize . nodeId && isMockNode ( node )
653+ ? {
654+ ...node ,
655+ width : clamp ( nextWidth , 160 , MAX_NODE_WIDTH ) ,
656+ height : Math . max ( 72 , nextHeight ) ,
657+ }
658+ : node
659+ ) ) ,
660+ } ) ) ;
661+ setDirty ( true ) ;
662+ }
663+
664+ function handleUp ( ) {
665+ setResizing ( null ) ;
666+ }
667+
668+ window . addEventListener ( 'mousemove' , handleMove ) ;
669+ window . addEventListener ( 'mouseup' , handleUp ) ;
670+
671+ return ( ) => {
672+ window . removeEventListener ( 'mousemove' , handleMove ) ;
673+ window . removeEventListener ( 'mouseup' , handleUp ) ;
674+ } ;
675+ } , [ resizing , canvasZoom ] ) ;
676+
611677 useEffect ( ( ) => {
612678 if ( ! panning ) return ;
613679 const currentPan = panning ;
@@ -649,15 +715,15 @@ export default function Flow() {
649715 } , [ ] ) ;
650716
651717 useEffect ( ( ) => {
652- if ( zoomMode !== 'fit' || dragging ) return ;
718+ if ( zoomMode !== 'fit' || dragging || resizing ) return ;
653719 const frame = requestAnimationFrame ( ( ) => {
654720 const { width, height } = getViewportDimensions ( ) ;
655721 const nextView = getFitViewState ( width , height , flowSpec ) ;
656722 setPanOffset ( nextView . offset ) ;
657723 setCanvasZoom ( nextView . zoom ) ;
658724 } ) ;
659725 return ( ) => cancelAnimationFrame ( frame ) ;
660- } , [ zoomMode , dragging , viewportSize . width , viewportSize . height , currentFlowName , currentNamespace , flowSpec ] ) ;
726+ } , [ zoomMode , dragging , resizing , viewportSize . width , viewportSize . height , currentFlowName , currentNamespace , flowSpec ] ) ;
661727
662728 useEffect ( ( ) => {
663729 if ( ! flowSettings . canEdit && mode === 'edit' ) {
@@ -1100,6 +1166,7 @@ export default function Flow() {
11001166 panning ? 'cursor-grabbing' : 'cursor-grab'
11011167 } `}
11021168 style = { {
1169+ overscrollBehavior : 'contain' ,
11031170 backgroundImage : 'linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px)' ,
11041171 backgroundSize : `${ 32 * canvasZoom } px ${ 32 * canvasZoom } px` ,
11051172 backgroundPosition : `${ canvasOffset . x + contentOffsetX * canvasZoom } px ${ canvasOffset . y + contentOffsetY * canvasZoom } px` ,
@@ -1121,9 +1188,10 @@ export default function Flow() {
11211188 originY : panOffset . y ,
11221189 } ) ;
11231190 } }
1124- onWheel = { ( event ) => {
1191+ onWheelCapture = { ( event ) => {
11251192 event . preventDefault ( ) ;
1126- applyManualZoom ( canvasZoom + ( event . deltaY < 0 ? ZOOM_STEP : - ZOOM_STEP ) ) ;
1193+ event . stopPropagation ( ) ;
1194+ applyZoomAtClientPoint ( canvasZoom + ( event . deltaY < 0 ? ZOOM_STEP : - ZOOM_STEP ) , event . clientX , event . clientY ) ;
11271195 } }
11281196 >
11291197 < div
@@ -1363,6 +1431,28 @@ export default function Flow() {
13631431 </ div >
13641432 </ div >
13651433 ) }
1434+ { ! readOnly && flowSettings . canEdit && isMockNode ( node ) && (
1435+ < button
1436+ type = "button"
1437+ data-flow-resize = "true"
1438+ onMouseDown = { ( event ) => {
1439+ event . stopPropagation ( ) ;
1440+ event . preventDefault ( ) ;
1441+ setRenderBounds ( flowBounds ) ;
1442+ setResizing ( {
1443+ nodeId : node . id ,
1444+ startClientX : event . clientX ,
1445+ startClientY : event . clientY ,
1446+ startWidth : nodeSize . width ,
1447+ startHeight : nodeSize . height ,
1448+ } ) ;
1449+ } }
1450+ className = "absolute bottom-1.5 right-1.5 h-4 w-4 cursor-se-resize rounded-sm border border-[var(--gantry-border)] bg-[var(--gantry-bg-primary)]/90 shadow-sm"
1451+ title = "Resize shape"
1452+ >
1453+ < span className = "pointer-events-none absolute bottom-0.5 right-0.5 h-2 w-2 border-b border-r border-[var(--gantry-text-secondary)]" />
1454+ </ button >
1455+ ) }
13661456 </ div >
13671457 </ div >
13681458 ) ;
@@ -1738,6 +1828,26 @@ export default function Flow() {
17381828 />
17391829 </ label >
17401830 </ div >
1831+ < div className = "grid grid-cols-2 gap-3" >
1832+ < label className = "space-y-1.5" >
1833+ < span className = "text-xs font-medium uppercase tracking-wide text-[var(--gantry-text-secondary)]" > Width</ span >
1834+ < input
1835+ type = "number"
1836+ value = { Math . round ( getNodeDimensions ( selectedNode ) . width ) }
1837+ onChange = { ( event ) => updateNode ( selectedNode . id , { width : Number ( event . target . value ) } as Partial < FlowMockNode > ) }
1838+ className = "w-full rounded-lg border border-[var(--gantry-border)] bg-[var(--gantry-bg-secondary)] px-3 py-2 text-sm text-[var(--gantry-text-primary)] focus:border-[var(--gantry-accent)] focus:outline-none"
1839+ />
1840+ </ label >
1841+ < label className = "space-y-1.5" >
1842+ < span className = "text-xs font-medium uppercase tracking-wide text-[var(--gantry-text-secondary)]" > Height</ span >
1843+ < input
1844+ type = "number"
1845+ value = { Math . round ( getNodeDimensions ( selectedNode ) . height ) }
1846+ onChange = { ( event ) => updateNode ( selectedNode . id , { height : Number ( event . target . value ) } as Partial < FlowMockNode > ) }
1847+ className = "w-full rounded-lg border border-[var(--gantry-border)] bg-[var(--gantry-bg-secondary)] px-3 py-2 text-sm text-[var(--gantry-text-primary)] focus:border-[var(--gantry-accent)] focus:outline-none"
1848+ />
1849+ </ label >
1850+ </ div >
17411851 </ >
17421852 ) }
17431853 < div className = "grid grid-cols-2 gap-3" >
0 commit comments