refactor app.tx

This commit is contained in:
2026-04-02 22:58:10 -07:00
parent 7de9bddec7
commit f6b47e6d79
4 changed files with 717 additions and 791 deletions

View File

@@ -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<string, any>): { 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<string>();
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<string, any>((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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>((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<string, any>((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<string, any>;
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<void>((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<void>((resolve) => requestAnimationFrame(() => resolve()));
await new Promise<void>((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<string, any>;
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<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menuPos, setMenuPos] = useState({ x, y });
const subMenuRef = useRef<HTMLDivElement | null>(null);
const [subPos, setSubPos] = useState({ x: 0, y: 0 });
const catRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
const selectedItemRef = useRef<HTMLDivElement | null>(null);
// Group by category, optionally filtering to compatible nodes
const categories = useMemo(() => {
const cats: Record<string, any> = {};
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 (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
<div className="context-item" style={{ color: 'var(--text-muted)' }}>No compatible nodes</div>
</div>
);
}
const catNames = categories.map((category) => category.name);
const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items]));
return (
<>
<div
className="context-menu ctx-root"
ref={menuRef}
style={{ left: menuPos.x, top: menuPos.y }}
onClick={(e) => 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);
}}
>
<div className="ctx-title">Add Node</div>
<div className="ctx-search-row">
<input
className="ctx-search"
type="text"
placeholder="Search…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpenCat(null); }}
onKeyDown={handleSearchKeyDown}
autoFocus
/>
</div>
{!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && (
<div
className="context-item"
onClick={() => { onCreateGroup(); onClose(); }}
>
create group
</div>
)}
{searchResults ? (
<div className="ctx-list">
{searchResults.length === 0 ? (
<div className="context-item" style={{ color: 'var(--text-muted)' }}>No matches</div>
) : (
searchResults.map(({ className, def }, idx) => (
<div
key={className}
ref={idx === selectedIndex ? selectedItemRef : null}
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
onClick={() => { onAdd(className, def); onClose(); }}
onMouseEnter={() => setSelectedIndex(idx)}
>
{def.display_name || className}
</div>
))
)}
</div>
) : (
<div className="ctx-list">
{catNames.map((cat) => (
<div
key={cat}
ref={(el) => { catRowRefs.current[cat] = el; }}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`}
onMouseEnter={() => handleCatEnter(cat)}
>
<span className="ctx-cat-label">{cat}</span>
<span className="ctx-cat-arrow"></span>
</div>
))}
</div>
)}
</div>
{/* Submenu rendered as a sibling, positioned at computed screen coords */}
{openCat && categoryMap[openCat] && (
<div
className="context-menu ctx-submenu"
ref={subMenuRef}
style={{ left: subPos.x, top: subPos.y }}
onClick={(e) => 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 }) => (
<div
key={className}
className="context-item"
onClick={() => { onAdd(className, def); onClose(); }}
>
{def.display_name || className}
</div>
))}
</div>
)}
</>
);
}
const DEBUG = false; // set to true for verbose logging
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────

View File

@@ -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<string, any>;
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<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menuPos, setMenuPos] = useState({ x, y });
const subMenuRef = useRef<HTMLDivElement | null>(null);
const [subPos, setSubPos] = useState({ x: 0, y: 0 });
const catRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
const selectedItemRef = useRef<HTMLDivElement | null>(null);
// Group by category, optionally filtering to compatible nodes
const categories = useMemo(() => {
const cats: Record<string, any> = {};
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 (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
<div className="context-item" style={{ color: 'var(--text-muted)' }}>No compatible nodes</div>
</div>
);
}
const catNames = categories.map((category) => category.name);
const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items]));
return (
<>
<div
className="context-menu ctx-root"
ref={menuRef}
style={{ left: menuPos.x, top: menuPos.y }}
onClick={(e) => 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);
}}
>
<div className="ctx-title">Add Node</div>
<div className="ctx-search-row">
<input
className="ctx-search"
type="text"
placeholder="Search…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpenCat(null); }}
onKeyDown={handleSearchKeyDown}
autoFocus
/>
</div>
{!filterType && selectedNodeCount > 1 && typeof onCreateGroup === 'function' && (
<div
className="context-item"
onClick={() => { onCreateGroup(); onClose(); }}
>
create group
</div>
)}
{searchResults ? (
<div className="ctx-list">
{searchResults.length === 0 ? (
<div className="context-item" style={{ color: 'var(--text-muted)' }}>No matches</div>
) : (
searchResults.map(({ className, def }, idx) => (
<div
key={className}
ref={idx === selectedIndex ? selectedItemRef : null}
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
onClick={() => { onAdd(className, def); onClose(); }}
onMouseEnter={() => setSelectedIndex(idx)}
>
{def.display_name || className}
</div>
))
)}
</div>
) : (
<div className="ctx-list">
{catNames.map((cat) => (
<div
key={cat}
ref={(el) => { catRowRefs.current[cat] = el; }}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`}
onMouseEnter={() => handleCatEnter(cat)}
>
<span className="ctx-cat-label">{cat}</span>
<span className="ctx-cat-arrow"></span>
</div>
))}
</div>
)}
</div>
{/* Submenu rendered as a sibling, positioned at computed screen coords */}
{openCat && categoryMap[openCat] && (
<div
className="context-menu ctx-submenu"
ref={subMenuRef}
style={{ left: subPos.x, top: subPos.y }}
onClick={(e) => 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 }) => (
<div
key={className}
className="context-item"
onClick={() => { onAdd(className, def); onClose(); }}
>
{def.display_name || className}
</div>
))}
</div>
)}
</>
);
}

View File

@@ -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 || ''));
}

View File

@@ -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<string, any>): { 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<string>();
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<string, any>((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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>((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<string, any>((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<string, any>;
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),
};
}