refactor app.tx
This commit is contained in:
@@ -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) ────────────
|
||||
|
||||
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