diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e37f7b..127629f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -36,6 +36,13 @@ import { 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 ───────────────────────────────────────────────── function getHandleType(handleId) { @@ -50,6 +57,228 @@ function getOutputSlot(handleId) { 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 = []) { if (a === b) return true; 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 ──────────────────────────────────────────── -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 [search, setSearch] = useState(''); const menuRef = useRef(null); @@ -396,6 +625,15 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti /> + {!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && ( +
{ onCreateGroup(); onClose(); }} + > + create group +
+ )} + {searchResults ? (
{searchResults.length === 0 ? ( @@ -474,6 +712,7 @@ function Flow() { const lastPastedClipboardTextRef = useRef(''); const pasteRepeatCountRef = useRef(0); const duplicateDragRef = useRef(null); + const activeDragNodeIdRef = useRef(null); const reactFlow = useReactFlow(); // ── WebSocket ─────────────────────────────────────────────────────── @@ -484,6 +723,286 @@ function Flow() { )); }, [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 = {}) => { setNodes((prev) => prev.map((node) => { if (node.id !== nodeId) return node; @@ -516,9 +1035,12 @@ function Flow() { (e) => e.target === nodeId && getInputName(e.targetHandle) === 'path' ); 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 outputSlot = getOutputSlot(edge.sourceHandle); + const outputSlot = getOutputSlot(sourceHandle); if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') { return outputPaths[outputSlot]; } @@ -653,38 +1175,66 @@ function Flow() { // ── Connection handling ───────────────────────────────────────────── const isValidConnection = useCallback((connection) => { - const srcType = getHandleType(connection.sourceHandle); - const tgtType = getHandleType(connection.targetHandle); + const srcType = getConnectionHandleType(connection.sourceHandle); + const tgtType = getConnectionHandleType(connection.targetHandle); return socketTypesCompatible(srcType, tgtType); }, []); 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 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) => { // Enforce single connection per input handle const filtered = eds.filter( (e) => !(e.target === params.target && e.targetHandle === params.targetHandle) ); - return addEdge( - { ...params, style: { stroke: color, strokeWidth: 2 } }, - filtered - ); + return addEdge(edgePayload, filtered); }); - if (getInputName(params.targetHandle) === 'path') { + const effectiveTargetHandle = targetProxy?.realHandle || params.targetHandle; + const effectiveTargetNode = targetProxy?.nodeId || params.target; + if (getInputName(effectiveTargetHandle) === 'path') { setTimeout(() => { - refreshLoadNodeOutputs(params.target); + refreshLoadNodeOutputs(effectiveTargetNode); }, 0); } - const targetNode = reactFlow.getNode(params.target); + const targetNode = reactFlow.getNode(effectiveTargetNode); if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) { setTimeout(() => { - refreshAnnotationNodeOutputs(params.target); + refreshAnnotationNodeOutputs(effectiveTargetNode); }, 0); } + if (sourceProxy) { + setTimeout(() => refreshGroupNode(params.source), 0); + } + if (targetProxy) { + setTimeout(() => refreshGroupNode(params.target), 0); + } 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 currentEdges = reactFlow.getEdges(); @@ -721,7 +1271,68 @@ function Flow() { }); }, 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 ────────────────────── @@ -733,7 +1344,7 @@ function Flow() { if (!fromHandle || !fromHandle.id) return; const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event; - const handleType = getHandleType(fromHandle.id); + const handleType = getConnectionHandleType(fromHandle.id); setContextMenu({ x: clientX, @@ -1058,7 +1669,9 @@ function Flow() { onRuntimeValuesChange, openFileBrowser, onManualTrigger, - }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger]); + onToggleGroupCollapse: toggleGroupCollapse, + onUngroup: ungroupGroup, + }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]); const clearGraph = useCallback(() => { setNodes([]); @@ -1298,8 +1911,20 @@ function Flow() { }, []); const onNodeDragStart = useCallback((event, node) => { + activeDragNodeIdRef.current = String(node.id); + if (!(event.ctrlKey || event.metaKey)) { 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; } @@ -1343,6 +1968,7 @@ function Flow() { ); duplicateDragRef.current = { + anchorId: String(node.id), draggedIds, originPositions, duplicateSourceById, @@ -1361,104 +1987,178 @@ function Flow() { }, [initializeDynamicNodes, reactFlow, setEdges, setNodes]); const onNodeDrag = useCallback((_event, node) => { + if (String(node.id) !== activeDragNodeIdRef.current) return; + const duplicateState = duplicateDragRef.current; - if (!duplicateState) return; + if (duplicateState) { + const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0]; + const anchorOrigin = duplicateState.originPositions[anchorId]; + if (!anchorOrigin) return; - const anchorId = duplicateState.draggedIds.includes(String(node.id)) - ? String(node.id) - : duplicateState.draggedIds[0]; - const anchorOrigin = duplicateState.originPositions[anchorId]; - if (!anchorOrigin) return; + const offset = { + x: (Number(node.position?.x) || 0) - anchorOrigin.x, + y: (Number(node.position?.y) || 0) - anchorOrigin.y, + }; + const draggedIdSet = new Set(duplicateState.draggedIds); - const offset = { - x: (Number(node.position?.x) || 0) - anchorOrigin.x, - y: (Number(node.position?.y) || 0) - anchorOrigin.y, - }; - const draggedIdSet = new Set(duplicateState.draggedIds); + setNodes((existing) => existing.map((candidate) => { + const candidateId = String(candidate.id); + const originalPosition = duplicateState.originPositions[candidateId]; + if (draggedIdSet.has(candidateId) && originalPosition) { + return { + ...candidate, + selected: false, + position: originalPosition, + }; + } - setNodes((existing) => existing.map((candidate) => { - const candidateId = String(candidate.id); - const originalPosition = duplicateState.originPositions[candidateId]; - if (draggedIdSet.has(candidateId) && originalPosition) { - return { - ...candidate, - selected: false, - position: originalPosition, - }; - } + const sourceId = duplicateState.duplicateSourceById[candidateId]; + if (sourceId) { + const sourceOrigin = duplicateState.originPositions[sourceId]; + if (!sourceOrigin) return candidate; + return { + ...candidate, + selected: true, + position: { + x: sourceOrigin.x + offset.x, + y: sourceOrigin.y + offset.y, + }, + }; + } - const sourceId = duplicateState.duplicateSourceById[candidateId]; - if (sourceId) { - const sourceOrigin = duplicateState.originPositions[sourceId]; - if (!sourceOrigin) return candidate; - return { - ...candidate, - selected: true, - position: { - x: sourceOrigin.x + offset.x, - y: sourceOrigin.y + offset.y, - }, - }; - } - - return candidate; - })); + return candidate; + })); + return; + } }, [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; duplicateDragRef.current = null; - if (!duplicateState) return; + if (duplicateState) { + const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0]; + const anchorOrigin = duplicateState.originPositions[anchorId]; + if (!anchorOrigin) return; - const anchorId = duplicateState.draggedIds.includes(String(node.id)) - ? String(node.id) - : duplicateState.draggedIds[0]; - const anchorOrigin = duplicateState.originPositions[anchorId]; - if (!anchorOrigin) return; + const offset = { + x: (Number(node.position?.x) || 0) - anchorOrigin.x, + y: (Number(node.position?.y) || 0) - anchorOrigin.y, + }; + const draggedIdSet = new Set(duplicateState.draggedIds); - const offset = { - x: (Number(node.position?.x) || 0) - anchorOrigin.x, - y: (Number(node.position?.y) || 0) - anchorOrigin.y, - }; - const draggedIdSet = new Set(duplicateState.draggedIds); + setNodes((existing) => existing.map((candidate) => { + const candidateId = String(candidate.id); + const originalPosition = duplicateState.originPositions[candidateId]; + if (draggedIdSet.has(candidateId) && originalPosition) { + return { + ...candidate, + selected: false, + position: originalPosition, + }; + } + + const sourceId = duplicateState.duplicateSourceById[candidateId]; + if (sourceId) { + const sourceOrigin = duplicateState.originPositions[sourceId]; + if (!sourceOrigin) return candidate; + return { + ...candidate, + selected: true, + position: { + x: sourceOrigin.x + offset.x, + y: sourceOrigin.y + offset.y, + }, + }; + } - setNodes((existing) => existing.map((candidate) => { - const candidateId = String(candidate.id); - const originalPosition = duplicateState.originPositions[candidateId]; - if (draggedIdSet.has(candidateId) && originalPosition) { return { ...candidate, selected: false, - position: originalPosition, }; + })); + + setStatus({ + text: `Duplicated ${Object.keys(duplicateState.duplicateSourceById).length} node${Object.keys(duplicateState.duplicateSourceById).length === 1 ? '' : 's'}.`, + level: 'info', + }); + scheduleAutoRun(); + 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', + }); + } } + } - const sourceId = duplicateState.duplicateSourceById[candidateId]; - if (sourceId) { - const sourceOrigin = duplicateState.originPositions[sourceId]; - if (!sourceOrigin) return candidate; - return { - ...candidate, - selected: true, - position: { - x: sourceOrigin.x + offset.x, - y: sourceOrigin.y + offset.y, - }, - }; - } + if (!changed) return; - return { - ...candidate, - selected: false, - }; - })); - - setStatus({ - text: `Duplicated ${Object.keys(duplicateState.duplicateSourceById).length} node${Object.keys(duplicateState.duplicateSourceById).length === 1 ? '' : 's'}.`, - level: 'info', - }); - scheduleAutoRun(); - }, [scheduleAutoRun, setNodes]); + setNodes(nextNodes); + setTimeout(() => { + touchedGroupIds.forEach((groupId) => { + if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()); + }); + }, 0); + }, [reactFlow, refreshGroupNode, scheduleAutoRun, setNodes]); // ── Keyboard shortcut ─────────────────────────────────────────────── @@ -1531,6 +2231,8 @@ function Flow() { return () => window.removeEventListener('pointerdown', handlePointerDown, true); }, [contextMenu]); + const selectedNodeCount = nodes.filter((node) => node.selected).length; + // ── Render ────────────────────────────────────────────────────────── return ( @@ -1569,7 +2271,7 @@ function Flow() { setContextMenu(null)} filterType={contextMenu.filterType} filterDirection={contextMenu.filterDirection} + selectedNodeCount={selectedNodeCount} /> )}
diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index d4fb0a0..7ccccc6 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -24,6 +24,103 @@ function formatUiLabel(text) { .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 ( +
+
+ + {formatUiLabel(data.label || 'group')} +
+ +
+
+ +
+ {collapsed ? ( + <> + {Array.from({ length: maxRows }, (_, index) => { + const input = proxyInputs[index]; + const output = proxyOutputs[index]; + return ( +
+
+ {input && ( + <> + + {formatUiLabel(input.label || input.name)} + + )} +
+
+ {output && ( + <> + {formatUiLabel(output.label || output.name)} + + + )} +
+
+ ); + })} +
{childCount} nodes
+ + ) : ( +
+
workflow group
+
{childCount} nodes
+
+ )} +
+
+ ); +} + class PreviewBoundary extends React.Component { constructor(props) { super(props); @@ -390,6 +487,8 @@ function getSourceTypeForInput(store, nodeId, inputName) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); if (!edge?.sourceHandle) return null; + const proxy = parseProxyHandle(edge.sourceHandle); + if (proxy) return proxy.type || null; const parts = edge.sourceHandle.split('::'); return parts[2] || null; } @@ -405,8 +504,11 @@ function getConnectedOutputInfo(store, nodeId, inputName) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); if (!edge?.sourceHandle) return null; - const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null; - const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10); + const proxy = parseProxyHandle(edge.sourceHandle); + 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; return { path: sourceNode.data?.definition?.output_paths?.[slot] || null, @@ -751,6 +853,9 @@ function NodeTable({ rows }) { function CustomNode({ id, data }) { const ctx = useContext(NodeContext); + if (data.className === 'Group') { + return ; + } const def = data.definition; const scalarDisplay = formatScalarDisplay(data.scalarValue); const processingTimeText = formatProcessingTime(data.processingTimeMs); diff --git a/frontend/src/executionGraph.js b/frontend/src/executionGraph.js index 1bc0cf7..759a3f5 100644 --- a/frontend/src/executionGraph.js +++ b/frontend/src/executionGraph.js @@ -1,4 +1,4 @@ -import { DATA_TYPES } from './constants'; +import { DATA_TYPES } from './constants.js'; function getInputName(handleId) { return handleId.split('::')[1]; @@ -8,11 +8,24 @@ function getOutputSlot(handleId) { 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) { const connectedNodeIds = new Set(); for (const edge of edges) { - connectedNodeIds.add(edge.source); - connectedNodeIds.add(edge.target); + const resolved = resolveExecutionEdge(edge); + connectedNodeIds.add(resolved.source); + connectedNodeIds.add(resolved.target); } return connectedNodeIds; } @@ -53,6 +66,7 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f if (!runnableNodeIds.has(node.id)) continue; const { className, definition, widgetValues, runtimeValues } = node.data; + if (className === 'Group') continue; if (!definition) 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) { const inputName = getInputName(edge.targetHandle); const outputSlot = getOutputSlot(edge.sourceHandle); @@ -97,12 +113,15 @@ export function hasBlockingAutoRunInput(node, edges) { const required = def.input.required || {}; for (const [name, spec] of Object.entries(required)) { const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; - const hiddenByConnectedInput = (() => { - const raw = opts?.hide_when_input_connected; - if (!raw) return false; - const inputs = Array.isArray(raw) ? raw : [raw]; - return inputs.some((inputName) => edges.some( - (edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName) + const hiddenByConnectedInput = (() => { + const raw = opts?.hide_when_input_connected; + if (!raw) return false; + const inputs = Array.isArray(raw) ? raw : [raw]; + return inputs.some((inputName) => edges.some( + (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; 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; } diff --git a/frontend/src/nodeClipboard.js b/frontend/src/nodeClipboard.js index b00506f..1726fb1 100644 --- a/frontend/src/nodeClipboard.js +++ b/frontend/src/nodeClipboard.js @@ -18,13 +18,52 @@ function clonePlainObject(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( nodes, edges, nodeIds, { includeIncomingExternalEdges = false } = {}, ) { - const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id))); + const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds); const selectedNodes = Array.isArray(nodes) ? nodes.filter((node) => selectedIdSet.has(String(node.id))) : []; @@ -50,12 +89,18 @@ export function buildNodeClipboardPayloadForIds( x: Number(node.position?.x) || 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', data: { label: node.data?.label || node.data?.className || 'Node', className: node.data?.className || '', widgetValues: clonePlainObject(node.data?.widgetValues), runtimeValues: clonePlainObject(node.data?.runtimeValues), + extraData: clonePlainObject(extractExtraData(node.data)), }, })), edges: capturedEdges.map((edge) => ({ @@ -64,15 +109,19 @@ export function buildNodeClipboardPayloadForIds( target: String(edge.target), targetHandle: edge.targetHandle, ...(edge.style ? { style: { ...edge.style } } : {}), + ...(edge.hidden ? { hidden: true } : {}), + ...(edge.data ? { data: cloneValue(edge.data) } : {}), })), }; } export function buildNodeClipboardPayload(nodes, edges) { - const selectedIds = Array.isArray(nodes) - ? nodes.filter((node) => node?.selected).map((node) => String(node.id)) + const selectedNodes = Array.isArray(nodes) + ? 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) { @@ -111,10 +160,15 @@ export function instantiateNodeClipboardPayload( return { id: newId, type: node.type || 'custom', + className: node.className, position: { x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 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', selected: true, data: { @@ -122,6 +176,7 @@ export function instantiateNodeClipboardPayload( className, widgetValues: clonePlainObject(node.data?.widgetValues), runtimeValues: clonePlainObject(node.data?.runtimeValues), + ...(clonePlainObject(node.data?.extraData)), definition, previewImage: null, tableRows: null, @@ -147,6 +202,8 @@ export function instantiateNodeClipboardPayload( targetHandle: edge.targetHandle, selected: false, ...(edge.style ? { style: { ...edge.style } } : {}), + ...(edge.hidden ? { hidden: true } : {}), + ...(edge.data ? { data: cloneValue(edge.data) } : {}), })); return { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a76a579..22ffc63 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -236,8 +236,104 @@ html, body, #root { 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 */ -.react-flow__node-custom { +.react-flow__node-custom:not(.group-shell) { width: auto !important; height: auto !important; } diff --git a/frontend/src/workflowCapture.js b/frontend/src/workflowCapture.js index b75a17f..7b59aa7 100644 --- a/frontend/src/workflowCapture.js +++ b/frontend/src/workflowCapture.js @@ -1,5 +1,5 @@ import { toBlob } from 'html-to-image'; -import { CANVAS_COLORS } from './constants'; +import { CANVAS_COLORS } from './constants.js'; export const OVERLAY_CAPTURE_SELECTORS = [ '.lineplot-overlay', diff --git a/frontend/src/workflowHydration.js b/frontend/src/workflowHydration.js index 3e97032..bdc1e55 100644 --- a/frontend/src/workflowHydration.js +++ b/frontend/src/workflowHydration.js @@ -40,18 +40,26 @@ export function hydrateWorkflowState(data, defs = {}) { return { ...node, type: node.type || 'custom', + className: node.className, + parentId: node.parentId, + extent: node.extent, + hidden: !!node.hidden, + style: node.style, dragHandle: node.dragHandle || '.drag-handle', data: { ...node.data, label: node.data?.label || node.data?.className || 'Node', widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition), - runtimeValues: {}, + runtimeValues: node.data?.runtimeValues || {}, + ...(node.data?.extraData || {}), definition, previewImage: null, tableRows: null, meshData: null, overlay: null, scalarValue: null, + processingTimeMs: null, + warning: null, }, }; }); diff --git a/frontend/src/workflowSerialization.js b/frontend/src/workflowSerialization.js index cdac583..170914a 100644 --- a/frontend/src/workflowSerialization.js +++ b/frontend/src/workflowSerialization.js @@ -1,15 +1,44 @@ 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 { version: 1, nodes: nodes.map((node) => ({ id: node.id, type: node.type || 'custom', 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', data: { label: node.data?.label || node.data?.className || 'Node', className: node.data?.className || '', 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_name: node.data?.definition?.output_name || [], }, @@ -21,6 +50,8 @@ export function serializeWorkflowState(nodes, edges) { target: edge.target, targetHandle: edge.targetHandle, ...(edge.style ? { style: edge.style } : {}), + ...(edge.hidden ? { hidden: true } : {}), + ...(edge.data ? { data: edge.data } : {}), })), }; } diff --git a/frontend/tests/executionGraph.test.mjs b/frontend/tests/executionGraph.test.mjs index 1c4c763..c6f1354 100644 --- a/frontend/tests/executionGraph.test.mjs +++ b/frontend/tests/executionGraph.test.mjs @@ -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', () => { const nodes = [ { id: '1', data: { definition: {}, widgetValues: {} } }, diff --git a/frontend/tests/nodeClipboard.test.mjs b/frontend/tests/nodeClipboard.test.mjs index 682ba4f..09fef6a 100644 --- a/frontend/tests/nodeClipboard.test.mjs +++ b/frontend/tests/nodeClipboard.test.mjs @@ -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.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'); +}); diff --git a/frontend/tests/workflowSerialization.test.mjs b/frontend/tests/workflowSerialization.test.mjs index 0bf80e4..f9ad553 100644 --- a/frontend/tests/workflowSerialization.test.mjs +++ b/frontend/tests/workflowSerialization.test.mjs @@ -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_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'); +});