Skip to content

Commit 006fa09

Browse files
committed
Add resizable flow mock nodes and pointer zoom
1 parent 49767e4 commit 006fa09

4 files changed

Lines changed: 148 additions & 24 deletions

File tree

internal/entity/schemas/flow.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@
9494
"color": {
9595
"type": "string"
9696
},
97+
"width": {
98+
"type": "number"
99+
},
100+
"height": {
101+
"type": "number"
102+
},
97103
"position": {
98104
"type": "object",
99105
"properties": {

web/src/components/FlowTab.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ function ensureFlowSpec(spec: Record<string, any> | undefined): FlowSpec {
4848
subtitle: typeof node.subtitle === 'string' ? node.subtitle : '',
4949
shape: MOCK_SHAPE_OPTIONS.includes(node.shape) ? node.shape : 'box',
5050
color: typeof node.color === 'string' && node.color.trim() ? node.color : '#64748B',
51+
width: typeof node.width === 'number' ? node.width : undefined,
52+
height: typeof node.height === 'number' ? node.height : undefined,
5153
position,
5254
};
5355
}
@@ -139,36 +141,40 @@ function getNodeDimensions(node: FlowNode): { width: number; height: number } {
139141

140142
switch (node.shape) {
141143
case 'pill': {
142-
const width = Math.min(340, Math.max(188, 188 + Math.max(0, longestText - 12) * 7));
144+
const baseWidth = Math.min(340, Math.max(188, 188 + Math.max(0, longestText - 12) * 7));
145+
const width = Math.min(MAX_NODE_WIDTH, Math.max(node.width || 0, baseWidth));
143146
const charsPerLine = Math.max(12, Math.floor((width - 44) / 8));
144147
const titleLines = estimateWrappedLines(title, charsPerLine);
145148
const subtitleLines = estimateWrappedLines(subtitle, charsPerLine);
146-
const height = Math.max(84, 48 + titleLines * 18 + subtitleLines * 16 + 18);
149+
const height = Math.max(node.height || 0, 84, 48 + titleLines * 18 + subtitleLines * 16 + 18);
147150
return { width, height };
148151
}
149152
case 'diamond': {
150-
const width = Math.min(MAX_NODE_WIDTH, Math.max(272, 272 + Math.max(0, longestText - 10) * 10));
153+
const baseWidth = Math.min(MAX_NODE_WIDTH, Math.max(272, 272 + Math.max(0, longestText - 10) * 10));
154+
const width = Math.min(MAX_NODE_WIDTH, Math.max(node.width || 0, baseWidth));
151155
const charsPerLine = Math.max(9, Math.floor((width * 0.42) / 8));
152156
const titleLines = estimateWrappedLines(title, charsPerLine);
153157
const subtitleLines = estimateWrappedLines(subtitle, charsPerLine);
154-
const height = Math.min(220, Math.max(120, 120 + Math.max(0, titleLines - 1) * 22 + subtitleLines * 20));
158+
const height = Math.max(node.height || 0, Math.min(220, Math.max(120, 120 + Math.max(0, titleLines - 1) * 22 + subtitleLines * 20)));
155159
return { width, height };
156160
}
157161
case 'note': {
158-
const width = Math.min(360, Math.max(196, 196 + Math.max(0, longestText - 14) * 7));
162+
const baseWidth = Math.min(360, Math.max(196, 196 + Math.max(0, longestText - 14) * 7));
163+
const width = Math.min(MAX_NODE_WIDTH, Math.max(node.width || 0, baseWidth));
159164
const charsPerLine = Math.max(13, Math.floor((width - 52) / 8));
160165
const titleLines = estimateWrappedLines(title, charsPerLine);
161166
const subtitleLines = estimateWrappedLines(subtitle, charsPerLine);
162-
const height = Math.max(MIN_NODE_HEIGHT, 56 + titleLines * 18 + subtitleLines * 16 + 24);
167+
const height = Math.max(node.height || 0, MIN_NODE_HEIGHT, 56 + titleLines * 18 + subtitleLines * 16 + 24);
163168
return { width, height };
164169
}
165170
case 'box':
166171
default: {
167-
const width = Math.min(320, Math.max(180, 180 + Math.max(0, longestText - 14) * 6));
172+
const baseWidth = Math.min(320, Math.max(180, 180 + Math.max(0, longestText - 14) * 6));
173+
const width = Math.min(MAX_NODE_WIDTH, Math.max(node.width || 0, baseWidth));
168174
const charsPerLine = Math.max(14, Math.floor((width - 36) / 8));
169175
const titleLines = estimateWrappedLines(title, charsPerLine);
170176
const subtitleLines = estimateWrappedLines(subtitle, charsPerLine);
171-
const height = Math.max(MIN_NODE_HEIGHT, 48 + titleLines * 18 + subtitleLines * 16 + 18);
177+
const height = Math.max(node.height || 0, MIN_NODE_HEIGHT, 48 + titleLines * 18 + subtitleLines * 16 + 18);
172178
return { width, height };
173179
}
174180
}

web/src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ export interface FlowMockNode {
165165
subtitle?: string;
166166
shape: FlowMockShape;
167167
color?: string;
168+
width?: number;
169+
height?: number;
168170
position: {
169171
x: number;
170172
y: number;

web/src/pages/Flow.tsx

Lines changed: 126 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)