work on fixing group drag
This commit is contained in:
@@ -36,6 +36,13 @@ import {
|
|||||||
|
|
||||||
const NODE_TYPES = { custom: CustomNode };
|
const NODE_TYPES = { custom: CustomNode };
|
||||||
|
|
||||||
|
const GROUP_PADDING_X = 24;
|
||||||
|
const GROUP_PADDING_Y = 24;
|
||||||
|
const GROUP_HEADER_HEIGHT = 36;
|
||||||
|
const GROUP_WORKSPACE_INSET = 12;
|
||||||
|
const GROUP_MIN_WIDTH = 260;
|
||||||
|
const GROUP_MIN_HEIGHT = 180;
|
||||||
|
|
||||||
// ── Handle ID helpers ─────────────────────────────────────────────────
|
// ── Handle ID helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function getHandleType(handleId) {
|
function getHandleType(handleId) {
|
||||||
@@ -50,6 +57,228 @@ function getOutputSlot(handleId) {
|
|||||||
return parseInt(handleId.split('::')[1], 10);
|
return parseInt(handleId.split('::')[1], 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeProxyHandleRef(handleId) {
|
||||||
|
return encodeURIComponent(String(handleId || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProxyHandleRef(encoded) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(String(encoded || ''));
|
||||||
|
} catch {
|
||||||
|
return String(encoded || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGroupProxyHandle(handleId) {
|
||||||
|
const text = String(handleId || '');
|
||||||
|
if (!text.startsWith('group-proxy::')) return null;
|
||||||
|
const parts = text.split('::');
|
||||||
|
if (parts.length < 5) return null;
|
||||||
|
return {
|
||||||
|
direction: parts[1],
|
||||||
|
nodeId: parts[2],
|
||||||
|
type: parts[3],
|
||||||
|
realHandle: decodeProxyHandleRef(parts.slice(4).join('::')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionHandleType(handleId) {
|
||||||
|
const proxy = parseGroupProxyHandle(handleId);
|
||||||
|
return proxy?.type || getHandleType(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeAbsolutePosition(node, nodeMap) {
|
||||||
|
if (node?.positionAbsolute) {
|
||||||
|
return {
|
||||||
|
x: Number(node.positionAbsolute.x) || 0,
|
||||||
|
y: Number(node.positionAbsolute.y) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const local = {
|
||||||
|
x: Number(node?.position?.x) || 0,
|
||||||
|
y: Number(node?.position?.y) || 0,
|
||||||
|
};
|
||||||
|
if (!node?.parentId) return local;
|
||||||
|
const parent = nodeMap.get(String(node.parentId));
|
||||||
|
if (!parent) return local;
|
||||||
|
const parentPos = getNodeAbsolutePosition(parent, nodeMap);
|
||||||
|
return { x: parentPos.x + local.x, y: parentPos.y + local.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectGroupDescendantIds(nodes, groupId) {
|
||||||
|
const allNodes = Array.isArray(nodes) ? nodes : [];
|
||||||
|
const result = new Set();
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const node of allNodes) {
|
||||||
|
const parentId = node?.parentId ? String(node.parentId) : null;
|
||||||
|
const nodeId = String(node?.id);
|
||||||
|
if (!parentId) continue;
|
||||||
|
if ((parentId === String(groupId) || result.has(parentId)) && !result.has(nodeId)) {
|
||||||
|
result.add(nodeId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupMembers(nodes, groupId) {
|
||||||
|
const descendants = collectGroupDescendantIds(nodes, groupId);
|
||||||
|
return Array.from(descendants);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupDisplayBounds(nodes, selectedIds) {
|
||||||
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
const node = nodeMap.get(String(id));
|
||||||
|
if (!node) continue;
|
||||||
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||||
|
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||||
|
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||||
|
minX = Math.min(minX, pos.x);
|
||||||
|
minY = Math.min(minY, pos.y);
|
||||||
|
maxX = Math.max(maxX, pos.x + width);
|
||||||
|
maxY = Math.max(maxY, pos.y + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupWorkspaceBounds(groupNode, nodeMap) {
|
||||||
|
const pos = getNodeAbsolutePosition(groupNode, nodeMap);
|
||||||
|
const width = Number(getNodeDimension(groupNode, 'width')) || 200;
|
||||||
|
const height = Number(getNodeDimension(groupNode, 'height')) || 120;
|
||||||
|
return {
|
||||||
|
left: pos.x + GROUP_WORKSPACE_INSET,
|
||||||
|
top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
|
||||||
|
right: pos.x + width - GROUP_WORKSPACE_INSET,
|
||||||
|
bottom: pos.y + height - GROUP_WORKSPACE_INSET,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeCenter(node, nodeMap) {
|
||||||
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||||
|
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||||
|
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||||
|
return {
|
||||||
|
x: pos.x + width / 2,
|
||||||
|
y: pos.y + height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectContainsPoint(rect, point) {
|
||||||
|
return point.x >= rect.left
|
||||||
|
&& point.x <= rect.right
|
||||||
|
&& point.y >= rect.top
|
||||||
|
&& point.y <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId) {
|
||||||
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
||||||
|
const anchorNode = nodeMap.get(String(anchorNodeId));
|
||||||
|
if (!anchorNode) return null;
|
||||||
|
|
||||||
|
const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id)));
|
||||||
|
const anchorCenter = getNodeCenter(anchorNode, nodeMap);
|
||||||
|
|
||||||
|
return (nodes || [])
|
||||||
|
.filter((node) => (
|
||||||
|
node?.data?.className === 'Group'
|
||||||
|
&& !node?.data?.collapsed
|
||||||
|
&& !draggedIdSet.has(String(node.id))
|
||||||
|
))
|
||||||
|
.map((node) => {
|
||||||
|
const rect = getGroupWorkspaceBounds(node, nodeMap);
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
rect,
|
||||||
|
area: Math.max(1, rect.right - rect.left) * Math.max(1, rect.bottom - rect.top),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(({ rect }) => rectContainsPoint(rect, anchorCenter))
|
||||||
|
.sort((a, b) => a.area - b.area)[0]?.node || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputLabelForNode(node, inputName) {
|
||||||
|
const inputs = {
|
||||||
|
...(node?.data?.definition?.input?.required || {}),
|
||||||
|
...(node?.data?.definition?.input?.optional || {}),
|
||||||
|
};
|
||||||
|
const spec = inputs[inputName];
|
||||||
|
if (!spec) return inputName;
|
||||||
|
const [, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||||
|
return opts?.label || inputName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputLabelForNode(node, slot, handleId) {
|
||||||
|
const outputNames = node?.data?.definition?.output_name || [];
|
||||||
|
const outputTypes = node?.data?.definition?.output || [];
|
||||||
|
if (Number.isInteger(slot) && outputNames[slot]) return outputNames[slot];
|
||||||
|
const proxy = parseGroupProxyHandle(handleId);
|
||||||
|
return proxy?.realHandle ? getOutputLabelForNode(node, getOutputSlot(proxy.realHandle), proxy.realHandle) : outputTypes[slot] || 'output';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupProxyData(groupId, nodes, edges) {
|
||||||
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
||||||
|
const memberIds = new Set(getGroupMembers(nodes, groupId));
|
||||||
|
const proxyInputs = [];
|
||||||
|
const proxyOutputs = [];
|
||||||
|
const seenInputs = new Set();
|
||||||
|
const seenOutputs = new Set();
|
||||||
|
|
||||||
|
for (const edge of edges || []) {
|
||||||
|
const original = edge?.data?.groupProxyOriginal || {};
|
||||||
|
const sourceId = String(original.source || edge.source);
|
||||||
|
const targetId = String(original.target || edge.target);
|
||||||
|
const sourceHandle = original.sourceHandle || edge.sourceHandle;
|
||||||
|
const targetHandle = original.targetHandle || edge.targetHandle;
|
||||||
|
const sourceInside = memberIds.has(sourceId);
|
||||||
|
const targetInside = memberIds.has(targetId);
|
||||||
|
|
||||||
|
if (!sourceInside && targetInside) {
|
||||||
|
const key = `${targetId}::${targetHandle}`;
|
||||||
|
if (seenInputs.has(key)) continue;
|
||||||
|
seenInputs.add(key);
|
||||||
|
proxyInputs.push({
|
||||||
|
key,
|
||||||
|
type: getHandleType(targetHandle),
|
||||||
|
label: getInputLabelForNode(nodeMap.get(targetId), getInputName(targetHandle)),
|
||||||
|
handleId: `group-proxy::in::${targetId}::${getHandleType(targetHandle)}::${encodeProxyHandleRef(targetHandle)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceInside && !targetInside) {
|
||||||
|
const key = `${sourceId}::${sourceHandle}`;
|
||||||
|
if (seenOutputs.has(key)) continue;
|
||||||
|
seenOutputs.add(key);
|
||||||
|
proxyOutputs.push({
|
||||||
|
key,
|
||||||
|
type: getHandleType(sourceHandle),
|
||||||
|
label: getOutputLabelForNode(nodeMap.get(sourceId), getOutputSlot(sourceHandle), sourceHandle),
|
||||||
|
handleId: `group-proxy::out::${sourceId}::${getHandleType(sourceHandle)}::${encodeProxyHandleRef(sourceHandle)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { proxyInputs, proxyOutputs, childCount: memberIds.size };
|
||||||
|
}
|
||||||
|
|
||||||
function sameStringArray(a = [], b = []) {
|
function sameStringArray(a = [], b = []) {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||||
@@ -243,7 +472,7 @@ async function captureViewportBlob(viewportEl, options) {
|
|||||||
|
|
||||||
// ── Context menu component ────────────────────────────────────────────
|
// ── Context menu component ────────────────────────────────────────────
|
||||||
|
|
||||||
function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) {
|
function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection, selectedNodeCount = 0, onCreateGroup = null }) {
|
||||||
const [openCat, setOpenCat] = useState(null);
|
const [openCat, setOpenCat] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
@@ -396,6 +625,15 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && (
|
||||||
|
<div
|
||||||
|
className="context-item"
|
||||||
|
onClick={() => { onCreateGroup(); onClose(); }}
|
||||||
|
>
|
||||||
|
create group
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{searchResults ? (
|
{searchResults ? (
|
||||||
<div className="ctx-list">
|
<div className="ctx-list">
|
||||||
{searchResults.length === 0 ? (
|
{searchResults.length === 0 ? (
|
||||||
@@ -474,6 +712,7 @@ function Flow() {
|
|||||||
const lastPastedClipboardTextRef = useRef('');
|
const lastPastedClipboardTextRef = useRef('');
|
||||||
const pasteRepeatCountRef = useRef(0);
|
const pasteRepeatCountRef = useRef(0);
|
||||||
const duplicateDragRef = useRef(null);
|
const duplicateDragRef = useRef(null);
|
||||||
|
const activeDragNodeIdRef = useRef(null);
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
|
|
||||||
// ── WebSocket ───────────────────────────────────────────────────────
|
// ── WebSocket ───────────────────────────────────────────────────────
|
||||||
@@ -484,6 +723,286 @@ function Flow() {
|
|||||||
));
|
));
|
||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
|
const refreshGroupNode = useCallback((groupId, explicitNodes = null, explicitEdges = null) => {
|
||||||
|
const currentNodes = explicitNodes || reactFlow.getNodes();
|
||||||
|
const currentEdges = explicitEdges || reactFlow.getEdges();
|
||||||
|
const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
|
||||||
|
if (!groupNode) return;
|
||||||
|
|
||||||
|
const { proxyInputs, proxyOutputs, childCount } = buildGroupProxyData(groupId, currentNodes, currentEdges);
|
||||||
|
setNodes((prev) => prev.map((node) => (
|
||||||
|
node.id !== groupId
|
||||||
|
? node
|
||||||
|
: {
|
||||||
|
...node,
|
||||||
|
className: 'group-shell',
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
proxyInputs,
|
||||||
|
proxyOutputs,
|
||||||
|
childCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)));
|
||||||
|
reactFlow.updateNodeInternals(groupId);
|
||||||
|
}, [reactFlow, setNodes]);
|
||||||
|
|
||||||
|
const toggleGroupCollapse = useCallback((groupId) => {
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const currentEdges = reactFlow.getEdges();
|
||||||
|
const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
|
||||||
|
if (!groupNode) return;
|
||||||
|
|
||||||
|
const memberIds = new Set(getGroupMembers(currentNodes, groupId));
|
||||||
|
const collapsed = !groupNode.data?.collapsed;
|
||||||
|
const proxyData = buildGroupProxyData(groupId, currentNodes, currentEdges);
|
||||||
|
|
||||||
|
const nextNodes = currentNodes.map((node) => {
|
||||||
|
if (memberIds.has(String(node.id))) {
|
||||||
|
return { ...node, hidden: collapsed };
|
||||||
|
}
|
||||||
|
if (node.id !== groupId) return node;
|
||||||
|
const expandedSize = groupNode.data?.expandedSize || {
|
||||||
|
width: Number(groupNode.style?.width) || 320,
|
||||||
|
height: Number(groupNode.style?.height) || 240,
|
||||||
|
};
|
||||||
|
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 },
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
collapsed,
|
||||||
|
expandedSize,
|
||||||
|
proxyInputs: proxyData.proxyInputs,
|
||||||
|
proxyOutputs: proxyData.proxyOutputs,
|
||||||
|
childCount: proxyData.childCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextEdges = currentEdges.map((edge) => {
|
||||||
|
if (collapsed) {
|
||||||
|
if (edge.data?.groupProxyOwner === groupId || edge.data?.groupInternalHiddenBy === groupId) {
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
const sourceInside = memberIds.has(String(edge.source));
|
||||||
|
const targetInside = memberIds.has(String(edge.target));
|
||||||
|
if (sourceInside && targetInside) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
hidden: true,
|
||||||
|
data: { ...(edge.data || {}), groupInternalHiddenBy: groupId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!sourceInside && targetInside) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
target: groupId,
|
||||||
|
targetHandle: `group-proxy::in::${edge.target}::${getHandleType(edge.targetHandle)}::${encodeProxyHandleRef(edge.targetHandle)}`,
|
||||||
|
data: {
|
||||||
|
...(edge.data || {}),
|
||||||
|
groupProxyOwner: groupId,
|
||||||
|
groupProxyOriginal: {
|
||||||
|
target: edge.target,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sourceInside && !targetInside) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
source: groupId,
|
||||||
|
sourceHandle: `group-proxy::out::${edge.source}::${getHandleType(edge.sourceHandle)}::${encodeProxyHandleRef(edge.sourceHandle)}`,
|
||||||
|
data: {
|
||||||
|
...(edge.data || {}),
|
||||||
|
groupProxyOwner: groupId,
|
||||||
|
groupProxyOriginal: {
|
||||||
|
source: edge.source,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge.data?.groupInternalHiddenBy === groupId) {
|
||||||
|
const nextData = { ...(edge.data || {}) };
|
||||||
|
delete nextData.groupInternalHiddenBy;
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
hidden: false,
|
||||||
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (edge.data?.groupProxyOwner === groupId) {
|
||||||
|
const nextData = { ...(edge.data || {}) };
|
||||||
|
const original = nextData.groupProxyOriginal || {};
|
||||||
|
delete nextData.groupProxyOwner;
|
||||||
|
delete nextData.groupProxyOriginal;
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
source: original.source || edge.source,
|
||||||
|
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
||||||
|
target: original.target || edge.target,
|
||||||
|
targetHandle: original.targetHandle || edge.targetHandle,
|
||||||
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return edge;
|
||||||
|
});
|
||||||
|
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setEdges(nextEdges);
|
||||||
|
setTimeout(() => refreshGroupNode(groupId, nextNodes, nextEdges), 0);
|
||||||
|
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
||||||
|
|
||||||
|
const ungroupGroup = useCallback((groupId) => {
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const currentEdges = reactFlow.getEdges();
|
||||||
|
const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
|
||||||
|
const groupNode = nodeMap.get(String(groupId));
|
||||||
|
if (!groupNode || groupNode.data?.className !== 'Group') return;
|
||||||
|
|
||||||
|
const memberIds = new Set(getGroupMembers(currentNodes, groupId));
|
||||||
|
const groupSelected = !!groupNode.selected;
|
||||||
|
|
||||||
|
const nextNodes = currentNodes
|
||||||
|
.filter((node) => String(node.id) !== String(groupId))
|
||||||
|
.map((node) => {
|
||||||
|
if (!memberIds.has(String(node.id))) return node;
|
||||||
|
const absolute = getNodeAbsolutePosition(node, nodeMap);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
hidden: false,
|
||||||
|
selected: groupSelected,
|
||||||
|
position: absolute,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextEdges = currentEdges
|
||||||
|
.map((edge) => {
|
||||||
|
if (edge.data?.groupInternalHiddenBy === groupId) {
|
||||||
|
const nextData = { ...(edge.data || {}) };
|
||||||
|
delete nextData.groupInternalHiddenBy;
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
hidden: false,
|
||||||
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (edge.data?.groupProxyOwner === groupId) {
|
||||||
|
const nextData = { ...(edge.data || {}) };
|
||||||
|
const original = nextData.groupProxyOriginal || {};
|
||||||
|
delete nextData.groupProxyOwner;
|
||||||
|
delete nextData.groupProxyOriginal;
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
source: original.source || edge.source,
|
||||||
|
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
||||||
|
target: original.target || edge.target,
|
||||||
|
targetHandle: original.targetHandle || edge.targetHandle,
|
||||||
|
hidden: false,
|
||||||
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return edge;
|
||||||
|
})
|
||||||
|
.filter((edge) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId));
|
||||||
|
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setEdges(nextEdges);
|
||||||
|
setTimeout(() => {
|
||||||
|
reactFlow.getNodes()
|
||||||
|
.filter((node) => node.data?.className === 'Group')
|
||||||
|
.forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges));
|
||||||
|
}, 0);
|
||||||
|
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
||||||
|
|
||||||
|
const createGroupFromSelection = useCallback(() => {
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const selectedNodes = currentNodes.filter((node) => node.selected && node.data?.className !== 'Group');
|
||||||
|
if (selectedNodes.length < 2) return;
|
||||||
|
|
||||||
|
const selectedIds = selectedNodes.map((node) => String(node.id));
|
||||||
|
const bounds = getGroupDisplayBounds(currentNodes, selectedIds);
|
||||||
|
if (!bounds) return;
|
||||||
|
|
||||||
|
const groupId = String(nextIdRef.current++);
|
||||||
|
const groupPosition = {
|
||||||
|
x: bounds.minX - GROUP_PADDING_X,
|
||||||
|
y: bounds.minY - (GROUP_HEADER_HEIGHT + GROUP_PADDING_Y),
|
||||||
|
};
|
||||||
|
const groupWidth = Math.max(
|
||||||
|
GROUP_MIN_WIDTH,
|
||||||
|
Math.round(bounds.maxX - bounds.minX + GROUP_PADDING_X * 2),
|
||||||
|
);
|
||||||
|
const groupHeight = Math.max(
|
||||||
|
GROUP_MIN_HEIGHT,
|
||||||
|
Math.round(bounds.maxY - bounds.minY + GROUP_HEADER_HEIGHT + GROUP_PADDING_Y * 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupNode = {
|
||||||
|
id: groupId,
|
||||||
|
type: 'custom',
|
||||||
|
className: 'group-shell',
|
||||||
|
position: groupPosition,
|
||||||
|
dragHandle: '.drag-handle',
|
||||||
|
style: { width: groupWidth, height: groupHeight },
|
||||||
|
data: {
|
||||||
|
label: 'group',
|
||||||
|
className: 'Group',
|
||||||
|
definition: null,
|
||||||
|
widgetValues: {},
|
||||||
|
runtimeValues: {},
|
||||||
|
collapsed: false,
|
||||||
|
expandedSize: { width: groupWidth, height: groupHeight },
|
||||||
|
proxyInputs: [],
|
||||||
|
proxyOutputs: [],
|
||||||
|
childCount: selectedNodes.length,
|
||||||
|
previewImage: null,
|
||||||
|
tableRows: null,
|
||||||
|
meshData: null,
|
||||||
|
overlay: null,
|
||||||
|
scalarValue: null,
|
||||||
|
processingTimeMs: null,
|
||||||
|
warning: null,
|
||||||
|
},
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
|
||||||
|
const nextNodes = [
|
||||||
|
...currentNodes.map((node) => {
|
||||||
|
if (!selectedIds.includes(String(node.id))) {
|
||||||
|
return { ...node, selected: false };
|
||||||
|
}
|
||||||
|
const absolute = getNodeAbsolutePosition(node, nodeMap);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
selected: false,
|
||||||
|
parentId: groupId,
|
||||||
|
extent: 'parent',
|
||||||
|
hidden: false,
|
||||||
|
position: {
|
||||||
|
x: absolute.x - groupPosition.x,
|
||||||
|
y: absolute.y - groupPosition.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
groupNode,
|
||||||
|
];
|
||||||
|
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setTimeout(() => refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()), 0);
|
||||||
|
}, [reactFlow, refreshGroupNode, setNodes]);
|
||||||
|
|
||||||
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
|
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
|
||||||
setNodes((prev) => prev.map((node) => {
|
setNodes((prev) => prev.map((node) => {
|
||||||
if (node.id !== nodeId) return node;
|
if (node.id !== nodeId) return node;
|
||||||
@@ -516,9 +1035,12 @@ function Flow() {
|
|||||||
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
|
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
|
||||||
);
|
);
|
||||||
if (!edge) return null;
|
if (!edge) return null;
|
||||||
const sourceNode = reactFlow.getNode(edge.source);
|
const original = edge.data?.groupProxyOriginal || {};
|
||||||
|
const sourceId = original.source || edge.source;
|
||||||
|
const sourceHandle = original.sourceHandle || edge.sourceHandle;
|
||||||
|
const sourceNode = reactFlow.getNode(sourceId);
|
||||||
const outputPaths = sourceNode?.data?.definition?.output_paths;
|
const outputPaths = sourceNode?.data?.definition?.output_paths;
|
||||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
const outputSlot = getOutputSlot(sourceHandle);
|
||||||
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
|
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
|
||||||
return outputPaths[outputSlot];
|
return outputPaths[outputSlot];
|
||||||
}
|
}
|
||||||
@@ -653,38 +1175,66 @@ function Flow() {
|
|||||||
// ── Connection handling ─────────────────────────────────────────────
|
// ── Connection handling ─────────────────────────────────────────────
|
||||||
|
|
||||||
const isValidConnection = useCallback((connection) => {
|
const isValidConnection = useCallback((connection) => {
|
||||||
const srcType = getHandleType(connection.sourceHandle);
|
const srcType = getConnectionHandleType(connection.sourceHandle);
|
||||||
const tgtType = getHandleType(connection.targetHandle);
|
const tgtType = getConnectionHandleType(connection.targetHandle);
|
||||||
return socketTypesCompatible(srcType, tgtType);
|
return socketTypesCompatible(srcType, tgtType);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onConnect = useCallback((params) => {
|
const onConnect = useCallback((params) => {
|
||||||
const type = getHandleType(params.sourceHandle);
|
const sourceProxy = parseGroupProxyHandle(params.sourceHandle);
|
||||||
|
const targetProxy = parseGroupProxyHandle(params.targetHandle);
|
||||||
|
const type = getConnectionHandleType(params.sourceHandle);
|
||||||
const color = TYPE_COLORS[type] || 'var(--fallback-type)';
|
const color = TYPE_COLORS[type] || 'var(--fallback-type)';
|
||||||
|
|
||||||
|
const edgePayload = {
|
||||||
|
...params,
|
||||||
|
style: { stroke: color, strokeWidth: 2 },
|
||||||
|
};
|
||||||
|
const proxyOriginal = {};
|
||||||
|
if (sourceProxy) {
|
||||||
|
proxyOriginal.source = sourceProxy.nodeId;
|
||||||
|
proxyOriginal.sourceHandle = sourceProxy.realHandle;
|
||||||
|
}
|
||||||
|
if (targetProxy) {
|
||||||
|
proxyOriginal.target = targetProxy.nodeId;
|
||||||
|
proxyOriginal.targetHandle = targetProxy.realHandle;
|
||||||
|
}
|
||||||
|
if (Object.keys(proxyOriginal).length > 0) {
|
||||||
|
edgePayload.data = {
|
||||||
|
...(edgePayload.data || {}),
|
||||||
|
groupProxyOwner: sourceProxy?.direction === 'out' ? params.source : params.target,
|
||||||
|
groupProxyOriginal: proxyOriginal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setEdges((eds) => {
|
setEdges((eds) => {
|
||||||
// Enforce single connection per input handle
|
// Enforce single connection per input handle
|
||||||
const filtered = eds.filter(
|
const filtered = eds.filter(
|
||||||
(e) => !(e.target === params.target && e.targetHandle === params.targetHandle)
|
(e) => !(e.target === params.target && e.targetHandle === params.targetHandle)
|
||||||
);
|
);
|
||||||
return addEdge(
|
return addEdge(edgePayload, filtered);
|
||||||
{ ...params, style: { stroke: color, strokeWidth: 2 } },
|
|
||||||
filtered
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
if (getInputName(params.targetHandle) === 'path') {
|
const effectiveTargetHandle = targetProxy?.realHandle || params.targetHandle;
|
||||||
|
const effectiveTargetNode = targetProxy?.nodeId || params.target;
|
||||||
|
if (getInputName(effectiveTargetHandle) === 'path') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshLoadNodeOutputs(params.target);
|
refreshLoadNodeOutputs(effectiveTargetNode);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
const targetNode = reactFlow.getNode(params.target);
|
const targetNode = reactFlow.getNode(effectiveTargetNode);
|
||||||
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshAnnotationNodeOutputs(params.target);
|
refreshAnnotationNodeOutputs(effectiveTargetNode);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
if (sourceProxy) {
|
||||||
|
setTimeout(() => refreshGroupNode(params.source), 0);
|
||||||
|
}
|
||||||
|
if (targetProxy) {
|
||||||
|
setTimeout(() => refreshGroupNode(params.target), 0);
|
||||||
|
}
|
||||||
scheduleAutoRun();
|
scheduleAutoRun();
|
||||||
}, [reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
}, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||||
|
|
||||||
const handleEdgesChange = useCallback((changes) => {
|
const handleEdgesChange = useCallback((changes) => {
|
||||||
const currentEdges = reactFlow.getEdges();
|
const currentEdges = reactFlow.getEdges();
|
||||||
@@ -721,7 +1271,68 @@ function Flow() {
|
|||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
|
setTimeout(() => {
|
||||||
|
reactFlow.getNodes()
|
||||||
|
.filter((node) => node.data?.className === 'Group')
|
||||||
|
.forEach((node) => refreshGroupNode(node.id));
|
||||||
|
}, 0);
|
||||||
|
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
||||||
|
|
||||||
|
const handleNodesChange = useCallback((changes) => {
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const selectedGroupIds = new Set(
|
||||||
|
changes
|
||||||
|
.filter((change) => change.type === 'select' && change.selected)
|
||||||
|
.map((change) => String(change.id))
|
||||||
|
.filter((id) => currentNodes.some((node) => String(node.id) === id && node.data?.className === 'Group')),
|
||||||
|
);
|
||||||
|
const removedIds = new Set(
|
||||||
|
changes
|
||||||
|
.filter((change) => change.type === 'remove')
|
||||||
|
.map((change) => String(change.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
onNodesChange(changes);
|
||||||
|
|
||||||
|
if (selectedGroupIds.size > 0) {
|
||||||
|
const deselectedDescendantIds = new Set();
|
||||||
|
selectedGroupIds.forEach((groupId) => {
|
||||||
|
collectGroupDescendantIds(currentNodes, groupId).forEach((id) => deselectedDescendantIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deselectedDescendantIds.size > 0) {
|
||||||
|
setNodes((existing) => existing.map((node) => (
|
||||||
|
deselectedDescendantIds.has(String(node.id))
|
||||||
|
? { ...node, selected: false }
|
||||||
|
: node
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedIds.size === 0) return;
|
||||||
|
|
||||||
|
const groupIds = currentNodes
|
||||||
|
.filter((node) => removedIds.has(String(node.id)) && node.data?.className === 'Group')
|
||||||
|
.map((node) => String(node.id));
|
||||||
|
const removedWithDescendants = new Set(removedIds);
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
collectGroupDescendantIds(currentNodes, groupId).forEach((id) => removedWithDescendants.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupIds.length > 0) {
|
||||||
|
setNodes((existing) => existing.filter((node) => !removedWithDescendants.has(String(node.id))));
|
||||||
|
setEdges((existing) => existing.filter((edge) => (
|
||||||
|
!removedWithDescendants.has(String(edge.source))
|
||||||
|
&& !removedWithDescendants.has(String(edge.target))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
reactFlow.getNodes()
|
||||||
|
.filter((node) => node.data?.className === 'Group')
|
||||||
|
.forEach((node) => refreshGroupNode(node.id));
|
||||||
|
}, 0);
|
||||||
|
}, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]);
|
||||||
|
|
||||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||||
|
|
||||||
@@ -733,7 +1344,7 @@ function Flow() {
|
|||||||
if (!fromHandle || !fromHandle.id) return;
|
if (!fromHandle || !fromHandle.id) return;
|
||||||
|
|
||||||
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
|
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
|
||||||
const handleType = getHandleType(fromHandle.id);
|
const handleType = getConnectionHandleType(fromHandle.id);
|
||||||
|
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
x: clientX,
|
x: clientX,
|
||||||
@@ -1058,7 +1669,9 @@ function Flow() {
|
|||||||
onRuntimeValuesChange,
|
onRuntimeValuesChange,
|
||||||
openFileBrowser,
|
openFileBrowser,
|
||||||
onManualTrigger,
|
onManualTrigger,
|
||||||
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger]);
|
onToggleGroupCollapse: toggleGroupCollapse,
|
||||||
|
onUngroup: ungroupGroup,
|
||||||
|
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]);
|
||||||
|
|
||||||
const clearGraph = useCallback(() => {
|
const clearGraph = useCallback(() => {
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
@@ -1298,8 +1911,20 @@ function Flow() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onNodeDragStart = useCallback((event, node) => {
|
const onNodeDragStart = useCallback((event, node) => {
|
||||||
|
activeDragNodeIdRef.current = String(node.id);
|
||||||
|
|
||||||
if (!(event.ctrlKey || event.metaKey)) {
|
if (!(event.ctrlKey || event.metaKey)) {
|
||||||
duplicateDragRef.current = null;
|
duplicateDragRef.current = null;
|
||||||
|
if (node.data?.className === 'Group') {
|
||||||
|
const descendantIds = collectGroupDescendantIds(reactFlow.getNodes(), node.id);
|
||||||
|
if (descendantIds.size > 0) {
|
||||||
|
setNodes((existing) => existing.map((candidate) => (
|
||||||
|
descendantIds.has(String(candidate.id))
|
||||||
|
? { ...candidate, selected: false }
|
||||||
|
: candidate
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,6 +1968,7 @@ function Flow() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
duplicateDragRef.current = {
|
duplicateDragRef.current = {
|
||||||
|
anchorId: String(node.id),
|
||||||
draggedIds,
|
draggedIds,
|
||||||
originPositions,
|
originPositions,
|
||||||
duplicateSourceById,
|
duplicateSourceById,
|
||||||
@@ -1361,12 +1987,11 @@ function Flow() {
|
|||||||
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
|
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
|
||||||
|
|
||||||
const onNodeDrag = useCallback((_event, node) => {
|
const onNodeDrag = useCallback((_event, node) => {
|
||||||
const duplicateState = duplicateDragRef.current;
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
||||||
if (!duplicateState) return;
|
|
||||||
|
|
||||||
const anchorId = duplicateState.draggedIds.includes(String(node.id))
|
const duplicateState = duplicateDragRef.current;
|
||||||
? String(node.id)
|
if (duplicateState) {
|
||||||
: duplicateState.draggedIds[0];
|
const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
|
||||||
const anchorOrigin = duplicateState.originPositions[anchorId];
|
const anchorOrigin = duplicateState.originPositions[anchorId];
|
||||||
if (!anchorOrigin) return;
|
if (!anchorOrigin) return;
|
||||||
|
|
||||||
@@ -1403,16 +2028,18 @@ function Flow() {
|
|||||||
|
|
||||||
return candidate;
|
return candidate;
|
||||||
}));
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
const onNodeDragStop = useCallback((_event, node) => {
|
const onNodeDragStop = useCallback((event, node) => {
|
||||||
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
||||||
|
activeDragNodeIdRef.current = null;
|
||||||
|
|
||||||
const duplicateState = duplicateDragRef.current;
|
const duplicateState = duplicateDragRef.current;
|
||||||
duplicateDragRef.current = null;
|
duplicateDragRef.current = null;
|
||||||
if (!duplicateState) return;
|
if (duplicateState) {
|
||||||
|
const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
|
||||||
const anchorId = duplicateState.draggedIds.includes(String(node.id))
|
|
||||||
? String(node.id)
|
|
||||||
: duplicateState.draggedIds[0];
|
|
||||||
const anchorOrigin = duplicateState.originPositions[anchorId];
|
const anchorOrigin = duplicateState.originPositions[anchorId];
|
||||||
if (!anchorOrigin) return;
|
if (!anchorOrigin) return;
|
||||||
|
|
||||||
@@ -1458,7 +2085,80 @@ function Flow() {
|
|||||||
level: 'info',
|
level: 'info',
|
||||||
});
|
});
|
||||||
scheduleAutoRun();
|
scheduleAutoRun();
|
||||||
}, [scheduleAutoRun, setNodes]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const touchedGroupIds = new Set();
|
||||||
|
let nextNodes = currentNodes;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const draggedNodes = node.data?.className === 'Group'
|
||||||
|
? []
|
||||||
|
: (
|
||||||
|
node.selected
|
||||||
|
? nextNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
|
||||||
|
: nextNodes.filter((candidate) => candidate.id === node.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draggedNodes.length > 0) {
|
||||||
|
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
|
||||||
|
const targetGroup = findExpandedGroupDropTarget(nextNodes, Array.from(draggedIdSet), node.id);
|
||||||
|
if (targetGroup) {
|
||||||
|
const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
|
||||||
|
const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap);
|
||||||
|
const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap);
|
||||||
|
let joinedCount = 0;
|
||||||
|
|
||||||
|
nextNodes = nextNodes.map((candidate) => {
|
||||||
|
if (!draggedIdSet.has(String(candidate.id))) return candidate;
|
||||||
|
|
||||||
|
const center = getNodeCenter(candidate, nodeMap);
|
||||||
|
if (!rectContainsPoint(targetRect, center)) return candidate;
|
||||||
|
|
||||||
|
const absolute = getNodeAbsolutePosition(candidate, nodeMap);
|
||||||
|
const nextPosition = {
|
||||||
|
x: absolute.x - targetAbs.x,
|
||||||
|
y: absolute.y - targetAbs.y,
|
||||||
|
};
|
||||||
|
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 (candidate.parentId) {
|
||||||
|
touchedGroupIds.add(String(candidate.parentId));
|
||||||
|
}
|
||||||
|
touchedGroupIds.add(String(targetGroup.id));
|
||||||
|
joinedCount += 1;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
parentId: String(targetGroup.id),
|
||||||
|
extent: 'parent',
|
||||||
|
hidden: false,
|
||||||
|
position: nextPosition,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (joinedCount > 0) {
|
||||||
|
setStatus({
|
||||||
|
text: `Added ${joinedCount} node${joinedCount === 1 ? '' : 's'} to group.`,
|
||||||
|
level: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setTimeout(() => {
|
||||||
|
touchedGroupIds.forEach((groupId) => {
|
||||||
|
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}, [reactFlow, refreshGroupNode, scheduleAutoRun, setNodes]);
|
||||||
|
|
||||||
// ── Keyboard shortcut ───────────────────────────────────────────────
|
// ── Keyboard shortcut ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1531,6 +2231,8 @@ function Flow() {
|
|||||||
return () => window.removeEventListener('pointerdown', handlePointerDown, true);
|
return () => window.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
const selectedNodeCount = nodes.filter((node) => node.selected).length;
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1569,7 +2271,7 @@ function Flow() {
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={handleEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onNodeDrag={onNodeDrag}
|
onNodeDrag={onNodeDrag}
|
||||||
@@ -1600,9 +2302,11 @@ function Flow() {
|
|||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
nodeDefs={nodeDefsRef.current}
|
nodeDefs={nodeDefsRef.current}
|
||||||
onAdd={addNode}
|
onAdd={addNode}
|
||||||
|
onCreateGroup={createGroupFromSelection}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
filterType={contextMenu.filterType}
|
filterType={contextMenu.filterType}
|
||||||
filterDirection={contextMenu.filterDirection}
|
filterDirection={contextMenu.filterDirection}
|
||||||
|
selectedNodeCount={selectedNodeCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,103 @@ function formatUiLabel(text) {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProxyHandle(handleId) {
|
||||||
|
const text = String(handleId || '');
|
||||||
|
if (!text.startsWith('group-proxy::')) return null;
|
||||||
|
const parts = text.split('::');
|
||||||
|
if (parts.length < 5) return null;
|
||||||
|
return {
|
||||||
|
direction: parts[1],
|
||||||
|
nodeId: parts[2],
|
||||||
|
type: parts[3],
|
||||||
|
realHandle: decodeURIComponent(parts.slice(4).join('::')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupNode({ id, data }) {
|
||||||
|
const ctx = useContext(NodeContext);
|
||||||
|
const proxyInputs = Array.isArray(data.proxyInputs) ? data.proxyInputs : [];
|
||||||
|
const proxyOutputs = Array.isArray(data.proxyOutputs) ? data.proxyOutputs : [];
|
||||||
|
const childCount = Number(data.childCount) || 0;
|
||||||
|
const collapsed = !!data.collapsed;
|
||||||
|
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
onClick={() => ctx.onToggleGroupCollapse?.(id)}
|
||||||
|
title={collapsed ? 'expand group' : 'collapse group'}
|
||||||
|
>
|
||||||
|
{collapsed ? '▸' : '▾'}
|
||||||
|
</button>
|
||||||
|
<span className="node-title-main">{formatUiLabel(data.label || 'group')}</span>
|
||||||
|
<div className="group-node-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group-toggle nodrag"
|
||||||
|
onClick={() => ctx.onUngroup?.(id)}
|
||||||
|
title="ungroup"
|
||||||
|
>
|
||||||
|
ungroup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="node-body">
|
||||||
|
{collapsed ? (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: maxRows }, (_, index) => {
|
||||||
|
const input = proxyInputs[index];
|
||||||
|
const output = proxyOutputs[index];
|
||||||
|
return (
|
||||||
|
<div className="io-row" key={`group-io-${index}`}>
|
||||||
|
<div className="io-left">
|
||||||
|
{input && (
|
||||||
|
<>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id={input.handleId}
|
||||||
|
className="typed-handle"
|
||||||
|
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
|
||||||
|
/>
|
||||||
|
<span className="io-label">{formatUiLabel(input.label || input.name)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="io-right">
|
||||||
|
{output && (
|
||||||
|
<>
|
||||||
|
<span className="io-label">{formatUiLabel(output.label || output.name)}</span>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id={output.handleId}
|
||||||
|
className="typed-handle"
|
||||||
|
style={{ background: TYPE_COLORS[output.type] || 'var(--fallback-type)' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="group-node-summary">{childCount} nodes</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="group-node-workspace">
|
||||||
|
<div className="group-node-workspace-label">workflow group</div>
|
||||||
|
<div className="group-node-summary">{childCount} nodes</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class PreviewBoundary extends React.Component {
|
class PreviewBoundary extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -390,6 +487,8 @@ function getSourceTypeForInput(store, nodeId, inputName) {
|
|||||||
const targetHandle = `input::${inputName}::`;
|
const targetHandle = `input::${inputName}::`;
|
||||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||||
if (!edge?.sourceHandle) return null;
|
if (!edge?.sourceHandle) return null;
|
||||||
|
const proxy = parseProxyHandle(edge.sourceHandle);
|
||||||
|
if (proxy) return proxy.type || null;
|
||||||
const parts = edge.sourceHandle.split('::');
|
const parts = edge.sourceHandle.split('::');
|
||||||
return parts[2] || null;
|
return parts[2] || null;
|
||||||
}
|
}
|
||||||
@@ -405,8 +504,11 @@ function getConnectedOutputInfo(store, nodeId, inputName) {
|
|||||||
const targetHandle = `input::${inputName}::`;
|
const targetHandle = `input::${inputName}::`;
|
||||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||||
if (!edge?.sourceHandle) return null;
|
if (!edge?.sourceHandle) return null;
|
||||||
const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
|
const proxy = parseProxyHandle(edge.sourceHandle);
|
||||||
const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10);
|
const sourceNodeId = proxy?.nodeId || edge.source;
|
||||||
|
const sourceHandle = proxy?.realHandle || edge.sourceHandle;
|
||||||
|
const sourceNode = store.nodeLookup?.get(sourceNodeId) || store.nodes?.find((n) => n.id === sourceNodeId) || null;
|
||||||
|
const slot = Number.parseInt(sourceHandle.split('::')[1], 10);
|
||||||
if (!sourceNode || !Number.isInteger(slot)) return null;
|
if (!sourceNode || !Number.isInteger(slot)) return null;
|
||||||
return {
|
return {
|
||||||
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
|
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
|
||||||
@@ -751,6 +853,9 @@ function NodeTable({ rows }) {
|
|||||||
|
|
||||||
function CustomNode({ id, data }) {
|
function CustomNode({ id, data }) {
|
||||||
const ctx = useContext(NodeContext);
|
const ctx = useContext(NodeContext);
|
||||||
|
if (data.className === 'Group') {
|
||||||
|
return <GroupNode id={id} data={data} />;
|
||||||
|
}
|
||||||
const def = data.definition;
|
const def = data.definition;
|
||||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DATA_TYPES } from './constants';
|
import { DATA_TYPES } from './constants.js';
|
||||||
|
|
||||||
function getInputName(handleId) {
|
function getInputName(handleId) {
|
||||||
return handleId.split('::')[1];
|
return handleId.split('::')[1];
|
||||||
@@ -8,11 +8,24 @@ function getOutputSlot(handleId) {
|
|||||||
return parseInt(handleId.split('::')[1], 10);
|
return parseInt(handleId.split('::')[1], 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveExecutionEdge(edge) {
|
||||||
|
const original = edge?.data?.groupProxyOriginal;
|
||||||
|
if (!original) return edge;
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
source: original.source || edge.source,
|
||||||
|
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
||||||
|
target: original.target || edge.target,
|
||||||
|
targetHandle: original.targetHandle || edge.targetHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getConnectedNodeIds(edges) {
|
export function getConnectedNodeIds(edges) {
|
||||||
const connectedNodeIds = new Set();
|
const connectedNodeIds = new Set();
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
connectedNodeIds.add(edge.source);
|
const resolved = resolveExecutionEdge(edge);
|
||||||
connectedNodeIds.add(edge.target);
|
connectedNodeIds.add(resolved.source);
|
||||||
|
connectedNodeIds.add(resolved.target);
|
||||||
}
|
}
|
||||||
return connectedNodeIds;
|
return connectedNodeIds;
|
||||||
}
|
}
|
||||||
@@ -53,6 +66,7 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
|||||||
if (!runnableNodeIds.has(node.id)) continue;
|
if (!runnableNodeIds.has(node.id)) continue;
|
||||||
|
|
||||||
const { className, definition, widgetValues, runtimeValues } = node.data;
|
const { className, definition, widgetValues, runtimeValues } = node.data;
|
||||||
|
if (className === 'Group') continue;
|
||||||
if (!definition) continue;
|
if (!definition) continue;
|
||||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||||
|
|
||||||
@@ -72,7 +86,9 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const incoming = edges.filter((edge) => edge.target === node.id);
|
const incoming = edges
|
||||||
|
.map(resolveExecutionEdge)
|
||||||
|
.filter((edge) => edge.target === node.id);
|
||||||
for (const edge of incoming) {
|
for (const edge of incoming) {
|
||||||
const inputName = getInputName(edge.targetHandle);
|
const inputName = getInputName(edge.targetHandle);
|
||||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||||
@@ -102,7 +118,10 @@ export function hasBlockingAutoRunInput(node, edges) {
|
|||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||||
return inputs.some((inputName) => edges.some(
|
return inputs.some((inputName) => edges.some(
|
||||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName)
|
(edge) => {
|
||||||
|
const resolved = resolveExecutionEdge(edge);
|
||||||
|
return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
|
||||||
|
}
|
||||||
));
|
));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -114,7 +133,10 @@ export function hasBlockingAutoRunInput(node, edges) {
|
|||||||
}
|
}
|
||||||
if (!DATA_TYPES.has(type)) continue;
|
if (!DATA_TYPES.has(type)) continue;
|
||||||
const hasEdge = edges.some(
|
const hasEdge = edges.some(
|
||||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === name
|
(edge) => {
|
||||||
|
const resolved = resolveExecutionEdge(edge);
|
||||||
|
return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (!hasEdge) return true;
|
if (!hasEdge) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,52 @@ function clonePlainObject(value) {
|
|||||||
return cloneValue(value) || {};
|
return cloneValue(value) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectSelectedNodeIds(nodes, nodeIds) {
|
||||||
|
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
|
||||||
|
if (selectedIdSet.size === 0) return selectedIdSet;
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
|
const parentId = node?.parentId ? String(node.parentId) : null;
|
||||||
|
const nodeId = String(node?.id);
|
||||||
|
if (parentId && selectedIdSet.has(parentId) && !selectedIdSet.has(nodeId)) {
|
||||||
|
selectedIdSet.add(nodeId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectedIdSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExtraData(data) {
|
||||||
|
const source = data || {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key]) => ![
|
||||||
|
'label',
|
||||||
|
'className',
|
||||||
|
'widgetValues',
|
||||||
|
'runtimeValues',
|
||||||
|
'definition',
|
||||||
|
'previewImage',
|
||||||
|
'tableRows',
|
||||||
|
'meshData',
|
||||||
|
'overlay',
|
||||||
|
'scalarValue',
|
||||||
|
'processingTimeMs',
|
||||||
|
'warning',
|
||||||
|
].includes(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildNodeClipboardPayloadForIds(
|
export function buildNodeClipboardPayloadForIds(
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
nodeIds,
|
nodeIds,
|
||||||
{ includeIncomingExternalEdges = false } = {},
|
{ includeIncomingExternalEdges = false } = {},
|
||||||
) {
|
) {
|
||||||
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
|
const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
|
||||||
const selectedNodes = Array.isArray(nodes)
|
const selectedNodes = Array.isArray(nodes)
|
||||||
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
|
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
|
||||||
: [];
|
: [];
|
||||||
@@ -50,12 +89,18 @@ export function buildNodeClipboardPayloadForIds(
|
|||||||
x: Number(node.position?.x) || 0,
|
x: Number(node.position?.x) || 0,
|
||||||
y: Number(node.position?.y) || 0,
|
y: Number(node.position?.y) || 0,
|
||||||
},
|
},
|
||||||
|
...(node.className ? { className: node.className } : {}),
|
||||||
|
...(node.parentId ? { parentId: String(node.parentId) } : {}),
|
||||||
|
...(node.extent ? { extent: node.extent } : {}),
|
||||||
|
...(node.hidden ? { hidden: true } : {}),
|
||||||
|
...(node.style ? { style: cloneValue(node.style) } : {}),
|
||||||
dragHandle: node.dragHandle || '.drag-handle',
|
dragHandle: node.dragHandle || '.drag-handle',
|
||||||
data: {
|
data: {
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
className: node.data?.className || '',
|
className: node.data?.className || '',
|
||||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||||
|
extraData: clonePlainObject(extractExtraData(node.data)),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
edges: capturedEdges.map((edge) => ({
|
edges: capturedEdges.map((edge) => ({
|
||||||
@@ -64,15 +109,19 @@ export function buildNodeClipboardPayloadForIds(
|
|||||||
target: String(edge.target),
|
target: String(edge.target),
|
||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||||
|
...(edge.hidden ? { hidden: true } : {}),
|
||||||
|
...(edge.data ? { data: cloneValue(edge.data) } : {}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNodeClipboardPayload(nodes, edges) {
|
export function buildNodeClipboardPayload(nodes, edges) {
|
||||||
const selectedIds = Array.isArray(nodes)
|
const selectedNodes = Array.isArray(nodes)
|
||||||
? nodes.filter((node) => node?.selected).map((node) => String(node.id))
|
? nodes.filter((node) => node?.selected)
|
||||||
: [];
|
: [];
|
||||||
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds);
|
const selectedIds = selectedNodes.map((node) => String(node.id));
|
||||||
|
const includeIncomingExternalEdges = selectedNodes.some((node) => node?.data?.className === 'Group');
|
||||||
|
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNodeClipboardPayload(text) {
|
export function parseNodeClipboardPayload(text) {
|
||||||
@@ -111,10 +160,15 @@ export function instantiateNodeClipboardPayload(
|
|||||||
return {
|
return {
|
||||||
id: newId,
|
id: newId,
|
||||||
type: node.type || 'custom',
|
type: node.type || 'custom',
|
||||||
|
className: node.className,
|
||||||
position: {
|
position: {
|
||||||
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
|
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
|
||||||
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
|
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
|
||||||
},
|
},
|
||||||
|
...(node.parentId ? { parentId: idMap.get(String(node.parentId)) || String(node.parentId) } : {}),
|
||||||
|
...(node.extent ? { extent: node.extent } : {}),
|
||||||
|
...(node.hidden ? { hidden: true } : {}),
|
||||||
|
...(node.style ? { style: cloneValue(node.style) } : {}),
|
||||||
dragHandle: node.dragHandle || '.drag-handle',
|
dragHandle: node.dragHandle || '.drag-handle',
|
||||||
selected: true,
|
selected: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -122,6 +176,7 @@ export function instantiateNodeClipboardPayload(
|
|||||||
className,
|
className,
|
||||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||||
|
...(clonePlainObject(node.data?.extraData)),
|
||||||
definition,
|
definition,
|
||||||
previewImage: null,
|
previewImage: null,
|
||||||
tableRows: null,
|
tableRows: null,
|
||||||
@@ -147,6 +202,8 @@ export function instantiateNodeClipboardPayload(
|
|||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
selected: false,
|
selected: false,
|
||||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||||
|
...(edge.hidden ? { hidden: true } : {}),
|
||||||
|
...(edge.data ? { data: cloneValue(edge.data) } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -236,8 +236,104 @@ html, body, #root {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-node {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 220px;
|
||||||
|
resize: none;
|
||||||
|
border-style: dashed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(30, 41, 59, 0.82), rgba(15, 23, 42, 0.72));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
|
||||||
|
inset 0 1px 18px rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-title {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-title .node-title-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-toggle {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.65);
|
||||||
|
color: var(--text-heading);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-toggle-collapse {
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-summary {
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node .node-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-expanded .node-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-workspace {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(15, 23, 42, 0.16), rgba(15, 23, 42, 0.34));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(15, 23, 42, 0.12),
|
||||||
|
inset 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-workspace-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 12px;
|
||||||
|
color: rgba(148, 163, 184, 0.58);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-node-expanded .group-node-summary {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 8px;
|
||||||
|
border-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Let React Flow node wrapper fit to the custom-node's size */
|
/* Let React Flow node wrapper fit to the custom-node's size */
|
||||||
.react-flow__node-custom {
|
.react-flow__node-custom:not(.group-shell) {
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toBlob } from 'html-to-image';
|
import { toBlob } from 'html-to-image';
|
||||||
import { CANVAS_COLORS } from './constants';
|
import { CANVAS_COLORS } from './constants.js';
|
||||||
|
|
||||||
export const OVERLAY_CAPTURE_SELECTORS = [
|
export const OVERLAY_CAPTURE_SELECTORS = [
|
||||||
'.lineplot-overlay',
|
'.lineplot-overlay',
|
||||||
|
|||||||
@@ -40,18 +40,26 @@ export function hydrateWorkflowState(data, defs = {}) {
|
|||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
type: node.type || 'custom',
|
type: node.type || 'custom',
|
||||||
|
className: node.className,
|
||||||
|
parentId: node.parentId,
|
||||||
|
extent: node.extent,
|
||||||
|
hidden: !!node.hidden,
|
||||||
|
style: node.style,
|
||||||
dragHandle: node.dragHandle || '.drag-handle',
|
dragHandle: node.dragHandle || '.drag-handle',
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
||||||
runtimeValues: {},
|
runtimeValues: node.data?.runtimeValues || {},
|
||||||
|
...(node.data?.extraData || {}),
|
||||||
definition,
|
definition,
|
||||||
previewImage: null,
|
previewImage: null,
|
||||||
tableRows: null,
|
tableRows: null,
|
||||||
meshData: null,
|
meshData: null,
|
||||||
overlay: null,
|
overlay: null,
|
||||||
scalarValue: null,
|
scalarValue: null,
|
||||||
|
processingTimeMs: null,
|
||||||
|
warning: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
export function serializeWorkflowState(nodes, edges) {
|
export function serializeWorkflowState(nodes, edges) {
|
||||||
|
const compactObject = (value) => {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||||
|
};
|
||||||
|
const getExtraData = (data) => compactObject(Object.fromEntries(
|
||||||
|
Object.entries(data || {}).filter(([key]) => ![
|
||||||
|
'label',
|
||||||
|
'className',
|
||||||
|
'widgetValues',
|
||||||
|
'runtimeValues',
|
||||||
|
'definition',
|
||||||
|
'previewImage',
|
||||||
|
'tableRows',
|
||||||
|
'meshData',
|
||||||
|
'overlay',
|
||||||
|
'scalarValue',
|
||||||
|
'processingTimeMs',
|
||||||
|
'warning',
|
||||||
|
].includes(key))
|
||||||
|
));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.type || 'custom',
|
type: node.type || 'custom',
|
||||||
position: node.position,
|
position: node.position,
|
||||||
|
...(node.className ? { className: node.className } : {}),
|
||||||
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
|
...(node.extent ? { extent: node.extent } : {}),
|
||||||
|
...(node.hidden ? { hidden: true } : {}),
|
||||||
|
...(node.style ? { style: node.style } : {}),
|
||||||
dragHandle: node.dragHandle || '.drag-handle',
|
dragHandle: node.dragHandle || '.drag-handle',
|
||||||
data: {
|
data: {
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
className: node.data?.className || '',
|
className: node.data?.className || '',
|
||||||
widgetValues: node.data?.widgetValues || {},
|
widgetValues: node.data?.widgetValues || {},
|
||||||
|
...(compactObject(node.data?.runtimeValues) ? { runtimeValues: compactObject(node.data?.runtimeValues) } : {}),
|
||||||
|
...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}),
|
||||||
output: node.data?.definition?.output || [],
|
output: node.data?.definition?.output || [],
|
||||||
output_name: node.data?.definition?.output_name || [],
|
output_name: node.data?.definition?.output_name || [],
|
||||||
},
|
},
|
||||||
@@ -21,6 +50,8 @@ export function serializeWorkflowState(nodes, edges) {
|
|||||||
target: edge.target,
|
target: edge.target,
|
||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
...(edge.style ? { style: edge.style } : {}),
|
...(edge.style ? { style: edge.style } : {}),
|
||||||
|
...(edge.hidden ? { hidden: true } : {}),
|
||||||
|
...(edge.data ? { data: edge.data } : {}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,73 @@ test('serializeExecutionGraph allows a singleton ImageDemo graph so previews can
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('serializeExecutionGraph ignores group shells and resolves collapsed proxy edges back to child endpoints', () => {
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
data: {
|
||||||
|
className: 'Image',
|
||||||
|
definition: {
|
||||||
|
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||||
|
manual_trigger: false,
|
||||||
|
},
|
||||||
|
widgetValues: { filename: 'scan.gwy' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
data: {
|
||||||
|
className: 'Group',
|
||||||
|
definition: null,
|
||||||
|
widgetValues: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
parentId: '10',
|
||||||
|
hidden: true,
|
||||||
|
data: {
|
||||||
|
className: 'PreviewImage',
|
||||||
|
definition: {
|
||||||
|
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
||||||
|
manual_trigger: false,
|
||||||
|
},
|
||||||
|
widgetValues: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
{
|
||||||
|
source: '1',
|
||||||
|
sourceHandle: 'output::0::DATA_FIELD',
|
||||||
|
target: '10',
|
||||||
|
targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD',
|
||||||
|
data: {
|
||||||
|
groupProxyOwner: '10',
|
||||||
|
groupProxyOriginal: {
|
||||||
|
target: '2',
|
||||||
|
targetHandle: 'input::field::DATA_FIELD',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const prompt = serializeExecutionGraph(nodes, edges);
|
||||||
|
|
||||||
|
assert.deepEqual(prompt, {
|
||||||
|
'1': {
|
||||||
|
class_type: 'Image',
|
||||||
|
inputs: { filename: 'scan.gwy' },
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
class_type: 'PreviewImage',
|
||||||
|
inputs: { field: ['1', 0] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal('10' in prompt, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
|
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
||||||
|
|||||||
@@ -265,3 +265,28 @@ test('clipboard payload deep-copies local widget and runtime fields', () => {
|
|||||||
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
|
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
|
||||||
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
|
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clipboard payload preserves wrapper class names for group shells', () => {
|
||||||
|
const payload = buildNodeClipboardPayloadForIds(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: '50',
|
||||||
|
type: 'custom',
|
||||||
|
className: 'group-shell',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'group',
|
||||||
|
className: 'Group',
|
||||||
|
widgetValues: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
['50'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const instantiated = instantiateNodeClipboardPayload(payload, {}, 80);
|
||||||
|
|
||||||
|
assert.equal(payload.nodes[0].className, 'group-shell');
|
||||||
|
assert.equal(instantiated.nodes[0].className, 'group-shell');
|
||||||
|
});
|
||||||
|
|||||||
@@ -226,3 +226,26 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
|
|||||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
|
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
|
||||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('workflow serialization preserves wrapper class names for group shells', () => {
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: '31',
|
||||||
|
type: 'custom',
|
||||||
|
className: 'group-shell',
|
||||||
|
position: { x: 5, y: 15 },
|
||||||
|
style: { width: 420, height: 260 },
|
||||||
|
data: {
|
||||||
|
label: 'group',
|
||||||
|
className: 'Group',
|
||||||
|
widgetValues: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const serialized = serializeWorkflowState(nodes, []);
|
||||||
|
const hydrated = hydrateWorkflowState(serialized, {});
|
||||||
|
|
||||||
|
assert.equal(serialized.nodes[0].className, 'group-shell');
|
||||||
|
assert.equal(hydrated.nodes[0].className, 'group-shell');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user