refactor app.tx
This commit is contained in:
@@ -10,9 +10,9 @@ import '@xyflow/react/dist/style.css';
|
|||||||
|
|
||||||
import CustomNode, { NodeContext } from './CustomNode';
|
import CustomNode, { NodeContext } from './CustomNode';
|
||||||
import HelpPanelManager from './HelpPanelManager';
|
import HelpPanelManager from './HelpPanelManager';
|
||||||
|
import ContextMenu from './ContextMenu';
|
||||||
import * as api from './api';
|
import * as api from './api';
|
||||||
import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker';
|
import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker';
|
||||||
import { toBlob } from 'html-to-image';
|
|
||||||
import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
||||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||||
import tonoIconUrl from '../../resources/icon_1024.png';
|
import tonoIconUrl from '../../resources/icon_1024.png';
|
||||||
@@ -62,6 +62,37 @@ import {
|
|||||||
CANVAS_COLORS,
|
CANVAS_COLORS,
|
||||||
} from './constants';
|
} 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 {
|
import type {
|
||||||
NodeData,
|
NodeData,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
@@ -89,801 +120,11 @@ declare global {
|
|||||||
|
|
||||||
const NODE_TYPES = { custom: CustomNode };
|
const NODE_TYPES = { custom: CustomNode };
|
||||||
|
|
||||||
const GROUP_PADDING_X = 24;
|
|
||||||
const GROUP_PADDING_Y = 24;
|
|
||||||
const GROUP_HEADER_HEIGHT = 36;
|
|
||||||
const GROUP_WORKSPACE_INSET = 12;
|
|
||||||
const GROUP_MIN_WIDTH = 260;
|
|
||||||
const GROUP_MIN_HEIGHT = 180;
|
|
||||||
const CANVAS_MIN_ZOOM = 0.2;
|
const CANVAS_MIN_ZOOM = 0.2;
|
||||||
const CANVAS_MAX_ZOOM = 4;
|
const CANVAS_MAX_ZOOM = 4;
|
||||||
const CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY = 0.0065;
|
const CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY = 0.0065;
|
||||||
const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5;
|
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
|
const DEBUG = false; // set to true for verbose logging
|
||||||
|
|
||||||
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
||||||
|
|||||||
300
frontend/src/ContextMenu.tsx
Normal file
300
frontend/src/ContextMenu.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/canvasEvents.ts
Normal file
90
frontend/src/canvasEvents.ts
Normal 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 || ''));
|
||||||
|
}
|
||||||
295
frontend/src/nodeGeometry.ts
Normal file
295
frontend/src/nodeGeometry.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user