get group resize, enter and exit working
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
18
frontend/src/groupDrag.js
Normal 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;
|
||||
}
|
||||
35
frontend/src/groupSizing.js
Normal file
35
frontend/src/groupSizing.js
Normal 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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user