get group resize, enter and exit working

This commit is contained in:
matei jordache
2026-03-27 14:13:09 -07:00
parent 98d36eb327
commit 1eda4030d1
11 changed files with 362 additions and 24 deletions

View File

@@ -89,8 +89,19 @@ function getConnectionHandleType(handleId) {
}
function getNodeDimension(node, axis) {
if (axis === 'width') return node.measured?.width || node.width || node.style?.width || 200;
return node.measured?.height || node.height || node.style?.height || 120;
if (axis === 'width') return node.measured?.width || node.style?.width || node.width || 200;
return node.measured?.height || node.style?.height || node.height || 120;
}
function applyNodeSize(node, width, height) {
const nextWidth = Math.round(Number(width) || 0);
const nextHeight = Math.round(Number(height) || 0);
return {
...node,
width: nextWidth,
height: nextHeight,
style: { ...(node.style || {}), width: nextWidth, height: nextHeight },
};
}
function getNodeAbsolutePosition(node, nodeMap) {
@@ -195,6 +206,17 @@ function getNodeRect(node, nodeMap) {
};
}
function getAbsoluteRectForNodePosition(node, absolutePosition) {
const width = Number(getNodeDimension(node, 'width')) || 200;
const height = Number(getNodeDimension(node, 'height')) || 120;
return {
left: absolutePosition.x,
top: absolutePosition.y,
right: absolutePosition.x + width,
bottom: absolutePosition.y + height,
};
}
function rectContainsPoint(rect, point) {
return point.x >= rect.left
&& point.x <= rect.right
@@ -202,6 +224,13 @@ function rectContainsPoint(rect, point) {
&& point.y <= rect.bottom;
}
function rectContainsRect(outerRect, innerRect) {
return innerRect.left >= outerRect.left
&& innerRect.top >= outerRect.top
&& innerRect.right <= outerRect.right
&& innerRect.bottom <= outerRect.bottom;
}
function getEventClientPosition(event) {
if (!event) return null;
const point = 'changedTouches' in event && event.changedTouches?.[0]
@@ -829,10 +858,11 @@ function Flow() {
};
const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26);
return {
...node,
style: collapsed
? { ...(node.style || {}), width: 260, height: collapsedHeight }
: { ...(node.style || {}), width: expandedSize.width, height: expandedSize.height },
...applyNodeSize(
node,
collapsed ? 260 : expandedSize.width,
collapsed ? collapsedHeight : expandedSize.height,
),
data: {
...node.data,
collapsed,
@@ -1014,6 +1044,8 @@ function Flow() {
type: 'custom',
className: 'group-shell',
position: groupPosition,
width: groupWidth,
height: groupHeight,
dragHandle: '.drag-handle',
style: { width: groupWidth, height: groupHeight },
data: {
@@ -1726,14 +1758,39 @@ function Flow() {
setNodes,
]);
const resizeGroup = useCallback((groupId, size) => {
const nextWidth = Math.round(Number(size?.width) || 0);
const nextHeight = Math.round(Number(size?.height) || 0);
if (!nextWidth || !nextHeight) return;
setNodes((existing) => existing.map((node) => {
if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node;
const sameSize = Math.abs((Number(node.style?.width) || 0) - nextWidth) < 0.5
&& Math.abs((Number(node.style?.height) || 0) - nextHeight) < 0.5;
if (sameSize) return node;
return {
...applyNodeSize(node, nextWidth, nextHeight),
data: {
...node.data,
expandedSize: { width: nextWidth, height: nextHeight },
},
};
}));
setTimeout(() => reactFlow.updateNodeInternals(String(groupId)), 0);
}, [reactFlow, setNodes]);
const contextValue = useMemo(() => ({
onWidgetChange,
onRuntimeValuesChange,
openFileBrowser,
onManualTrigger,
onToggleGroupCollapse: toggleGroupCollapse,
onResizeGroup: resizeGroup,
onUngroup: ungroupGroup,
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]);
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, resizeGroup, toggleGroupCollapse, ungroupGroup]);
const clearGraph = useCallback(() => {
setNodes([]);
@@ -2000,6 +2057,8 @@ function Flow() {
anchorId: String(node.id),
anchorStartAbsolute: anchorAbsolute,
absolutePositions,
releasedNodeIds: new Set(),
touchedGroupIds: new Set(),
pointerOffset: {
x: pointerFlowPos.x - anchorAbsolute.x,
y: pointerFlowPos.y - anchorAbsolute.y,
@@ -2077,7 +2136,7 @@ function Flow() {
initializeDynamicNodes(duplicated.nodes);
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
const onNodeDrag = useCallback((_event, node) => {
const onNodeDrag = useCallback((event, node) => {
if (String(node.id) !== activeDragNodeIdRef.current) return;
const duplicateState = duplicateDragRef.current;
@@ -2121,7 +2180,103 @@ function Flow() {
}));
return;
}
}, [setNodes]);
const dragState = dragStateRef.current;
if (!dragState || node.data?.className === 'Group') return;
const currentNodes = reactFlow.getNodes();
const draggedNodes = node.selected
? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
: currentNodes.filter((candidate) => candidate.id === node.id);
if (draggedNodes.length === 0) return;
const dragIntent = getDragIntent(event, reactFlow, dragState);
if (!dragIntent?.pointerFlowPos) return;
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate]));
const releasedNodeIds = dragState.releasedNodeIds instanceof Set
? new Set(dragState.releasedNodeIds)
: new Set();
const touchedGroupIds = dragState.touchedGroupIds instanceof Set
? new Set(dragState.touchedGroupIds)
: new Set();
let nextNodes = currentNodes;
let changed = false;
let structureChanged = false;
nextNodes = nextNodes.map((candidate) => {
const candidateId = String(candidate.id);
if (!draggedIdSet.has(candidateId)) return candidate;
const absolute = dragIntent.absolutePositions.get(candidateId)
|| getNodeAbsolutePosition(candidate, nodeMap);
if (!absolute) return candidate;
if (candidate.parentId) {
const parentId = String(candidate.parentId);
const parentNode = nodeMap.get(parentId);
if (parentNode?.data?.className === 'Group') {
const parentRect = getGroupWorkspaceBounds(parentNode, nodeMap);
const parentAbsolute = getNodeAbsolutePosition(parentNode, nodeMap);
const nextPosition = {
x: absolute.x - parentAbsolute.x,
y: absolute.y - parentAbsolute.y,
};
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
if (!releasedNodeIds.has(candidateId) && !rectContainsRect(parentRect, candidateRect)) {
releasedNodeIds.add(candidateId);
changed = true;
return {
...candidate,
extent: undefined,
hidden: false,
position: nextPosition,
};
}
if (releasedNodeIds.has(candidateId)) {
if (!candidate.parentId && !candidate.extent && candidate.hidden !== true && samePosition) {
return candidate;
}
changed = true;
return {
...candidate,
extent: undefined,
hidden: false,
position: nextPosition,
};
}
}
}
if (!releasedNodeIds.has(candidateId)) return candidate;
return candidate;
});
if (!changed) return;
dragStateRef.current = {
...dragState,
releasedNodeIds,
touchedGroupIds,
};
setNodes(structureChanged ? sortNodesForParentOrder(nextNodes) : nextNodes);
if (structureChanged) {
setTimeout(() => {
touchedGroupIds.forEach((groupId) => {
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
});
}, 0);
}
}, [reactFlow, refreshGroupNode, setNodes]);
const onNodeDragStop = useCallback((event, node) => {
if (String(node.id) !== activeDragNodeIdRef.current) return;
@@ -2183,7 +2338,9 @@ function Flow() {
const currentNodes = reactFlow.getNodes();
const dragIntent = getDragIntent(event, reactFlow, dragState);
const touchedGroupIds = new Set();
const touchedGroupIds = dragState?.touchedGroupIds instanceof Set
? new Set(dragState.touchedGroupIds)
: new Set();
let nextNodes = currentNodes;
let changed = false;
@@ -2237,7 +2394,7 @@ function Flow() {
const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id);
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
if (alreadyInTarget && samePosition) return candidate;
if (alreadyInTarget && candidate.extent === 'parent' && samePosition) return candidate;
if (candidate.parentId) {
touchedGroupIds.add(String(candidate.parentId));
@@ -2261,7 +2418,6 @@ function Flow() {
});
}
} else {
const pointerFlowPos = dragIntent?.pointerFlowPos || getEventFlowPosition(event, reactFlow);
let removedCount = 0;
nextNodes = nextNodes.map((candidate) => {
@@ -2270,13 +2426,20 @@ function Flow() {
const parentId = String(candidate.parentId);
const parentNode = nodeMap.get(parentId);
if (!parentNode || parentNode.data?.className !== 'Group') return candidate;
if (!pointerFlowPos) return candidate;
if (rectContainsPoint(getNodeRect(parentNode, nodeMap), pointerFlowPos)) {
return candidate;
}
const absolute = dragIntent?.absolutePositions.get(String(candidate.id))
|| getNodeAbsolutePosition(candidate, nodeMap);
const parentWorkspaceRect = getGroupWorkspaceBounds(parentNode, nodeMap);
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
if (rectContainsRect(parentWorkspaceRect, candidateRect)) {
if (candidate.extent === 'parent') return candidate;
changed = true;
return {
...candidate,
extent: 'parent',
hidden: false,
};
}
touchedGroupIds.add(parentId);
removedCount += 1;
changed = true;
@@ -2298,7 +2461,16 @@ function Flow() {
}
}
if (!changed) return;
if (!changed) {
const releasedCount = dragState?.releasedNodeIds instanceof Set ? dragState.releasedNodeIds.size : 0;
if (releasedCount > 0) {
setStatus({
text: `Removed ${releasedCount} node${releasedCount === 1 ? '' : 's'} from group.`,
level: 'info',
});
}
return;
}
setNodes(sortNodesForParentOrder(nextNodes));
setTimeout(() => {

View File

@@ -1,5 +1,5 @@
import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react';
import { Handle, Position, useStore } from '@xyflow/react';
import { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react';
import LinePlotOverlay from './LinePlotOverlay';
const SurfaceView = lazy(() => import('./SurfaceView'));
@@ -11,6 +11,7 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
import {
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
} from './constants';
import { getGroupMinimumSize } from './groupSizing.js';
// ── Context (provided by App) ─────────────────────────────────────────
@@ -44,10 +45,37 @@ function GroupNode({ id, data }) {
const childCount = Number(data.childCount) || 0;
const collapsed = !!data.collapsed;
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
const selected = useStore(
useCallback(
(s) => {
const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate) => candidate.id === id);
return !!node?.selected;
},
[id],
),
);
const groupMinSize = useStore(
useCallback(
(s) => getGroupMinimumSize(
(s.nodes || []).filter((candidate) => String(candidate.parentId || '') === String(id)),
),
[id],
),
);
return (
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
<div className="node-title drag-handle group-node-title">
<>
{!collapsed && selected && (
<NodeResizeControl
position="bottom-right"
className="node-resize-handle"
minWidth={groupMinSize.width}
minHeight={groupMinSize.height}
onResizeEnd={(event, params) => ctx.onResizeGroup?.(id, params)}
/>
)}
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
<div className="node-title drag-handle group-node-title">
<button
type="button"
className="group-toggle group-toggle-collapse nodrag"
@@ -117,7 +145,8 @@ function GroupNode({ id, data }) {
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -49,7 +49,7 @@ export const SOCKET_COMPATIBILITY = {
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']),
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']),
SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'ANNOTATION_SOURCE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']),
FLOAT: new Set(['INT']),
INT: new Set(['FLOAT']),
LINE: new Set(['COORDPAIR']),

18
frontend/src/groupDrag.js Normal file
View File

@@ -0,0 +1,18 @@
export const GROUP_DRAG_RELEASE_DISTANCE = 18;
export function getPointDistanceOutsideRect(rect, point) {
if (!rect || !point) return Infinity;
const dx = point.x < rect.left
? rect.left - point.x
: (point.x > rect.right ? point.x - rect.right : 0);
const dy = point.y < rect.top
? rect.top - point.y
: (point.y > rect.bottom ? point.y - rect.bottom : 0);
return Math.hypot(dx, dy);
}
export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) {
return getPointDistanceOutsideRect(rect, point) >= threshold;
}

View File

@@ -0,0 +1,35 @@
const DEFAULT_CHILD_WIDTH = 200;
const DEFAULT_CHILD_HEIGHT = 120;
function getNodeSize(node, axis) {
const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT;
const measured = Number(node?.measured?.[axis]);
if (Number.isFinite(measured) && measured > 0) return measured;
const direct = Number(node?.[axis]);
if (Number.isFinite(direct) && direct > 0) return direct;
const styled = Number(node?.style?.[axis]);
if (Number.isFinite(styled) && styled > 0) return styled;
return fallback;
}
export function getGroupMinimumSize(memberNodes, {
minWidth = 260,
minHeight = 180,
paddingX = 24,
paddingY = 24,
} = {}) {
let maxRight = 0;
let maxBottom = 0;
for (const node of memberNodes || []) {
const x = Number(node?.position?.x) || 0;
const y = Number(node?.position?.y) || 0;
maxRight = Math.max(maxRight, x + getNodeSize(node, 'width'));
maxBottom = Math.max(maxBottom, y + getNodeSize(node, 'height'));
}
return {
width: Math.max(minWidth, Math.ceil(maxRight + paddingX)),
height: Math.max(minHeight, Math.ceil(maxBottom + paddingY)),
};
}