| // 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 './nodegraph.scss'; | |
| /** | |
| * 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, ButtonGroup, ButtonVariant} from './button'; | |
| import {Icon} from './icon'; | |
| import {PopupMenu} from './menu'; | |
| import {classNames} from '../base/classnames'; | |
| import {Icons} from '../base/semantic_icons'; | |
| import {ensureExists} from '../base/assert'; | |
| import {shortUuid} from '../base/uuid'; | |
| // Default height estimate for labels (used for box selection calculations) | |
| const DEFAULT_LABEL_MIN_HEIGHT = 30; | |
| // Typical height estimate for labels with content (used for autofit calculations) | |
| // Labels can vary in height based on content, but this provides a reasonable | |
| // estimate for bounding box calculations when actual DOM measurements aren't available | |
| const TYPICAL_LABEL_HEIGHT = 100; | |
| 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 | |
| readonly className?: string; // Extra CSS class(es) on the .pf-node element | |
| } | |
| 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; | |
| panBy: (dx: number, dy: number) => void; | |
| /** | |
| * Zooms the canvas by the given delta factor. | |
| * @param deltaZoom - The zoom delta (e.g., 0.1 for 10% zoom in, -0.1 for 10% zoom out) | |
| * @param centerX - X coordinate to zoom around (in viewport space). Defaults to canvas center. | |
| * @param centerY - Y coordinate to zoom around (in viewport space). Defaults to canvas center. | |
| */ | |
| zoomBy: (deltaZoom: number, centerX?: number, centerY?: number) => void; | |
| /** | |
| * Reset the canvas zoom level to the default (1.0) retaining the current | |
| * center point. | |
| */ | |
| resetZoom: () => void; | |
| } | |
| 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; | |
| // Unique instance ID for SVG marker references. Multiple NodeGraph instances | |
| // (e.g. in different tabs) each create <marker id="..."> elements. Without | |
| // unique IDs, url(#arrowhead) resolves to the first matching marker in | |
| // document order, which may be inside a hidden tab (display:none). | |
| const instanceId = shortUuid(); | |
| // Shared pan function used by both internal handlers and external API | |
| const panBy = (dx: number, dy: number) => { | |
| canvasState.panOffset.x += dx; | |
| canvasState.panOffset.y += dy; | |
| }; | |
| // Shared zoom function used by both internal handlers and external API | |
| // Zooms around the center of the canvas (or specified point) | |
| const zoomBy = (deltaZoom: number, centerX?: number, centerY?: number) => { | |
| if (!canvasElement) return; | |
| const canvas = canvasElement; | |
| const canvasRect = canvas.getBoundingClientRect(); | |
| // Use center of canvas if no center point specified | |
| const zoomX = centerX ?? canvasRect.width / 2; | |
| const zoomY = centerY ?? canvasRect.height / 2; | |
| const newZoom = Math.max( | |
| 0.1, | |
| Math.min(5.0, canvasState.zoom * (1 + deltaZoom)), | |
| ); | |
| // Calculate the point in canvas space (before zoom) | |
| const canvasX = (zoomX - canvasState.panOffset.x) / canvasState.zoom; | |
| const canvasY = (zoomY - canvasState.panOffset.y) / canvasState.zoom; | |
| // Update zoom | |
| canvasState.zoom = newZoom; | |
| // Adjust pan to keep the same point under the zoom center | |
| canvasState.panOffset = { | |
| x: zoomX - canvasX * newZoom, | |
| y: zoomY - canvasY * newZoom, | |
| }; | |
| }; | |
| // API functions that are exposed to parent components via onReady callback | |
| // These are initialized in oncreate and can be used in subsequent lifecycle hooks | |
| let autoLayoutApi: (() => void) | null = null; | |
| let recenterApi: (() => void) | null = null; | |
| let resetZoom: (() => void) | null = null; | |
| let findPlacementForNodeApi: | |
| ((newNode: Omit<Node, 'x' | 'y'>) => Position) | 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; | |
| panBy(dx, 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 ports within this NodeGraph instance only (not globally). | |
| // Using document.querySelectorAll would pick up ports from other | |
| // NodeGraph instances (e.g. hidden tabs), causing incorrect positions. | |
| const container = ensureExists(canvasElement); | |
| const allPorts = container.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-${instanceId})`, | |
| '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-${instanceId})`, | |
| }); | |
| } | |
| // Render everything using mithril's render function. | |
| // Use instance-unique marker ID to avoid conflicts when multiple | |
| // NodeGraph instances exist in the document (e.g. tabs). | |
| const markerId = `arrowhead-${instanceId}`; | |
| m.render(svg, [ | |
| m('defs', [arrowheadMarker(markerId)]), | |
| 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`; | |
| // Scope to this NodeGraph instance to avoid matching elements from other | |
| // instances (e.g. hidden tabs with the same node IDs). | |
| const scope = ensureExists(canvasElement); | |
| const portElement = scope.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 scope = ensureExists(canvasElement); | |
| const nodeElement = scope.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>, | |
| labels: ReadonlyArray<Label>, | |
| canvas: HTMLElement, | |
| ) { | |
| if (nodes.length === 0 && labels.length === 0) return; | |
| // Initialize bounding box | |
| // If we have nodes, start with their bounding box | |
| // If we only have labels, initialize with Infinity values that will be replaced | |
| let minX = Infinity; | |
| let minY = Infinity; | |
| let maxX = -Infinity; | |
| let maxY = -Infinity; | |
| if (nodes.length > 0) { | |
| const nodesBBox = getNodesBoundingBox(nodes, true); | |
| minX = nodesBBox.minX; | |
| minY = nodesBBox.minY; | |
| maxX = nodesBBox.maxX; | |
| maxY = nodesBBox.maxY; | |
| } | |
| // Include labels in bounding box calculation | |
| labels.forEach((label) => { | |
| minX = Math.min(minX, label.x); | |
| minY = Math.min(minY, label.y); | |
| maxX = Math.max(maxX, label.x + label.width); | |
| maxY = Math.max(maxY, label.y + TYPICAL_LABEL_HEIGHT); | |
| }); | |
| // 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(1.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 if (e.shiftKey) { | |
| // Emulate horizontal scroll while shift held | |
| panBy(-e.deltaY, 0); | |
| } else { | |
| // Pan the canvas based on wheel delta | |
| panBy(-e.deltaX, -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, | |
| className: nodeClassName, | |
| } = 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', | |
| nodeClassName, | |
| ); | |
| // 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; | |
| } | |
| const {onNodeSelect, selectedNodeIds} = vnode.attrs; | |
| // When multiple nodes are selected, disable all dragging. | |
| // Only allow selection change and focus. | |
| if (selectedNodeIds !== undefined && selectedNodeIds.size > 1) { | |
| // If clicking an unselected node, select just that node | |
| if (!selectedNodeIds.has(id) && onNodeSelect !== undefined) { | |
| onNodeSelect(id); | |
| } | |
| // Focus the canvas for keyboard events (Delete, etc.) | |
| 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}; | |
| } | |
| // Only change selection if the clicked node is not already selected | |
| // This prevents unnecessary selection changes when starting to drag | |
| if (!selectedNodeIds?.has(id) && 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 | |
| autoLayoutApi = () => { | |
| const {nodes = [], connections = [], onNodeMove} = vnode.attrs; | |
| autoLayoutGraph(nodes, connections, onNodeMove); | |
| }; | |
| // Create recenter function that brings all nodes into view | |
| recenterApi = () => { | |
| if (latestVnode === null || canvasElement === null) { | |
| return; | |
| } | |
| const {nodes = [], labels = []} = latestVnode.attrs; | |
| const canvas = canvasElement; | |
| autofit(nodes, labels, canvas); | |
| }; | |
| // Find a non-overlapping position for a new node | |
| findPlacementForNodeApi = (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 with animation disabled | |
| m.render( | |
| tempContainer, | |
| m( | |
| '.pf-node-wrapper', | |
| 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; | |
| }; | |
| // Reset zoom to 100% (1.0x) around canvas center | |
| resetZoom = () => { | |
| if (!canvasElement) return; | |
| const canvas = canvasElement; | |
| const canvasRect = canvas.getBoundingClientRect(); | |
| const centerX = canvasRect.width / 2; | |
| const centerY = canvasRect.height / 2; | |
| // Calculate the point in canvas space (before zoom) | |
| const canvasX = (centerX - canvasState.panOffset.x) / canvasState.zoom; | |
| const canvasY = (centerY - canvasState.panOffset.y) / canvasState.zoom; | |
| // Reset zoom to 1.0 | |
| canvasState.zoom = 1.0; | |
| // Adjust pan to keep the same point under the center | |
| canvasState.panOffset = { | |
| x: centerX - canvasX, | |
| y: centerY - canvasY, | |
| }; | |
| m.redraw(); | |
| }; | |
| // Provide API to parent | |
| if ( | |
| onReady !== undefined && | |
| autoLayoutApi !== null && | |
| recenterApi !== null && | |
| findPlacementForNodeApi !== null | |
| ) { | |
| onReady({ | |
| autoLayout: autoLayoutApi, | |
| recenter: recenterApi, | |
| findPlacementForNode: findPlacementForNodeApi, | |
| panBy, | |
| zoomBy, | |
| resetZoom, | |
| }); | |
| } | |
| }, | |
| onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => { | |
| latestVnode = vnode; | |
| const { | |
| connections = [], | |
| nodes = [], | |
| onConnectionRemove, | |
| onReady, | |
| } = vnode.attrs; | |
| // Re-render connections when component updates | |
| const svg = vnode.dom.querySelector('svg'); | |
| if (svg) { | |
| renderConnections( | |
| svg as SVGElement, | |
| connections, | |
| nodes, | |
| onConnectionRemove, | |
| ); | |
| } | |
| // Call onReady after every render cycle so parent can perform | |
| // post-render actions like recentering | |
| if ( | |
| onReady !== undefined && | |
| autoLayoutApi !== null && | |
| recenterApi !== null && | |
| findPlacementForNodeApi !== null && | |
| resetZoom !== null | |
| ) { | |
| onReady({ | |
| autoLayout: autoLayoutApi, | |
| recenter: recenterApi, | |
| findPlacementForNode: findPlacementForNodeApi, | |
| panBy, | |
| zoomBy, | |
| resetZoom, | |
| }); | |
| } | |
| }, | |
| 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>(), | |
| 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: (() => { | |
| const minPixelSpacing = 10; | |
| let gridSize = 20; | |
| while (gridSize * canvasState.zoom < minPixelSpacing) { | |
| gridSize *= 2; | |
| } | |
| const size = gridSize * canvasState.zoom; | |
| return `${size}px ${size}px`; | |
| })(), | |
| backgroundPosition: (() => { | |
| const minPixelSpacing = 10; | |
| let gridSize = 20; | |
| while (gridSize * canvasState.zoom < minPixelSpacing) { | |
| gridSize *= 2; | |
| } | |
| const size = gridSize * canvasState.zoom; | |
| // Subtract size/2 so the dot (centered in its tile) lands exactly | |
| // on the world origin — stays fixed when gridSize doubles. | |
| const x = canvasState.panOffset.x - size / 2; | |
| const y = canvasState.panOffset.y - size / 2; | |
| return `${x}px ${y}px`; | |
| })(), | |
| ...vnode.attrs.style, | |
| }, | |
| }, | |
| [ | |
| (vnode.attrs.toolbarItems !== undefined || | |
| !vnode.attrs.hideControls) && | |
| m('.pf-nodegraph-controls', [ | |
| vnode.attrs.toolbarItems, | |
| !vnode.attrs.hideControls && | |
| m(Button, { | |
| title: 'Auto layout', | |
| icon: 'account_tree', | |
| variant: ButtonVariant.Filled, | |
| onclick: () => autoLayoutApi?.(), | |
| }), | |
| m( | |
| ButtonGroup, | |
| m(Button, { | |
| title: 'Fit to screen', | |
| icon: 'center_focus_strong', | |
| variant: ButtonVariant.Filled, | |
| onclick: () => recenterApi?.(), | |
| }), | |
| m(Button, { | |
| title: 'Reset zoom to 100%', | |
| icon: 'view_real_size', | |
| variant: ButtonVariant.Filled, | |
| onclick: () => resetZoom?.(), | |
| }), | |
| m(Button, { | |
| title: 'Zoom in', | |
| icon: 'zoom_in', | |
| variant: ButtonVariant.Filled, | |
| onclick: () => zoomBy(0.2), | |
| }), | |
| m(Button, { | |
| title: 'Zoom out', | |
| icon: 'zoom_out', | |
| variant: ButtonVariant.Filled, | |
| onclick: () => zoomBy(-0.2), | |
| }), | |
| ), | |
| ]), | |
| // 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); | |
| }), | |
| ], | |
| ), | |
| ], | |
| ); | |
| }, | |
| }; | |
| } |