From f6b47e6d79db073b42373c745c2592fef817a90a Mon Sep 17 00:00:00 2001 From: matei jordache Date: Thu, 2 Apr 2026 22:58:10 -0700 Subject: [PATCH] refactor app.tx --- frontend/src/App.tsx | 823 ++--------------------------------- frontend/src/ContextMenu.tsx | 300 +++++++++++++ frontend/src/canvasEvents.ts | 90 ++++ frontend/src/nodeGeometry.ts | 295 +++++++++++++ 4 files changed, 717 insertions(+), 791 deletions(-) create mode 100644 frontend/src/ContextMenu.tsx create mode 100644 frontend/src/canvasEvents.ts create mode 100644 frontend/src/nodeGeometry.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5170003..c981fb3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,9 +10,9 @@ import '@xyflow/react/dist/style.css'; import CustomNode, { NodeContext } from './CustomNode'; import HelpPanelManager from './HelpPanelManager'; +import ContextMenu from './ContextMenu'; import * as api from './api'; import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker'; -import { toBlob } from 'html-to-image'; import { embedWorkflow, extractWorkflow } from './pngMetadata'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import tonoIconUrl from '../../resources/icon_1024.png'; @@ -62,6 +62,37 @@ import { CANVAS_COLORS, } from './constants'; +import { + GROUP_PADDING_X, + GROUP_PADDING_Y, + GROUP_HEADER_HEIGHT, + GROUP_MIN_WIDTH, + GROUP_MIN_HEIGHT, + getNodeDimension, + applyNodeSize, + getNodeAbsolutePosition, + collectGroupDescendantIds, + getGroupMembers, + getGroupDisplayBounds, + getGroupWorkspaceBounds, + getNodeCenter, + getAbsoluteRectForNodePosition, + rectContainsPoint, + rectContainsRect, + findExpandedGroupDropTarget, + getRenderedNodeBounds, + buildGroupProxyData, + sameStringArray, +} from './nodeGeometry'; + +import { + getEventFlowPosition, + getDragIntent, + isEditableTarget, + clampNumber, + canStartCanvasRightDragZoom, +} from './canvasEvents'; + import type { NodeData, NodeDefinition, @@ -89,801 +120,11 @@ declare global { 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; const CANVAS_MIN_ZOOM = 0.2; const CANVAS_MAX_ZOOM = 4; const CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY = 0.0065; const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5; -function getNodeDimension(node: any, axis: string): number { - if (axis === 'width') return node.measured?.width || node.style?.width || node.width || 200; - return node.measured?.height || node.style?.height || node.height || 120; -} - -function applyNodeSize(node: any, width: any, height: any) { - const nextWidth = Math.round(Number(width) || 0); - const nextHeight = Math.round(Number(height) || 0); - return { - ...node, - width: nextWidth, - height: nextHeight, - style: { ...(node.style || {}), width: nextWidth, height: nextHeight }, - }; -} - -function getNodeAbsolutePosition(node: any, nodeMap: Map): { x: number; y: number } { - 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: any[], groupId: any) { - 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: any[], groupId: any) { - const descendants = collectGroupDescendantIds(nodes, groupId); - return Array.from(descendants); -} - -function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) { - const nodeMap = new Map((nodes || []).map((node: any) => [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: any, nodeMap: Map) { - 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: any, nodeMap: Map) { - 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 getNodeRect(node: any, nodeMap: Map) { - const pos = getNodeAbsolutePosition(node, nodeMap); - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; - return { - left: pos.x, - top: pos.y, - right: pos.x + width, - bottom: pos.y + height, - }; -} - -function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) { - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; - return { - left: absolutePosition.x, - top: absolutePosition.y, - right: absolutePosition.x + width, - bottom: absolutePosition.y + height, - }; -} - -function rectContainsPoint(rect: { left: number; right: number; top: number; bottom: number }, point: { x: number; y: number }) { - return point.x >= rect.left - && point.x <= rect.right - && point.y >= rect.top - && point.y <= rect.bottom; -} - -function rectContainsRect(outerRect: { left: number; right: number; top: number; bottom: number }, innerRect: { left: number; right: number; top: number; bottom: number }) { - return innerRect.left >= outerRect.left - && innerRect.top >= outerRect.top - && innerRect.right <= outerRect.right - && innerRect.bottom <= outerRect.bottom; -} - -function getEventClientPosition(event: any) { - if (!event) return null; - const point = 'changedTouches' in event && event.changedTouches?.[0] - ? event.changedTouches[0] - : ('touches' in event && event.touches?.[0] ? event.touches[0] : event); - if (!Number.isFinite(point?.clientX) || !Number.isFinite(point?.clientY)) return null; - return { x: point.clientX, y: point.clientY }; -} - -function getEventFlowPosition(event: any, reactFlow: any) { - const clientPosition = getEventClientPosition(event); - if (!clientPosition || typeof reactFlow?.screenToFlowPosition !== 'function') return null; - return reactFlow.screenToFlowPosition(clientPosition); -} - -function getDragIntent(event: any, reactFlow: any, dragState: any) { - if (!dragState?.pointerOffset || !dragState?.anchorStartAbsolute) return null; - const pointerFlowPos = getEventFlowPosition(event, reactFlow); - if (!pointerFlowPos) return null; - - const anchorAbsolute = { - x: pointerFlowPos.x - dragState.pointerOffset.x, - y: pointerFlowPos.y - dragState.pointerOffset.y, - }; - const delta = { - x: anchorAbsolute.x - (Number(dragState.anchorStartAbsolute.x) || 0), - y: anchorAbsolute.y - (Number(dragState.anchorStartAbsolute.y) || 0), - }; - const absolutePositions = new Map( - Object.entries(dragState.absolutePositions || {}).map(([id, pos]: [string, any]) => [ - id, - { - x: (Number(pos?.x) || 0) + delta.x, - y: (Number(pos?.y) || 0) + delta.y, - }, - ]), - ); - - return { - pointerFlowPos, - anchorAbsolute, - absolutePositions, - }; -} - -function findExpandedGroupDropTarget(nodes: any[], draggedNodeIds: any[], anchorNodeId: any, anchorPoint: { x: number; y: number } | null = null) { - const nodeMap = new Map((nodes || []).map((node: any) => [String(node.id), node])); - const anchorNode = nodeMap.get(String(anchorNodeId)); - if (!anchorNode) return null; - - const draggedIdSet = new Set((draggedNodeIds || []).map((id: any) => String(id))); - const anchorCenter = anchorPoint && Number.isFinite(anchorPoint.x) && Number.isFinite(anchorPoint.y) - ? anchorPoint - : getNodeCenter(anchorNode, nodeMap); - - return (nodes || []) - .filter((node: any) => ( - node?.data?.className === 'Group' - && !node?.data?.collapsed - && !draggedIdSet.has(String(node.id)) - )) - .map((node: any) => { - 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 }: { rect: any }) => rectContainsPoint(rect, anchorCenter)) - .sort((a: any, b: any) => a.area - b.area)[0]?.node || null; -} - -function getInputLabelForNode(node: any, inputName: string) { - 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: any, slot: number, handleId: string): string { - 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: string, nodes: any[], edges: any[]) { - const nodeMap = new Map((nodes || []).map((node: any) => [String(node.id), node])); - const memberIds = new Set(getGroupMembers(nodes, groupId)); - const proxyInputs: { key: string; type: string; label: string; handleId: string }[] = []; - const proxyOutputs: { key: string; type: string; label: string; handleId: string }[] = []; - const seenInputs = new Set(); - const seenOutputs = new Set(); - - for (const edge of edges || []) { - const original = (edge?.data?.groupProxyOriginal || {}) as Record; - 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: any[] = [], b: any[] = []) { - if (a === b) return true; - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - return a.every((item, index) => item === b[index]); -} - -function isEditableTarget(target: any) { - if (!target || !(target instanceof Element)) return false; - if (target.closest('input, textarea, select')) return true; - return target.closest('[contenteditable="true"]') !== null; -} - -function clampNumber(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -function canStartCanvasRightDragZoom(target: any) { - if (!target || !(target instanceof Element)) return false; - if (isEditableTarget(target)) return false; - if (target.closest('.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container')) { - return false; - } - return target.closest('.react-flow__pane, .react-flow__background') !== null; -} - -function compareMenuNodes(a: any, b: any) { - const orderA = Number.isFinite(a?.menu_order) - ? a.menu_order - : Number.isFinite(a?.def?.menu_order) - ? a.def.menu_order - : Number.MAX_SAFE_INTEGER; - const orderB = Number.isFinite(b?.menu_order) - ? b.menu_order - : Number.isFinite(b?.def?.menu_order) - ? b.def.menu_order - : Number.MAX_SAFE_INTEGER; - if (orderA !== orderB) return orderA - orderB; - - const nameA = (a?.def?.display_name || a?.className || '').toLowerCase(); - const nameB = (b?.def?.display_name || b?.className || '').toLowerCase(); - return nameA.localeCompare(nameB); -} - -function compareMenuCategories(a: any, b: any) { - const orderA = Number.isFinite(a?.order) ? a.order : Number.MAX_SAFE_INTEGER; - const orderB = Number.isFinite(b?.order) ? b.order : Number.MAX_SAFE_INTEGER; - if (orderA !== orderB) return orderA - orderB; - return String(a?.name || '').localeCompare(String(b?.name || '')); -} - -function getRenderedNodeBounds(nodes: any[]) { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - let found = false; - - for (const node of nodes) { - const selectorId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' - ? CSS.escape(String(node.id)) - : String(node.id); - const el = document.querySelector(`.react-flow__node[data-id="${selectorId}"]`) as HTMLElement | null; - const width = el?.offsetWidth || node.measured?.width || node.width || 0; - const height = el?.offsetHeight || node.measured?.height || node.height || 0; - const x = node.positionAbsolute?.x ?? node.position?.x ?? 0; - const y = node.positionAbsolute?.y ?? node.position?.y ?? 0; - - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - continue; - } - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + width); - maxY = Math.max(maxY, y + height); - found = true; - } - - if (!found) { - return null; - } - - return { - x: minX, - y: minY, - width: Math.max(1, maxX - minX), - height: Math.max(1, maxY - minY), - }; -} - -async function waitForImageElement(img: HTMLImageElement) { - if (img.complete && img.naturalWidth > 0) return; - if (typeof img.decode === 'function') { - try { - await img.decode(); - return; - } catch { - // Fall back to load/error listeners below. - } - } - await new Promise((resolve) => { - const done = () => { - img.removeEventListener('load', done); - img.removeEventListener('error', done); - resolve(); - }; - img.addEventListener('load', done, { once: true }); - img.addEventListener('error', done, { once: true }); - }); -} - -async function getCaptureImageDataUrl(img: HTMLImageElement) { - const src = img.currentSrc || img.src; - if (!src) return null; - if (!src.startsWith('data:')) return src; - - const rect = img.getBoundingClientRect(); - const width = Math.max(1, Math.round(img.clientWidth || rect.width)); - const height = Math.max(1, Math.round(img.clientHeight || rect.height)); - const scale = Math.min(2, window.devicePixelRatio || 1); - - const canvas = document.createElement('canvas'); - canvas.width = Math.max(1, Math.round(width * scale)); - canvas.height = Math.max(1, Math.round(height * scale)); - - const ctx = canvas.getContext('2d'); - if (!ctx) return src; - - try { - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - return canvas.toDataURL('image/png'); - } catch { - return src; - } -} - -function createCapturePlaceholder(el: HTMLElement, dataUrl: string) { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - const placeholder = document.createElement('div'); - - placeholder.style.display = style.display === 'inline' ? 'inline-block' : style.display; - placeholder.style.width = `${el.clientWidth || rect.width}px`; - placeholder.style.height = `${el.clientHeight || rect.height}px`; - placeholder.style.maxWidth = style.maxWidth; - placeholder.style.maxHeight = style.maxHeight; - placeholder.style.minWidth = style.minWidth; - placeholder.style.minHeight = style.minHeight; - placeholder.style.borderRadius = style.borderRadius; - placeholder.style.backgroundImage = `url("${dataUrl}")`; - placeholder.style.backgroundRepeat = 'no-repeat'; - placeholder.style.backgroundPosition = 'center'; - placeholder.style.backgroundSize = el.tagName === 'CANVAS' ? '100% 100%' : 'contain'; - placeholder.style.flexShrink = style.flexShrink; - - return placeholder; -} - -async function captureViewportBlob(viewportEl: HTMLElement, options: any) { - const restorers: (() => void)[] = []; - const images = Array.from(viewportEl.querySelectorAll('img')) as HTMLImageElement[]; - await Promise.all(images.map(waitForImageElement)); - - for (const img of images) { - if (!img.parentNode) continue; - const dataUrl = await getCaptureImageDataUrl(img); - if (!dataUrl) continue; - const placeholder = createCapturePlaceholder(img, dataUrl); - img.parentNode.replaceChild(placeholder, img); - restorers.push(() => { - if (placeholder.parentNode) { - placeholder.parentNode.replaceChild(img, placeholder); - } - }); - } - - const canvases = Array.from(viewportEl.querySelectorAll('canvas')) as HTMLCanvasElement[]; - for (const canvas of canvases) { - if (!canvas.parentNode) continue; - let dataUrl = 'data:,'; - try { - dataUrl = canvas.toDataURL('image/png'); - } catch { - dataUrl = 'data:,'; - } - if (dataUrl === 'data:,') continue; - - const placeholder = createCapturePlaceholder(canvas, dataUrl); - canvas.parentNode.replaceChild(placeholder, canvas); - restorers.push(() => { - if (placeholder.parentNode) { - placeholder.parentNode.replaceChild(canvas, placeholder); - } - }); - } - - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - - try { - return await toBlob(viewportEl, options); - } finally { - restorers.reverse().forEach((restore) => restore()); - } -} - -// ── Context menu component ──────────────────────────────────────────── - -function ContextMenu({ - x, - y, - nodeDefs, - onAdd, - onClose, - filterType, - filterSpec = null, - filterDirection, - selectedNodeCount = 0, - onCreateGroup = null, -}: { - x: number; - y: number; - nodeDefs: Record; - onAdd: (className: string, def: any) => void; - onClose: () => void; - filterType?: string | null; - filterSpec?: any; - filterDirection?: string | null; - selectedNodeCount?: number; - onCreateGroup?: (() => void) | null; -}) { - const [openCat, setOpenCat] = useState(null); - const [search, setSearch] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(0); - const menuRef = useRef(null); - const [menuPos, setMenuPos] = useState({ x, y }); - const subMenuRef = useRef(null); - const [subPos, setSubPos] = useState({ x: 0, y: 0 }); - const catRowRefs = useRef>({}); - const selectedItemRef = useRef(null); - - // Group by category, optionally filtering to compatible nodes - const categories = useMemo(() => { - const cats: Record = {}; - for (const [className, def] of Object.entries(nodeDefs) as [string, any][]) { - if (filterType && filterDirection) { - if (filterDirection === 'source') { - const req = def.input.required || {}; - const opt = def.input.optional || {}; - const allInputs = { ...req, ...opt }; - const hasMatch = Object.values(allInputs).some((spec: any) => { - return socketSpecAcceptsType(filterType, spec); - }); - if (!hasMatch) continue; - } else { - const hasMatch = def.output.some((type: string, idx: number) => - outputTypeCanConnectToTarget(type, filterSpec || filterType, def.output_accepted_types?.[idx] || []) - ); - if (!hasMatch) continue; - } - } - const menuCategories = Array.isArray(def.menu_categories) && def.menu_categories.length > 0 - ? def.menu_categories - : [{ - category: def.category || 'uncategorized', - category_order: def.category_order, - menu_order: def.menu_order, - }]; - - for (const menuCategory of menuCategories) { - const cat = menuCategory?.category || def.category || 'uncategorized'; - if (!cats[cat]) { - cats[cat] = { - name: cat, - order: Number.isFinite(menuCategory?.category_order) - ? menuCategory.category_order - : Number.MAX_SAFE_INTEGER, - items: [], - }; - } - cats[cat].order = Math.min( - cats[cat].order, - Number.isFinite(menuCategory?.category_order) - ? menuCategory.category_order - : Number.MAX_SAFE_INTEGER, - ); - cats[cat].items.push({ - className, - def, - menu_order: Number.isFinite(menuCategory?.menu_order) ? menuCategory.menu_order : def.menu_order, - }); - } - } - return Object.values(cats) - .map((category: any) => ({ - ...category, - items: [...category.items].sort(compareMenuNodes), - })) - .sort(compareMenuCategories); - }, [nodeDefs, filterDirection, filterSpec, filterType]); - - // Flat filtered list for search - const searchResults = useMemo(() => { - if (!search.trim()) return null; - const q = search.toLowerCase(); - const results: { className: string; def: any }[] = []; - const seen = new Set(); - for (const category of categories) { - for (const { className, def } of category.items) { - if (seen.has(className)) continue; - const name = (def.display_name || className).toLowerCase(); - if (name.includes(q)) { - results.push({ className, def }); - seen.add(className); - } - } - } - return results; - }, [search, categories]); - - // Reset selection to top whenever results change - useEffect(() => { - setSelectedIndex(0); - }, [searchResults]); - - // Scroll selected item into view - useEffect(() => { - selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); - }, [selectedIndex]); - - const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { - if (!searchResults || searchResults.length === 0) return; - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === 'Enter') { - e.preventDefault(); - const item = searchResults[selectedIndex]; - if (item) { onAdd(item.className, item.def); onClose(); } - } - }, [searchResults, selectedIndex, onAdd, onClose]); - - // Clamp main menu position to viewport on mount - useEffect(() => { - const el = menuRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - let nx = x, ny = y; - if (x + rect.width > vw) nx = vw - rect.width - 8; - if (y + rect.height > vh) ny = vh - rect.height - 8; - if (nx < 4) nx = 4; - if (ny < 4) ny = 4; - setMenuPos({ x: nx, y: ny }); - }, [x, y]); - - // Position submenu next to the hovered category row, clamped to viewport - useEffect(() => { - if (!openCat) return; - const rowEl = catRowRefs.current[openCat]; - const subEl = subMenuRef.current; - if (!rowEl || !subEl) return; - - const rowRect = rowEl.getBoundingClientRect(); - const menuRect = menuRef.current!.getBoundingClientRect(); - const subRect = subEl.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - // Horizontal: prefer right side, fall back to left - let sx = menuRect.right - 1; - if (sx + subRect.width > vw - 8) { - sx = menuRect.left - subRect.width + 1; - } - if (sx < 4) sx = 4; - - // Vertical: align top with hovered row, clamp to viewport - let sy = rowRect.top; - if (sy + subRect.height > vh - 8) { - sy = vh - subRect.height - 8; - } - if (sy < 4) sy = 4; - - setSubPos({ x: sx, y: sy }); - }, [openCat]); - - const handleCatEnter = useCallback((cat: string) => { - setOpenCat(cat); - }, []); - - if (categories.length === 0) { - return ( -
e.stopPropagation()}> -
No compatible nodes
-
- ); - } - - const catNames = categories.map((category) => category.name); - const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items])); - - return ( - <> -
e.stopPropagation()} - onMouseLeave={(e) => { - // Close submenu only if mouse didn't move into the submenu - const related = e.relatedTarget; - if (subMenuRef.current && subMenuRef.current.contains(related as globalThis.Node | null)) return; - setOpenCat(null); - }} - > -
Add Node
-
- { setSearch(e.target.value); setOpenCat(null); }} - onKeyDown={handleSearchKeyDown} - autoFocus - /> -
- - {!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && ( -
{ onCreateGroup(); onClose(); }} - > - create group -
- )} - - {searchResults ? ( -
- {searchResults.length === 0 ? ( -
No matches
- ) : ( - searchResults.map(({ className, def }, idx) => ( -
{ onAdd(className, def); onClose(); }} - onMouseEnter={() => setSelectedIndex(idx)} - > - {def.display_name || className} -
- )) - )} -
- ) : ( -
- {catNames.map((cat) => ( -
{ catRowRefs.current[cat] = el; }} - className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`} - onMouseEnter={() => handleCatEnter(cat)} - > - {cat} - -
- ))} -
- )} -
- - {/* Submenu rendered as a sibling, positioned at computed screen coords */} - {openCat && categoryMap[openCat] && ( -
e.stopPropagation()} - onMouseLeave={(e) => { - const related = e.relatedTarget; - if (menuRef.current && menuRef.current.contains(related as globalThis.Node | null)) return; - setOpenCat(null); - }} - > - {categoryMap[openCat].map(({ className, def }: { className: string; def: any }) => ( -
{ onAdd(className, def); onClose(); }} - > - {def.display_name || className} -
- ))} -
- )} - - ); -} - const DEBUG = false; // set to true for verbose logging // ── Main flow component (needs ReactFlowProvider ancestor) ──────────── diff --git a/frontend/src/ContextMenu.tsx b/frontend/src/ContextMenu.tsx new file mode 100644 index 0000000..88fcfe6 --- /dev/null +++ b/frontend/src/ContextMenu.tsx @@ -0,0 +1,300 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { socketSpecAcceptsType } from './constants'; +import { outputTypeCanConnectToTarget } from './connectionUtils'; +import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; + +export default function ContextMenu({ + x, + y, + nodeDefs, + onAdd, + onClose, + filterType, + filterSpec = null, + filterDirection, + selectedNodeCount = 0, + onCreateGroup = null, +}: { + x: number; + y: number; + nodeDefs: Record; + onAdd: (className: string, def: any) => void; + onClose: () => void; + filterType?: string | null; + filterSpec?: any; + filterDirection?: string | null; + selectedNodeCount?: number; + onCreateGroup?: (() => void) | null; +}) { + const [openCat, setOpenCat] = useState(null); + const [search, setSearch] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const menuRef = useRef(null); + const [menuPos, setMenuPos] = useState({ x, y }); + const subMenuRef = useRef(null); + const [subPos, setSubPos] = useState({ x: 0, y: 0 }); + const catRowRefs = useRef>({}); + const selectedItemRef = useRef(null); + + // Group by category, optionally filtering to compatible nodes + const categories = useMemo(() => { + const cats: Record = {}; + for (const [className, def] of Object.entries(nodeDefs) as [string, any][]) { + if (filterType && filterDirection) { + if (filterDirection === 'source') { + const req = def.input.required || {}; + const opt = def.input.optional || {}; + const allInputs = { ...req, ...opt }; + const hasMatch = Object.values(allInputs).some((spec: any) => { + return socketSpecAcceptsType(filterType, spec); + }); + if (!hasMatch) continue; + } else { + const hasMatch = def.output.some((type: string, idx: number) => + outputTypeCanConnectToTarget(type, filterSpec || filterType, def.output_accepted_types?.[idx] || []) + ); + if (!hasMatch) continue; + } + } + const menuCategories = Array.isArray(def.menu_categories) && def.menu_categories.length > 0 + ? def.menu_categories + : [{ + category: def.category || 'uncategorized', + category_order: def.category_order, + menu_order: def.menu_order, + }]; + + for (const menuCategory of menuCategories) { + const cat = menuCategory?.category || def.category || 'uncategorized'; + if (!cats[cat]) { + cats[cat] = { + name: cat, + order: Number.isFinite(menuCategory?.category_order) + ? menuCategory.category_order + : Number.MAX_SAFE_INTEGER, + items: [], + }; + } + cats[cat].order = Math.min( + cats[cat].order, + Number.isFinite(menuCategory?.category_order) + ? menuCategory.category_order + : Number.MAX_SAFE_INTEGER, + ); + cats[cat].items.push({ + className, + def, + menu_order: Number.isFinite(menuCategory?.menu_order) ? menuCategory.menu_order : def.menu_order, + }); + } + } + return Object.values(cats) + .map((category: any) => ({ + ...category, + items: [...category.items].sort(compareMenuNodes), + })) + .sort(compareMenuCategories); + }, [nodeDefs, filterDirection, filterSpec, filterType]); + + // Flat filtered list for search + const searchResults = useMemo(() => { + if (!search.trim()) return null; + const q = search.toLowerCase(); + const results: { className: string; def: any }[] = []; + const seen = new Set(); + for (const category of categories) { + for (const { className, def } of category.items) { + if (seen.has(className)) continue; + const name = (def.display_name || className).toLowerCase(); + if (name.includes(q)) { + results.push({ className, def }); + seen.add(className); + } + } + } + return results; + }, [search, categories]); + + // Reset selection to top whenever results change + useEffect(() => { + setSelectedIndex(0); + }, [searchResults]); + + // Scroll selected item into view + useEffect(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!searchResults || searchResults.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = searchResults[selectedIndex]; + if (item) { onAdd(item.className, item.def); onClose(); } + } + }, [searchResults, selectedIndex, onAdd, onClose]); + + // Clamp main menu position to viewport on mount + useEffect(() => { + const el = menuRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + let nx = x, ny = y; + if (x + rect.width > vw) nx = vw - rect.width - 8; + if (y + rect.height > vh) ny = vh - rect.height - 8; + if (nx < 4) nx = 4; + if (ny < 4) ny = 4; + setMenuPos({ x: nx, y: ny }); + }, [x, y]); + + // Position submenu next to the hovered category row, clamped to viewport + useEffect(() => { + if (!openCat) return; + const rowEl = catRowRefs.current[openCat]; + const subEl = subMenuRef.current; + if (!rowEl || !subEl) return; + + const rowRect = rowEl.getBoundingClientRect(); + const menuRect = menuRef.current!.getBoundingClientRect(); + const subRect = subEl.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Horizontal: prefer right side, fall back to left + let sx = menuRect.right - 1; + if (sx + subRect.width > vw - 8) { + sx = menuRect.left - subRect.width + 1; + } + if (sx < 4) sx = 4; + + // Vertical: align top with hovered row, clamp to viewport + let sy = rowRect.top; + if (sy + subRect.height > vh - 8) { + sy = vh - subRect.height - 8; + } + if (sy < 4) sy = 4; + + setSubPos({ x: sx, y: sy }); + }, [openCat]); + + const handleCatEnter = useCallback((cat: string) => { + setOpenCat(cat); + }, []); + + if (categories.length === 0) { + return ( +
e.stopPropagation()}> +
No compatible nodes
+
+ ); + } + + const catNames = categories.map((category) => category.name); + const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items])); + + return ( + <> +
e.stopPropagation()} + onMouseLeave={(e) => { + // Close submenu only if mouse didn't move into the submenu + const related = e.relatedTarget; + if (subMenuRef.current && subMenuRef.current.contains(related as globalThis.Node | null)) return; + setOpenCat(null); + }} + > +
Add Node
+
+ { setSearch(e.target.value); setOpenCat(null); }} + onKeyDown={handleSearchKeyDown} + autoFocus + /> +
+ + {!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && ( +
{ onCreateGroup(); onClose(); }} + > + create group +
+ )} + + {searchResults ? ( +
+ {searchResults.length === 0 ? ( +
No matches
+ ) : ( + searchResults.map(({ className, def }, idx) => ( +
{ onAdd(className, def); onClose(); }} + onMouseEnter={() => setSelectedIndex(idx)} + > + {def.display_name || className} +
+ )) + )} +
+ ) : ( +
+ {catNames.map((cat) => ( +
{ catRowRefs.current[cat] = el; }} + className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`} + onMouseEnter={() => handleCatEnter(cat)} + > + {cat} + +
+ ))} +
+ )} +
+ + {/* Submenu rendered as a sibling, positioned at computed screen coords */} + {openCat && categoryMap[openCat] && ( +
e.stopPropagation()} + onMouseLeave={(e) => { + const related = e.relatedTarget; + if (menuRef.current && menuRef.current.contains(related as globalThis.Node | null)) return; + setOpenCat(null); + }} + > + {categoryMap[openCat].map(({ className, def }: { className: string; def: any }) => ( +
{ onAdd(className, def); onClose(); }} + > + {def.display_name || className} +
+ ))} +
+ )} + + ); +} diff --git a/frontend/src/canvasEvents.ts b/frontend/src/canvasEvents.ts new file mode 100644 index 0000000..70669db --- /dev/null +++ b/frontend/src/canvasEvents.ts @@ -0,0 +1,90 @@ +import { getNodeCenter, getGroupWorkspaceBounds, rectContainsPoint } from './nodeGeometry'; + +export function getEventClientPosition(event: any) { + if (!event) return null; + const point = 'changedTouches' in event && event.changedTouches?.[0] + ? event.changedTouches[0] + : ('touches' in event && event.touches?.[0] ? event.touches[0] : event); + if (!Number.isFinite(point?.clientX) || !Number.isFinite(point?.clientY)) return null; + return { x: point.clientX, y: point.clientY }; +} + +export function getEventFlowPosition(event: any, reactFlow: any) { + const clientPosition = getEventClientPosition(event); + if (!clientPosition || typeof reactFlow?.screenToFlowPosition !== 'function') return null; + return reactFlow.screenToFlowPosition(clientPosition); +} + +export function getDragIntent(event: any, reactFlow: any, dragState: any) { + if (!dragState?.pointerOffset || !dragState?.anchorStartAbsolute) return null; + const pointerFlowPos = getEventFlowPosition(event, reactFlow); + if (!pointerFlowPos) return null; + + const anchorAbsolute = { + x: pointerFlowPos.x - dragState.pointerOffset.x, + y: pointerFlowPos.y - dragState.pointerOffset.y, + }; + const delta = { + x: anchorAbsolute.x - (Number(dragState.anchorStartAbsolute.x) || 0), + y: anchorAbsolute.y - (Number(dragState.anchorStartAbsolute.y) || 0), + }; + const absolutePositions = new Map( + Object.entries(dragState.absolutePositions || {}).map(([id, pos]: [string, any]) => [ + id, + { + x: (Number(pos?.x) || 0) + delta.x, + y: (Number(pos?.y) || 0) + delta.y, + }, + ]), + ); + + return { + pointerFlowPos, + anchorAbsolute, + absolutePositions, + }; +} + +export function isEditableTarget(target: any) { + if (!target || !(target instanceof Element)) return false; + if (target.closest('input, textarea, select')) return true; + return target.closest('[contenteditable="true"]') !== null; +} + +export function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function canStartCanvasRightDragZoom(target: any) { + if (!target || !(target instanceof Element)) return false; + if (isEditableTarget(target)) return false; + if (target.closest('.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container')) { + return false; + } + return target.closest('.react-flow__pane, .react-flow__background') !== null; +} + +export function compareMenuNodes(a: any, b: any) { + const orderA = Number.isFinite(a?.menu_order) + ? a.menu_order + : Number.isFinite(a?.def?.menu_order) + ? a.def.menu_order + : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b?.menu_order) + ? b.menu_order + : Number.isFinite(b?.def?.menu_order) + ? b.def.menu_order + : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + + const nameA = (a?.def?.display_name || a?.className || '').toLowerCase(); + const nameB = (b?.def?.display_name || b?.className || '').toLowerCase(); + return nameA.localeCompare(nameB); +} + +export function compareMenuCategories(a: any, b: any) { + const orderA = Number.isFinite(a?.order) ? a.order : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b?.order) ? b.order : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return String(a?.name || '').localeCompare(String(b?.name || '')); +} diff --git a/frontend/src/nodeGeometry.ts b/frontend/src/nodeGeometry.ts new file mode 100644 index 0000000..154f462 --- /dev/null +++ b/frontend/src/nodeGeometry.ts @@ -0,0 +1,295 @@ +import { + getHandleType, + getInputName, + getOutputSlot, + encodeProxyHandleRef, + parseGroupProxyHandle, +} from './connectionUtils'; + +export const GROUP_PADDING_X = 24; +export const GROUP_PADDING_Y = 24; +export const GROUP_HEADER_HEIGHT = 36; +export const GROUP_WORKSPACE_INSET = 12; +export const GROUP_MIN_WIDTH = 260; +export const GROUP_MIN_HEIGHT = 180; + +export function getNodeDimension(node: any, axis: string): number { + if (axis === 'width') return node.measured?.width || node.style?.width || node.width || 200; + return node.measured?.height || node.style?.height || node.height || 120; +} + +export function applyNodeSize(node: any, width: any, height: any) { + const nextWidth = Math.round(Number(width) || 0); + const nextHeight = Math.round(Number(height) || 0); + return { + ...node, + width: nextWidth, + height: nextHeight, + style: { ...(node.style || {}), width: nextWidth, height: nextHeight }, + }; +} + +export function getNodeAbsolutePosition(node: any, nodeMap: Map): { x: number; y: number } { + 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 }; +} + +export function collectGroupDescendantIds(nodes: any[], groupId: any) { + 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; +} + +export function getGroupMembers(nodes: any[], groupId: any) { + const descendants = collectGroupDescendantIds(nodes, groupId); + return Array.from(descendants); +} + +export function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) { + const nodeMap = new Map((nodes || []).map((node: any) => [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 }; +} + +export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map) { + 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, + }; +} + +export function getNodeCenter(node: any, nodeMap: Map) { + 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, + }; +} + +export function getNodeRect(node: any, nodeMap: Map) { + const pos = getNodeAbsolutePosition(node, nodeMap); + const width = Number(getNodeDimension(node, 'width')) || 200; + const height = Number(getNodeDimension(node, 'height')) || 120; + return { + left: pos.x, + top: pos.y, + right: pos.x + width, + bottom: pos.y + height, + }; +} + +export function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) { + const width = Number(getNodeDimension(node, 'width')) || 200; + const height = Number(getNodeDimension(node, 'height')) || 120; + return { + left: absolutePosition.x, + top: absolutePosition.y, + right: absolutePosition.x + width, + bottom: absolutePosition.y + height, + }; +} + +export function rectContainsPoint(rect: { left: number; right: number; top: number; bottom: number }, point: { x: number; y: number }) { + return point.x >= rect.left + && point.x <= rect.right + && point.y >= rect.top + && point.y <= rect.bottom; +} + +export function rectContainsRect(outerRect: { left: number; right: number; top: number; bottom: number }, innerRect: { left: number; right: number; top: number; bottom: number }) { + return innerRect.left >= outerRect.left + && innerRect.top >= outerRect.top + && innerRect.right <= outerRect.right + && innerRect.bottom <= outerRect.bottom; +} + +export function findExpandedGroupDropTarget(nodes: any[], draggedNodeIds: any[], anchorNodeId: any, anchorPoint: { x: number; y: number } | null = null) { + const nodeMap = new Map((nodes || []).map((node: any) => [String(node.id), node])); + const anchorNode = nodeMap.get(String(anchorNodeId)); + if (!anchorNode) return null; + + const draggedIdSet = new Set((draggedNodeIds || []).map((id: any) => String(id))); + const anchorCenter = anchorPoint && Number.isFinite(anchorPoint.x) && Number.isFinite(anchorPoint.y) + ? anchorPoint + : getNodeCenter(anchorNode, nodeMap); + + return (nodes || []) + .filter((node: any) => ( + node?.data?.className === 'Group' + && !node?.data?.collapsed + && !draggedIdSet.has(String(node.id)) + )) + .map((node: any) => { + 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 }: { rect: any }) => rectContainsPoint(rect, anchorCenter)) + .sort((a: any, b: any) => a.area - b.area)[0]?.node || null; +} + +export function getInputLabelForNode(node: any, inputName: string) { + 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; +} + +export function getOutputLabelForNode(node: any, slot: number, handleId: string): string { + 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'; +} + +export function buildGroupProxyData(groupId: string, nodes: any[], edges: any[]) { + const nodeMap = new Map((nodes || []).map((node: any) => [String(node.id), node])); + const memberIds = new Set(getGroupMembers(nodes, groupId)); + const proxyInputs: { key: string; type: string; label: string; handleId: string }[] = []; + const proxyOutputs: { key: string; type: string; label: string; handleId: string }[] = []; + const seenInputs = new Set(); + const seenOutputs = new Set(); + + for (const edge of edges || []) { + const original = (edge?.data?.groupProxyOriginal || {}) as Record; + 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 }; +} + +export function sameStringArray(a: any[] = [], b: any[] = []) { + if (a === b) return true; + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, index) => item === b[index]); +} + +export function getRenderedNodeBounds(nodes: any[]) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let found = false; + + for (const node of nodes) { + const selectorId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(String(node.id)) + : String(node.id); + const el = document.querySelector(`.react-flow__node[data-id="${selectorId}"]`) as HTMLElement | null; + const width = el?.offsetWidth || node.measured?.width || node.width || 0; + const height = el?.offsetHeight || node.measured?.height || node.height || 0; + const x = node.positionAbsolute?.x ?? node.position?.x ?? 0; + const y = node.positionAbsolute?.y ?? node.position?.y ?? 0; + + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + continue; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + width); + maxY = Math.max(maxY, y + height); + found = true; + } + + if (!found) { + return null; + } + + return { + x: minX, + y: minY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + }; +}