blob: a688a4022543503936f6855aee7d105bb5ba4895 [file] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {Button, ButtonVariant} from './button';
import {PopupMenu} from './menu';
import {classNames} from '../base/classnames';
// ========================================
// TYPE DEFINITIONS
// ========================================
interface Position {
x: number;
y: number;
transformedX?: number;
transformedY?: number;
}
export interface Connection {
fromNode: string;
fromPort: number;
toNode: string;
toPort: number;
}
export interface Node {
id: string;
x: number;
y: number;
inputs?: string[];
outputs?: string[];
content?: m.Children; // Optional custom content to render in node body
contextMenu?: m.Children; // Optional context menu items
next?: Omit<Node, 'x' | 'y'>; // Next node in chain (linked list)
addMenuItems?: m.Children;
allInputsLeft?: boolean;
allOutputsRight?: boolean;
}
interface ConnectingState {
nodeId: string;
portIndex: number;
type: 'input' | 'output';
portType: 'top' | 'bottom' | 'left' | 'right';
x: number;
y: number;
transformedX: number;
transformedY: number;
}
interface UndockCandidate {
nodeId: string;
parentId: string;
startX: number;
startY: number;
renderY: number;
}
interface CanvasState {
draggedNode: string | null;
dragOffset: Position;
connecting: ConnectingState | null;
mousePos: Position;
selectedNode: string | null;
panOffset: Position;
isPanning: boolean;
panStart: Position;
zoom: number;
dockTarget: string | null; // Node being targeted for docking
isDockZone: boolean; // Whether we're in valid dock position
undockCandidate: UndockCandidate | null; // Tracks potential undock before threshold
}
export interface NodeGraphApi {
autoLayout: () => void;
recenter: () => void;
}
export interface NodeGraphAttrs {
readonly nodes?: Node[];
readonly connections?: Connection[];
readonly onConnect?: (connection: Connection) => void;
readonly onNodeDrag?: (nodeId: string, x: number, y: number) => void;
readonly onConnectionRemove?: (index: number) => void;
readonly onReady?: (api: NodeGraphApi) => void;
readonly selectedNodeId?: string | null;
readonly onNodeSelect?: (nodeId: string | null) => void;
readonly onDock?: (
parentId: string,
childNode: Omit<Node, 'x' | 'y'>,
) => void;
readonly onUndock?: (parentId: string) => void;
readonly onNodeRemove?: (nodeId: string) => void;
}
// ========================================
// CONSTANTS
// ========================================
const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking
// ========================================
// HELPER FUNCTIONS
// ========================================
function isPortConnected(
nodeId: string,
portType: 'input' | 'output',
portIndex: number,
connections: Connection[],
): boolean {
return connections.some((conn) => {
if (portType === 'input') {
return conn.toNode === nodeId && conn.toPort === portIndex;
} else {
return conn.fromNode === nodeId && conn.fromPort === portIndex;
}
});
}
// Get the entire chain starting from a root node
function getChain(rootNode: Node): Array<Node | Omit<Node, 'x' | 'y'>> {
const chain: Array<Node | Omit<Node, 'x' | 'y'>> = [rootNode];
let current = rootNode.next;
while (current) {
chain.push(current);
current = current.next;
}
return chain;
}
function createCurve(
x1: number,
y1: number,
x2: number,
y2: number,
fromPortType?: 'top' | 'bottom' | 'left' | 'right',
toPortType?: 'top' | 'bottom' | 'left' | 'right',
shortenEnd = 0,
): string {
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
let cx1: number;
let cy1: number;
let cx2: number;
let cy2: number;
// For top/bottom ports, control points extend vertically
// For left/right ports, control points extend horizontally
if (fromPortType === 'bottom' || fromPortType === 'top') {
// First control point extends vertically
const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
cx1 = x1;
cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset;
} else {
// First control point extends horizontally for left/right ports
const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
cx1 = x1 + horizontalOffset;
cy1 = y1; // Keep Y constant for horizontal extension
}
if (toPortType === 'bottom' || toPortType === 'top') {
// Second control point extends vertically
const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
cx2 = x2;
cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset;
} else {
// Second control point extends horizontally for left/right ports
const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
cx2 = x2 - horizontalOffset;
cy2 = y2; // Keep Y constant for horizontal extension
}
if (shortenEnd > 0) {
const tangentX = x2 - cx2;
const tangentY = y2 - cy2;
const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
if (tangentLength > shortenEnd) {
const unitTangentX = tangentX / tangentLength;
const unitTangentY = tangentY / tangentLength;
x2 -= unitTangentX * shortenEnd;
y2 -= unitTangentY * shortenEnd;
}
}
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}
// Auto-layout nodes in a hierarchical arrangement based on connections
export function autoLayoutNodes(
nodes: Node[],
connections: Connection[],
): void {
// Find root nodes (nodes with no incoming connections)
const incomingCounts = new Map<string, number>();
nodes.forEach((node) => incomingCounts.set(node.id, 0));
connections.forEach((conn) => {
const currentCount = incomingCounts.get(conn.toNode) ?? 0;
incomingCounts.set(conn.toNode, currentCount + 1);
});
const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0);
const visited = new Set<string>();
const layers: string[][] = [];
// BFS to assign nodes to layers
const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({
id: n.id,
layer: 0,
}));
while (queue.length > 0) {
const {id, layer} = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
if (layers[layer] === undefined) layers[layer] = [];
layers[layer].push(id);
// Add connected nodes to next layer
connections
.filter((conn) => conn.fromNode === id)
.forEach((conn) => {
if (!visited.has(conn.toNode)) {
queue.push({id: conn.toNode, layer: layer + 1});
}
});
}
// Position nodes
const layerSpacing = 300;
const nodeSpacing = 120;
layers.forEach((layer, layerIndex) => {
layer.forEach((nodeId, nodeIndex) => {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.x = layerIndex * layerSpacing + 50;
node.y = nodeIndex * nodeSpacing + 50;
}
});
});
}
// ========================================
// CANVAS COMPONENT
// ========================================
export function NodeGraph(): m.Component<NodeGraphAttrs> {
// ========================================
// CANVAS STATE (shared across all instances)
// ========================================
const canvasState: CanvasState = {
draggedNode: null,
dragOffset: {x: 0, y: 0},
connecting: null,
mousePos: {x: 0, y: 0},
selectedNode: null,
panOffset: {x: 0, y: 0},
isPanning: false,
panStart: {x: 0, y: 0},
zoom: 1.0,
dockTarget: null,
isDockZone: false,
undockCandidate: null,
};
let latestVnode: m.Vnode<NodeGraphAttrs> | null = null;
let canvasElement: HTMLElement | null = null;
const handleMouseMove = (e: MouseEvent) => {
m.redraw();
if (!latestVnode || !canvasElement) return;
const vnode = latestVnode;
const canvas = canvasElement;
const canvasRect = canvas.getBoundingClientRect();
// Store both screen and transformed coordinates
canvasState.mousePos = {
x: e.clientX,
y: e.clientY,
transformedX:
(e.clientX - canvasRect.left - canvasState.panOffset.x) /
canvasState.zoom,
transformedY:
(e.clientY - canvasRect.top - canvasState.panOffset.y) /
canvasState.zoom,
};
if (canvasState.isPanning) {
// Pan the canvas
const dx = e.clientX - canvasState.panStart.x;
const dy = e.clientY - canvasState.panStart.y;
canvasState.panOffset = {
x: canvasState.panOffset.x + dx,
y: canvasState.panOffset.y + dy,
};
canvasState.panStart = {x: e.clientX, y: e.clientY};
m.redraw();
} else if (canvasState.undockCandidate !== null) {
// Check if we've exceeded the undock threshold
const dx = e.clientX - canvasState.undockCandidate.startX;
const dy = e.clientY - canvasState.undockCandidate.startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > UNDOCK_THRESHOLD) {
// Exceeded threshold - perform undock
const {onUndock, onNodeDrag} = vnode.attrs;
if (onUndock && onNodeDrag) {
onUndock(canvasState.undockCandidate.parentId);
onNodeDrag(
canvasState.undockCandidate.nodeId,
(canvasState.undockCandidate.startX -
canvasRect.left -
canvasState.panOffset.x) /
canvasState.zoom -
canvasState.dragOffset.x / canvasState.zoom,
canvasState.undockCandidate.renderY,
);
m.redraw(); // Force update so nodes array regenerates
}
canvasState.undockCandidate = null;
}
} else if (canvasState.draggedNode !== null) {
// Calculate new position relative to canvas container (accounting for pan and zoom)
const newX =
(e.clientX - canvasRect.left - canvasState.panOffset.x) /
canvasState.zoom -
canvasState.dragOffset.x / canvasState.zoom;
const newY =
(e.clientY - canvasRect.top - canvasState.panOffset.y) /
canvasState.zoom -
canvasState.dragOffset.y / canvasState.zoom;
// ONLY move the dragged node itself
// Children follow automatically via render position calculation
const {onNodeDrag, nodes = []} = vnode.attrs;
if (onNodeDrag !== undefined) {
onNodeDrag(canvasState.draggedNode, newX, newY);
}
// Check if we're in a dock zone (exclude the parent we just undocked from)
const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
if (draggedNode) {
const dockInfo = findDockTarget(draggedNode, newX, newY, nodes);
canvasState.dockTarget = dockInfo.targetNodeId;
canvasState.isDockZone = dockInfo.isValidZone;
}
m.redraw();
}
};
const handleMouseUp = () => {
if (!latestVnode) return;
const vnode = latestVnode;
// Handle docking if in dock zone
if (
canvasState.draggedNode &&
canvasState.isDockZone &&
canvasState.dockTarget
) {
const {nodes = [], onDock} = vnode.attrs;
const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
if (onDock && draggedNode) {
// Create child node without x/y coordinates
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {x, y, ...childNode} = draggedNode;
onDock(canvasState.dockTarget, childNode);
}
}
// Check for collision (only for non-docked nodes)
if (canvasState.draggedNode !== null) {
const {nodes = [], onNodeDrag} = vnode.attrs;
const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
// Only do overlap checking if NOT being docked
if (draggedNode && !canvasState.isDockZone && onNodeDrag) {
// Get actual node dimensions from DOM
const dims = getNodeDimensions(draggedNode.id);
// Calculate total height of the dragged node's chain
const chain = getChain(draggedNode);
let chainHeight = 0;
chain.forEach((chainNode) => {
chainHeight += getNodeDimensions(chainNode.id).height;
});
// Check if node (and its entire chain) overlaps with any other nodes
if (
checkNodeOverlap(
draggedNode.x,
draggedNode.y,
draggedNode.id,
nodes,
dims.width,
chainHeight,
)
) {
// Find nearest non-overlapping position
const newPos = findNearestNonOverlappingPosition(
draggedNode.x,
draggedNode.y,
draggedNode.id,
nodes,
dims.width,
chainHeight,
);
// Update to the non-overlapping position
onNodeDrag(draggedNode.id, newPos.x, newPos.y);
}
}
}
canvasState.draggedNode = null;
canvasState.connecting = null;
canvasState.isPanning = false;
canvasState.dockTarget = null;
canvasState.isDockZone = false;
canvasState.undockCandidate = null;
m.redraw();
};
// Helper to determine port type based on port index
function getPortType(
nodeId: string,
portType: 'input' | 'output',
portIndex: number,
nodes: Node[],
): 'top' | 'bottom' | 'left' | 'right' {
// Search in main nodes array
let node: Node | Omit<Node, 'x' | 'y'> | undefined = nodes.find(
(n) => n.id === nodeId,
);
// If not found, search in the next chains of all nodes
if (!node) {
for (const rootNode of nodes) {
let current = rootNode.next;
while (current) {
if (current.id === nodeId) {
node = current;
break;
}
current = current.next;
}
if (node) break;
}
}
if (!node) return portType === 'input' ? 'left' : 'right';
if (portType === 'input') {
if (node.allInputsLeft) return 'left';
return portIndex === 0 ? 'top' : 'left';
} else {
if (node.allOutputsRight) return 'right';
return portIndex === 0 ? 'bottom' : 'right';
}
}
function renderConnections(
svg: SVGElement,
connections: Connection[],
nodes: Node[],
onConnectionRemove?: (index: number) => void,
) {
// Clear existing paths
svg.innerHTML = '';
const shortenLength = 16;
const arrowheadLength = 4;
// Create arrow marker definition
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS(
'http://www.w3.org/2000/svg',
'marker',
);
marker.setAttribute('id', 'arrowhead');
marker.setAttribute('viewBox', `0 0 ${arrowheadLength} 10`);
marker.setAttribute('refX', '0');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', `${arrowheadLength}`);
marker.setAttribute('markerHeight', '10');
marker.setAttribute('orient', 'auto');
const polygon = document.createElementNS(
'http://www.w3.org/2000/svg',
'polygon',
);
polygon.setAttribute('points', `0 2.5, ${arrowheadLength} 5, 0 7.5`);
polygon.setAttribute('fill', 'var(--pf-color-accent)');
marker.appendChild(polygon);
defs.appendChild(marker);
svg.appendChild(defs);
// Only render explicit connections (not implicit dock connections)
connections.forEach((conn, idx) => {
const from = getPortPosition(conn.fromNode, 'output', conn.fromPort);
const to = getPortPosition(conn.toNode, 'input', conn.toPort);
if (from.x !== 0 || from.y !== 0) {
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path',
);
path.setAttribute('class', 'pf-connection');
const fromPortType = getPortType(
conn.fromNode,
'output',
conn.fromPort,
nodes,
);
const toPortType = getPortType(
conn.toNode,
'input',
conn.toPort,
nodes,
);
path.setAttribute(
'd',
createCurve(
from.x,
from.y,
to.x,
to.y,
fromPortType,
toPortType,
shortenLength,
),
);
path.setAttribute('marker-end', 'url(#arrowhead)');
path.style.pointerEvents = 'stroke';
path.style.cursor = 'pointer';
// Prevent canvas pan from starting when clicking connections
path.onmousedown = (e) => {
e.stopPropagation();
e.preventDefault();
};
path.onclick = (e) => {
e.stopPropagation();
if (onConnectionRemove !== undefined) {
onConnectionRemove(idx);
}
};
svg.appendChild(path);
}
});
// Render temp connection if connecting
if (canvasState.connecting) {
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path',
);
path.setAttribute('class', 'pf-temp-connection');
// Convert screen coordinates to canvas content coordinates
const fromX = canvasState.connecting.transformedX;
const fromY = canvasState.connecting.transformedY;
const toX = canvasState.mousePos.transformedX ?? 0;
const toY = canvasState.mousePos.transformedY ?? 0;
// For temp connections, use the stored port type
const fromPortType = canvasState.connecting.portType;
// The target end defaults to the opposite type for visual feedback
const toPortType =
fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left';
path.setAttribute(
'd',
createCurve(fromX, fromY, toX, toY, fromPortType, toPortType),
);
svg.appendChild(path);
}
}
function getPortPosition(
nodeId: string,
portType: 'input' | 'output',
portIndex: number,
): Position {
// For port index 0 (top/bottom), data-port is on .pf-port itself
// For port index 1+ (left/right), data-port is on .pf-port-row wrapper
const selector =
portIndex === 0
? `[data-node="${nodeId}"] .pf-port[data-port="${portType}-${portIndex}"]`
: `[data-node="${nodeId}"] [data-port="${portType}-${portIndex}"] .pf-port`;
const portElement = document.querySelector(selector);
if (portElement) {
const nodeElement = portElement.closest('.pf-node') as HTMLElement | null;
if (nodeElement !== null) {
// Check if node is in a dock chain (flexbox positioning)
const chainContainer = nodeElement.closest(
'.pf-dock-chain',
) as HTMLElement | null;
let nodeLeft: number;
let nodeTop: number;
if (chainContainer) {
// Node is in a dock chain - use container's position
nodeLeft = parseFloat(chainContainer.style.left) || 0;
nodeTop = parseFloat(chainContainer.style.top) || 0;
// Add offset of node within the chain
const chainRect = chainContainer.getBoundingClientRect();
const nodeRect = nodeElement.getBoundingClientRect();
const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;
nodeTop += offsetY;
} else {
// Standalone node - use its position directly
nodeLeft = parseFloat(nodeElement.style.left) || 0;
nodeTop = parseFloat(nodeElement.style.top) || 0;
}
// Get port's position relative to the node
const portRect = portElement.getBoundingClientRect();
const nodeRect = nodeElement.getBoundingClientRect();
// Calculate offset in screen space, then divide by zoom to get canvas content space
const portX =
(portRect.left - nodeRect.left + portRect.width / 2) /
canvasState.zoom;
const portY =
(portRect.top - nodeRect.top + portRect.height / 2) /
canvasState.zoom;
return {
x: nodeLeft + portX,
y: nodeTop + portY,
};
}
}
return {x: 0, y: 0};
}
// Find if dragged node is in dock zone of any node
function findDockTarget(
draggedNode: Node,
draggedX: number,
draggedY: number,
nodes: Node[],
): {targetNodeId: string | null; isValidZone: boolean} {
const DOCK_DISTANCE = 30;
const HORIZONTAL_TOLERANCE = 100;
// Check if dragged node can be docked.
// It can be docked if it has a top input port.
const draggedCanDock =
(draggedNode.inputs?.length ?? 0) > 0 && !draggedNode.allInputsLeft;
if (!draggedCanDock) {
return {targetNodeId: null, isValidZone: false};
}
const draggedPos = {x: draggedX, y: draggedY};
for (const node of nodes) {
if (node.id === draggedNode.id) continue;
// Find the last node in this chain
let lastInChain: Node | Omit<Node, 'x' | 'y'> = node;
while (lastInChain.next) {
lastInChain = lastInChain.next;
}
// Check if last node in chain allows docking below it.
// It can have nodes dock below if it has a bottom output port.
const lastCanDockBelow =
(lastInChain.outputs?.length ?? 0) > 0 && !lastInChain.allOutputsRight;
if (!lastCanDockBelow) {
continue; // Skip this node as a dock target
}
const nodePos = {x: node.x, y: node.y};
const lastDims = getNodeDimensions(lastInChain.id);
// Calculate position of last node in chain
let chainHeight = 0;
let current: Node | Omit<Node, 'x' | 'y'> = node;
while (current !== lastInChain) {
chainHeight += getNodeDimensions(current.id).height;
current = current.next!;
}
const nodeBottom = nodePos.y + chainHeight + lastDims.height;
const verticalDist = draggedPos.y - nodeBottom;
const isBelow = verticalDist >= -10 && verticalDist <= DOCK_DISTANCE;
const draggedDims = getNodeDimensions(draggedNode.id);
const nodeDims = getNodeDimensions(node.id);
const horizontalDist = Math.abs(
nodePos.x + nodeDims.width / 2 - (draggedPos.x + draggedDims.width / 2),
);
const isAligned = horizontalDist <= HORIZONTAL_TOLERANCE;
if (isBelow && isAligned) {
// Return the ID of the LAST node in the chain
return {targetNodeId: lastInChain.id, isValidZone: true};
}
}
return {targetNodeId: null, isValidZone: false};
}
function getNodeDimensions(nodeId: string): {width: number; height: number} {
const nodeElement = document.querySelector(`[data-node="${nodeId}"]`);
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect();
// Divide by zoom to get canvas content space dimensions
return {
width: rect.width / canvasState.zoom,
height: rect.height / canvasState.zoom,
};
}
// Fallback if DOM element not found
return {width: 180, height: 100};
}
function checkNodeOverlap(
x: number,
y: number,
nodeId: string,
nodes: Node[],
nodeWidth: number,
nodeHeight: number,
): boolean {
const padding = 10;
for (const node of nodes) {
if (node.id === nodeId) continue; // Don't check against self
// Get dimensions of the node we're checking against
const otherDims = getNodeDimensions(node.id);
// Calculate total height of the other node's chain
const chain = getChain(node);
let otherChainHeight = 0;
chain.forEach((chainNode) => {
otherChainHeight += getNodeDimensions(chainNode.id).height;
});
const overlaps = !(
x + nodeWidth + padding < node.x ||
x > node.x + otherDims.width + padding ||
y + nodeHeight + padding < node.y ||
y > node.y + otherChainHeight + padding
);
if (overlaps) return true;
}
return false;
}
function findNearestNonOverlappingPosition(
startX: number,
startY: number,
nodeId: string,
nodes: Node[],
nodeWidth: number,
nodeHeight: number,
): Position {
// If no overlap at current position, return it
if (
!checkNodeOverlap(startX, startY, nodeId, nodes, nodeWidth, nodeHeight)
) {
return {x: startX, y: startY};
}
// Search in a spiral pattern for a non-overlapping position
const step = 20; // Step size for searching
const maxRadius = 500; // Maximum search radius
for (let radius = step; radius <= maxRadius; radius += step) {
// Try positions in a circle around the original position
const numSteps = Math.ceil((2 * Math.PI * radius) / step);
for (let i = 0; i < numSteps; i++) {
const angle = (2 * Math.PI * i) / numSteps;
const x = Math.round(startX + radius * Math.cos(angle));
const y = Math.round(startY + radius * Math.sin(angle));
if (!checkNodeOverlap(x, y, nodeId, nodes, nodeWidth, nodeHeight)) {
return {x, y};
}
}
}
// Fallback: return original position if no free space found
return {x: startX, y: startY};
}
function getNodesBoundingBox(
nodes: Node[],
includeChains: boolean,
): {minX: number; minY: number; maxX: number; maxY: number} {
if (nodes.length === 0) {
return {minX: 0, minY: 0, maxX: 0, maxY: 0};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
nodes.forEach((node) => {
const dims = getNodeDimensions(node.id);
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + dims.width);
if (includeChains) {
const chain = getChain(node);
let chainHeight = 0;
chain.forEach((chainNode) => {
const chainDims = getNodeDimensions(chainNode.id);
chainHeight += chainDims.height;
});
maxY = Math.max(maxY, node.y + chainHeight);
} else {
maxY = Math.max(maxY, node.y + dims.height);
}
});
return {minX, minY, maxX, maxY};
}
// Helper to perform auto-layout
function autoLayoutGraph(
nodes: Node[],
connections: Connection[],
onNodeDrag: ((nodeId: string, x: number, y: number) => void) | undefined,
) {
// Build a map from any node ID (including nodes in chains) to its root node ID
const nodeIdToRootId = new Map<string, string>();
nodes.forEach((node) => {
nodeIdToRootId.set(node.id, node.id);
const chain = getChain(node);
chain.slice(1).forEach((chainNode) => {
nodeIdToRootId.set(chainNode.id, node.id);
});
});
// Find root nodes (nodes with no incoming connections)
// Count connections to any node in a chain as connections to the root
const incomingCounts = new Map<string, number>();
nodes.forEach((node) => incomingCounts.set(node.id, 0));
connections.forEach((conn) => {
const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
const currentCount = incomingCounts.get(rootId) ?? 0;
incomingCounts.set(rootId, currentCount + 1);
});
const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0);
const visited = new Set<string>();
const layers: string[][] = [];
// BFS to assign nodes to layers
const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({
id: n.id,
layer: 0,
}));
while (queue.length > 0) {
const {id, layer} = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
if (layers[layer] === undefined) layers[layer] = [];
layers[layer].push(id);
// Add connected nodes to next layer
// If connection goes to a node in a chain, add the root node
connections
.filter((conn) => {
// Check if this node or any node in its chain is the source
const node = nodes.find((n) => n.id === id);
if (!node) return false;
const chain = getChain(node);
return chain.some((chainNode) => chainNode.id === conn.fromNode);
})
.forEach((conn) => {
const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
if (!visited.has(rootId)) {
queue.push({id: rootId, layer: layer + 1});
}
});
}
// Position nodes using actual DOM dimensions
const layerSpacing = 50; // Horizontal spacing between layers
let currentX = 50; // Start position
layers.forEach((layer) => {
// Find the widest node in this layer (considering entire chains)
let maxWidth = 0;
layer.forEach((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
// Check width of all nodes in the chain
const chain = getChain(node);
chain.forEach((chainNode) => {
const chainDims = getNodeDimensions(chainNode.id);
maxWidth = Math.max(maxWidth, chainDims.width);
});
}
});
// Position each node in this layer
let currentY = 50;
layer.forEach((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
if (node && onNodeDrag) {
onNodeDrag(node.id, currentX, currentY);
// Calculate height of entire chain
const chain = getChain(node);
let chainHeight = 0;
chain.forEach((chainNode) => {
const dims = getNodeDimensions(chainNode.id);
chainHeight += dims.height;
});
currentY += chainHeight + 30;
}
});
// Move to next layer
currentX += maxWidth + layerSpacing;
});
m.redraw();
}
function autofit(nodes: Node[], canvas: HTMLElement) {
if (nodes.length === 0) return;
const {minX, minY, maxX, maxY} = getNodesBoundingBox(nodes, true);
// Calculate bounding box dimensions
const boundingWidth = maxX - minX;
const boundingHeight = maxY - minY;
// Get canvas dimensions
const canvasRect = canvas.getBoundingClientRect();
// Calculate zoom to fit with buffer (10% padding)
const bufferFactor = 0.9; // Use 90% of viewport to leave 10% buffer
const zoomX = (canvasRect.width * bufferFactor) / boundingWidth;
const zoomY = (canvasRect.height * bufferFactor) / boundingHeight;
const newZoom = Math.max(0.1, Math.min(5.0, Math.min(zoomX, zoomY)));
// Calculate the scaled bounding box dimensions
const scaledWidth = boundingWidth * newZoom;
const scaledHeight = boundingHeight * newZoom;
// Calculate pan offset to center the bounding box with equal padding on all sides
const paddingX = (canvasRect.width - scaledWidth) / 2;
const paddingY = (canvasRect.height - scaledHeight) / 2;
canvasState.zoom = newZoom;
canvasState.panOffset = {
x: paddingX - minX * newZoom,
y: paddingY - minY * newZoom,
};
m.redraw();
}
const handleWheel = (e: WheelEvent) => {
if (!canvasElement) return;
e.preventDefault();
// Zoom with Ctrl+wheel, pan without Ctrl
if (e.ctrlKey || e.metaKey) {
// Zoom around mouse position
const canvas = canvasElement;
const canvasRect = canvas.getBoundingClientRect();
const mouseX = e.clientX - canvasRect.left;
const mouseY = e.clientY - canvasRect.top;
// Calculate zoom delta (negative deltaY = zoom in)
const zoomDelta = -e.deltaY * 0.003;
const newZoom = Math.max(
0.1,
Math.min(5.0, canvasState.zoom * (1 + zoomDelta)),
);
// Calculate the point in canvas space (before zoom)
const canvasX = (mouseX - canvasState.panOffset.x) / canvasState.zoom;
const canvasY = (mouseY - canvasState.panOffset.y) / canvasState.zoom;
// Update zoom
canvasState.zoom = newZoom;
// Adjust pan to keep the same point under the mouse
canvasState.panOffset = {
x: mouseX - canvasX * newZoom,
y: mouseY - canvasY * newZoom,
};
} else {
// Pan the canvas based on wheel delta
canvasState.panOffset = {
x: canvasState.panOffset.x - e.deltaX,
y: canvasState.panOffset.y - e.deltaY,
};
}
m.redraw();
};
return {
oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
latestVnode = vnode;
canvasElement = vnode.dom as HTMLElement;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
canvasElement.addEventListener('wheel', handleWheel, {passive: false});
const {
connections = [],
nodes = [],
onConnectionRemove,
onReady,
} = vnode.attrs;
// Render connections after DOM is ready
const svg = vnode.dom.querySelector('svg');
if (svg) {
renderConnections(
svg as SVGElement,
connections,
nodes,
onConnectionRemove,
);
}
// Create auto-layout function that uses actual DOM dimensions
const autoLayout = () => {
const {nodes = [], connections = [], onNodeDrag} = vnode.attrs;
autoLayoutGraph(nodes, connections, onNodeDrag);
};
// Create recenter function that brings all nodes into view
const recenter = () => {
const {nodes = []} = vnode.attrs;
const canvas = vnode.dom as HTMLElement;
autofit(nodes, canvas);
};
// Provide API to parent
if (onReady) {
onReady({autoLayout, recenter});
}
},
onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
latestVnode = vnode;
const {connections = [], nodes = [], onConnectionRemove} = vnode.attrs;
// Re-render connections when component updates
const svg = vnode.dom.querySelector('svg');
if (svg) {
renderConnections(
svg as SVGElement,
connections,
nodes,
onConnectionRemove,
);
}
},
onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
(vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
},
view: (vnode: m.Vnode<NodeGraphAttrs>) => {
latestVnode = vnode;
const {
nodes = [],
connections = [],
onConnect,
selectedNodeId,
} = vnode.attrs;
const className = classNames(
canvasState.connecting && 'pf-connecting',
canvasState.connecting &&
`connecting-from-${canvasState.connecting.type}`,
canvasState.isPanning && 'pf-panning',
);
return m(
'.pf-canvas',
{
className,
tabindex: 0, // Make div focusable to capture keyboard events
onmousedown: (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.classList.contains('pf-canvas') ||
target.tagName === 'svg'
) {
// Start panning if clicking on canvas background or SVG
canvasState.selectedNode = null;
// Call onNodeSelect callback with null to indicate deselection
const {onNodeSelect} = vnode.attrs;
if (onNodeSelect !== undefined) {
onNodeSelect(null);
}
canvasState.isPanning = true;
canvasState.panStart = {x: e.clientX, y: e.clientY};
e.preventDefault();
}
},
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
const {selectedNodeId, onNodeRemove} = vnode.attrs;
if (selectedNodeId && onNodeRemove) {
onNodeRemove(selectedNodeId);
e.preventDefault();
}
}
},
style: `background-size: ${20 * canvasState.zoom}px ${20 * canvasState.zoom}px;
background-position: ${canvasState.panOffset.x}px ${canvasState.panOffset.y}px;`,
},
[
// Control buttons
m('.pf-nodegraph-controls', [
m(Button, {
label: 'Auto Layout',
icon: 'account_tree',
variant: ButtonVariant.Filled,
onclick: () => {
const {nodes = [], connections = [], onNodeDrag} = vnode.attrs;
autoLayoutGraph(nodes, connections, onNodeDrag);
},
}),
m(Button, {
label: 'Fit to Screen',
icon: 'center_focus_strong',
variant: ButtonVariant.Filled,
onclick: (e: MouseEvent) => {
const {nodes = []} = vnode.attrs;
const canvas = (e.currentTarget as HTMLElement).closest(
'.pf-canvas',
);
if (canvas) {
autofit(nodes, canvas as HTMLElement);
}
},
}),
]),
// Container for nodes and SVG that gets transformed
m(
'.pf-canvas-content',
{
style: `transform: translate(${canvasState.panOffset.x}px, ${canvasState.panOffset.y}px) scale(${canvasState.zoom}); transform-origin: 0 0;`,
},
[
// SVG container for connections (rendered imperatively in oncreate/onupdate)
m('svg'),
// Render all nodes - wrap dock chains in flex container
nodes
.map((node: Node) => {
const {id, inputs = [], outputs = []} = node;
// Check if this is the root of a dock chain
const chain = getChain(node);
const isChainRoot = chain.length > 1;
// Use node's x,y directly (it's a root node)
const renderPos = {x: node.x, y: node.y};
// If this is a chain root, wrap all chain nodes in flex container
if (isChainRoot) {
return m(
'.pf-dock-chain',
{
key: `chain-${id}`,
style: `left: ${renderPos.x}px; top: ${renderPos.y}px; z-index: ${canvasState.draggedNode === id ? 1000 : 10}`,
},
chain.map((chainNode) => {
const {
id: cId,
inputs: cInputs = [],
outputs: cOutputs = [],
addMenuItems,
} = chainNode;
const cIsDockedChild = 'x' in chainNode === false;
const cHasDockedChild = chainNode.next !== undefined;
const cIsDockTarget =
canvasState.dockTarget === cId &&
canvasState.isDockZone;
const cClasses = [
selectedNodeId === cId ? 'pf-selected' : '',
cIsDockedChild ? 'pf-docked-child' : '',
cHasDockedChild ? 'pf-has-docked-child' : '',
cIsDockTarget ? 'pf-dock-target' : '',
]
.filter(Boolean)
.join(' ');
return m(
'.pf-node',
{
'key': cId,
'data-node': cId,
'class': cClasses,
'onmousedown': (e: MouseEvent) => {
if (
(e.target as HTMLElement).closest('.pf-port')
) {
return;
}
e.stopPropagation();
// Check if this is a chained node (not root)
if (!('x' in chainNode)) {
// Don't undock immediately - wait for drag threshold
// Calculate current render position
let yOffset = node.y;
const chainArr = getChain(node);
for (const cn of chainArr) {
if (cn.id === cId) break;
yOffset += getNodeDimensions(cn.id).height;
}
// Find parent node in chain
let parentId = node.id;
let curr = node.next;
while (curr && curr.id !== cId) {
parentId = curr.id;
curr = curr.next;
}
// Store undock candidate - will undock if dragged beyond threshold
canvasState.undockCandidate = {
nodeId: cId,
parentId: parentId,
startX: e.clientX,
startY: e.clientY,
renderY: yOffset,
};
}
canvasState.draggedNode = cId;
canvasState.selectedNode = cId;
const {onNodeSelect} = vnode.attrs;
if (onNodeSelect !== undefined) {
onNodeSelect(cId);
}
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
canvasState.dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
},
},
[
// First input on top (if exists and not docked child)
!chainNode.allInputsLeft &&
cInputs.length > 0 &&
!cIsDockedChild &&
m('.pf-port.pf-input.pf-port-top', {
'data-port': 'input-0',
'class': isPortConnected(
cId,
'input',
0,
connections,
)
? 'pf-connected'
: '',
'onmousedown': (e: MouseEvent) => {
e.stopPropagation();
const existingConnIdx = connections.findIndex(
(conn) =>
conn.toNode === cId && conn.toPort === 0,
);
if (existingConnIdx !== -1) {
const existingConn =
connections[existingConnIdx];
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
const outputPos = getPortPosition(
existingConn.fromNode,
'output',
existingConn.fromPort,
);
canvasState.connecting = {
nodeId: existingConn.fromNode,
portIndex: existingConn.fromPort,
type: 'output',
portType: getPortType(
existingConn.fromNode,
'output',
existingConn.fromPort,
nodes,
),
x: 0,
y: 0,
transformedX: outputPos.x,
transformedY: outputPos.y,
};
m.redraw();
}
},
'onmouseup': (e: MouseEvent) => {
e.stopPropagation();
if (
canvasState.connecting &&
canvasState.connecting.type === 'output'
) {
const existingConnIdx =
connections.findIndex(
(conn) =>
conn.toNode === cId &&
conn.toPort === 0,
);
if (existingConnIdx !== -1) {
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
}
const connection = {
fromNode: canvasState.connecting.nodeId,
fromPort:
canvasState.connecting.portIndex,
toNode: cId,
toPort: 0,
};
if (onConnect !== undefined) {
onConnect(connection);
}
canvasState.connecting = null;
}
},
}),
chainNode.content !== undefined &&
m(
'.pf-node-content',
{
onkeydown: (e: KeyboardEvent) => {
e.stopPropagation();
},
},
chainNode.content,
),
// Remaining inputs on left side (inputs[1+])
cInputs
.slice(chainNode.allInputsLeft ? 0 : 1)
.map((input: string, i: number) =>
m(
'.pf-port-row.pf-port-input',
{
'data-port': `input-${i + 1}`,
},
[
m('.pf-port.pf-input', {
class: isPortConnected(
cId,
'input',
i + 1,
connections,
)
? 'pf-connected'
: '',
onmousedown: (e: MouseEvent) => {
e.stopPropagation();
const existingConnIdx =
connections.findIndex(
(conn) =>
conn.toNode === cId &&
conn.toPort === i + 1,
);
if (existingConnIdx !== -1) {
const existingConn =
connections[existingConnIdx];
const {onConnectionRemove} =
vnode.attrs;
if (
onConnectionRemove !== undefined
) {
onConnectionRemove(existingConnIdx);
}
const outputPos = getPortPosition(
existingConn.fromNode,
'output',
existingConn.fromPort,
);
canvasState.connecting = {
nodeId: existingConn.fromNode,
portIndex: existingConn.fromPort,
type: 'output',
portType: getPortType(
existingConn.fromNode,
'output',
existingConn.fromPort,
nodes,
),
x: 0,
y: 0,
transformedX: outputPos.x,
transformedY: outputPos.y,
};
m.redraw();
}
},
onmouseup: (e: MouseEvent) => {
e.stopPropagation();
if (
canvasState.connecting &&
canvasState.connecting.type ===
'output'
) {
const existingConnIdx =
connections.findIndex(
(conn) =>
conn.toNode === cId &&
conn.toPort === i + 1,
);
if (existingConnIdx !== -1) {
const {onConnectionRemove} =
vnode.attrs;
if (
onConnectionRemove !== undefined
) {
onConnectionRemove(
existingConnIdx,
);
}
}
const connection = {
fromNode:
canvasState.connecting.nodeId,
fromPort:
canvasState.connecting.portIndex,
toNode: cId,
toPort: i + 1,
};
if (onConnect !== undefined) {
onConnect(connection);
}
canvasState.connecting = null;
}
},
}),
m('span', input),
],
),
),
// Remaining outputs on right side (outputs[1+])
cOutputs
.slice(chainNode.allOutputsRight ? 0 : 1)
.map((output: string, i: number) =>
m(
'.pf-port-row.pf-port-output',
{
'data-port': `output-${i + 1}`,
},
[
m('span', output),
m('.pf-port.pf-output', {
class: [
isPortConnected(
cId,
'output',
i + 1,
connections,
)
? 'pf-connected'
: '',
canvasState.connecting &&
canvasState.connecting.nodeId === cId &&
canvasState.connecting.portIndex ===
i + 1
? 'pf-active'
: '',
]
.filter(Boolean)
.join(' '),
onmousedown: (e: MouseEvent) => {
e.stopPropagation();
const portPos = getPortPosition(
cId,
'output',
i + 1,
);
canvasState.connecting = {
nodeId: cId,
portIndex: i + 1,
type: 'output',
portType: 'right',
x: 0,
y: 0,
transformedX: portPos.x,
transformedY: portPos.y,
};
},
}),
],
),
),
// First output on bottom (if exists and no docked child below)
!chainNode.allOutputsRight &&
cOutputs.length > 0 &&
!cHasDockedChild &&
m(
PopupMenu,
{
trigger: m(
'.pf-port.pf-output.pf-port-bottom',
{
'data-port': 'output-0',
'class': [
isPortConnected(
cId,
'output',
0,
connections,
)
? 'pf-connected'
: '',
canvasState.connecting &&
canvasState.connecting.nodeId === cId &&
canvasState.connecting.portIndex === 0
? 'pf-active'
: '',
]
.filter(Boolean)
.join(' '),
'onmousedown': (e: MouseEvent) => {
e.stopPropagation();
const portPos = getPortPosition(
cId,
'output',
0,
);
canvasState.connecting = {
nodeId: cId,
portIndex: 0,
type: 'output',
portType: 'bottom',
x: 0,
y: 0,
transformedX: portPos.x,
transformedY: portPos.y,
};
},
},
),
},
addMenuItems,
),
],
);
}),
);
}
// Render standalone node (not part of a chain)
const isDockTarget =
canvasState.dockTarget === id && canvasState.isDockZone;
const classes = [
selectedNodeId === id ? 'pf-selected' : '',
isDockTarget ? 'pf-dock-target' : '',
]
.filter(Boolean)
.join(' ');
return m(
'.pf-node',
{
'key': id,
'data-node': id,
'class': classes,
'style': `left: ${renderPos.x}px; top: ${renderPos.y}px; z-index: ${canvasState.draggedNode === id ? 1000 : 10}`,
'onmousedown': (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.pf-port')) {
return;
}
e.stopPropagation();
// Start dragging
canvasState.draggedNode = id;
canvasState.selectedNode = id;
// Call onNodeSelect callback
const {onNodeSelect} = vnode.attrs;
if (onNodeSelect !== undefined) {
onNodeSelect(id);
}
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
canvasState.dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
},
},
[
// First input on top (if exists)
// Note: standalone nodes are never docked children, so always show if inputs exist
!node.allInputsLeft &&
inputs.length > 0 &&
m('.pf-port.pf-input.pf-port-top', {
'data-port': 'input-0',
'class': isPortConnected(id, 'input', 0, connections)
? 'pf-connected'
: '',
'onmousedown': (e: MouseEvent) => {
e.stopPropagation();
// Check if this input is already connected
const existingConnIdx = connections.findIndex(
(conn) => conn.toNode === id && conn.toPort === 0,
);
if (existingConnIdx !== -1) {
const existingConn = connections[existingConnIdx];
// Remove the existing connection
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
// Start a new connection from the original output port
const outputPos = getPortPosition(
existingConn.fromNode,
'output',
existingConn.fromPort,
);
canvasState.connecting = {
nodeId: existingConn.fromNode,
portIndex: existingConn.fromPort,
type: 'output',
portType: getPortType(
existingConn.fromNode,
'output',
existingConn.fromPort,
nodes,
),
x: 0,
y: 0,
transformedX: outputPos.x,
transformedY: outputPos.y,
};
m.redraw();
}
},
'onmouseup': (e: MouseEvent) => {
e.stopPropagation();
if (
canvasState.connecting &&
canvasState.connecting.type === 'output'
) {
// Check if this input already has a connection
const existingConnIdx = connections.findIndex(
(conn) =>
conn.toNode === id && conn.toPort === 0,
);
// Remove existing connection if present
if (existingConnIdx !== -1) {
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
}
const connection = {
fromNode: canvasState.connecting.nodeId,
fromPort: canvasState.connecting.portIndex,
toNode: id,
toPort: 0,
};
// Call onConnect callback if provided
if (onConnect !== undefined) {
onConnect(connection);
}
canvasState.connecting = null;
}
},
}),
// Render custom content if provided
node.content !== undefined &&
m(
'.pf-node-content',
{
onkeydown: (e: KeyboardEvent) => {
e.stopPropagation();
},
},
node.content,
),
// Remaining inputs on left side (inputs[1+])
inputs
.slice(node.allInputsLeft ? 0 : 1)
.map((input: string, i: number) =>
m(
'.pf-port-row.pf-port-input',
{
'data-port': `input-${i + 1}`,
},
[
m('.pf-port.pf-input', {
class: isPortConnected(
id,
'input',
i + 1,
connections,
)
? 'pf-connected'
: '',
onmousedown: (e: MouseEvent) => {
e.stopPropagation();
// Check if this input is already connected
const existingConnIdx = connections.findIndex(
(conn) =>
conn.toNode === id &&
conn.toPort === i + 1,
);
if (existingConnIdx !== -1) {
const existingConn =
connections[existingConnIdx];
// Remove the existing connection
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
// Start a new connection from the original output port
const outputPos = getPortPosition(
existingConn.fromNode,
'output',
existingConn.fromPort,
);
canvasState.connecting = {
nodeId: existingConn.fromNode,
portIndex: existingConn.fromPort,
type: 'output',
portType: getPortType(
existingConn.fromNode,
'output',
existingConn.fromPort,
nodes,
),
x: 0,
y: 0,
transformedX: outputPos.x,
transformedY: outputPos.y,
};
m.redraw();
}
},
onmouseup: (e: MouseEvent) => {
e.stopPropagation();
if (
canvasState.connecting &&
canvasState.connecting.type === 'output'
) {
// Check if this input already has a connection
const existingConnIdx =
connections.findIndex(
(conn) =>
conn.toNode === id &&
conn.toPort === i + 1,
);
// Remove existing connection if present
if (existingConnIdx !== -1) {
const {onConnectionRemove} = vnode.attrs;
if (onConnectionRemove !== undefined) {
onConnectionRemove(existingConnIdx);
}
}
const connection = {
fromNode: canvasState.connecting.nodeId,
fromPort:
canvasState.connecting.portIndex,
toNode: id,
toPort: i + 1,
};
// Call onConnect callback if provided
if (onConnect !== undefined) {
onConnect(connection);
}
canvasState.connecting = null;
}
},
}),
m('span', input),
],
),
),
// Remaining outputs on right side (outputs[1+])
outputs
.slice(node.allOutputsRight ? 0 : 1)
.map((output: string, i: number) =>
m(
'.pf-port-row.pf-port-output',
{
'data-port': `output-${i + 1}`,
},
[
m('span', output),
m('.pf-port.pf-output', {
class: [
isPortConnected(
id,
'output',
i + 1,
connections,
)
? 'pf-connected'
: '',
canvasState.connecting &&
canvasState.connecting.nodeId === id &&
canvasState.connecting.portIndex === i + 1
? 'pf-active'
: '',
]
.filter(Boolean)
.join(' '),
onmousedown: (e: MouseEvent) => {
e.stopPropagation();
const portPos = getPortPosition(
id,
'output',
i + 1,
);
canvasState.connecting = {
nodeId: id,
portIndex: i + 1,
type: 'output',
portType: 'right',
x: 0,
y: 0,
transformedX: portPos.x,
transformedY: portPos.y,
};
},
}),
],
),
),
// First output on bottom (if exists)
// Note: standalone nodes never have docked children, so always show if outputs exist
!node.allOutputsRight &&
outputs.length > 0 &&
m(
PopupMenu,
{
trigger: m('.pf-port.pf-output.pf-port-bottom', {
'data-port': 'output-0',
'class': [
isPortConnected(id, 'output', 0, connections)
? 'pf-connected'
: '',
canvasState.connecting &&
canvasState.connecting.nodeId === id &&
canvasState.connecting.portIndex === 0
? 'pf-active'
: '',
]
.filter(Boolean)
.join(' '),
'onmousedown': (e: MouseEvent) => {
e.stopPropagation();
const portPos = getPortPosition(
id,
'output',
0,
);
canvasState.connecting = {
nodeId: id,
portIndex: 0,
type: 'output',
portType: 'bottom',
x: 0,
y: 0,
transformedX: portPos.x,
transformedY: portPos.y,
};
},
}),
},
node.addMenuItems,
),
],
);
})
.filter((vnode) => vnode !== null),
],
),
],
);
},
};
}