| // 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. |
| |
| /** |
| * A component for displaying and interacting with a node-based graph. |
| * |
| * Features: |
| * - Draggable, selectable, and removable nodes. |
| * - Pannable and zoomable canvas. |
| * - Connectable ports to create links between nodes. |
| * - Docking nodes to each other to form chains. |
| * - Customizable node content and appearance. |
| * - Auto-layout and fit-to-screen functionality. |
| * |
| * Minimal example: |
| * |
| * ```typescript |
| * const nodes: Node[] = [ |
| * {id: 'node1', x: 50, y: 50, outputs: [{direction: 'right'}]}, |
| * {id: 'node2', x: 250, y: 50, inputs: [{direction: 'left'}]}, |
| * ]; |
| * |
| * const connections: Connection[] = [ |
| * {fromNode: 'node1', fromPort: 0, toNode: 'node2', toPort: 0}, |
| * ]; |
| * |
| * m(NodeGraph, { |
| * nodes, |
| * connections, |
| * onConnect: (newConnection) => { |
| * // Handle new connection |
| * }, |
| * onNodeMove: (nodeId, x, y) => { |
| * // Handle node position change (called when node is dropped) |
| * }, |
| * }); |
| * ``` |
| */ |
| import m from 'mithril'; |
| import {Button, ButtonVariant} from './button'; |
| import {Icon} from './icon'; |
| import {PopupMenu} from './menu'; |
| import {classNames} from '../base/classnames'; |
| import {Icons} from '../base/semantic_icons'; |
| |
| // Default height estimate for labels (used for box selection calculations) |
| const DEFAULT_LABEL_MIN_HEIGHT = 30; |
| |
| interface Position { |
| x: number; |
| y: number; |
| transformedX?: number; |
| transformedY?: number; |
| } |
| |
| export interface Connection { |
| readonly fromNode: string; |
| readonly fromPort: number; |
| readonly toNode: string; |
| readonly toPort: number; |
| } |
| |
| export interface NodeTitleBar { |
| readonly title: m.Children; |
| readonly icon?: string; |
| } |
| |
| export interface NodePort { |
| readonly content?: m.Children; |
| readonly direction: 'top' | 'left' | 'right' | 'bottom'; |
| readonly contextMenuItems?: m.Children; |
| } |
| |
| export type DockedNode = Omit<Node, 'x' | 'y'>; |
| |
| export interface Node { |
| readonly id: string; |
| readonly x: number; |
| readonly y: number; |
| readonly hue?: number; // Color of the title / accent bar (0-360) |
| readonly accentBar?: boolean; // Optional strip of accent color on the left side (doesn't work well with titleBar) |
| readonly titleBar?: NodeTitleBar; // Optional title bar (doesn't work well with accentBar or docking) |
| readonly inputs?: ReadonlyArray<NodePort>; |
| readonly outputs?: ReadonlyArray<NodePort>; |
| readonly content?: m.Children; // Optional custom content to render in node body |
| readonly next?: DockedNode; // Next node in chain |
| readonly canDockTop?: boolean; |
| readonly canDockBottom?: boolean; |
| readonly contextMenuItems?: m.Children; |
| readonly invalid?: boolean; // Whether this node is in an invalid state |
| } |
| |
| export interface Label { |
| readonly id: string; |
| x: number; |
| y: number; |
| width: number; // Width of the label box (user can resize) |
| content?: m.Children; // Content to render inside the label (optional, defaults to empty) |
| selectable?: boolean; // Whether clicking the label selects it (default: false, only shift+click works) |
| } |
| |
| 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 UndockedNode { |
| nodeId: string; |
| parentId: string; |
| } |
| |
| interface SelectionRect { |
| startX: number; |
| startY: number; |
| currentX: number; |
| currentY: number; |
| } |
| |
| interface CanvasState { |
| draggedNode: string | null; |
| dragOffset: Position; |
| connecting: ConnectingState | null; |
| mousePos: Position; |
| selectedNodes: ReadonlySet<string>; |
| 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 |
| undockedNode: UndockedNode | null; // Node that was undocked (set when threshold exceeded) |
| hoveredPort: { |
| nodeId: string; |
| portIndex: number; |
| type: 'input' | 'output'; |
| } | null; |
| selectionRect: SelectionRect | null; // Box selection state |
| canvasMouseDownPos: Position; |
| tempNodePositions: Map<string, Position>; // Temporary positions during drag |
| tempLabelPositions: Map<string, Position>; // Temporary label positions during drag |
| tempLabelWidths: Map<string, number>; // Temporary label widths during resize |
| draggedLabel: string | null; // ID of label being dragged |
| labelDragStartPos: Position | null; // Position where label drag started |
| resizingLabel: string | null; // ID of label being resized |
| resizeStartWidth: number; // Width when resize started |
| resizeStartX: number; // Mouse X position when resize started |
| } |
| |
| export interface NodeGraphApi { |
| autoLayout: () => void; |
| recenter: () => void; |
| findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position; |
| } |
| |
| export interface NodeGraphAttrs { |
| readonly nodes: ReadonlyArray<Node>; |
| readonly connections: ReadonlyArray<Connection>; |
| readonly labels?: ReadonlyArray<Label>; |
| readonly onConnect?: (connection: Connection) => void; |
| readonly onNodeMove?: (nodeId: string, x: number, y: number) => void; |
| readonly onConnectionRemove?: (index: number) => void; |
| readonly onReady?: (api: NodeGraphApi) => void; |
| // Selection state and callbacks apply to both nodes and labels. |
| // selectedNodeIds should contain IDs of both selected nodes and labels. |
| readonly selectedNodeIds?: ReadonlySet<string>; |
| // Called when a node or label is selected (replacing current selection). |
| readonly onNodeSelect?: (nodeId: string) => void; |
| // Called when a node or label is added to the current selection (multiselect). |
| readonly onNodeAddToSelection?: (nodeId: string) => void; |
| // Called when a node or label is removed from the current selection. |
| readonly onNodeRemoveFromSelection?: (nodeId: string) => void; |
| readonly onSelectionClear?: () => void; |
| readonly onDock?: ( |
| parentId: string, |
| childNode: Omit<Node, 'x' | 'y'>, |
| ) => void; |
| readonly onUndock?: ( |
| parentId: string, |
| nodeId: string, |
| x: number, |
| y: number, |
| ) => void; |
| readonly onNodeRemove?: (nodeId: string) => void; |
| readonly onLabelMove?: (labelId: string, x: number, y: number) => void; |
| readonly onLabelResize?: (labelId: string, width: number) => void; |
| readonly onLabelRemove?: (labelId: string) => void; |
| readonly hideControls?: boolean; |
| readonly multiselect?: boolean; // Enable multi-node selection (default: true) |
| readonly contextMenuOnHover?: boolean; // Show context menu on hover (default: false) |
| readonly fillHeight?: boolean; |
| readonly toolbarItems?: m.Children; |
| readonly style?: Partial<CSSStyleDeclaration>; |
| } |
| |
| const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking |
| |
| function isPortConnected( |
| nodeId: string, |
| portType: 'input' | 'output', |
| portIndex: number, |
| connections: ReadonlyArray<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; |
| |
| if (shortenEnd > 0) { |
| if (toPortType === 'bottom') { |
| y2 += shortenEnd; |
| } else if (toPortType === 'top') { |
| y2 -= shortenEnd; |
| } else if (toPortType === 'left') { |
| x2 -= shortenEnd; |
| } else if (toPortType === 'right') { |
| x2 += shortenEnd; |
| } |
| } |
| |
| // 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}`; |
| } |
| |
| export function NodeGraph(): m.Component<NodeGraphAttrs> { |
| const canvasState: CanvasState = { |
| draggedNode: null, |
| dragOffset: {x: 0, y: 0}, |
| connecting: null, |
| mousePos: {x: 0, y: 0}, |
| selectedNodes: new Set<string>(), |
| panOffset: {x: 0, y: 0}, |
| isPanning: false, |
| panStart: {x: 0, y: 0}, |
| zoom: 1.0, |
| dockTarget: null, |
| isDockZone: false, |
| undockCandidate: null, |
| undockedNode: null, |
| hoveredPort: null, |
| selectionRect: null, |
| canvasMouseDownPos: {x: 0, y: 0}, |
| tempNodePositions: new Map<string, Position>(), |
| tempLabelPositions: new Map<string, Position>(), |
| tempLabelWidths: new Map<string, number>(), |
| draggedLabel: null, |
| labelDragStartPos: null, |
| resizingLabel: null, |
| resizeStartWidth: 0, |
| resizeStartX: 0, |
| }; |
| |
| // Track drag state for batching updates |
| let dragStartPosition: {nodeId: string; x: number; y: number} | null = null; |
| let currentDragPosition: {x: number; y: number} | null = null; |
| |
| let latestVnode: m.Vnode<NodeGraphAttrs> | null = null; |
| let canvasElement: HTMLElement | null = null; |
| |
| const handleMouseMove = (e: PointerEvent) => { |
| 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, |
| }; |
| |
| // Track hovered port (useful for connection snapping and visual feedback) |
| const portElement = (e.target as HTMLElement).closest('.pf-port.pf-input'); |
| if (portElement) { |
| const nodeElement = portElement.closest( |
| '[data-node]', |
| ) as HTMLElement | null; |
| const portId = |
| portElement.getAttribute('data-port') || |
| portElement.parentElement?.getAttribute('data-port'); |
| |
| if (nodeElement && portId) { |
| const nodeId = nodeElement.dataset.node!; |
| const [type, portIndexStr] = portId.split('-'); |
| if (type === 'input') { |
| const portIndex = parseInt(portIndexStr, 10); |
| canvasState.hoveredPort = {nodeId, portIndex, type: 'input'}; |
| } else { |
| canvasState.hoveredPort = null; |
| } |
| } else { |
| canvasState.hoveredPort = null; |
| } |
| } else { |
| canvasState.hoveredPort = null; |
| } |
| |
| if (canvasState.selectionRect) { |
| // Update selection rectangle |
| canvasState.selectionRect.currentX = |
| canvasState.mousePos.transformedX ?? 0; |
| canvasState.selectionRect.currentY = |
| canvasState.mousePos.transformedY ?? 0; |
| m.redraw(); |
| } else if (canvasState.draggedLabel !== null) { |
| // Handle label dragging - store temp position, don't call callback yet |
| const newX = |
| (canvasState.mousePos.transformedX ?? 0) - canvasState.dragOffset.x; |
| const newY = |
| (canvasState.mousePos.transformedY ?? 0) - canvasState.dragOffset.y; |
| |
| // Store temporary position during drag |
| canvasState.tempLabelPositions.set(canvasState.draggedLabel, { |
| x: newX, |
| y: newY, |
| }); |
| m.redraw(); |
| } else if (canvasState.resizingLabel !== null) { |
| // Handle label resizing - store temp width, don't call callback yet |
| const currentX = canvasState.mousePos.transformedX ?? 0; |
| const deltaX = currentX - canvasState.resizeStartX; |
| const newWidth = Math.max(100, canvasState.resizeStartWidth + deltaX); |
| |
| // Store temporary width during resize |
| canvasState.tempLabelWidths.set(canvasState.resizingLabel, newWidth); |
| m.redraw(); |
| } else 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 - call onUndock immediately so node becomes independent |
| const {onUndock} = vnode.attrs; |
| const tempX = |
| (canvasState.undockCandidate.startX - |
| canvasRect.left - |
| canvasState.panOffset.x) / |
| canvasState.zoom - |
| canvasState.dragOffset.x / canvasState.zoom; |
| const tempY = canvasState.undockCandidate.renderY; |
| |
| // Store temp position for this node |
| canvasState.tempNodePositions.set(canvasState.undockCandidate.nodeId, { |
| x: tempX, |
| y: tempY, |
| }); |
| |
| // Immediately call onUndock so the node becomes independent |
| if (onUndock) { |
| onUndock( |
| canvasState.undockCandidate.parentId, |
| canvasState.undockCandidate.nodeId, |
| tempX, |
| tempY, |
| ); |
| } |
| |
| // Mark as undocked so we track it as a regular drag now |
| canvasState.undockedNode = { |
| nodeId: canvasState.undockCandidate.nodeId, |
| parentId: canvasState.undockCandidate.parentId, |
| }; |
| |
| canvasState.undockCandidate = null; |
| m.redraw(); // Force update so nodes array regenerates |
| } |
| } 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; |
| |
| // Store current position internally |
| currentDragPosition = {x: newX, y: newY}; |
| canvasState.tempNodePositions.set(canvasState.draggedNode, { |
| x: newX, |
| y: newY, |
| }); |
| |
| // Check if we're in a dock zone (exclude the parent we just undocked from) |
| const {nodes} = vnode.attrs; |
| 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 box selection completion |
| if (canvasState.selectionRect) { |
| const {nodes = [], labels = []} = vnode.attrs; |
| const rect = canvasState.selectionRect; |
| const minX = Math.min(rect.startX, rect.currentX); |
| const maxX = Math.max(rect.startX, rect.currentX); |
| const minY = Math.min(rect.startY, rect.currentY); |
| const maxY = Math.max(rect.startY, rect.currentY); |
| |
| // Helper to check if a node at given position overlaps with selection rectangle |
| const nodeOverlapsRect = ( |
| nodeX: number, |
| nodeY: number, |
| nodeId: string, |
| ): boolean => { |
| const dims = getNodeDimensions(nodeId); |
| const nodeRight = nodeX + dims.width; |
| const nodeBottom = nodeY + dims.height; |
| |
| return ( |
| nodeX < maxX && nodeRight > minX && nodeY < maxY && nodeBottom > minY |
| ); |
| }; |
| |
| // Helper to check if a label overlaps with selection rectangle |
| const labelOverlapsRect = (label: Label): boolean => { |
| const labelRight = label.x + label.width; |
| const labelBottom = label.y + DEFAULT_LABEL_MIN_HEIGHT; |
| |
| return ( |
| label.x < maxX && |
| labelRight > minX && |
| label.y < maxY && |
| labelBottom > minY |
| ); |
| }; |
| |
| // Find all nodes (including chained/docked nodes) that intersect with the selection rectangle |
| const selectedInRect: string[] = []; |
| nodes.forEach((node) => { |
| // Check root node |
| if (nodeOverlapsRect(node.x, node.y, node.id)) { |
| selectedInRect.push(node.id); |
| } |
| |
| // Check all chained nodes |
| const chain = getChain(node); |
| let currentY = node.y; |
| chain.slice(1).forEach((chainNode) => { |
| // For chained nodes, calculate their Y position |
| const previousNodeId = chain[chain.indexOf(chainNode) - 1].id; |
| currentY += getNodeDimensions(previousNodeId).height; |
| |
| if (nodeOverlapsRect(node.x, currentY, chainNode.id)) { |
| selectedInRect.push(chainNode.id); |
| } |
| }); |
| }); |
| |
| // Find all labels that intersect with the selection rectangle |
| labels.forEach((label) => { |
| if (labelOverlapsRect(label)) { |
| selectedInRect.push(label.id); |
| } |
| }); |
| |
| // Add all selected nodes and labels to selection |
| const {onNodeAddToSelection} = vnode.attrs; |
| selectedInRect.forEach((id) => { |
| if (!canvasState.selectedNodes.has(id)) { |
| if (onNodeAddToSelection !== undefined) { |
| onNodeAddToSelection(id); |
| } |
| } |
| }); |
| |
| canvasState.selectionRect = null; |
| m.redraw(); |
| return; |
| } |
| |
| // 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 and finalize drag (only for non-docked/undocked nodes) |
| if (canvasState.draggedNode !== null && !canvasState.isDockZone) { |
| const {nodes = [], onNodeMove} = vnode.attrs; |
| const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode); |
| |
| // Only do overlap checking if NOT being docked |
| if (draggedNode) { |
| // 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 ( |
| currentDragPosition && |
| checkNodeOverlap( |
| currentDragPosition.x, |
| currentDragPosition.y, |
| draggedNode.id, |
| nodes, |
| dims.width, |
| chainHeight, |
| ) |
| ) { |
| // Find nearest non-overlapping position |
| const newPos = findNearestNonOverlappingPosition( |
| currentDragPosition.x, |
| currentDragPosition.y, |
| draggedNode.id, |
| nodes, |
| dims.width, |
| chainHeight, |
| ); |
| // Update to the non-overlapping position |
| currentDragPosition = newPos; |
| canvasState.tempNodePositions.set(draggedNode.id, newPos); |
| } |
| } |
| |
| // Call onNodeMove with final position if it changed |
| // For undocked nodes, this provides the final position after dragging |
| // For regular nodes, this is the only position update |
| if (onNodeMove !== undefined && currentDragPosition !== null) { |
| const startX = dragStartPosition?.x ?? 0; |
| const startY = dragStartPosition?.y ?? 0; |
| const moved = |
| Math.abs(currentDragPosition.x - startX) > 0.5 || |
| Math.abs(currentDragPosition.y - startY) > 0.5; |
| if (moved || canvasState.undockedNode !== null) { |
| onNodeMove( |
| canvasState.draggedNode, |
| currentDragPosition.x, |
| currentDragPosition.y, |
| ); |
| } |
| } |
| } |
| |
| // Handle label callbacks with final values |
| const {onLabelMove, onLabelResize} = vnode.attrs; |
| |
| if (canvasState.draggedLabel !== null) { |
| const finalPos = canvasState.tempLabelPositions.get( |
| canvasState.draggedLabel, |
| ); |
| if (finalPos && onLabelMove) { |
| onLabelMove(canvasState.draggedLabel, finalPos.x, finalPos.y); |
| } |
| } |
| |
| if (canvasState.resizingLabel !== null) { |
| const finalWidth = canvasState.tempLabelWidths.get( |
| canvasState.resizingLabel, |
| ); |
| if (finalWidth !== undefined && onLabelResize) { |
| onLabelResize(canvasState.resizingLabel, finalWidth); |
| } |
| } |
| |
| // Cleanup label state |
| canvasState.draggedLabel = null; |
| canvasState.labelDragStartPos = null; |
| canvasState.resizingLabel = null; |
| canvasState.tempLabelPositions.clear(); |
| canvasState.tempLabelWidths.clear(); |
| |
| canvasState.draggedNode = null; |
| dragStartPosition = null; |
| currentDragPosition = null; |
| canvasState.connecting = null; |
| canvasState.hoveredPort = null; |
| canvasState.isPanning = false; |
| canvasState.dockTarget = null; |
| canvasState.isDockZone = false; |
| canvasState.undockCandidate = null; |
| canvasState.undockedNode = null; |
| canvasState.tempNodePositions.clear(); |
| m.redraw(); |
| }; |
| |
| // Helper to determine port type based on port index |
| function getPortType( |
| nodeId: string, |
| portType: 'input' | 'output', |
| portIndex: number, |
| nodes: ReadonlyArray<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'; |
| |
| // Get the port from the node |
| const ports = portType === 'input' ? node.inputs : node.outputs; |
| if (!ports || portIndex >= ports.length) { |
| return portType === 'input' ? 'left' : 'right'; |
| } |
| |
| return ports[portIndex].direction; |
| } |
| |
| function renderConnections( |
| svg: SVGElement, |
| connections: ReadonlyArray<Connection>, |
| nodes: ReadonlyArray<Node>, |
| onConnectionRemove?: (index: number) => void, |
| ) { |
| const shortenLength = 16; |
| const arrowheadLength = 4; |
| |
| // Cache all port positions at once for performance |
| const portPositionCache = new Map<string, Position>(); |
| |
| // Query all ports in one go and cache their positions |
| const allPorts = document.querySelectorAll('.pf-port[data-port]'); |
| allPorts.forEach((portElement) => { |
| const portId = portElement.getAttribute('data-port'); |
| if (!portId) return; |
| |
| const nodeElement = portElement.closest( |
| '[data-node]', |
| ) as HTMLElement | null; |
| if (!nodeElement) return; |
| |
| const nodeId = nodeElement.dataset.node; |
| if (!nodeId) return; |
| |
| const [portType, portIndexStr] = portId.split('-'); |
| const cacheKey = `${nodeId}-${portType}-${portIndexStr}`; |
| |
| // Calculate position |
| const chainContainer = nodeElement.closest( |
| '.pf-node-wrapper', |
| ) 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; |
| |
| portPositionCache.set(cacheKey, { |
| x: nodeLeft + portX, |
| y: nodeTop + portY, |
| }); |
| }); |
| |
| // Helper function to get port position from cache or fallback to direct lookup |
| const getPortPos = ( |
| nodeId: string, |
| portType: 'input' | 'output', |
| portIndex: number, |
| ): Position => { |
| const cacheKey = `${nodeId}-${portType}-${portIndex}`; |
| return ( |
| portPositionCache.get(cacheKey) || |
| getPortPosition(nodeId, portType, portIndex) |
| ); |
| }; |
| |
| // Build arrowhead markers using mithril |
| const arrowheadMarker = (id: string) => |
| m( |
| 'marker', |
| { |
| id, |
| viewBox: `0 0 ${arrowheadLength} 10`, |
| refX: '0', |
| refY: '5', |
| markerWidth: `${arrowheadLength}`, |
| markerHeight: '10', |
| orient: 'auto', |
| }, |
| m('polygon', { |
| points: `0 2.5, ${arrowheadLength} 5, 0 7.5`, |
| fill: 'context-stroke', |
| }), |
| ); |
| |
| // Build connection paths using mithril |
| // Each connection is rendered as two paths: a wider invisible hitbox and the visible line |
| const connectionPaths = connections |
| .map((conn, idx) => { |
| const from = getPortPos(conn.fromNode, 'output', conn.fromPort); |
| const to = getPortPos(conn.toNode, 'input', conn.toPort); |
| |
| // Validate that both ports exist (return {x: 0, y: 0} if not found) |
| const fromValid = from.x !== 0 || from.y !== 0; |
| const toValid = to.x !== 0 || to.y !== 0; |
| |
| if (!fromValid || !toValid) { |
| console.warn( |
| `Invalid connection: ${conn.fromNode}:${conn.fromPort} -> ${conn.toNode}:${conn.toPort}`, |
| !fromValid ? `(source port not found)` : `(target port not found)`, |
| ); |
| return null; |
| } |
| |
| const fromPortType = getPortType( |
| conn.fromNode, |
| 'output', |
| conn.fromPort, |
| nodes, |
| ); |
| const toPortType = getPortType( |
| conn.toNode, |
| 'input', |
| conn.toPort, |
| nodes, |
| ); |
| |
| const pathData = createCurve( |
| from.x, |
| from.y, |
| to.x, |
| to.y, |
| fromPortType, |
| toPortType, |
| shortenLength, |
| ); |
| |
| const handlePointerDown = (e: PointerEvent) => { |
| e.stopPropagation(); |
| e.preventDefault(); |
| }; |
| |
| const handleClick = (e: Event) => { |
| e.stopPropagation(); |
| if (onConnectionRemove !== undefined) { |
| onConnectionRemove(idx); |
| } |
| }; |
| |
| // Return a group with both the hitbox and visible path |
| return m('g', {key: `conn-${idx}`, class: 'pf-connection-group'}, [ |
| // Invisible wider hitbox path |
| m('path', { |
| d: pathData, |
| class: 'pf-connection-hitbox', |
| style: { |
| stroke: 'transparent', |
| strokeWidth: '20', |
| fill: 'none', |
| pointerEvents: 'stroke', |
| cursor: 'pointer', |
| }, |
| onpointerdown: handlePointerDown, |
| onclick: handleClick, |
| }), |
| // Visible connection path |
| m('path', { |
| 'd': pathData, |
| 'class': 'pf-connection', |
| 'marker-end': 'url(#arrowhead)', |
| 'style': { |
| pointerEvents: 'none', |
| }, |
| 'onpointerdown': handlePointerDown, |
| 'onclick': handleClick, |
| }), |
| ]); |
| }) |
| .filter((path) => path !== null); |
| |
| // Build temp connection if connecting |
| let tempConnectionPath = null; |
| if (canvasState.connecting) { |
| const fromX = canvasState.connecting.transformedX; |
| const fromY = canvasState.connecting.transformedY; |
| let toX = canvasState.mousePos.transformedX ?? 0; |
| let toY = canvasState.mousePos.transformedY ?? 0; |
| |
| const fromPortType = canvasState.connecting.portType; |
| let toPortType: 'top' | 'left' | 'right' | 'bottom' = |
| fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left'; |
| |
| if ( |
| canvasState.hoveredPort && |
| canvasState.connecting.type === 'output' && |
| canvasState.hoveredPort.type === 'input' |
| ) { |
| const {nodeId, portIndex, type} = canvasState.hoveredPort; |
| const hoverPos = getPortPos(nodeId, type, portIndex); |
| if (hoverPos.x !== 0 || hoverPos.y !== 0) { |
| toX = hoverPos.x; |
| toY = hoverPos.y; |
| toPortType = getPortType(nodeId, type, portIndex, nodes); |
| } |
| } |
| |
| tempConnectionPath = m('path', { |
| 'class': 'pf-temp-connection', |
| 'd': createCurve( |
| fromX, |
| fromY, |
| toX, |
| toY, |
| fromPortType, |
| toPortType, |
| shortenLength, |
| ), |
| 'marker-end': 'url(#arrowhead)', |
| }); |
| } |
| |
| // Render everything using mithril's render function |
| m.render(svg, [ |
| m('defs', [arrowheadMarker('arrowhead')]), |
| m('g', connectionPaths), |
| tempConnectionPath, |
| ]); |
| } |
| |
| 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-node-wrapper', |
| ) 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: ReadonlyArray<Node>, |
| ): {targetNodeId: string | null; isValidZone: boolean} { |
| const DOCK_DISTANCE = 30; |
| const HORIZONTAL_TOLERANCE = 100; |
| |
| // Check if dragged node can be docked at the top |
| if (!draggedNode.canDockTop) { |
| 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 |
| if (!lastInChain.canDockBottom) { |
| 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: ReadonlyArray<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: ReadonlyArray<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: ReadonlyArray<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: ReadonlyArray<Node>, |
| connections: ReadonlyArray<Connection>, |
| onNodeMove: ((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 && onNodeMove) { |
| onNodeMove(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: ReadonlyArray<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(); |
| }; |
| |
| // Helper function to render a single node |
| function renderNode( |
| node: Node | Omit<Node, 'x' | 'y'>, |
| vnode: m.Vnode<NodeGraphAttrs>, |
| options: { |
| isDockedChild: boolean; |
| hasDockedChild: boolean; |
| isDockTarget: boolean; |
| rootNode?: Node; |
| multiselect: boolean; |
| contextMenuOnHover: boolean; |
| }, |
| ): m.Vnode { |
| const { |
| id, |
| inputs = [], |
| outputs = [], |
| titleBar, |
| content, |
| hue, |
| accentBar, |
| contextMenuItems, |
| invalid, |
| } = node; |
| const { |
| isDockedChild, |
| hasDockedChild, |
| isDockTarget, |
| rootNode, |
| multiselect, |
| contextMenuOnHover, |
| } = options; |
| const {connections = [], onConnect, nodes = []} = vnode.attrs; |
| |
| // Separate ports by direction |
| const topInputs = inputs.filter((p) => p.direction === 'top'); |
| const leftInputs = inputs.filter((p) => p.direction === 'left'); |
| const bottomOutputs = outputs.filter((p) => p.direction === 'bottom'); |
| const rightOutputs = outputs.filter((p) => p.direction === 'right'); |
| |
| const classes = classNames( |
| canvasState.selectedNodes.has(id) && 'pf-selected', |
| isDockedChild && 'pf-docked-child', |
| hasDockedChild && 'pf-has-docked-child', |
| isDockTarget && 'pf-dock-target', |
| accentBar && 'pf-node--has-accent-bar', |
| invalid && 'pf-invalid', |
| ); |
| |
| // Helper to render a port |
| const renderPort = ( |
| port: NodePort, |
| portIndex: number, |
| portType: 'input' | 'output', |
| forceConnected?: boolean, |
| ) => { |
| const portId = `${portType}-${portIndex}`; |
| const cssClass = classNames( |
| portType === 'input' ? 'pf-input' : 'pf-output', |
| `pf-port-${port.direction}`, |
| (forceConnected || |
| isPortConnected(id, portType, portIndex, connections)) && |
| 'pf-connected', |
| canvasState.connecting && |
| canvasState.connecting.nodeId === id && |
| canvasState.connecting.portIndex === portIndex && |
| canvasState.connecting.type === portType && |
| 'pf-active', |
| port.contextMenuItems !== undefined && 'pf-port--with-context-menu', |
| ); |
| |
| const portElement = m('.pf-port', { |
| 'data-port': portId, |
| 'className': cssClass, |
| 'onpointerdown': (e: PointerEvent) => { |
| e.stopPropagation(); |
| if (portType === 'input') { |
| // Input port - check for existing connection |
| const existingConnIdx = connections.findIndex( |
| (conn) => conn.toNode === id && conn.toPort === portIndex, |
| ); |
| 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(); |
| } |
| } else { |
| // Output port - start connection |
| const portPos = getPortPosition(id, portType, portIndex); |
| canvasState.connecting = { |
| nodeId: id, |
| portIndex, |
| type: portType, |
| portType: port.direction, |
| x: 0, |
| y: 0, |
| transformedX: portPos.x, |
| transformedY: portPos.y, |
| }; |
| } |
| }, |
| 'onpointerup': (e: PointerEvent) => { |
| e.stopPropagation(); |
| if (portType === 'input') { |
| if ( |
| canvasState.connecting && |
| canvasState.connecting.type === 'output' |
| ) { |
| // Input port receiving connection |
| const existingConnIdx = connections.findIndex( |
| (conn) => conn.toNode === id && conn.toPort === portIndex, |
| ); |
| 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: portIndex, |
| }; |
| if (onConnect !== undefined) { |
| onConnect(connection); |
| } |
| canvasState.connecting = null; |
| } |
| } else if (portType === 'output') { |
| // Clear connecting state if releasing on output port without completing connection |
| canvasState.connecting = null; |
| } |
| }, |
| }); |
| |
| // Wrap with PopupMenu if contextMenuItems exist |
| if (port.contextMenuItems !== undefined) { |
| return m(PopupMenu, {trigger: portElement}, port.contextMenuItems); |
| } |
| return portElement; |
| }; |
| |
| const style = hue !== undefined ? {'--pf-node-hue': `${hue}`} : undefined; |
| |
| return m( |
| '.pf-node', |
| { |
| 'key': id, |
| 'data-node': id, |
| 'class': classes, |
| 'style': { |
| ...style, |
| }, |
| 'onpointerdown': (e: PointerEvent) => { |
| if ((e.target as HTMLElement).closest('.pf-port')) { |
| return; |
| } |
| e.stopPropagation(); |
| |
| // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled) |
| if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) { |
| // Toggle selection |
| if (canvasState.selectedNodes.has(id)) { |
| const {onNodeRemoveFromSelection} = vnode.attrs; |
| if (onNodeRemoveFromSelection !== undefined) { |
| onNodeRemoveFromSelection(id); |
| } |
| } else { |
| const {onNodeAddToSelection} = vnode.attrs; |
| if (onNodeAddToSelection !== undefined) { |
| onNodeAddToSelection(id); |
| } |
| } |
| |
| // Focus the canvas element to ensure keyboard events (like Delete) are captured |
| if (canvasElement) { |
| canvasElement.focus(); |
| } |
| |
| return; |
| } |
| |
| // Check if this is a chained node (not root) |
| if (isDockedChild && rootNode) { |
| // Don't undock immediately - wait for drag threshold |
| // Calculate current render position |
| let yOffset = rootNode.y; |
| const chainArr = getChain(rootNode); |
| for (const cn of chainArr) { |
| if (cn.id === id) break; |
| yOffset += getNodeDimensions(cn.id).height; |
| } |
| |
| // Find parent node in chain |
| let parentId = rootNode.id; |
| let curr = rootNode.next; |
| while (curr && curr.id !== id) { |
| parentId = curr.id; |
| curr = curr.next; |
| } |
| |
| // Store undock candidate - will undock if dragged beyond threshold |
| canvasState.undockCandidate = { |
| nodeId: id, |
| parentId: parentId, |
| startX: e.clientX, |
| startY: e.clientY, |
| renderY: yOffset, |
| }; |
| } |
| |
| canvasState.draggedNode = id; |
| |
| // Store initial drag position for batching |
| // Check if node has x,y properties (root nodes) vs docked children (no x,y) |
| if ('x' in node && 'y' in node) { |
| dragStartPosition = {nodeId: id, x: node.x, y: node.y}; |
| currentDragPosition = {x: node.x, y: node.y}; |
| } |
| |
| const {onNodeSelect} = vnode.attrs; |
| if (onNodeSelect !== undefined) { |
| onNodeSelect(id); |
| } |
| |
| // Focus the canvas element to ensure keyboard events (like Delete) are captured |
| if (canvasElement) { |
| canvasElement.focus(); |
| } |
| |
| const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
| canvasState.dragOffset = { |
| x: e.clientX - rect.left, |
| y: e.clientY - rect.top, |
| }; |
| }, |
| }, |
| [ |
| // Render node title if it exists |
| titleBar !== undefined && |
| m('.pf-node-header', [ |
| titleBar.icon !== undefined && |
| m(Icon, {icon: titleBar.icon, className: 'pf-node-title-icon'}), |
| m('.pf-node-title', titleBar.title), |
| contextMenuItems !== undefined && |
| m( |
| PopupMenu, |
| { |
| trigger: m(Button, { |
| rounded: true, |
| icon: Icons.ContextMenuAlt, |
| className: contextMenuOnHover ? 'pf-show-on-hover' : '', |
| }), |
| }, |
| contextMenuItems, |
| ), |
| ]), |
| |
| // Context menu button for nodes without titlebar |
| titleBar === undefined && |
| contextMenuItems !== undefined && |
| m( |
| '.pf-node-context-menu', |
| {className: contextMenuOnHover ? 'pf-show-on-hover' : ''}, |
| m( |
| PopupMenu, |
| { |
| trigger: m(Button, { |
| rounded: true, |
| icon: Icons.ContextMenuAlt, |
| }), |
| }, |
| contextMenuItems, |
| ), |
| ), |
| |
| // Top input ports (if not docked child) |
| topInputs.map((port) => { |
| const portIndex = inputs.indexOf(port); |
| return renderPort(port, portIndex, 'input'); |
| }), |
| |
| m('.pf-node-body', [ |
| content !== undefined && |
| m( |
| '.pf-node-content', |
| { |
| onkeydown: (e: KeyboardEvent) => { |
| e.stopPropagation(); |
| }, |
| }, |
| content, |
| ), |
| |
| // Left input ports |
| leftInputs.map((port) => { |
| const portIndex = inputs.indexOf(port); |
| return m( |
| '.pf-port-row.pf-port-input', |
| { |
| 'data-port': `input-${portIndex}`, |
| }, |
| [renderPort(port, portIndex, 'input'), port.content], |
| ); |
| }), |
| |
| // Right output ports |
| rightOutputs.map((port) => { |
| const portIndex = outputs.indexOf(port); |
| return m( |
| '.pf-port-row.pf-port-output', |
| { |
| 'data-port': `output-${portIndex}`, |
| }, |
| [port.content, renderPort(port, portIndex, 'output')], |
| ); |
| }), |
| ]), |
| |
| // Bottom output ports (if no docked child below) |
| bottomOutputs.map((port) => { |
| const portIndex = outputs.indexOf(port); |
| return renderPort(port, portIndex, 'output'); |
| }), |
| ], |
| ); |
| } |
| |
| function renderLabel(label: Label, vnode: m.Vnode<NodeGraphAttrs>): m.Vnode { |
| const {id, x, y, width, content, selectable = false} = label; |
| const isDragging = canvasState.draggedLabel === id; |
| const isSelected = canvasState.selectedNodes.has(id); |
| |
| // Use temporary position/width during drag if available |
| const tempPos = canvasState.tempLabelPositions.get(id); |
| const tempWidth = canvasState.tempLabelWidths.get(id); |
| const renderX = tempPos?.x ?? x; |
| const renderY = tempPos?.y ?? y; |
| const renderWidth = tempWidth ?? width; |
| |
| return m( |
| '.pf-label', |
| { |
| 'key': `label-${id}`, |
| 'data-label': id, |
| 'className': classNames( |
| isDragging && 'pf-dragging', |
| isSelected && 'pf-selected', |
| ), |
| 'style': { |
| left: `${renderX}px`, |
| top: `${renderY}px`, |
| width: `${renderWidth}px`, |
| }, |
| 'onpointerdown': (e: PointerEvent) => { |
| const target = e.target as HTMLElement; |
| |
| // Check if clicking on the resize handle or delete button |
| if ( |
| target.closest('.pf-label-resize-handle') || |
| target.closest('.pf-label-delete-button') |
| ) { |
| e.stopPropagation(); |
| return; |
| } |
| |
| // Check if clicking on a textarea that is being edited (not readonly) |
| // Allow normal text selection behavior in edit mode |
| if (target instanceof HTMLTextAreaElement && !target.readOnly) { |
| // Don't start dragging, allow text selection |
| return; |
| } |
| |
| const {multiselect = true} = vnode.attrs; |
| |
| // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled) |
| if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) { |
| // Toggle selection |
| if (isSelected) { |
| const {onNodeRemoveFromSelection} = vnode.attrs; |
| if (onNodeRemoveFromSelection !== undefined) { |
| onNodeRemoveFromSelection(id); |
| } |
| } else { |
| const {onNodeAddToSelection} = vnode.attrs; |
| if (onNodeAddToSelection !== undefined) { |
| onNodeAddToSelection(id); |
| } |
| } |
| |
| // Focus the canvas element to ensure keyboard events (like Delete) are captured |
| if (canvasElement) { |
| canvasElement.focus(); |
| } |
| |
| e.stopPropagation(); |
| return; |
| } |
| |
| // Start dragging the label |
| canvasState.draggedLabel = id; |
| canvasState.dragOffset = { |
| x: (canvasState.mousePos.transformedX ?? 0) - x, |
| y: (canvasState.mousePos.transformedY ?? 0) - y, |
| }; |
| |
| // Select the label if selectable (replace current selection) |
| if (selectable) { |
| const {onNodeSelect} = vnode.attrs; |
| if (onNodeSelect !== undefined) { |
| onNodeSelect(id); |
| } |
| } |
| |
| // Focus the canvas element to ensure keyboard events (like Delete) are captured |
| if (canvasElement) { |
| canvasElement.focus(); |
| } |
| |
| e.stopPropagation(); |
| }, |
| }, |
| [ |
| // Render the content (or placeholder if not provided) |
| m( |
| '.pf-label-content', |
| content ?? m('.pf-label-placeholder', 'Empty label'), |
| ), |
| // Resize handle (always rendered) |
| m('.pf-label-resize-handle', { |
| onpointerdown: (e: PointerEvent) => { |
| // Start resizing |
| canvasState.resizingLabel = id; |
| canvasState.resizeStartWidth = width; |
| canvasState.resizeStartX = canvasState.mousePos.transformedX ?? 0; |
| e.stopPropagation(); |
| }, |
| }), |
| // Delete button (always rendered, visible on hover/selection) |
| m( |
| '.pf-label-delete-button', |
| { |
| onclick: (e: PointerEvent) => { |
| const {onLabelRemove} = vnode.attrs; |
| if (onLabelRemove !== undefined) { |
| onLabelRemove(id); |
| } |
| e.stopPropagation(); |
| }, |
| }, |
| m(Icon, {icon: 'close'}), |
| ), |
| ], |
| ); |
| } |
| |
| return { |
| oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => { |
| latestVnode = vnode; |
| canvasElement = vnode.dom as HTMLElement; |
| document.addEventListener('pointermove', handleMouseMove); |
| document.addEventListener('pointerup', 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 = [], onNodeMove} = vnode.attrs; |
| autoLayoutGraph(nodes, connections, onNodeMove); |
| }; |
| |
| // Create recenter function that brings all nodes into view |
| const recenter = () => { |
| const {nodes = []} = vnode.attrs; |
| const canvas = vnode.dom as HTMLElement; |
| autofit(nodes, canvas); |
| }; |
| |
| // Find a non-overlapping position for a new node |
| const findPlacementForNode = ( |
| newNode: Omit<Node, 'x' | 'y'>, |
| ): Position => { |
| if (latestVnode === null || canvasElement === null) { |
| return {x: 0, y: 0}; |
| } |
| |
| const {nodes = []} = latestVnode.attrs; |
| const canvas = canvasElement; |
| |
| // Default starting position (center of viewport in canvas space) |
| const canvasRect = canvas.getBoundingClientRect(); |
| const centerX = |
| (canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom; |
| const centerY = |
| (canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom; |
| |
| // Create a temporary node with coordinates to render and measure |
| const tempNode: Node = { |
| ...newNode, |
| x: centerX, |
| y: centerY, |
| }; |
| |
| // Create temporary DOM element to measure size |
| const tempContainer = document.createElement('div'); |
| tempContainer.style.position = 'absolute'; |
| tempContainer.style.left = '-9999px'; |
| tempContainer.style.visibility = 'hidden'; |
| canvas.appendChild(tempContainer); |
| |
| // Render the node into the temporary container |
| m.render( |
| tempContainer, |
| m( |
| '.pf-node', |
| { |
| 'data-node': tempNode.id, |
| 'style': { |
| ...(tempNode.hue !== undefined |
| ? {'--pf-node-hue': `${tempNode.hue}`} |
| : {}), |
| }, |
| }, |
| [ |
| tempNode.titleBar && |
| m('.pf-node-header', [ |
| m('.pf-node-title', tempNode.titleBar.title), |
| ]), |
| m('.pf-node-body', [ |
| tempNode.content !== undefined && |
| m('.pf-node-content', tempNode.content), |
| tempNode.inputs |
| ?.filter((p) => p.direction === 'left') |
| .map((port) => |
| m('.pf-port-row.pf-port-input', [ |
| m('.pf-port'), |
| port.content, |
| ]), |
| ), |
| tempNode.outputs |
| ?.filter((p) => p.direction === 'right') |
| .map((port) => |
| m('.pf-port-row.pf-port-output', [ |
| port.content, |
| m('.pf-port'), |
| ]), |
| ), |
| ]), |
| ], |
| ), |
| ); |
| |
| // Get dimensions from the rendered element |
| const dims = getNodeDimensions(tempNode.id); |
| |
| // Calculate chain height |
| const chain = getChain(tempNode); |
| let chainHeight = 0; |
| chain.forEach((chainNode) => { |
| const chainDims = getNodeDimensions(chainNode.id); |
| chainHeight += chainDims.height; |
| }); |
| |
| // Clean up temporary element |
| canvas.removeChild(tempContainer); |
| |
| // Find non-overlapping position starting from center |
| const finalPos = findNearestNonOverlappingPosition( |
| centerX - dims.width / 2, |
| centerY - dims.height / 2, |
| tempNode.id, |
| nodes, |
| dims.width, |
| chainHeight, |
| ); |
| |
| return finalPos; |
| }; |
| |
| // Provide API to parent |
| if (onReady) { |
| onReady({autoLayout, recenter, findPlacementForNode}); |
| } |
| }, |
| |
| 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('pointermove', handleMouseMove); |
| document.removeEventListener('pointerup', handleMouseUp); |
| (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel); |
| }, |
| |
| view: (vnode: m.Vnode<NodeGraphAttrs>) => { |
| latestVnode = vnode; |
| const { |
| nodes, |
| selectedNodeIds = new Set<string>(), |
| hideControls = false, |
| multiselect = true, |
| contextMenuOnHover = false, |
| fillHeight, |
| } = vnode.attrs; |
| |
| // Sync internal state with prop |
| canvasState.selectedNodes = selectedNodeIds; |
| |
| const className = classNames( |
| fillHeight && 'pf-canvas--fill-height', |
| 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 |
| oncontextmenu: (e: Event) => { |
| e.preventDefault(); // Disable default context menu |
| }, |
| onpointerdown: (e: PointerEvent) => { |
| const target = e.target as HTMLElement; |
| if ( |
| target.classList.contains('pf-canvas') || |
| target.tagName === 'svg' |
| ) { |
| // Start box selection with Shift (only if multiselect is enabled) |
| if (multiselect && e.shiftKey) { |
| const transformedX = canvasState.mousePos.transformedX ?? 0; |
| const transformedY = canvasState.mousePos.transformedY ?? 0; |
| canvasState.selectionRect = { |
| startX: transformedX, |
| startY: transformedY, |
| currentX: transformedX, |
| currentY: transformedY, |
| }; |
| return; |
| } |
| |
| // Start panning and store position to detect click vs drag |
| canvasState.isPanning = true; |
| canvasState.panStart = {x: e.clientX, y: e.clientY}; |
| canvasState.canvasMouseDownPos = {x: e.clientX, y: e.clientY}; |
| } |
| }, |
| onclick: (e: PointerEvent) => { |
| const target = e.target as HTMLElement; |
| // Clear selection on canvas click (only if mouse didn't move significantly) |
| if ( |
| target.classList.contains('pf-canvas') || |
| target.tagName === 'svg' |
| ) { |
| const dx = Math.abs(e.clientX - canvasState.canvasMouseDownPos.x); |
| const dy = Math.abs(e.clientY - canvasState.canvasMouseDownPos.y); |
| const threshold = 3; // Pixels of movement tolerance |
| |
| // Only clear if it was a click (not a drag) |
| if (dx <= threshold && dy <= threshold) { |
| const {onSelectionClear} = vnode.attrs; |
| if (onSelectionClear !== undefined) { |
| onSelectionClear(); |
| } |
| } |
| } |
| }, |
| onkeydown: (e: KeyboardEvent) => { |
| if (e.key === 'Escape') { |
| // Deselect all nodes and labels |
| const hasSelection = canvasState.selectedNodes.size > 0; |
| if (hasSelection) { |
| const {onSelectionClear} = vnode.attrs; |
| if (onSelectionClear !== undefined) { |
| onSelectionClear(); |
| } |
| } |
| } else if (e.key === 'Delete' || e.key === 'Backspace') { |
| const {onNodeRemove, onLabelRemove, labels = []} = vnode.attrs; |
| |
| if (canvasState.selectedNodes.size > 0) { |
| // Flatten all nodes including docked nodes (via 'next' property) |
| const allNodeIds = new Set<string>(); |
| const queue: Array<Node | DockedNode> = [...nodes]; |
| while (queue.length > 0) { |
| const node = queue.shift(); |
| if (node) { |
| allNodeIds.add(node.id); |
| // Traverse docked children via 'next' property |
| if (node.next) { |
| queue.push(node.next); |
| } |
| } |
| } |
| |
| const labelIds = new Set(labels.map((l) => l.id)); |
| |
| // Delete selected nodes and labels |
| canvasState.selectedNodes.forEach((id) => { |
| if (allNodeIds.has(id) && onNodeRemove !== undefined) { |
| onNodeRemove(id); |
| } else if (labelIds.has(id) && onLabelRemove !== undefined) { |
| onLabelRemove(id); |
| } |
| }); |
| } |
| } |
| }, |
| style: { |
| backgroundSize: `${20 * canvasState.zoom}px ${20 * canvasState.zoom}px`, |
| backgroundPosition: `${canvasState.panOffset.x}px ${canvasState.panOffset.y}px`, |
| ...vnode.attrs.style, |
| }, |
| }, |
| [ |
| // Control buttons (can be hidden via hideControls prop) |
| !hideControls && |
| m('.pf-nodegraph-controls', [ |
| vnode.attrs.toolbarItems, |
| m(Button, { |
| label: 'Auto Layout', |
| icon: 'account_tree', |
| variant: ButtonVariant.Filled, |
| onclick: () => { |
| const { |
| nodes = [], |
| connections = [], |
| onNodeMove, |
| } = vnode.attrs; |
| autoLayoutGraph(nodes, connections, onNodeMove); |
| }, |
| }), |
| m(Button, { |
| label: 'Fit to Screen', |
| icon: 'center_focus_strong', |
| variant: ButtonVariant.Filled, |
| onclick: (e: PointerEvent) => { |
| 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'), |
| |
| // Selection rectangle overlay |
| canvasState.selectionRect && |
| m('.pf-selection-rect', { |
| style: { |
| left: `${Math.min(canvasState.selectionRect.startX, canvasState.selectionRect.currentX)}px`, |
| top: `${Math.min(canvasState.selectionRect.startY, canvasState.selectionRect.currentY)}px`, |
| width: `${Math.abs(canvasState.selectionRect.currentX - canvasState.selectionRect.startX)}px`, |
| height: `${Math.abs(canvasState.selectionRect.currentY - canvasState.selectionRect.startY)}px`, |
| }, |
| }), |
| |
| // Render all nodes - wrap dock chains in flex container |
| nodes |
| .map((node: Node) => { |
| const {id} = node; |
| |
| // Check if this is the root of a dock chain |
| const chain = getChain(node); |
| const isChainRoot = chain.length > 1; |
| |
| // Check if we have a temp position for this node (during drag) |
| const tempPos = canvasState.tempNodePositions.get(id); |
| const renderPos = tempPos || {x: node.x, y: node.y}; |
| |
| // If this is a chain root, wrap all chain nodes in flex container |
| // Always wrap in a chain root container for consistency |
| |
| if (isChainRoot) { |
| return m( |
| '.pf-node-wrapper', |
| { |
| key: `chain-${id}`, |
| style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`, |
| className: classNames( |
| canvasState.draggedNode === id && |
| 'pf-node-wrapper--dragging', |
| ), |
| }, |
| chain.map((chainNode) => { |
| const cIsDockedChild = 'x' in chainNode === false; |
| const cHasDockedChild = chainNode.next !== undefined; |
| const cIsDockTarget = |
| canvasState.dockTarget === chainNode.id && |
| canvasState.isDockZone; |
| |
| return renderNode(chainNode, vnode, { |
| isDockedChild: cIsDockedChild, |
| hasDockedChild: cHasDockedChild, |
| isDockTarget: cIsDockTarget, |
| rootNode: node, |
| multiselect, |
| contextMenuOnHover, |
| }); |
| }), |
| ); |
| } else { |
| // Render standalone node (not part of a chain) |
| const isDockTarget = |
| canvasState.dockTarget === id && canvasState.isDockZone; |
| |
| return m( |
| '.pf-node-wrapper', |
| { |
| key: `chain-${id}`, |
| style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`, |
| className: classNames( |
| canvasState.draggedNode === id && |
| 'pf-node-wrapper--dragging', |
| ), |
| }, |
| renderNode(node, vnode, { |
| isDockedChild: false, |
| hasDockedChild: false, |
| isDockTarget, |
| rootNode: undefined, |
| multiselect, |
| contextMenuOnHover, |
| }), |
| ); |
| } |
| }) |
| .filter((vnode) => vnode !== null), |
| |
| // Render all labels |
| (vnode.attrs.labels ?? []).map((label: Label) => { |
| return renderLabel(label, vnode); |
| }), |
| ], |
| ), |
| ], |
| ); |
| }, |
| }; |
| } |