3028 lines
106 KiB
JavaScript
3028 lines
106 KiB
JavaScript
import React, {
|
|
useState, useCallback, useEffect, useRef, useMemo,
|
|
} from 'react';
|
|
import {
|
|
ReactFlow, Background, Controls, MiniMap,
|
|
useNodesState, useEdgesState, addEdge, useReactFlow,
|
|
ReactFlowProvider, getViewportForBounds, PanOnScrollMode, SelectionMode,
|
|
} from '@xyflow/react';
|
|
import '@xyflow/react/dist/style.css';
|
|
|
|
import CustomNode, { NodeContext } from './CustomNode';
|
|
import HelpPanelManager from './HelpPanelManager';
|
|
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';
|
|
import { hydrateWorkflowState } from './workflowHydration';
|
|
import { serializeWorkflowState } from './workflowSerialization';
|
|
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
|
import {
|
|
buildNodeClipboardPayload,
|
|
buildNodeClipboardPayloadForIds,
|
|
instantiateNodeClipboardPayload,
|
|
NODE_CLIPBOARD_MIME,
|
|
parseNodeClipboardPayload,
|
|
} from './nodeClipboard';
|
|
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
|
|
import {
|
|
serializeExecutionGraph,
|
|
getAutoRunnableNodes,
|
|
hasBlockingAutoRunInput,
|
|
} from './executionGraph';
|
|
import {
|
|
beginTrackedNodeRequest,
|
|
isTrackedNodeRequestCurrent,
|
|
resolveLoadNodeChannelPath,
|
|
} from './loadNodeOutputs.js';
|
|
import { buildDefaultWidgetValues } from './nodeWidgetDefaults.js';
|
|
import {
|
|
getHandleType,
|
|
getInputName,
|
|
getOutputSlot,
|
|
encodeProxyHandleRef,
|
|
parseGroupProxyHandle,
|
|
getConnectionHandleType,
|
|
getResolvedHandleRef,
|
|
getNodeInputSpecForHandle,
|
|
outputTypeCanConnectToTarget,
|
|
resolveOutputTypeForTarget,
|
|
checkConnectionValid,
|
|
} from './connectionUtils.js';
|
|
|
|
import {
|
|
getSpecTypeAndOptions,
|
|
socketSpecAcceptsType,
|
|
TYPE_COLORS,
|
|
CAT_COLORS,
|
|
CANVAS_COLORS,
|
|
} from './constants';
|
|
|
|
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, axis) {
|
|
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, width, height) {
|
|
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, nodeMap) {
|
|
if (node?.positionAbsolute) {
|
|
return {
|
|
x: Number(node.positionAbsolute.x) || 0,
|
|
y: Number(node.positionAbsolute.y) || 0,
|
|
};
|
|
}
|
|
const local = {
|
|
x: Number(node?.position?.x) || 0,
|
|
y: Number(node?.position?.y) || 0,
|
|
};
|
|
if (!node?.parentId) return local;
|
|
const parent = nodeMap.get(String(node.parentId));
|
|
if (!parent) return local;
|
|
const parentPos = getNodeAbsolutePosition(parent, nodeMap);
|
|
return { x: parentPos.x + local.x, y: parentPos.y + local.y };
|
|
}
|
|
|
|
function collectGroupDescendantIds(nodes, groupId) {
|
|
const allNodes = Array.isArray(nodes) ? nodes : [];
|
|
const result = new Set();
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const node of allNodes) {
|
|
const parentId = node?.parentId ? String(node.parentId) : null;
|
|
const nodeId = String(node?.id);
|
|
if (!parentId) continue;
|
|
if ((parentId === String(groupId) || result.has(parentId)) && !result.has(nodeId)) {
|
|
result.add(nodeId);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getGroupMembers(nodes, groupId) {
|
|
const descendants = collectGroupDescendantIds(nodes, groupId);
|
|
return Array.from(descendants);
|
|
}
|
|
|
|
function getGroupDisplayBounds(nodes, selectedIds) {
|
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const id of selectedIds) {
|
|
const node = nodeMap.get(String(id));
|
|
if (!node) continue;
|
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
|
const width = Number(getNodeDimension(node, 'width')) || 200;
|
|
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
minX = Math.min(minX, pos.x);
|
|
minY = Math.min(minY, pos.y);
|
|
maxX = Math.max(maxX, pos.x + width);
|
|
maxY = Math.max(maxY, pos.y + height);
|
|
}
|
|
|
|
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
return null;
|
|
}
|
|
|
|
return { minX, minY, maxX, maxY };
|
|
}
|
|
|
|
function getGroupWorkspaceBounds(groupNode, nodeMap) {
|
|
const pos = getNodeAbsolutePosition(groupNode, nodeMap);
|
|
const width = Number(getNodeDimension(groupNode, 'width')) || 200;
|
|
const height = Number(getNodeDimension(groupNode, 'height')) || 120;
|
|
return {
|
|
left: pos.x + GROUP_WORKSPACE_INSET,
|
|
top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
|
|
right: pos.x + width - GROUP_WORKSPACE_INSET,
|
|
bottom: pos.y + height - GROUP_WORKSPACE_INSET,
|
|
};
|
|
}
|
|
|
|
function getNodeCenter(node, nodeMap) {
|
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
|
const width = Number(getNodeDimension(node, 'width')) || 200;
|
|
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
return {
|
|
x: pos.x + width / 2,
|
|
y: pos.y + height / 2,
|
|
};
|
|
}
|
|
|
|
function getNodeRect(node, nodeMap) {
|
|
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, absolutePosition) {
|
|
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, point) {
|
|
return point.x >= rect.left
|
|
&& point.x <= rect.right
|
|
&& point.y >= rect.top
|
|
&& point.y <= rect.bottom;
|
|
}
|
|
|
|
function rectContainsRect(outerRect, innerRect) {
|
|
return innerRect.left >= outerRect.left
|
|
&& innerRect.top >= outerRect.top
|
|
&& innerRect.right <= outerRect.right
|
|
&& innerRect.bottom <= outerRect.bottom;
|
|
}
|
|
|
|
function getEventClientPosition(event) {
|
|
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, reactFlow) {
|
|
const clientPosition = getEventClientPosition(event);
|
|
if (!clientPosition || typeof reactFlow?.screenToFlowPosition !== 'function') return null;
|
|
return reactFlow.screenToFlowPosition(clientPosition);
|
|
}
|
|
|
|
function getDragIntent(event, reactFlow, dragState) {
|
|
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]) => [
|
|
id,
|
|
{
|
|
x: (Number(pos?.x) || 0) + delta.x,
|
|
y: (Number(pos?.y) || 0) + delta.y,
|
|
},
|
|
]),
|
|
);
|
|
|
|
return {
|
|
pointerFlowPos,
|
|
anchorAbsolute,
|
|
absolutePositions,
|
|
};
|
|
}
|
|
|
|
function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId, anchorPoint = null) {
|
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
|
const anchorNode = nodeMap.get(String(anchorNodeId));
|
|
if (!anchorNode) return null;
|
|
|
|
const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id)));
|
|
const anchorCenter = anchorPoint && Number.isFinite(anchorPoint.x) && Number.isFinite(anchorPoint.y)
|
|
? anchorPoint
|
|
: getNodeCenter(anchorNode, nodeMap);
|
|
|
|
return (nodes || [])
|
|
.filter((node) => (
|
|
node?.data?.className === 'Group'
|
|
&& !node?.data?.collapsed
|
|
&& !draggedIdSet.has(String(node.id))
|
|
))
|
|
.map((node) => {
|
|
const rect = getGroupWorkspaceBounds(node, nodeMap);
|
|
return {
|
|
node,
|
|
rect,
|
|
area: Math.max(1, rect.right - rect.left) * Math.max(1, rect.bottom - rect.top),
|
|
};
|
|
})
|
|
.filter(({ rect }) => rectContainsPoint(rect, anchorCenter))
|
|
.sort((a, b) => a.area - b.area)[0]?.node || null;
|
|
}
|
|
|
|
function getInputLabelForNode(node, inputName) {
|
|
const inputs = {
|
|
...(node?.data?.definition?.input?.required || {}),
|
|
...(node?.data?.definition?.input?.optional || {}),
|
|
};
|
|
const spec = inputs[inputName];
|
|
if (!spec) return inputName;
|
|
const [, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
|
return opts?.label || inputName;
|
|
}
|
|
|
|
function getOutputLabelForNode(node, slot, handleId) {
|
|
const outputNames = node?.data?.definition?.output_name || [];
|
|
const outputTypes = node?.data?.definition?.output || [];
|
|
if (Number.isInteger(slot) && outputNames[slot]) return outputNames[slot];
|
|
const proxy = parseGroupProxyHandle(handleId);
|
|
return proxy?.realHandle ? getOutputLabelForNode(node, getOutputSlot(proxy.realHandle), proxy.realHandle) : outputTypes[slot] || 'output';
|
|
}
|
|
|
|
function buildGroupProxyData(groupId, nodes, edges) {
|
|
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
|
|
const memberIds = new Set(getGroupMembers(nodes, groupId));
|
|
const proxyInputs = [];
|
|
const proxyOutputs = [];
|
|
const seenInputs = new Set();
|
|
const seenOutputs = new Set();
|
|
|
|
for (const edge of edges || []) {
|
|
const original = edge?.data?.groupProxyOriginal || {};
|
|
const sourceId = String(original.source || edge.source);
|
|
const targetId = String(original.target || edge.target);
|
|
const sourceHandle = original.sourceHandle || edge.sourceHandle;
|
|
const targetHandle = original.targetHandle || edge.targetHandle;
|
|
const sourceInside = memberIds.has(sourceId);
|
|
const targetInside = memberIds.has(targetId);
|
|
|
|
if (!sourceInside && targetInside) {
|
|
const key = `${targetId}::${targetHandle}`;
|
|
if (seenInputs.has(key)) continue;
|
|
seenInputs.add(key);
|
|
proxyInputs.push({
|
|
key,
|
|
type: getHandleType(targetHandle),
|
|
label: getInputLabelForNode(nodeMap.get(targetId), getInputName(targetHandle)),
|
|
handleId: `group-proxy::in::${targetId}::${getHandleType(targetHandle)}::${encodeProxyHandleRef(targetHandle)}`,
|
|
});
|
|
}
|
|
|
|
if (sourceInside && !targetInside) {
|
|
const key = `${sourceId}::${sourceHandle}`;
|
|
if (seenOutputs.has(key)) continue;
|
|
seenOutputs.add(key);
|
|
proxyOutputs.push({
|
|
key,
|
|
type: getHandleType(sourceHandle),
|
|
label: getOutputLabelForNode(nodeMap.get(sourceId), getOutputSlot(sourceHandle), sourceHandle),
|
|
handleId: `group-proxy::out::${sourceId}::${getHandleType(sourceHandle)}::${encodeProxyHandleRef(sourceHandle)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { proxyInputs, proxyOutputs, childCount: memberIds.size };
|
|
}
|
|
|
|
function sameStringArray(a = [], b = []) {
|
|
if (a === b) return true;
|
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
return a.every((item, index) => item === b[index]);
|
|
}
|
|
|
|
function isEditableTarget(target) {
|
|
if (!target || !(target instanceof Element)) return false;
|
|
if (target.closest('input, textarea, select')) return true;
|
|
return target.closest('[contenteditable="true"]') !== null;
|
|
}
|
|
|
|
function clampNumber(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function canStartCanvasRightDragZoom(target) {
|
|
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, b) {
|
|
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, b) {
|
|
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) {
|
|
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}"]`);
|
|
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) {
|
|
if (img.complete && img.naturalWidth > 0) return;
|
|
if (typeof img.decode === 'function') {
|
|
try {
|
|
await img.decode();
|
|
return;
|
|
} catch {
|
|
// Fall back to load/error listeners below.
|
|
}
|
|
}
|
|
await new Promise((resolve) => {
|
|
const done = () => {
|
|
img.removeEventListener('load', done);
|
|
img.removeEventListener('error', done);
|
|
resolve();
|
|
};
|
|
img.addEventListener('load', done, { once: true });
|
|
img.addEventListener('error', done, { once: true });
|
|
});
|
|
}
|
|
|
|
async function getCaptureImageDataUrl(img) {
|
|
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, dataUrl) {
|
|
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, options) {
|
|
const restorers = [];
|
|
const images = Array.from(viewportEl.querySelectorAll('img'));
|
|
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'));
|
|
for (const canvas of canvases) {
|
|
if (!canvas.parentNode) continue;
|
|
let dataUrl = 'data:,';
|
|
try {
|
|
dataUrl = canvas.toDataURL('image/png');
|
|
} catch {
|
|
dataUrl = 'data:,';
|
|
}
|
|
if (dataUrl === 'data:,') continue;
|
|
|
|
const placeholder = createCapturePlaceholder(canvas, dataUrl);
|
|
canvas.parentNode.replaceChild(placeholder, canvas);
|
|
restorers.push(() => {
|
|
if (placeholder.parentNode) {
|
|
placeholder.parentNode.replaceChild(canvas, placeholder);
|
|
}
|
|
});
|
|
}
|
|
|
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
|
|
try {
|
|
return await toBlob(viewportEl, options);
|
|
} finally {
|
|
restorers.reverse().forEach((restore) => restore());
|
|
}
|
|
}
|
|
|
|
// ── Context menu component ────────────────────────────────────────────
|
|
|
|
function ContextMenu({
|
|
x,
|
|
y,
|
|
nodeDefs,
|
|
onAdd,
|
|
onClose,
|
|
filterType,
|
|
filterSpec = null,
|
|
filterDirection,
|
|
selectedNodeCount = 0,
|
|
onCreateGroup = null,
|
|
}) {
|
|
const [openCat, setOpenCat] = useState(null);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const menuRef = useRef(null);
|
|
const [menuPos, setMenuPos] = useState({ x, y });
|
|
const subMenuRef = useRef(null);
|
|
const [subPos, setSubPos] = useState({ x: 0, y: 0 });
|
|
const catRowRefs = useRef({});
|
|
const selectedItemRef = useRef(null);
|
|
|
|
// Group by category, optionally filtering to compatible nodes
|
|
const categories = useMemo(() => {
|
|
const cats = {};
|
|
for (const [className, def] of Object.entries(nodeDefs)) {
|
|
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) => {
|
|
return socketSpecAcceptsType(filterType, spec);
|
|
});
|
|
if (!hasMatch) continue;
|
|
} else {
|
|
const hasMatch = def.output.some((type, idx) =>
|
|
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) => ({
|
|
...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 = [];
|
|
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) => {
|
|
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) => {
|
|
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)) 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)) return;
|
|
setOpenCat(null);
|
|
}}
|
|
>
|
|
{categoryMap[openCat].map(({ className, def }) => (
|
|
<div
|
|
key={className}
|
|
className="context-item"
|
|
onClick={() => { onAdd(className, def); onClose(); }}
|
|
>
|
|
{def.display_name || className}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
|
|
|
function Flow() {
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
|
const [contextMenu, setContextMenu] = useState(null);
|
|
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
|
const [executingNodeId, setExecutingNodeId] = useState(null);
|
|
const [helpTabs, setHelpTabs] = useState([]);
|
|
const [activeHelpTab, setActiveHelpTab] = useState(null);
|
|
|
|
const flowContainerRef = useRef(null);
|
|
const panTimerRef = useRef(null);
|
|
const nodeDefsRef = useRef({});
|
|
const nextIdRef = useRef(1);
|
|
const autoRunTimer = useRef(null);
|
|
const autoRunRef = useRef(null);
|
|
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
|
const lastPastedClipboardTextRef = useRef('');
|
|
const pasteRepeatCountRef = useRef(0);
|
|
const duplicateDragRef = useRef(null);
|
|
const dragStateRef = useRef(null);
|
|
const activeDragNodeIdRef = useRef(null);
|
|
const canvasRightZoomRef = useRef(null);
|
|
const suppressPaneContextMenuUntilRef = useRef(0);
|
|
const loadNodeOutputRequestVersionsRef = useRef(new Map());
|
|
const journalContentRef = useRef('');
|
|
const reactFlow = useReactFlow();
|
|
|
|
// ── WebSocket ───────────────────────────────────────────────────────
|
|
|
|
const updateNodeData = useCallback((nodeId, patch) => {
|
|
setNodes((ns) => ns.map((n) =>
|
|
n.id !== nodeId ? n : { ...n, data: { ...n.data, ...patch } }
|
|
));
|
|
}, [setNodes]);
|
|
|
|
const refreshGroupNode = useCallback((groupId, explicitNodes = null, explicitEdges = null) => {
|
|
const currentNodes = explicitNodes || reactFlow.getNodes();
|
|
const currentEdges = explicitEdges || reactFlow.getEdges();
|
|
const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
|
|
if (!groupNode) return;
|
|
|
|
const { proxyInputs, proxyOutputs, childCount } = buildGroupProxyData(groupId, currentNodes, currentEdges);
|
|
setNodes((prev) => prev.map((node) => (
|
|
node.id !== groupId
|
|
? node
|
|
: {
|
|
...node,
|
|
className: 'group-shell',
|
|
data: {
|
|
...node.data,
|
|
proxyInputs,
|
|
proxyOutputs,
|
|
childCount,
|
|
},
|
|
}
|
|
)));
|
|
reactFlow.updateNodeInternals(groupId);
|
|
}, [reactFlow, setNodes]);
|
|
|
|
const toggleGroupCollapse = useCallback((groupId) => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
const groupNode = currentNodes.find((node) => node.id === groupId && node.data?.className === 'Group');
|
|
if (!groupNode) return;
|
|
|
|
const memberIds = new Set(getGroupMembers(currentNodes, groupId));
|
|
const collapsed = !groupNode.data?.collapsed;
|
|
const proxyData = buildGroupProxyData(groupId, currentNodes, currentEdges);
|
|
|
|
const nextNodes = currentNodes.map((node) => {
|
|
if (memberIds.has(String(node.id))) {
|
|
return { ...node, hidden: collapsed };
|
|
}
|
|
if (node.id !== groupId) return node;
|
|
const expandedSize = groupNode.data?.expandedSize || {
|
|
width: Number(groupNode.style?.width) || 320,
|
|
height: Number(groupNode.style?.height) || 240,
|
|
};
|
|
const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26);
|
|
return {
|
|
...applyNodeSize(
|
|
node,
|
|
collapsed ? 260 : expandedSize.width,
|
|
collapsed ? collapsedHeight : expandedSize.height,
|
|
),
|
|
data: {
|
|
...node.data,
|
|
collapsed,
|
|
expandedSize,
|
|
proxyInputs: proxyData.proxyInputs,
|
|
proxyOutputs: proxyData.proxyOutputs,
|
|
childCount: proxyData.childCount,
|
|
},
|
|
};
|
|
});
|
|
|
|
const nextEdges = currentEdges.map((edge) => {
|
|
if (collapsed) {
|
|
if (edge.data?.groupProxyOwner === groupId || edge.data?.groupInternalHiddenBy === groupId) {
|
|
return edge;
|
|
}
|
|
const sourceInside = memberIds.has(String(edge.source));
|
|
const targetInside = memberIds.has(String(edge.target));
|
|
if (sourceInside && targetInside) {
|
|
return {
|
|
...edge,
|
|
hidden: true,
|
|
data: { ...(edge.data || {}), groupInternalHiddenBy: groupId },
|
|
};
|
|
}
|
|
if (!sourceInside && targetInside) {
|
|
return {
|
|
...edge,
|
|
target: groupId,
|
|
targetHandle: `group-proxy::in::${edge.target}::${getHandleType(edge.targetHandle)}::${encodeProxyHandleRef(edge.targetHandle)}`,
|
|
data: {
|
|
...(edge.data || {}),
|
|
groupProxyOwner: groupId,
|
|
groupProxyOriginal: {
|
|
target: edge.target,
|
|
targetHandle: edge.targetHandle,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (sourceInside && !targetInside) {
|
|
return {
|
|
...edge,
|
|
source: groupId,
|
|
sourceHandle: `group-proxy::out::${edge.source}::${getHandleType(edge.sourceHandle)}::${encodeProxyHandleRef(edge.sourceHandle)}`,
|
|
data: {
|
|
...(edge.data || {}),
|
|
groupProxyOwner: groupId,
|
|
groupProxyOriginal: {
|
|
source: edge.source,
|
|
sourceHandle: edge.sourceHandle,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return edge;
|
|
}
|
|
|
|
if (edge.data?.groupInternalHiddenBy === groupId) {
|
|
const nextData = { ...(edge.data || {}) };
|
|
delete nextData.groupInternalHiddenBy;
|
|
return {
|
|
...edge,
|
|
hidden: false,
|
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
|
};
|
|
}
|
|
if (edge.data?.groupProxyOwner === groupId) {
|
|
const nextData = { ...(edge.data || {}) };
|
|
const original = nextData.groupProxyOriginal || {};
|
|
delete nextData.groupProxyOwner;
|
|
delete nextData.groupProxyOriginal;
|
|
return {
|
|
...edge,
|
|
source: original.source || edge.source,
|
|
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
|
target: original.target || edge.target,
|
|
targetHandle: original.targetHandle || edge.targetHandle,
|
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
|
};
|
|
}
|
|
return edge;
|
|
});
|
|
|
|
setNodes(nextNodes);
|
|
setEdges(nextEdges);
|
|
setTimeout(() => refreshGroupNode(groupId, nextNodes, nextEdges), 0);
|
|
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
|
|
|
const ungroupGroup = useCallback((groupId) => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
|
|
const groupNode = nodeMap.get(String(groupId));
|
|
if (!groupNode || groupNode.data?.className !== 'Group') return;
|
|
|
|
const memberIds = new Set(getGroupMembers(currentNodes, groupId));
|
|
const groupSelected = !!groupNode.selected;
|
|
|
|
const nextNodes = currentNodes
|
|
.filter((node) => String(node.id) !== String(groupId))
|
|
.map((node) => {
|
|
if (!memberIds.has(String(node.id))) return node;
|
|
const absolute = getNodeAbsolutePosition(node, nodeMap);
|
|
return {
|
|
...node,
|
|
parentId: undefined,
|
|
extent: undefined,
|
|
hidden: false,
|
|
selected: groupSelected,
|
|
position: absolute,
|
|
};
|
|
});
|
|
|
|
const nextEdges = currentEdges
|
|
.map((edge) => {
|
|
if (edge.data?.groupInternalHiddenBy === groupId) {
|
|
const nextData = { ...(edge.data || {}) };
|
|
delete nextData.groupInternalHiddenBy;
|
|
return {
|
|
...edge,
|
|
hidden: false,
|
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
|
};
|
|
}
|
|
if (edge.data?.groupProxyOwner === groupId) {
|
|
const nextData = { ...(edge.data || {}) };
|
|
const original = nextData.groupProxyOriginal || {};
|
|
delete nextData.groupProxyOwner;
|
|
delete nextData.groupProxyOriginal;
|
|
return {
|
|
...edge,
|
|
source: original.source || edge.source,
|
|
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
|
target: original.target || edge.target,
|
|
targetHandle: original.targetHandle || edge.targetHandle,
|
|
hidden: false,
|
|
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
|
};
|
|
}
|
|
return edge;
|
|
})
|
|
.filter((edge) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId));
|
|
|
|
setNodes(nextNodes);
|
|
setEdges(nextEdges);
|
|
setTimeout(() => {
|
|
reactFlow.getNodes()
|
|
.filter((node) => node.data?.className === 'Group')
|
|
.forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges));
|
|
}, 0);
|
|
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
|
|
|
const createGroupFromSelection = useCallback(() => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const selectedNodes = currentNodes.filter((node) => node.selected && node.data?.className !== 'Group');
|
|
if (selectedNodes.length < 2) return;
|
|
|
|
const selectedIds = selectedNodes.map((node) => String(node.id));
|
|
const bounds = getGroupDisplayBounds(currentNodes, selectedIds);
|
|
if (!bounds) return;
|
|
|
|
const groupId = String(nextIdRef.current++);
|
|
const groupPosition = {
|
|
x: bounds.minX - GROUP_PADDING_X,
|
|
y: bounds.minY - (GROUP_HEADER_HEIGHT + GROUP_PADDING_Y),
|
|
};
|
|
const groupWidth = Math.max(
|
|
GROUP_MIN_WIDTH,
|
|
Math.round(bounds.maxX - bounds.minX + GROUP_PADDING_X * 2),
|
|
);
|
|
const groupHeight = Math.max(
|
|
GROUP_MIN_HEIGHT,
|
|
Math.round(bounds.maxY - bounds.minY + GROUP_HEADER_HEIGHT + GROUP_PADDING_Y * 2),
|
|
);
|
|
|
|
const groupNode = {
|
|
id: groupId,
|
|
type: 'custom',
|
|
className: 'group-shell',
|
|
position: groupPosition,
|
|
width: groupWidth,
|
|
height: groupHeight,
|
|
dragHandle: '.drag-handle',
|
|
style: { width: groupWidth, height: groupHeight },
|
|
data: {
|
|
label: 'group',
|
|
className: 'Group',
|
|
definition: null,
|
|
widgetValues: {},
|
|
runtimeValues: {},
|
|
collapsed: false,
|
|
expandedSize: { width: groupWidth, height: groupHeight },
|
|
proxyInputs: [],
|
|
proxyOutputs: [],
|
|
childCount: selectedNodes.length,
|
|
previewImage: null,
|
|
tableRows: null,
|
|
meshData: null,
|
|
overlay: null,
|
|
scalarValue: null,
|
|
processingTimeMs: null,
|
|
warning: null,
|
|
},
|
|
selected: true,
|
|
};
|
|
|
|
const nodeMap = new Map(currentNodes.map((node) => [String(node.id), node]));
|
|
const nextNodes = [
|
|
...currentNodes.map((node) => {
|
|
if (!selectedIds.includes(String(node.id))) {
|
|
return { ...node, selected: false };
|
|
}
|
|
const absolute = getNodeAbsolutePosition(node, nodeMap);
|
|
return {
|
|
...node,
|
|
selected: false,
|
|
parentId: groupId,
|
|
extent: 'parent',
|
|
hidden: false,
|
|
position: {
|
|
x: absolute.x - groupPosition.x,
|
|
y: absolute.y - groupPosition.y,
|
|
},
|
|
};
|
|
}),
|
|
groupNode,
|
|
];
|
|
|
|
const orderedNodes = sortNodesForParentOrder(nextNodes);
|
|
setNodes(orderedNodes);
|
|
setTimeout(() => refreshGroupNode(groupId, orderedNodes, reactFlow.getEdges()), 0);
|
|
}, [reactFlow, refreshGroupNode, setNodes]);
|
|
|
|
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
|
|
setNodes((prev) => prev.map((node) => {
|
|
if (node.id !== nodeId) return node;
|
|
const currentDefinition = node.data.definition || {};
|
|
const nextDefinition = {
|
|
...currentDefinition,
|
|
...extraDefinitionPatch,
|
|
output,
|
|
output_name: outputName,
|
|
};
|
|
const sameOutputs = sameStringArray(currentDefinition.output, output);
|
|
const sameNames = sameStringArray(currentDefinition.output_name, outputName);
|
|
const sameOutputPaths = sameStringArray(currentDefinition.output_paths, nextDefinition.output_paths);
|
|
if (sameOutputs && sameNames && sameOutputPaths) {
|
|
return node;
|
|
}
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
definition: nextDefinition,
|
|
},
|
|
};
|
|
}));
|
|
reactFlow.updateNodeInternals(nodeId);
|
|
}, [reactFlow, setNodes]);
|
|
|
|
const getResolvedPathInput = useCallback((nodeId) => {
|
|
const edge = reactFlow.getEdges().find(
|
|
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
|
|
);
|
|
if (!edge) return null;
|
|
const original = edge.data?.groupProxyOriginal || {};
|
|
const sourceId = original.source || edge.source;
|
|
const sourceHandle = original.sourceHandle || edge.sourceHandle;
|
|
const sourceNode = reactFlow.getNode(sourceId);
|
|
const outputPaths = sourceNode?.data?.definition?.output_paths;
|
|
const outputSlot = getOutputSlot(sourceHandle);
|
|
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
|
|
return outputPaths[outputSlot];
|
|
}
|
|
return null;
|
|
}, [reactFlow]);
|
|
|
|
const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => {
|
|
const node = reactFlow.getNode(nodeId);
|
|
const resolvedPath = resolveLoadNodeChannelPath({
|
|
explicitPath,
|
|
resolvedPathInput: getResolvedPathInput(nodeId),
|
|
className: node?.data?.className || '',
|
|
widgetValues: node?.data?.widgetValues || {},
|
|
});
|
|
const requestVersion = beginTrackedNodeRequest(loadNodeOutputRequestVersionsRef.current, nodeId);
|
|
|
|
if (!resolvedPath) {
|
|
if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
|
|
return;
|
|
}
|
|
setNodeOutputs(nodeId, ['FILE_PATH', 'DATA_FIELD'], ['path', 'field'], { output_paths: [] });
|
|
return;
|
|
}
|
|
|
|
const channels = await api.getChannels(resolvedPath);
|
|
if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
|
|
return;
|
|
}
|
|
setNodeOutputs(
|
|
nodeId,
|
|
['FILE_PATH', ...channels.map((channel) => channel.type)],
|
|
['path', ...channels.map((channel) => channel.name)],
|
|
{ output_paths: [] },
|
|
);
|
|
}, [getResolvedPathInput, reactFlow, setNodeOutputs]);
|
|
|
|
const refreshFolderNodeOutputs = useCallback(async (nodeId, folderPath) => {
|
|
const entries = folderPath ? await api.getFolderFiles(folderPath) : [];
|
|
setNodeOutputs(
|
|
nodeId,
|
|
entries.map((entry) => entry.type),
|
|
entries.map((entry) => entry.name),
|
|
{ output_paths: entries.map((entry) => entry.path) },
|
|
);
|
|
|
|
const downstreamPathEdges = reactFlow.getEdges().filter(
|
|
(edge) => edge.source === nodeId && getInputName(edge.targetHandle) === 'path'
|
|
);
|
|
for (const edge of downstreamPathEdges) {
|
|
const outputSlot = getOutputSlot(edge.sourceHandle);
|
|
const resolvedPath = entries[outputSlot]?.path || null;
|
|
await refreshLoadNodeOutputs(edge.target, resolvedPath);
|
|
}
|
|
}, [reactFlow, refreshLoadNodeOutputs, setNodeOutputs]);
|
|
|
|
const refreshAnnotationNodeOutputs = useCallback((nodeId) => {
|
|
const node = reactFlow.getNode(nodeId);
|
|
if (!node) return;
|
|
|
|
const inputEdge = reactFlow.getEdges().find(
|
|
(edge) => edge.target === nodeId && getInputName(edge.targetHandle) === 'input'
|
|
);
|
|
const outputType = inputEdge ? getHandleType(inputEdge.sourceHandle) : 'ANNOTATION_SOURCE';
|
|
setNodeOutputs(nodeId, [outputType], ['Output']);
|
|
|
|
if (!inputEdge || outputType === 'ANNOTATION_SOURCE') return;
|
|
|
|
setEdges((prev) => prev.filter((edge) => {
|
|
if (edge.source !== nodeId) return true;
|
|
const resolvedTarget = getResolvedHandleRef(edge.target, edge.targetHandle);
|
|
const targetNode = reactFlow.getNode(resolvedTarget.nodeId);
|
|
const targetSpec = getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) || resolvedTarget.type;
|
|
return socketSpecAcceptsType(outputType, targetSpec);
|
|
}));
|
|
}, [reactFlow, setEdges, setNodeOutputs]);
|
|
|
|
useEffect(() => {
|
|
api.setMessageHandler((msg) => {
|
|
console.log('[tono] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
|
switch (msg.type) {
|
|
case 'execution_start':
|
|
setNodes((ns) => ns.map((n) => ({
|
|
...n,
|
|
data: { ...n.data, processingTimeMs: null },
|
|
})));
|
|
setExecutingNodeId(null);
|
|
setStatus({ text: 'Running workflow…', level: 'info' });
|
|
break;
|
|
case 'executing':
|
|
setExecutingNodeId(String(msg.data.node));
|
|
setStatus({ text: `Executing node ${msg.data.node}…`, level: 'info' });
|
|
break;
|
|
case 'execution_complete':
|
|
setExecutingNodeId(null);
|
|
setStatus({ text: 'Done.', level: 'info' });
|
|
break;
|
|
case 'execution_error':
|
|
setExecutingNodeId(null);
|
|
setStatus({ text: 'Error: ' + msg.data.message, level: 'error' });
|
|
console.error('[tono] execution error', msg.data);
|
|
break;
|
|
case 'preview':
|
|
updateNodeData(msg.data.node_id, { previewImage: msg.data.image });
|
|
break;
|
|
case 'table':
|
|
updateNodeData(msg.data.node_id, { tableRows: msg.data.rows });
|
|
break;
|
|
case 'scalar':
|
|
updateNodeData(msg.data.node_id, {
|
|
scalarValue: {
|
|
value: msg.data.value,
|
|
unit: typeof msg.data.unit === 'string' ? msg.data.unit : '',
|
|
},
|
|
});
|
|
break;
|
|
case 'node_timing':
|
|
updateNodeData(msg.data.node_id, { processingTimeMs: msg.data.elapsed_ms });
|
|
break;
|
|
case 'mesh3d':
|
|
updateNodeData(msg.data.node_id, { meshData: msg.data.mesh });
|
|
break;
|
|
case 'overlay':
|
|
updateNodeData(
|
|
msg.data.node_id,
|
|
msg.data.overlay?.kind === 'mask_paint' || msg.data.overlay?.kind === 'markup'
|
|
? { overlay: msg.data.overlay, previewImage: null }
|
|
: { overlay: msg.data.overlay },
|
|
);
|
|
break;
|
|
case 'node_warning':
|
|
updateNodeData(msg.data.node_id, { warning: msg.data.message });
|
|
break;
|
|
case 'nodes_updated':
|
|
api.getNodes().then((defs) => {
|
|
nodeDefsRef.current = defs;
|
|
setStatus({ text: `Plugin loaded — ${Object.keys(defs).length} nodes available.`, level: 'info' });
|
|
}).catch(() => {});
|
|
break;
|
|
}
|
|
});
|
|
api.initWS();
|
|
return () => api.closeWS();
|
|
}, [updateNodeData]);
|
|
|
|
// ── Connection handling ─────────────────────────────────────────────
|
|
|
|
const isValidConnection = useCallback(
|
|
(connection) => checkConnectionValid(connection, (id) => reactFlow.getNode(id)),
|
|
[reactFlow],
|
|
);
|
|
|
|
const onConnect = useCallback((params) => {
|
|
const sourceProxy = parseGroupProxyHandle(params.sourceHandle);
|
|
const targetProxy = parseGroupProxyHandle(params.targetHandle);
|
|
const type = getConnectionHandleType(params.sourceHandle);
|
|
const color = TYPE_COLORS[type] || 'var(--fallback-type)';
|
|
|
|
const edgePayload = {
|
|
...params,
|
|
style: { stroke: color, strokeWidth: 2 },
|
|
};
|
|
const proxyOriginal = {};
|
|
if (sourceProxy) {
|
|
proxyOriginal.source = sourceProxy.nodeId;
|
|
proxyOriginal.sourceHandle = sourceProxy.realHandle;
|
|
}
|
|
if (targetProxy) {
|
|
proxyOriginal.target = targetProxy.nodeId;
|
|
proxyOriginal.targetHandle = targetProxy.realHandle;
|
|
}
|
|
if (Object.keys(proxyOriginal).length > 0) {
|
|
edgePayload.data = {
|
|
...(edgePayload.data || {}),
|
|
groupProxyOwner: sourceProxy?.direction === 'out' ? params.source : params.target,
|
|
groupProxyOriginal: proxyOriginal,
|
|
};
|
|
}
|
|
|
|
setEdges((eds) => {
|
|
// Enforce single connection per input handle
|
|
const filtered = eds.filter(
|
|
(e) => !(e.target === params.target && e.targetHandle === params.targetHandle)
|
|
);
|
|
return addEdge(edgePayload, filtered);
|
|
});
|
|
const effectiveTargetHandle = targetProxy?.realHandle || params.targetHandle;
|
|
const effectiveTargetNode = targetProxy?.nodeId || params.target;
|
|
if (getInputName(effectiveTargetHandle) === 'path') {
|
|
setTimeout(() => {
|
|
refreshLoadNodeOutputs(effectiveTargetNode);
|
|
}, 0);
|
|
}
|
|
const targetNode = reactFlow.getNode(effectiveTargetNode);
|
|
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
|
setTimeout(() => {
|
|
refreshAnnotationNodeOutputs(effectiveTargetNode);
|
|
}, 0);
|
|
}
|
|
if (sourceProxy) {
|
|
setTimeout(() => refreshGroupNode(params.source), 0);
|
|
}
|
|
if (targetProxy) {
|
|
setTimeout(() => refreshGroupNode(params.target), 0);
|
|
}
|
|
scheduleAutoRun();
|
|
}, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
|
|
|
const handleEdgesChange = useCallback((changes) => {
|
|
const currentEdges = reactFlow.getEdges();
|
|
onEdgesChange(changes);
|
|
|
|
const affectedPathTargets = new Set();
|
|
const affectedAnnotationTargets = new Set();
|
|
for (const change of changes) {
|
|
if (change.type !== 'remove') continue;
|
|
const removedEdge = currentEdges.find((edge) => edge.id === change.id);
|
|
if (!removedEdge) continue;
|
|
if (getInputName(removedEdge.targetHandle) === 'path') {
|
|
affectedPathTargets.add(removedEdge.target);
|
|
}
|
|
if (getInputName(removedEdge.targetHandle) === 'input') {
|
|
const targetNode = reactFlow.getNode(removedEdge.target);
|
|
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
|
affectedAnnotationTargets.add(removedEdge.target);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (affectedPathTargets.size > 0) {
|
|
setTimeout(() => {
|
|
affectedPathTargets.forEach((nodeId) => {
|
|
refreshLoadNodeOutputs(nodeId);
|
|
});
|
|
}, 0);
|
|
}
|
|
if (affectedAnnotationTargets.size > 0) {
|
|
setTimeout(() => {
|
|
affectedAnnotationTargets.forEach((nodeId) => {
|
|
refreshAnnotationNodeOutputs(nodeId);
|
|
});
|
|
}, 0);
|
|
}
|
|
setTimeout(() => {
|
|
reactFlow.getNodes()
|
|
.filter((node) => node.data?.className === 'Group')
|
|
.forEach((node) => refreshGroupNode(node.id));
|
|
}, 0);
|
|
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
|
|
|
const handleNodesChange = useCallback((changes) => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const selectedGroupIds = new Set(
|
|
changes
|
|
.filter((change) => change.type === 'select' && change.selected)
|
|
.map((change) => String(change.id))
|
|
.filter((id) => currentNodes.some((node) => String(node.id) === id && node.data?.className === 'Group')),
|
|
);
|
|
const removedIds = new Set(
|
|
changes
|
|
.filter((change) => change.type === 'remove')
|
|
.map((change) => String(change.id)),
|
|
);
|
|
|
|
onNodesChange(changes);
|
|
|
|
if (selectedGroupIds.size > 0) {
|
|
const deselectedDescendantIds = new Set();
|
|
selectedGroupIds.forEach((groupId) => {
|
|
collectGroupDescendantIds(currentNodes, groupId).forEach((id) => deselectedDescendantIds.add(id));
|
|
});
|
|
|
|
if (deselectedDescendantIds.size > 0) {
|
|
setNodes((existing) => existing.map((node) => (
|
|
deselectedDescendantIds.has(String(node.id))
|
|
? { ...node, selected: false }
|
|
: node
|
|
)));
|
|
}
|
|
}
|
|
|
|
if (removedIds.size === 0) return;
|
|
|
|
const groupIds = currentNodes
|
|
.filter((node) => removedIds.has(String(node.id)) && node.data?.className === 'Group')
|
|
.map((node) => String(node.id));
|
|
const removedWithDescendants = new Set(removedIds);
|
|
for (const groupId of groupIds) {
|
|
collectGroupDescendantIds(currentNodes, groupId).forEach((id) => removedWithDescendants.add(id));
|
|
}
|
|
|
|
if (groupIds.length > 0) {
|
|
setNodes((existing) => existing.filter((node) => !removedWithDescendants.has(String(node.id))));
|
|
setEdges((existing) => existing.filter((edge) => (
|
|
!removedWithDescendants.has(String(edge.source))
|
|
&& !removedWithDescendants.has(String(edge.target))
|
|
)));
|
|
}
|
|
|
|
setTimeout(() => {
|
|
reactFlow.getNodes()
|
|
.filter((node) => node.data?.className === 'Group')
|
|
.forEach((node) => refreshGroupNode(node.id));
|
|
}, 0);
|
|
}, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]);
|
|
|
|
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
|
|
|
const onConnectEnd = useCallback((event, connectionState) => {
|
|
// If the connection was completed (dropped on a valid handle), do nothing
|
|
if (connectionState.isValid) return;
|
|
|
|
const fromHandle = connectionState.fromHandle;
|
|
if (!fromHandle || !fromHandle.id) return;
|
|
|
|
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
|
|
const handleType = getConnectionHandleType(fromHandle.id);
|
|
const resolvedFromHandle = getResolvedHandleRef(fromHandle.nodeId, fromHandle.id);
|
|
const fromNode = reactFlow.getNode(resolvedFromHandle.nodeId);
|
|
const filterSpec = fromHandle.type === 'target'
|
|
? (getNodeInputSpecForHandle(fromNode, resolvedFromHandle.handleId) || handleType)
|
|
: handleType;
|
|
|
|
setContextMenu({
|
|
x: clientX,
|
|
y: clientY,
|
|
filterType: handleType,
|
|
filterSpec,
|
|
filterDirection: fromHandle.type,
|
|
pendingNodeId: fromHandle.nodeId,
|
|
pendingHandleId: fromHandle.id,
|
|
pendingHandleType: fromHandle.type,
|
|
});
|
|
}, [reactFlow]);
|
|
|
|
// ── Widget change callback ──────────────────────────────────────────
|
|
|
|
const onWidgetChange = useCallback((nodeId, name, value) => {
|
|
setNodes((ns) => ns.map((n) => {
|
|
if (n.id !== nodeId) return n;
|
|
return {
|
|
...n,
|
|
data: {
|
|
...n.data,
|
|
widgetValues: { ...n.data.widgetValues, [name]: value },
|
|
// Clear warning when user changes a value
|
|
warning: null,
|
|
},
|
|
};
|
|
}));
|
|
|
|
const node = reactFlow.getNode(nodeId);
|
|
if (node && node.data.className === 'Folder' && name === 'folder') {
|
|
refreshFolderNodeOutputs(nodeId, value);
|
|
}
|
|
|
|
if (node && (node.data.className === 'Image' || node.data.className === 'ImageDemo') && (name === 'filename' || name === 'name')) {
|
|
refreshLoadNodeOutputs(nodeId, value);
|
|
}
|
|
|
|
scheduleAutoRun();
|
|
}, [reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes]); // scheduleAutoRun is stable (no deps)
|
|
|
|
// ── File browser ────────────────────────────────────────────────────
|
|
|
|
const uploadBrowserSelection = useCallback(async (selection, selectionMode) => {
|
|
if (!selection) return null;
|
|
|
|
if (selectionMode === 'folder') {
|
|
const rootName = String(selection.rootName || '').trim();
|
|
if (!rootName) {
|
|
throw new Error('Selected folder is empty or could not be read.');
|
|
}
|
|
|
|
setStatus({
|
|
text: `Importing folder "${rootName}" into this session…`,
|
|
level: 'info',
|
|
});
|
|
|
|
const folder = await api.createUploadFolder(rootName);
|
|
for (const entry of selection.entries || []) {
|
|
await api.uploadFile(entry.file, { relativePath: entry.relativePath });
|
|
}
|
|
return folder.path;
|
|
}
|
|
|
|
const [entry] = selection.entries || [];
|
|
if (!entry) return null;
|
|
|
|
setStatus({
|
|
text: `Uploading ${entry.file.name}…`,
|
|
level: 'info',
|
|
});
|
|
|
|
const uploaded = await api.uploadFile(entry.file, { relativePath: entry.relativePath });
|
|
return uploaded.path;
|
|
}, []);
|
|
|
|
const openFileBrowser = useCallback(async (callback, { selectionMode = 'file' } = {}) => {
|
|
if (selectionMode === 'folder' && window.pywebview?.api?.open_folder_dialog) {
|
|
window.pywebview.api.open_folder_dialog().then((path) => {
|
|
if (path) callback(path);
|
|
});
|
|
return;
|
|
}
|
|
if (selectionMode === 'file' && window.pywebview?.api?.open_file_dialog) {
|
|
window.pywebview.api.open_file_dialog().then((path) => {
|
|
if (path) callback(path);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const selection = selectionMode === 'folder'
|
|
? await pickNativeDirectorySelection()
|
|
: await pickNativeFileSelection();
|
|
if (!selection) return;
|
|
|
|
const uploadedPath = await uploadBrowserSelection(selection, selectionMode);
|
|
if (uploadedPath) callback(uploadedPath);
|
|
} catch (error) {
|
|
setStatus({
|
|
text: `Browse failed: ${error.message || String(error)}`,
|
|
level: 'error',
|
|
});
|
|
}
|
|
}, [uploadBrowserSelection]);
|
|
|
|
// ── Node context value (stable) ─────────────────────────────────────
|
|
|
|
const onManualTrigger = useCallback((nodeId) => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
// Include ALL nodes (no excludeManualTrigger) so the save node is in the prompt
|
|
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
|
if (!prompt || Object.keys(prompt).length === 0) return;
|
|
setStatus({ text: 'Saving…', level: 'info' });
|
|
api.runPrompt(prompt).catch((err) => {
|
|
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
|
});
|
|
}, [reactFlow]);
|
|
|
|
// ── Add node from context menu ──────────────────────────────────────
|
|
|
|
const addNode = useCallback((className, def) => {
|
|
if (!contextMenu) return;
|
|
if (className === 'TextNote') {
|
|
openJournalTab();
|
|
setContextMenu(null);
|
|
return;
|
|
}
|
|
const position = reactFlow.screenToFlowPosition({
|
|
x: contextMenu.x,
|
|
y: contextMenu.y,
|
|
});
|
|
|
|
const widgetValues = buildDefaultWidgetValues(def);
|
|
|
|
const newNodeId = String(nextIdRef.current++);
|
|
const isTextNote = className === 'TextNote';
|
|
const newNode = {
|
|
id: newNodeId,
|
|
type: 'custom',
|
|
position,
|
|
dragHandle: '.drag-handle',
|
|
...(isTextNote ? { width: 300, height: 220, style: { width: 300, height: 220 } } : {}),
|
|
data: {
|
|
label: def.display_name || className,
|
|
className,
|
|
definition: def,
|
|
widgetValues,
|
|
runtimeValues: {},
|
|
previewImage: null,
|
|
tableRows: null,
|
|
meshData: null,
|
|
overlay: null,
|
|
scalarValue: null,
|
|
processingTimeMs: null,
|
|
},
|
|
};
|
|
|
|
setNodes((ns) => [...ns, newNode]);
|
|
|
|
// Initialize dynamic outputs for nodes that depend on the selected path/folder.
|
|
setTimeout(() => {
|
|
if (className === 'Folder' && widgetValues.folder) {
|
|
refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
|
|
}
|
|
|
|
// For Image/ImageDemo, auto-fetch channels for the default value.
|
|
// Delay this until after the node exists in React Flow so the async
|
|
// response cannot be dropped on creation.
|
|
if (className === 'ImageDemo' && widgetValues.name) {
|
|
refreshLoadNodeOutputs(newNodeId, widgetValues.name);
|
|
}
|
|
if (className === 'Image' && widgetValues.filename) {
|
|
refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
|
|
}
|
|
}, 0);
|
|
|
|
// Auto-connect if this was triggered by dropping a connection on blank space
|
|
if (contextMenu.pendingHandleId) {
|
|
const filterType = contextMenu.filterType;
|
|
const filterSpec = contextMenu.filterSpec || filterType;
|
|
|
|
if (contextMenu.pendingHandleType === 'source') {
|
|
// Dragged from an output → connect to the first matching input on the new node
|
|
const allInputs = { ...(def.input.required || {}), ...(def.input.optional || {}) };
|
|
const inputName = Object.entries(allInputs).find(([, spec]) => {
|
|
return socketSpecAcceptsType(filterType, spec);
|
|
})?.[0];
|
|
if (inputName) {
|
|
const targetType = (() => {
|
|
const spec = allInputs[inputName];
|
|
const [type] = getSpecTypeAndOptions(spec);
|
|
return type;
|
|
})();
|
|
const targetHandle = `input::${inputName}::${targetType}`;
|
|
const color = TYPE_COLORS[filterType] || 'var(--fallback-type)';
|
|
setEdges((eds) => addEdge({
|
|
source: contextMenu.pendingNodeId,
|
|
sourceHandle: contextMenu.pendingHandleId,
|
|
target: newNodeId,
|
|
targetHandle,
|
|
style: { stroke: color, strokeWidth: 2 },
|
|
}, eds));
|
|
}
|
|
} else {
|
|
// Dragged from an input → connect from the first matching output on the new node
|
|
const outputIdx = def.output.findIndex((type, idx) =>
|
|
outputTypeCanConnectToTarget(type, filterSpec, def.output_accepted_types?.[idx] || [])
|
|
);
|
|
if (outputIdx !== -1) {
|
|
const outputType = resolveOutputTypeForTarget(def.output[outputIdx], filterSpec);
|
|
const sourceHandle = `output::${outputIdx}::${outputType}`;
|
|
const color = TYPE_COLORS[outputType] || 'var(--fallback-type)';
|
|
setEdges((eds) => addEdge({
|
|
source: newNodeId,
|
|
sourceHandle,
|
|
target: contextMenu.pendingNodeId,
|
|
targetHandle: contextMenu.pendingHandleId,
|
|
style: { stroke: color, strokeWidth: 2 },
|
|
}, eds));
|
|
}
|
|
}
|
|
}
|
|
|
|
setContextMenu(null);
|
|
scheduleAutoRun();
|
|
}, [contextMenu, reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]); // scheduleAutoRun stable; openJournalTab stable ([] deps)
|
|
|
|
// ── Toolbar actions ─────────────────────────────────────────────────
|
|
|
|
const runWorkflow = useCallback(async () => {
|
|
// Read current state via functional ref to avoid stale closure
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
|
|
|
if (!prompt || Object.keys(prompt).length === 0) {
|
|
setStatus({ text: 'Graph is empty — add some nodes first.', level: 'error' });
|
|
return;
|
|
}
|
|
setStatus({ text: 'Running…', level: 'info' });
|
|
try {
|
|
await api.runPrompt(prompt);
|
|
} catch (err) {
|
|
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
|
}
|
|
}, [reactFlow]);
|
|
|
|
// Debounced auto-run via ref to avoid dependency chains
|
|
autoRunRef.current = () => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
const runnableNodes = getAutoRunnableNodes(currentNodes, currentEdges);
|
|
|
|
// Don't run if any non-manual node has unconnected required data inputs
|
|
// or any FILE_PICKER widget is empty
|
|
for (const node of runnableNodes) {
|
|
if (hasBlockingAutoRunInput(node, currentEdges)) return;
|
|
}
|
|
|
|
const prompt = serializeExecutionGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
|
|
if (!prompt || Object.keys(prompt).length === 0) return;
|
|
setStatus({ text: 'Running…', level: 'info' });
|
|
api.runPrompt(prompt).catch((err) => {
|
|
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
|
});
|
|
};
|
|
|
|
const scheduleAutoRun = useCallback(() => {
|
|
clearTimeout(autoRunTimer.current);
|
|
autoRunTimer.current = setTimeout(() => autoRunRef.current?.(), 300);
|
|
}, []);
|
|
|
|
const onRuntimeValuesChange = useCallback((nodeId, patch, { scheduleRun = false } = {}) => {
|
|
if (!patch || typeof patch !== 'object') return;
|
|
|
|
setNodes((ns) => ns.map((n) => {
|
|
if (n.id !== nodeId) return n;
|
|
return {
|
|
...n,
|
|
data: {
|
|
...n.data,
|
|
runtimeValues: { ...(n.data.runtimeValues || {}), ...patch },
|
|
},
|
|
};
|
|
}));
|
|
|
|
if (scheduleRun) {
|
|
scheduleAutoRun();
|
|
}
|
|
}, [setNodes, scheduleAutoRun]);
|
|
|
|
const initializeDynamicNodes = useCallback((nodesToInitialize) => {
|
|
setTimeout(() => {
|
|
nodesToInitialize.forEach((node) => {
|
|
if (node.data.className === 'Folder' && node.data.widgetValues?.folder) {
|
|
refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder);
|
|
}
|
|
});
|
|
nodesToInitialize.forEach((node) => {
|
|
if (node.data.className === 'Image' || node.data.className === 'ImageDemo') {
|
|
refreshLoadNodeOutputs(node.id);
|
|
}
|
|
});
|
|
nodesToInitialize.forEach((node) => {
|
|
if (node.data.className === 'Annotations' || node.data.className === 'Markup') {
|
|
refreshAnnotationNodeOutputs(node.id);
|
|
}
|
|
});
|
|
nodesToInitialize.forEach((node) => {
|
|
reactFlow.updateNodeInternals(node.id);
|
|
});
|
|
}, 0);
|
|
}, [reactFlow, refreshAnnotationNodeOutputs, refreshFolderNodeOutputs, refreshLoadNodeOutputs]);
|
|
|
|
const pasteClipboardSelection = useCallback((clipboardText) => {
|
|
const payload = parseNodeClipboardPayload(clipboardText);
|
|
if (!payload) return false;
|
|
|
|
if (clipboardText === lastPastedClipboardTextRef.current) {
|
|
pasteRepeatCountRef.current += 1;
|
|
} else {
|
|
lastPastedClipboardTextRef.current = clipboardText;
|
|
pasteRepeatCountRef.current = 1;
|
|
}
|
|
|
|
const offsetAmount = 36 * pasteRepeatCountRef.current;
|
|
const pasted = instantiateNodeClipboardPayload(
|
|
payload,
|
|
nodeDefsRef.current,
|
|
nextIdRef.current,
|
|
{ x: offsetAmount, y: offsetAmount },
|
|
);
|
|
|
|
if (pasted.nodes.length === 0) return false;
|
|
|
|
nextIdRef.current = pasted.nextNodeId;
|
|
|
|
setNodes((existing) => sortNodesForParentOrder([
|
|
...existing.map((node) => ({ ...node, selected: false })),
|
|
...pasted.nodes,
|
|
]));
|
|
setEdges((existing) => [
|
|
...existing.map((edge) => ({ ...edge, selected: false })),
|
|
...pasted.edges,
|
|
]);
|
|
|
|
initializeDynamicNodes(pasted.nodes);
|
|
|
|
setStatus({
|
|
text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`,
|
|
level: 'info',
|
|
});
|
|
scheduleAutoRun();
|
|
return true;
|
|
}, [
|
|
initializeDynamicNodes,
|
|
reactFlow,
|
|
scheduleAutoRun,
|
|
setEdges,
|
|
setNodes,
|
|
]);
|
|
|
|
const resizeGroup = useCallback((groupId, size) => {
|
|
const nextWidth = Math.round(Number(size?.width) || 0);
|
|
const nextHeight = Math.round(Number(size?.height) || 0);
|
|
if (!nextWidth || !nextHeight) return;
|
|
|
|
setNodes((existing) => existing.map((node) => {
|
|
if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node;
|
|
|
|
const sameSize = Math.abs((Number(node.style?.width) || 0) - nextWidth) < 0.5
|
|
&& Math.abs((Number(node.style?.height) || 0) - nextHeight) < 0.5;
|
|
if (sameSize) return node;
|
|
|
|
return {
|
|
...applyNodeSize(node, nextWidth, nextHeight),
|
|
data: {
|
|
...node.data,
|
|
expandedSize: { width: nextWidth, height: nextHeight },
|
|
},
|
|
};
|
|
}));
|
|
|
|
setTimeout(() => reactFlow.updateNodeInternals(String(groupId)), 0);
|
|
}, [reactFlow, setNodes]);
|
|
|
|
const renameGroup = useCallback((groupId, label) => {
|
|
const nextLabel = String(label || '').trim() || 'group';
|
|
setNodes((existing) => existing.map((node) => {
|
|
if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node;
|
|
if (String(node.data?.label || 'group') === nextLabel) return node;
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
label: nextLabel,
|
|
},
|
|
};
|
|
}));
|
|
}, [setNodes]);
|
|
|
|
const openHelp = useCallback(async (label) => {
|
|
setHelpTabs((prev) => {
|
|
if (prev.find((t) => t.label === label)) return prev;
|
|
return [...prev, { label, content: null }];
|
|
});
|
|
setActiveHelpTab(label);
|
|
const text = await api.getNodeDoc(label);
|
|
setHelpTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.label === label
|
|
? { ...t, content: text || '*No documentation available for this node.*' }
|
|
: t,
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
const closeHelpTab = useCallback((label) => {
|
|
setHelpTabs((prev) => {
|
|
const next = prev.filter((t) => t.label !== label);
|
|
setActiveHelpTab((cur) => {
|
|
if (cur !== label) return cur;
|
|
return next.length > 0 ? next[next.length - 1].label : null;
|
|
});
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const openJournalTab = useCallback(() => {
|
|
setHelpTabs((prev) => {
|
|
if (prev.find((t) => t.label === 'Journal')) return prev;
|
|
return [...prev, { label: 'Journal', type: 'journal', content: journalContentRef.current }];
|
|
});
|
|
setActiveHelpTab('Journal');
|
|
}, []);
|
|
|
|
const updateTabContent = useCallback((label, content) => {
|
|
if (label === 'Journal') journalContentRef.current = content;
|
|
setHelpTabs((prev) => prev.map((t) => t.label === label ? { ...t, content } : t));
|
|
}, []);
|
|
|
|
const contextValue = useMemo(() => ({
|
|
onWidgetChange,
|
|
onRuntimeValuesChange,
|
|
openFileBrowser,
|
|
onManualTrigger,
|
|
onToggleGroupCollapse: toggleGroupCollapse,
|
|
onResizeGroup: resizeGroup,
|
|
onRenameGroup: renameGroup,
|
|
onUngroup: ungroupGroup,
|
|
executingNodeId,
|
|
openHelp,
|
|
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup, executingNodeId, openHelp]);
|
|
|
|
const clearGraph = useCallback(() => {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
nextIdRef.current = 1;
|
|
setStatus({ text: 'Graph cleared.', level: 'info' });
|
|
}, [setNodes, setEdges]);
|
|
|
|
const applyWorkflowData = useCallback((data) => {
|
|
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current);
|
|
setNodes(sortNodesForParentOrder(hydrated.nodes));
|
|
setEdges(hydrated.edges);
|
|
nextIdRef.current = hydrated.nextNodeId;
|
|
journalContentRef.current = data.journalContent || '';
|
|
setHelpTabs((prev) => prev.map((t) =>
|
|
t.label === 'Journal' ? { ...t, content: journalContentRef.current } : t,
|
|
));
|
|
initializeDynamicNodes(hydrated.nodes);
|
|
}, [initializeDynamicNodes, setNodes, setEdges]);
|
|
|
|
const loadDefaultWorkflow = useCallback(async () => {
|
|
if (defaultWorkflowLoadAttemptedRef.current) return;
|
|
defaultWorkflowLoadAttemptedRef.current = true;
|
|
|
|
const graphHasContent = () => {
|
|
const currentNodes = reactFlow.getNodes();
|
|
const currentEdges = reactFlow.getEdges();
|
|
return currentNodes.length > 0 || currentEdges.length > 0;
|
|
};
|
|
|
|
if (graphHasContent()) return;
|
|
|
|
try {
|
|
const loaded = await loadDefaultWorkflowAsset();
|
|
if (!loaded || graphHasContent()) return;
|
|
|
|
applyWorkflowData(loaded.workflow);
|
|
setStatus({ text: `Loaded default workflow from ${loaded.source}.`, level: 'info' });
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => scheduleAutoRun());
|
|
});
|
|
} catch (err) {
|
|
setStatus({ text: 'Default workflow failed to load: ' + err.message, level: 'error' });
|
|
}
|
|
}, [applyWorkflowData, reactFlow, scheduleAutoRun]);
|
|
|
|
// ── Load node definitions ───────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
api.getNodes().then((defs) => {
|
|
nodeDefsRef.current = defs;
|
|
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
|
|
loadDefaultWorkflow();
|
|
}).catch((err) => {
|
|
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
|
|
});
|
|
}, [loadDefaultWorkflow]);
|
|
|
|
const stampLogoOnBlob = useCallback(async (blob) => {
|
|
const [img, logo] = await Promise.all([blob, tonoIconUrl].map((src) => new Promise((resolve, reject) => {
|
|
const el = new Image();
|
|
el.onload = () => resolve(el);
|
|
el.onerror = reject;
|
|
el.src = typeof src === 'string' ? src : URL.createObjectURL(src);
|
|
})));
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const margin = 16;
|
|
const size = Math.min(128, Math.floor(img.naturalWidth / 6), Math.floor(img.naturalHeight / 6));
|
|
if (size >= 16) {
|
|
const logoX = img.naturalWidth - size - margin;
|
|
const logoY = img.naturalHeight - size - margin;
|
|
const fontSize = Math.max(11, Math.round(size * 0.18));
|
|
ctx.font = `500 ${fontSize}px system-ui, sans-serif`;
|
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('open with', logoX + size / 2, logoY - 6);
|
|
ctx.drawImage(logo, logoX, logoY, size, size);
|
|
}
|
|
|
|
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
}, []);
|
|
|
|
const getWorkflowBlob = useCallback(async () => {
|
|
const viewportEl = document.querySelector('.react-flow__viewport');
|
|
if (!viewportEl) throw new Error('Flow element not found');
|
|
|
|
const allNodes = reactFlow.getNodes();
|
|
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
|
|
|
const bounds = getRenderedNodeBounds(allNodes);
|
|
if (!bounds) {
|
|
throw new Error('Could not determine rendered node bounds');
|
|
}
|
|
const pad = 0.1; // 10% margin on each side
|
|
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
|
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
|
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
|
|
|
const blob = await captureWorkflowViewportBlob(viewportEl, {
|
|
backgroundColor: CANVAS_COLORS.bgDeep,
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
style: {
|
|
width: `${imageWidth}px`,
|
|
height: `${imageHeight}px`,
|
|
transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`,
|
|
},
|
|
});
|
|
if (!blob) throw new Error('Capture returned empty');
|
|
|
|
const stampedBlob = await stampLogoOnBlob(blob);
|
|
const workflow = serializeWorkflowState(allNodes, reactFlow.getEdges());
|
|
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
|
return embedWorkflow(stampedBlob, workflow);
|
|
}, [reactFlow]);
|
|
|
|
const saveWorkflow = useCallback(async () => {
|
|
setStatus({ text: 'Saving…', level: 'info' });
|
|
try {
|
|
const finalBlob = await getWorkflowBlob();
|
|
|
|
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
|
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path('workflow.png');
|
|
if (!requestedPath) {
|
|
setStatus({ text: 'Save cancelled.', level: 'info' });
|
|
return;
|
|
}
|
|
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'image/png',
|
|
},
|
|
body: finalBlob,
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text() || `Save failed (${resp.status})`);
|
|
}
|
|
const { path: savedPath } = await resp.json();
|
|
if (!savedPath) {
|
|
setStatus({ text: 'Save cancelled.', level: 'info' });
|
|
return;
|
|
}
|
|
setStatus({ text: `Workflow saved to ${savedPath}.`, level: 'info' });
|
|
return;
|
|
}
|
|
|
|
if ('showSaveFilePicker' in window) {
|
|
try {
|
|
const handle = await window.showSaveFilePicker({
|
|
suggestedName: 'workflow.png',
|
|
types: [
|
|
{
|
|
description: 'PNG image',
|
|
accept: { 'image/png': ['.png'] },
|
|
},
|
|
],
|
|
});
|
|
const writable = await handle.createWritable();
|
|
await writable.write(finalBlob);
|
|
await writable.close();
|
|
setStatus({ text: 'Workflow saved as workflow.png.', level: 'info' });
|
|
return;
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') {
|
|
setStatus({ text: 'Save cancelled.', level: 'info' });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Final fallback: trigger a browser download and tell the user where it went.
|
|
const resp = await fetch('/download?filename=workflow.png', {
|
|
method: 'POST',
|
|
body: finalBlob,
|
|
});
|
|
const dlBlob = await resp.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(dlBlob);
|
|
a.download = 'workflow.png';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
|
|
setStatus({
|
|
text: 'Workflow downloaded as workflow.png to your browser default downloads folder.',
|
|
level: 'info',
|
|
});
|
|
} catch (err) {
|
|
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
|
}
|
|
}, [getWorkflowBlob]);
|
|
|
|
const copySnapshot = useCallback(() => {
|
|
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
|
// Pass a Promise<Blob> to ClipboardItem so the clipboard.write() call
|
|
// happens synchronously within the user gesture, avoiding permission errors.
|
|
const blobPromise = getWorkflowBlob().catch((err) => {
|
|
setStatus({ text: 'Snapshot failed: ' + err.message, level: 'error' });
|
|
throw err;
|
|
});
|
|
navigator.clipboard.write([new ClipboardItem({ 'image/png': blobPromise })]).then(() => {
|
|
setStatus({ text: 'Snapshot copied to clipboard.', level: 'info' });
|
|
}).catch((err) => {
|
|
setStatus({ text: 'Copy failed: ' + err.message, level: 'error' });
|
|
});
|
|
}, [getWorkflowBlob]);
|
|
|
|
const loadWorkflow = useCallback(() => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json,.png';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
try {
|
|
let data;
|
|
const lowerName = file.name.toLowerCase();
|
|
if (lowerName.endsWith('.png') || file.type === 'image/png') {
|
|
data = await extractWorkflow(file);
|
|
if (!data) {
|
|
setStatus({ text: 'No workflow data found in image.', level: 'error' });
|
|
return;
|
|
}
|
|
} else {
|
|
data = JSON.parse(await file.text());
|
|
}
|
|
applyWorkflowData(data);
|
|
setStatus({ text: 'Workflow loaded.', level: 'info' });
|
|
} catch {
|
|
setStatus({ text: 'Invalid workflow file.', level: 'error' });
|
|
}
|
|
};
|
|
input.click();
|
|
}, [applyWorkflowData]);
|
|
|
|
const uploadPlugin = useCallback(() => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.py';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
setStatus({ text: 'Uploading plugin…', level: 'info' });
|
|
try {
|
|
await api.uploadPlugin(file);
|
|
// Node list refresh is handled by the nodes_updated WebSocket message.
|
|
} catch (err) {
|
|
setStatus({ text: err.message, level: 'error' });
|
|
}
|
|
};
|
|
input.click();
|
|
}, []);
|
|
|
|
// ── Drag-and-drop workflow image loading ───────────────────────────
|
|
|
|
const onDropFile = useCallback(async (event) => {
|
|
const files = event.dataTransfer?.files;
|
|
if (!files || files.length === 0) return;
|
|
event.preventDefault();
|
|
|
|
const file = files[0];
|
|
const lowerName = file.name.toLowerCase();
|
|
if (file.type !== 'image/png' && !lowerName.endsWith('.png')) return;
|
|
|
|
try {
|
|
const data = await extractWorkflow(file);
|
|
if (!data) {
|
|
setStatus({ text: 'No workflow data in this image.', level: 'error' });
|
|
return;
|
|
}
|
|
applyWorkflowData(data);
|
|
setStatus({ text: 'Workflow loaded from image.', level: 'info' });
|
|
} catch (err) {
|
|
setStatus({ text: 'Failed to load: ' + err.message, level: 'error' });
|
|
}
|
|
}, [applyWorkflowData]);
|
|
|
|
const onDragOver = useCallback((event) => {
|
|
if (event.dataTransfer?.types?.includes('Files')) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}, []);
|
|
|
|
const onNodeDragStart = useCallback((event, node) => {
|
|
activeDragNodeIdRef.current = String(node.id);
|
|
dragStateRef.current = null;
|
|
|
|
if (!(event.ctrlKey || event.metaKey)) {
|
|
duplicateDragRef.current = null;
|
|
const currentNodes = reactFlow.getNodes();
|
|
const draggedNodes = node.data?.className === 'Group'
|
|
? []
|
|
: (
|
|
node.selected
|
|
? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
|
|
: currentNodes.filter((candidate) => candidate.id === node.id)
|
|
);
|
|
const pointerFlowPos = getEventFlowPosition(event, reactFlow);
|
|
if (draggedNodes.length > 0 && pointerFlowPos) {
|
|
const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate]));
|
|
const absolutePositions = Object.fromEntries(
|
|
draggedNodes.map((candidate) => [
|
|
String(candidate.id),
|
|
getNodeAbsolutePosition(candidate, nodeMap),
|
|
]),
|
|
);
|
|
const anchorAbsolute = absolutePositions[String(node.id)] || getNodeAbsolutePosition(node, nodeMap);
|
|
dragStateRef.current = {
|
|
anchorId: String(node.id),
|
|
anchorStartAbsolute: anchorAbsolute,
|
|
absolutePositions,
|
|
releasedNodeIds: new Set(),
|
|
touchedGroupIds: new Set(),
|
|
pointerOffset: {
|
|
x: pointerFlowPos.x - anchorAbsolute.x,
|
|
y: pointerFlowPos.y - anchorAbsolute.y,
|
|
},
|
|
};
|
|
}
|
|
if (node.data?.className === 'Group') {
|
|
const descendantIds = collectGroupDescendantIds(currentNodes, node.id);
|
|
if (descendantIds.size > 0) {
|
|
setNodes((existing) => existing.map((candidate) => (
|
|
descendantIds.has(String(candidate.id))
|
|
? { ...candidate, selected: false }
|
|
: candidate
|
|
)));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentNodes = reactFlow.getNodes();
|
|
const draggedNodes = node.selected
|
|
? currentNodes.filter((candidate) => candidate.selected)
|
|
: currentNodes.filter((candidate) => candidate.id === node.id);
|
|
if (draggedNodes.length === 0) return;
|
|
|
|
const draggedIds = draggedNodes.map((candidate) => String(candidate.id));
|
|
const payload = buildNodeClipboardPayloadForIds(
|
|
currentNodes,
|
|
reactFlow.getEdges(),
|
|
draggedIds,
|
|
{ includeIncomingExternalEdges: true },
|
|
);
|
|
if (!payload) return;
|
|
|
|
const duplicated = instantiateNodeClipboardPayload(
|
|
payload,
|
|
nodeDefsRef.current,
|
|
nextIdRef.current,
|
|
{ x: 0, y: 0 },
|
|
{ keepExternalSources: true },
|
|
);
|
|
if (duplicated.nodes.length === 0) return;
|
|
|
|
nextIdRef.current = duplicated.nextNodeId;
|
|
|
|
const originPositions = Object.fromEntries(
|
|
draggedNodes.map((candidate) => [
|
|
String(candidate.id),
|
|
{
|
|
x: Number(candidate.position?.x) || 0,
|
|
y: Number(candidate.position?.y) || 0,
|
|
},
|
|
]),
|
|
);
|
|
const duplicateSourceById = Object.fromEntries(
|
|
payload.nodes.map((candidate, index) => [duplicated.nodes[index]?.id, String(candidate.id)]).filter(([id]) => !!id),
|
|
);
|
|
|
|
duplicateDragRef.current = {
|
|
anchorId: String(node.id),
|
|
draggedIds,
|
|
originPositions,
|
|
duplicateSourceById,
|
|
};
|
|
|
|
setNodes((existing) => sortNodesForParentOrder([
|
|
...existing.map((candidate) => ({ ...candidate, selected: false })),
|
|
...duplicated.nodes,
|
|
]));
|
|
setEdges((existing) => [
|
|
...existing.map((edge) => ({ ...edge, selected: false })),
|
|
...duplicated.edges,
|
|
]);
|
|
|
|
initializeDynamicNodes(duplicated.nodes);
|
|
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
|
|
|
|
const onNodeDrag = useCallback((event, node) => {
|
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
|
|
|
const duplicateState = duplicateDragRef.current;
|
|
if (duplicateState) {
|
|
const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
|
|
const anchorOrigin = duplicateState.originPositions[anchorId];
|
|
if (!anchorOrigin) return;
|
|
|
|
const offset = {
|
|
x: (Number(node.position?.x) || 0) - anchorOrigin.x,
|
|
y: (Number(node.position?.y) || 0) - anchorOrigin.y,
|
|
};
|
|
const draggedIdSet = new Set(duplicateState.draggedIds);
|
|
|
|
setNodes((existing) => existing.map((candidate) => {
|
|
const candidateId = String(candidate.id);
|
|
const originalPosition = duplicateState.originPositions[candidateId];
|
|
if (draggedIdSet.has(candidateId) && originalPosition) {
|
|
return {
|
|
...candidate,
|
|
selected: false,
|
|
position: originalPosition,
|
|
};
|
|
}
|
|
|
|
const sourceId = duplicateState.duplicateSourceById[candidateId];
|
|
if (sourceId) {
|
|
const sourceOrigin = duplicateState.originPositions[sourceId];
|
|
if (!sourceOrigin) return candidate;
|
|
return {
|
|
...candidate,
|
|
selected: true,
|
|
position: {
|
|
x: sourceOrigin.x + offset.x,
|
|
y: sourceOrigin.y + offset.y,
|
|
},
|
|
};
|
|
}
|
|
|
|
return candidate;
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const dragState = dragStateRef.current;
|
|
if (!dragState || node.data?.className === 'Group') return;
|
|
|
|
const currentNodes = reactFlow.getNodes();
|
|
const draggedNodes = node.selected
|
|
? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
|
|
: currentNodes.filter((candidate) => candidate.id === node.id);
|
|
if (draggedNodes.length === 0) return;
|
|
|
|
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
|
if (!dragIntent?.pointerFlowPos) return;
|
|
|
|
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
|
|
const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate]));
|
|
const releasedNodeIds = dragState.releasedNodeIds instanceof Set
|
|
? new Set(dragState.releasedNodeIds)
|
|
: new Set();
|
|
const touchedGroupIds = dragState.touchedGroupIds instanceof Set
|
|
? new Set(dragState.touchedGroupIds)
|
|
: new Set();
|
|
|
|
let nextNodes = currentNodes;
|
|
let changed = false;
|
|
let structureChanged = false;
|
|
|
|
nextNodes = nextNodes.map((candidate) => {
|
|
const candidateId = String(candidate.id);
|
|
if (!draggedIdSet.has(candidateId)) return candidate;
|
|
|
|
const absolute = dragIntent.absolutePositions.get(candidateId)
|
|
|| getNodeAbsolutePosition(candidate, nodeMap);
|
|
if (!absolute) return candidate;
|
|
|
|
if (candidate.parentId) {
|
|
const parentId = String(candidate.parentId);
|
|
const parentNode = nodeMap.get(parentId);
|
|
if (parentNode?.data?.className === 'Group') {
|
|
const parentRect = getGroupWorkspaceBounds(parentNode, nodeMap);
|
|
const parentAbsolute = getNodeAbsolutePosition(parentNode, nodeMap);
|
|
const nextPosition = {
|
|
x: absolute.x - parentAbsolute.x,
|
|
y: absolute.y - parentAbsolute.y,
|
|
};
|
|
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
|
|
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
|
|
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
|
|
|
|
if (!releasedNodeIds.has(candidateId) && !rectContainsRect(parentRect, candidateRect)) {
|
|
releasedNodeIds.add(candidateId);
|
|
changed = true;
|
|
return {
|
|
...candidate,
|
|
extent: undefined,
|
|
hidden: false,
|
|
position: nextPosition,
|
|
};
|
|
}
|
|
|
|
if (releasedNodeIds.has(candidateId)) {
|
|
if (!candidate.parentId && !candidate.extent && candidate.hidden !== true && samePosition) {
|
|
return candidate;
|
|
}
|
|
|
|
changed = true;
|
|
return {
|
|
...candidate,
|
|
extent: undefined,
|
|
hidden: false,
|
|
position: nextPosition,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!releasedNodeIds.has(candidateId)) return candidate;
|
|
return candidate;
|
|
});
|
|
|
|
if (!changed) return;
|
|
|
|
dragStateRef.current = {
|
|
...dragState,
|
|
releasedNodeIds,
|
|
touchedGroupIds,
|
|
};
|
|
|
|
setNodes(structureChanged ? sortNodesForParentOrder(nextNodes) : nextNodes);
|
|
|
|
if (structureChanged) {
|
|
setTimeout(() => {
|
|
touchedGroupIds.forEach((groupId) => {
|
|
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
|
|
});
|
|
}, 0);
|
|
}
|
|
}, [reactFlow, refreshGroupNode, setNodes]);
|
|
|
|
const onNodeDragStop = useCallback((event, node) => {
|
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
|
activeDragNodeIdRef.current = null;
|
|
|
|
const dragState = dragStateRef.current;
|
|
dragStateRef.current = null;
|
|
const duplicateState = duplicateDragRef.current;
|
|
duplicateDragRef.current = null;
|
|
if (duplicateState) {
|
|
const anchorId = duplicateState.anchorId || duplicateState.draggedIds[0];
|
|
const anchorOrigin = duplicateState.originPositions[anchorId];
|
|
if (!anchorOrigin) return;
|
|
|
|
const offset = {
|
|
x: (Number(node.position?.x) || 0) - anchorOrigin.x,
|
|
y: (Number(node.position?.y) || 0) - anchorOrigin.y,
|
|
};
|
|
const draggedIdSet = new Set(duplicateState.draggedIds);
|
|
|
|
setNodes((existing) => existing.map((candidate) => {
|
|
const candidateId = String(candidate.id);
|
|
const originalPosition = duplicateState.originPositions[candidateId];
|
|
if (draggedIdSet.has(candidateId) && originalPosition) {
|
|
return {
|
|
...candidate,
|
|
selected: false,
|
|
position: originalPosition,
|
|
};
|
|
}
|
|
|
|
const sourceId = duplicateState.duplicateSourceById[candidateId];
|
|
if (sourceId) {
|
|
const sourceOrigin = duplicateState.originPositions[sourceId];
|
|
if (!sourceOrigin) return candidate;
|
|
return {
|
|
...candidate,
|
|
selected: true,
|
|
position: {
|
|
x: sourceOrigin.x + offset.x,
|
|
y: sourceOrigin.y + offset.y,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
...candidate,
|
|
selected: false,
|
|
};
|
|
}));
|
|
|
|
setStatus({
|
|
text: `Duplicated ${Object.keys(duplicateState.duplicateSourceById).length} node${Object.keys(duplicateState.duplicateSourceById).length === 1 ? '' : 's'}.`,
|
|
level: 'info',
|
|
});
|
|
scheduleAutoRun();
|
|
return;
|
|
}
|
|
|
|
const currentNodes = reactFlow.getNodes();
|
|
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
|
const touchedGroupIds = dragState?.touchedGroupIds instanceof Set
|
|
? new Set(dragState.touchedGroupIds)
|
|
: new Set();
|
|
let nextNodes = currentNodes;
|
|
let changed = false;
|
|
|
|
const draggedNodes = node.data?.className === 'Group'
|
|
? []
|
|
: (
|
|
node.selected
|
|
? nextNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
|
|
: nextNodes.filter((candidate) => candidate.id === node.id)
|
|
);
|
|
|
|
if (draggedNodes.length > 0) {
|
|
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
|
|
const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
|
|
const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id));
|
|
const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id))
|
|
|| (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null);
|
|
const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute
|
|
? {
|
|
x: intendedAnchorAbsolute.x + (Number(getNodeDimension(anchorNode, 'width')) || 200) / 2,
|
|
y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2,
|
|
}
|
|
: null;
|
|
const targetGroup = findExpandedGroupDropTarget(
|
|
nextNodes,
|
|
Array.from(draggedIdSet),
|
|
node.id,
|
|
intendedAnchorCenter,
|
|
);
|
|
if (targetGroup) {
|
|
const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap);
|
|
const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap);
|
|
let joinedCount = 0;
|
|
|
|
nextNodes = nextNodes.map((candidate) => {
|
|
if (!draggedIdSet.has(String(candidate.id))) return candidate;
|
|
|
|
const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id));
|
|
const width = Number(getNodeDimension(candidate, 'width')) || 200;
|
|
const height = Number(getNodeDimension(candidate, 'height')) || 120;
|
|
const center = intendedAbsolute
|
|
? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 }
|
|
: getNodeCenter(candidate, nodeMap);
|
|
if (!rectContainsPoint(targetRect, center)) return candidate;
|
|
|
|
const absolute = intendedAbsolute || getNodeAbsolutePosition(candidate, nodeMap);
|
|
const nextPosition = {
|
|
x: absolute.x - targetAbs.x,
|
|
y: absolute.y - targetAbs.y,
|
|
};
|
|
const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id);
|
|
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
|
|
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
|
|
if (alreadyInTarget && candidate.extent === 'parent' && samePosition) return candidate;
|
|
|
|
if (candidate.parentId) {
|
|
touchedGroupIds.add(String(candidate.parentId));
|
|
}
|
|
touchedGroupIds.add(String(targetGroup.id));
|
|
joinedCount += 1;
|
|
changed = true;
|
|
return {
|
|
...candidate,
|
|
parentId: String(targetGroup.id),
|
|
extent: 'parent',
|
|
hidden: false,
|
|
position: nextPosition,
|
|
};
|
|
});
|
|
|
|
if (joinedCount > 0) {
|
|
setStatus({
|
|
text: `Added ${joinedCount} node${joinedCount === 1 ? '' : 's'} to group.`,
|
|
level: 'info',
|
|
});
|
|
}
|
|
} else {
|
|
let removedCount = 0;
|
|
|
|
nextNodes = nextNodes.map((candidate) => {
|
|
if (!draggedIdSet.has(String(candidate.id)) || !candidate.parentId) return candidate;
|
|
|
|
const parentId = String(candidate.parentId);
|
|
const parentNode = nodeMap.get(parentId);
|
|
if (!parentNode || parentNode.data?.className !== 'Group') return candidate;
|
|
const absolute = dragIntent?.absolutePositions.get(String(candidate.id))
|
|
|| getNodeAbsolutePosition(candidate, nodeMap);
|
|
const parentWorkspaceRect = getGroupWorkspaceBounds(parentNode, nodeMap);
|
|
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
|
|
if (rectContainsRect(parentWorkspaceRect, candidateRect)) {
|
|
if (candidate.extent === 'parent') return candidate;
|
|
changed = true;
|
|
return {
|
|
...candidate,
|
|
extent: 'parent',
|
|
hidden: false,
|
|
};
|
|
}
|
|
|
|
touchedGroupIds.add(parentId);
|
|
removedCount += 1;
|
|
changed = true;
|
|
return {
|
|
...candidate,
|
|
parentId: undefined,
|
|
extent: undefined,
|
|
hidden: false,
|
|
position: absolute,
|
|
};
|
|
});
|
|
|
|
if (removedCount > 0) {
|
|
setStatus({
|
|
text: `Removed ${removedCount} node${removedCount === 1 ? '' : 's'} from group.`,
|
|
level: 'info',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
const releasedCount = dragState?.releasedNodeIds instanceof Set ? dragState.releasedNodeIds.size : 0;
|
|
if (releasedCount > 0) {
|
|
setStatus({
|
|
text: `Removed ${releasedCount} node${releasedCount === 1 ? '' : 's'} from group.`,
|
|
level: 'info',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
setNodes(sortNodesForParentOrder(nextNodes));
|
|
setTimeout(() => {
|
|
touchedGroupIds.forEach((groupId) => {
|
|
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
|
|
});
|
|
}, 0);
|
|
}, [reactFlow, refreshGroupNode, scheduleAutoRun, setNodes]);
|
|
|
|
// ── Keyboard shortcut ───────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
runWorkflow();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [runWorkflow]);
|
|
|
|
useEffect(() => {
|
|
const handleCopy = (event) => {
|
|
if (isEditableTarget(event.target)) return;
|
|
|
|
const payload = buildNodeClipboardPayload(reactFlow.getNodes(), reactFlow.getEdges());
|
|
if (!payload) return;
|
|
|
|
const serialized = JSON.stringify(payload);
|
|
event.preventDefault();
|
|
event.clipboardData?.setData(NODE_CLIPBOARD_MIME, serialized);
|
|
event.clipboardData?.setData('text/plain', serialized);
|
|
setStatus({
|
|
text: `Copied ${payload.nodes.length} node${payload.nodes.length === 1 ? '' : 's'}.`,
|
|
level: 'info',
|
|
});
|
|
};
|
|
|
|
const handlePaste = (event) => {
|
|
if (isEditableTarget(event.target)) return;
|
|
|
|
const clipboardText = event.clipboardData?.getData(NODE_CLIPBOARD_MIME)
|
|
|| event.clipboardData?.getData('text/plain')
|
|
|| '';
|
|
if (!clipboardText) return;
|
|
|
|
const pasted = pasteClipboardSelection(clipboardText);
|
|
if (pasted) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('copy', handleCopy);
|
|
window.addEventListener('paste', handlePaste);
|
|
return () => {
|
|
window.removeEventListener('copy', handleCopy);
|
|
window.removeEventListener('paste', handlePaste);
|
|
};
|
|
}, [pasteClipboardSelection, reactFlow]);
|
|
|
|
// ── Context menu ────────────────────────────────────────────────────
|
|
|
|
const onPaneContextMenu = useCallback((event) => {
|
|
event.preventDefault();
|
|
if (performance.now() < suppressPaneContextMenuUntilRef.current) {
|
|
suppressPaneContextMenuUntilRef.current = 0;
|
|
return;
|
|
}
|
|
setContextMenu({ x: event.clientX, y: event.clientY });
|
|
}, []);
|
|
|
|
const onFlowContainerPointerDown = useCallback((event) => {
|
|
if (event.button !== 2) return;
|
|
if (!canStartCanvasRightDragZoom(event.target)) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setContextMenu(null);
|
|
|
|
const viewport = reactFlow.getViewport();
|
|
canvasRightZoomRef.current = {
|
|
pointerId: event.pointerId,
|
|
startY: event.clientY,
|
|
startZoom: Number(viewport.zoom) || 1,
|
|
moved: false,
|
|
};
|
|
setIsCanvasRightZooming(true);
|
|
|
|
try {
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
} catch {
|
|
// Ignore capture failures; global listeners still complete the interaction.
|
|
}
|
|
}, [reactFlow]);
|
|
|
|
const onFlowContainerContextMenuCapture = useCallback((event) => {
|
|
if (canvasRightZoomRef.current?.moved || performance.now() < suppressPaneContextMenuUntilRef.current) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
}, []);
|
|
|
|
const onFlowContainerWheel = useCallback(() => {
|
|
const container = flowContainerRef.current;
|
|
if (!container) return;
|
|
container.classList.add('is-panning');
|
|
clearTimeout(panTimerRef.current);
|
|
panTimerRef.current = setTimeout(() => {
|
|
container.classList.remove('is-panning');
|
|
}, 150);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handlePointerMove = (event) => {
|
|
const zoomState = canvasRightZoomRef.current;
|
|
if (!zoomState || event.pointerId !== zoomState.pointerId) return;
|
|
|
|
const deltaY = event.clientY - zoomState.startY;
|
|
if (Math.abs(deltaY) < CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD) return;
|
|
|
|
event.preventDefault();
|
|
zoomState.moved = true;
|
|
|
|
const container = flowContainerRef.current;
|
|
if (!container) return;
|
|
const bounds = container.getBoundingClientRect();
|
|
const localX = event.clientX - bounds.left;
|
|
const localY = event.clientY - bounds.top;
|
|
const currentViewport = reactFlow.getViewport();
|
|
const flowX = (localX - currentViewport.x) / currentViewport.zoom;
|
|
const flowY = (localY - currentViewport.y) / currentViewport.zoom;
|
|
const nextZoom = clampNumber(
|
|
zoomState.startZoom * Math.exp(-deltaY * CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY),
|
|
CANVAS_MIN_ZOOM,
|
|
CANVAS_MAX_ZOOM,
|
|
);
|
|
|
|
reactFlow.setViewport({
|
|
x: localX - (flowX * nextZoom),
|
|
y: localY - (flowY * nextZoom),
|
|
zoom: nextZoom,
|
|
}, { duration: 0 });
|
|
};
|
|
|
|
const finishPointerInteraction = (event) => {
|
|
const zoomState = canvasRightZoomRef.current;
|
|
if (!zoomState || event.pointerId !== zoomState.pointerId) return;
|
|
|
|
if (zoomState.moved) {
|
|
suppressPaneContextMenuUntilRef.current = performance.now() + 250;
|
|
}
|
|
canvasRightZoomRef.current = null;
|
|
setIsCanvasRightZooming(false);
|
|
|
|
const container = flowContainerRef.current;
|
|
if (container?.hasPointerCapture?.(event.pointerId)) {
|
|
try {
|
|
container.releasePointerCapture(event.pointerId);
|
|
} catch {
|
|
// Ignore capture release errors.
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pointermove', handlePointerMove, true);
|
|
window.addEventListener('pointerup', finishPointerInteraction, true);
|
|
window.addEventListener('pointercancel', finishPointerInteraction, true);
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove, true);
|
|
window.removeEventListener('pointerup', finishPointerInteraction, true);
|
|
window.removeEventListener('pointercancel', finishPointerInteraction, true);
|
|
};
|
|
}, [reactFlow]);
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return undefined;
|
|
|
|
const handlePointerDown = (event) => {
|
|
if (event.target.closest('.context-menu')) return;
|
|
setContextMenu(null);
|
|
};
|
|
|
|
window.addEventListener('pointerdown', handlePointerDown, true);
|
|
return () => window.removeEventListener('pointerdown', handlePointerDown, true);
|
|
}, [contextMenu]);
|
|
|
|
const selectedNodeCount = nodes.filter((node) => node.selected).length;
|
|
|
|
// ── Render ──────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<NodeContext.Provider value={contextValue}>
|
|
<div className="app-container">
|
|
{/* Toolbar */}
|
|
<div id="toolbar">
|
|
<span id="app-title">tono</span>
|
|
|
|
<div className="toolbar-group">
|
|
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)">
|
|
▶ Run
|
|
</button>
|
|
<button className="btn" onClick={clearGraph} title="Clear graph">
|
|
✕ Clear
|
|
</button>
|
|
</div>
|
|
|
|
<div className="toolbar-group">
|
|
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
|
|
⤓ Save
|
|
</button>
|
|
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
|
|
⤒ Load
|
|
</button>
|
|
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
|
|
⎘ Snapshot
|
|
</button>
|
|
<button className="btn" onClick={uploadPlugin} title="Upload a plugin (.py)">
|
|
⊕ Plugin
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`status-bar ${status.level}`}>{status.text}</div>
|
|
</div>
|
|
|
|
{/* React Flow canvas */}
|
|
<div
|
|
ref={flowContainerRef}
|
|
className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`}
|
|
onDrop={onDropFile}
|
|
onDragOver={onDragOver}
|
|
onWheel={onFlowContainerWheel}
|
|
onPointerDownCapture={onFlowContainerPointerDown}
|
|
onContextMenuCapture={onFlowContainerContextMenuCapture}
|
|
>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={handleNodesChange}
|
|
onEdgesChange={handleEdgesChange}
|
|
onNodeDragStart={onNodeDragStart}
|
|
onNodeDrag={onNodeDrag}
|
|
onNodeDragStop={onNodeDragStop}
|
|
onConnect={onConnect}
|
|
onConnectEnd={onConnectEnd}
|
|
isValidConnection={isValidConnection}
|
|
nodeTypes={NODE_TYPES}
|
|
onPaneContextMenu={onPaneContextMenu}
|
|
colorMode="dark"
|
|
panOnDrag={[1]}
|
|
panOnScroll
|
|
panOnScrollSpeed={1.5}
|
|
panOnScrollMode={PanOnScrollMode.Free}
|
|
zoomOnScroll={false}
|
|
selectionOnDrag
|
|
selectionMode={SelectionMode.Partial}
|
|
multiSelectionKeyCode={['Shift']}
|
|
deleteKeyCode={['Backspace', 'Delete']}
|
|
defaultEdgeOptions={{ type: 'default' }}
|
|
>
|
|
<Background />
|
|
<Controls />
|
|
<MiniMap
|
|
nodeColor={(n) => {
|
|
const cat = n.data?.definition?.category;
|
|
return CAT_COLORS[cat] || 'var(--fallback-cat)';
|
|
}}
|
|
/>
|
|
</ReactFlow>
|
|
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
nodeDefs={nodeDefsRef.current}
|
|
onAdd={addNode}
|
|
onCreateGroup={createGroupFromSelection}
|
|
onClose={() => setContextMenu(null)}
|
|
filterType={contextMenu.filterType}
|
|
filterSpec={contextMenu.filterSpec}
|
|
filterDirection={contextMenu.filterDirection}
|
|
selectedNodeCount={selectedNodeCount}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
<HelpPanelManager
|
|
tabs={helpTabs}
|
|
activeTab={activeHelpTab}
|
|
onTabSelect={setActiveHelpTab}
|
|
onTabClose={closeHelpTab}
|
|
onTabContentChange={updateTabContent}
|
|
onOpenJournal={openJournalTab}
|
|
/>
|
|
</NodeContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ── App wrapper with ReactFlowProvider ────────────────────────────────
|
|
|
|
export default function App() {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<Flow />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|