From 889d136154b7fcfb9a05c6e2e69026cd3261802b Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sun, 17 Aug 2025 21:07:23 -0400 Subject: [PATCH] [chore] Extract link rendering out of LGraphCanvas (#4994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement CRDT-based layout system for Vue nodes Major refactor to solve snap-back issues and create single source of truth for node positions: - Add Yjs-based CRDT layout store for conflict-free position management - Implement layout mutations service with clean API - Create Vue composables for layout access and node dragging - Add one-way sync from layout store to LiteGraph - Disable LiteGraph dragging when Vue nodes mode is enabled - Add z-index management with bring-to-front on node interaction - Add comprehensive TypeScript types for layout system - Include unit tests for layout store operations - Update documentation to reflect CRDT architecture This provides a solid foundation for both single-user performance and future real-time collaboration features. Co-Authored-By: Claude * style: Apply linter fixes to layout system * fix: Remove unnecessary README files and revert services README - Remove unnecessary types/README.md file - Revert unrelated changes to services/README.md - Keep only relevant documentation for the layout system implementation These were issues identified during PR review that needed to be addressed. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: Clean up layout store and implement proper CRDT operations - Created dedicated layoutOperations.ts with production-grade CRDT interfaces - Integrated existing QuadTree spatial index instead of simple cache - Split composables into separate files (useLayout, useNodeLayout, useLayoutSync) - Cleaned up operation handlers using specific types instead of Extract - Added proper operation interfaces with type guards and extensibility - Updated all type references to use new operation structure The layout store now properly uses the existing QuadTree infrastructure for efficient spatial queries and follows CRDT best practices with well-defined operation interfaces. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: Extract services and split composables for better organization - Created SpatialIndexManager to handle QuadTree operations separately - Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations) - Split GraphNodeManager into focused composables: - useNodeWidgets: Widget state and callback management - useNodeChangeDetection: RAF-based geometry change detection - useNodeState: Node visibility and reactive state management - Extracted constants for magic numbers and configuration values - Updated layout store to use SpatialIndexManager and constants This improves code organization, testability, and makes it easier to swap CRDT implementations or mock services for testing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add node slots to layout tree * Revert "Add node slots to layout tree" This reverts commit 460493a6203d0d7c15422fb4fd2f021eb6809e37. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit 2b9d83efb81d9b9800f868e8804601ce7983c0d8. * Revert "Remove slots from layoutTypes" This reverts commit 18f78ff786411f640bb22dd52d917501fad53b04. * Reapply "Add node slots to layout tree" This reverts commit 236fecb549c9ffcb642412d8a70df3d37629260d. * Revert "Add node slots to layout tree" This reverts commit 460493a6203d0d7c15422fb4fd2f021eb6809e37. * docs: Replace architecture docs with comprehensive ADR - Add ADR-0002 for CRDT-based layout system decision - Follow established ADR template with persuasive reasoning - Include performance benefits, collaboration readiness, and architectural advantages - Update ADR index * Add node slots to layout tree * Revert "Add node slots to layout tree" This reverts commit 460493a6203d0d7c15422fb4fd2f021eb6809e37. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Remove unused methods in LGLA * Extract slot position calculations to shared utility - Create slotCalculations.ts utility for centralized slot position logic - Update LGraphNode to delegate to helper while maintaining compatibility - Modify LitegraphLinkAdapter to use layout tree positions when available - Enable link rendering to use layout system coordinates instead of litegraph positions This allows the layout tree to control link rendering positions, enabling proper synchronization between Vue components and canvas rendering. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * [fix] Restore original link rendering behavior after refactor This commit fixes several rendering discrepancies introduced during the link rendering refactor to ensure exact parity with the original litegraph implementation: Path Shape Fixes: - STRAIGHT_LINK: Now correctly applies l=10 offset to create innerA/innerB points and uses midX=(innerA.x+innerB.x)*0.5 for elbow placement, matching the original 6-segment path - LINEAR_LINK: Restored 4-point path with l=15 directional offsets (start → innerA → innerB → end) Arrow Rendering: - computeConnectionPoint: Now always uses bezier math with 0.25 factor spline offsets regardless of render mode, ensuring arrow positions match original - Arrow positions: Fixed to render at 0.25 and 0.75 positions along the path - Arrow gating: Moved scale>=0.6 and highQuality checks to adapter layer to maintain PathRenderer purity - Arrow shape: Restored original triangle dimensions (-5,-3) to (0,+7) to (+5,-3) Center Marker: - Fixed 'None' option: Center marker now correctly hidden when LinkMarkerShape.None is selected - Center point calculation: Updated for all render modes to match original positions - STRAIGHT_LINK center: Uses midX and average of innerA/innerB y-coordinates - LINEAR_LINK center: Uses midpoint between innerA and innerB control points These fixes ensure backward compatibility while maintaining the clean separation between the pure PathRenderer and litegraph-specific LitegraphLinkAdapter. Fixes #Issue-Number --------- Co-authored-by: bymyself Co-authored-by: Claude --- src/lib/litegraph/src/LGraphCanvas.ts | 423 ++------- src/lib/litegraph/src/LGraphNode.ts | 148 +--- .../adapters/LitegraphLinkAdapter.ts | 525 +++++++++++ src/rendering/canvas/PathRenderer.ts | 820 ++++++++++++++++++ src/utils/slotCalculations.ts | 231 +++++ 5 files changed, 1681 insertions(+), 466 deletions(-) create mode 100644 src/rendering/adapters/LitegraphLinkAdapter.ts create mode 100644 src/rendering/canvas/PathRenderer.ts create mode 100644 src/utils/slotCalculations.ts diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index a4cc5ff7f..012afba0d 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -2,6 +2,10 @@ import { toString } from 'es-toolkit/compat' import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector' +import { + type LinkRenderContext, + LitegraphLinkAdapter +} from '@/rendering/adapters/LitegraphLinkAdapter' import { CanvasPointer } from './CanvasPointer' import type { ContextMenu } from './ContextMenu' @@ -51,7 +55,6 @@ import { containsRect, createBounds, distance, - findPointOnCurve, isInRect, isInRectangle, isPointInRect, @@ -235,9 +238,6 @@ export class LGraphCanvas static #tmp_area = new Float32Array(4) static #margin_area = new Float32Array(4) static #link_bounding = new Float32Array(4) - static #lTempA: Point = new Float32Array(2) - static #lTempB: Point = new Float32Array(2) - static #lTempC: Point = new Float32Array(2) static DEFAULT_BACKGROUND_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=' @@ -639,6 +639,9 @@ export class LGraphCanvas /** Set on keydown, keyup. @todo */ #shiftDown: boolean = false + /** Link rendering adapter for litegraph-to-canvas integration */ + linkRenderer: LitegraphLinkAdapter | null = null + /** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */ dragZoomEnabled: boolean = false /** The start position of the drag zoom. */ @@ -700,6 +703,11 @@ export class LGraphCanvas this.ds = new DragAndScale(canvas) this.pointer = new CanvasPointer(canvas) + // Initialize link renderer if graph is available + if (graph) { + this.linkRenderer = new LitegraphLinkAdapter(graph) + } + this.linkConnector.events.addEventListener('link-created', () => this.#dirty() ) @@ -1793,6 +1801,9 @@ export class LGraphCanvas this.clear() newGraph.attachCanvas(this) + // Re-initialize link renderer with new graph + this.linkRenderer = new LitegraphLinkAdapter(newGraph) + this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph }) this.#dirty() } @@ -4590,18 +4601,26 @@ export class LGraphCanvas : LiteGraph.CONNECTING_LINK_COLOR // the connection being dragged by the mouse - this.renderLink( - ctx, - pos, - highlightPos, - null, - false, - null, - colour, - fromDirection, - dragDirection - ) + if (this.linkRenderer) { + const context = this.buildLinkRenderContext() + this.linkRenderer.renderLinkDirect( + ctx, + pos, + highlightPos, + null, + false, + null, + colour, + fromDirection, + dragDirection, + context, + { + disabled: false + } + ) + } + ctx.fillStyle = colour ctx.beginPath() if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) { ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10) @@ -5725,6 +5744,34 @@ export class LGraphCanvas } } + /** + * Build LinkRenderContext from canvas properties + * Helper method for using LitegraphLinkAdapter + */ + private buildLinkRenderContext(): LinkRenderContext { + return { + // Canvas settings + renderMode: this.links_render_mode, + connectionWidth: this.connections_width, + renderBorder: this.render_connections_border, + lowQuality: this.low_quality, + highQualityRender: this.highquality_render, + scale: this.ds.scale, + linkMarkerShape: this.linkMarkerShape, + renderConnectionArrows: this.render_connection_arrows, + + // State + highlightedLinks: new Set(Object.keys(this.highlighted_links)), + + // Colors + defaultLinkColor: this.default_link_color, + linkTypeColors: LGraphCanvas.link_type_colors, + + // Pattern for disabled links + disabledPattern: this._pattern + } + } + /** * draws a link between two points * @param ctx Canvas 2D rendering context @@ -5766,333 +5813,27 @@ export class LGraphCanvas disabled?: boolean } = {} ): void { - const linkColour = - link != null && this.highlighted_links[link.id] - ? '#FFF' - : color || - link?.color || - (link?.type != null && LGraphCanvas.link_type_colors[link.type]) || - this.default_link_color - const startDir = start_dir || LinkDirection.RIGHT - const endDir = end_dir || LinkDirection.LEFT - - const dist = - this.links_render_mode == LinkRenderType.SPLINE_LINK && - (!endControl || !startControl) - ? distance(a, b) - : 0 - - // TODO: Subline code below was inserted in the wrong place - should be before this statement - if (this.render_connections_border && !this.low_quality) { - ctx.lineWidth = this.connections_width + 4 - } - ctx.lineJoin = 'round' - num_sublines ||= 1 - if (num_sublines > 1) ctx.lineWidth = 0.5 - - // begin line shape - const path = new Path2D() - - /** The link or reroute we're currently rendering */ - const linkSegment = reroute ?? link - if (linkSegment) linkSegment.path = path - - const innerA = LGraphCanvas.#lTempA - const innerB = LGraphCanvas.#lTempB - - /** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */ - const pos: Point = linkSegment?._pos ?? [0, 0] - - for (let i = 0; i < num_sublines; i++) { - const offsety = (i - (num_sublines - 1) * 0.5) * 5 - innerA[0] = a[0] - innerA[1] = a[1] - innerB[0] = b[0] - innerB[1] = b[1] - - if (this.links_render_mode == LinkRenderType.SPLINE_LINK) { - if (endControl) { - innerB[0] = b[0] + endControl[0] - innerB[1] = b[1] + endControl[1] - } else { - this.#addSplineOffset(innerB, endDir, dist) + if (this.linkRenderer) { + const context = this.buildLinkRenderContext() + this.linkRenderer.renderLinkDirect( + ctx, + a, + b, + link, + skip_border, + flow, + color, + start_dir, + end_dir, + context, + { + reroute, + startControl, + endControl, + num_sublines, + disabled } - if (startControl) { - innerA[0] = a[0] + startControl[0] - innerA[1] = a[1] + startControl[1] - } else { - this.#addSplineOffset(innerA, startDir, dist) - } - path.moveTo(a[0], a[1] + offsety) - path.bezierCurveTo( - innerA[0], - innerA[1] + offsety, - innerB[0], - innerB[1] + offsety, - b[0], - b[1] + offsety - ) - - // Calculate centre point - findPointOnCurve(pos, a, b, innerA, innerB, 0.5) - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const justPastCentre = LGraphCanvas.#lTempC - findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51) - - linkSegment._centreAngle = Math.atan2( - justPastCentre[1] - pos[1], - justPastCentre[0] - pos[0] - ) - } - } else { - const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10 - switch (startDir) { - case LinkDirection.LEFT: - innerA[0] += -l - break - case LinkDirection.RIGHT: - innerA[0] += l - break - case LinkDirection.UP: - innerA[1] += -l - break - case LinkDirection.DOWN: - innerA[1] += l - break - } - switch (endDir) { - case LinkDirection.LEFT: - innerB[0] += -l - break - case LinkDirection.RIGHT: - innerB[0] += l - break - case LinkDirection.UP: - innerB[1] += -l - break - case LinkDirection.DOWN: - innerB[1] += l - break - } - if (this.links_render_mode == LinkRenderType.LINEAR_LINK) { - path.moveTo(a[0], a[1] + offsety) - path.lineTo(innerA[0], innerA[1] + offsety) - path.lineTo(innerB[0], innerB[1] + offsety) - path.lineTo(b[0], b[1] + offsety) - - // Calculate centre point - pos[0] = (innerA[0] + innerB[0]) * 0.5 - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - linkSegment._centreAngle = Math.atan2( - innerB[1] - innerA[1], - innerB[0] - innerA[0] - ) - } - } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) { - const midX = (innerA[0] + innerB[0]) * 0.5 - - path.moveTo(a[0], a[1]) - path.lineTo(innerA[0], innerA[1]) - path.lineTo(midX, innerA[1]) - path.lineTo(midX, innerB[1]) - path.lineTo(innerB[0], innerB[1]) - path.lineTo(b[0], b[1]) - - // Calculate centre point - pos[0] = midX - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const diff = innerB[1] - innerA[1] - if (Math.abs(diff) < 4) linkSegment._centreAngle = 0 - else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5 - else linkSegment._centreAngle = -(Math.PI * 0.5) - } - } else { - return - } - } - } - - // rendering the outline of the connection can be a little bit slow - if (this.render_connections_border && !this.low_quality && !skip_border) { - ctx.strokeStyle = 'rgba(0,0,0,0.5)' - ctx.stroke(path) - } - - ctx.lineWidth = this.connections_width - ctx.fillStyle = ctx.strokeStyle = linkColour - ctx.stroke(path) - - // render arrow in the middle - if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) { - // render arrow - if (this.render_connection_arrows) { - // compute two points in the connection - const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir) - const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir) - const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir) - const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir) - - // compute the angle between them so the arrow points in the right direction - let angleA = 0 - let angleB = 0 - if (this.render_curved_connections) { - angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1]) - angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1]) - } else { - angleB = angleA = b[1] > a[1] ? 0 : Math.PI - } - - // render arrow - const transform = ctx.getTransform() - ctx.translate(posA[0], posA[1]) - ctx.rotate(angleA) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - - ctx.translate(posC[0], posC[1]) - ctx.rotate(angleB) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - } - - // Draw link centre marker - ctx.beginPath() - if (this.linkMarkerShape === LinkMarkerShape.Arrow) { - const transform = ctx.getTransform() - ctx.translate(pos[0], pos[1]) - if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle) - // The math is off, but it currently looks better in chromium - ctx.moveTo(-3.2, -5) - ctx.lineTo(+7, 0) - ctx.lineTo(-3.2, +5) - ctx.setTransform(transform) - } else if ( - this.linkMarkerShape == null || - this.linkMarkerShape === LinkMarkerShape.Circle - ) { - ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) - } - if (disabled) { - const { fillStyle, globalAlpha } = ctx - ctx.fillStyle = this._pattern ?? '#797979' - ctx.globalAlpha = 0.75 - ctx.fill() - ctx.globalAlpha = globalAlpha - ctx.fillStyle = fillStyle - } - ctx.fill() - - if (LLink._drawDebug) { - const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx - ctx.globalAlpha = 1 - ctx.lineWidth = 4 - ctx.fillStyle = 'white' - ctx.strokeStyle = 'black' - ctx.font = '16px Arial' - - const text = String(linkSegment.id) - const { width, actualBoundingBoxAscent } = ctx.measureText(text) - const x = pos[0] - width * 0.5 - const y = pos[1] + actualBoundingBoxAscent * 0.5 - ctx.strokeText(text, x, y) - ctx.fillText(text, x, y) - - ctx.font = font - ctx.globalAlpha = globalAlpha - ctx.lineWidth = lineWidth - ctx.fillStyle = fillStyle - ctx.strokeStyle = strokeStyle - } - } - - // render flowing points - if (flow) { - ctx.fillStyle = linkColour - for (let i = 0; i < 5; ++i) { - const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 - const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir) - ctx.beginPath() - ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) - ctx.fill() - } - } - } - - /** - * Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir. - * @param a Start point - * @param b End point - * @param t Time: distance between points (e.g 0.25 is 25% along the line) - * @param start_dir Spline start direction - * @param end_dir Spline end direction - * @returns The point at {@link t} distance along the spline a-b. - */ - computeConnectionPoint( - a: ReadOnlyPoint, - b: ReadOnlyPoint, - t: number, - start_dir: LinkDirection, - end_dir: LinkDirection - ): Point { - start_dir ||= LinkDirection.RIGHT - end_dir ||= LinkDirection.LEFT - - const dist = distance(a, b) - const pa: Point = [a[0], a[1]] - const pb: Point = [b[0], b[1]] - - this.#addSplineOffset(pa, start_dir, dist) - this.#addSplineOffset(pb, end_dir, dist) - - const c1 = (1 - t) * (1 - t) * (1 - t) - const c2 = 3 * ((1 - t) * (1 - t)) * t - const c3 = 3 * (1 - t) * (t * t) - const c4 = t * t * t - - const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0] - const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1] - return [x, y] - } - - /** - * Modifies an existing point, adding a single-axis offset. - * @param point The point to add the offset to - * @param direction The direction to add the offset in - * @param dist Distance to offset - * @param factor Distance is mulitplied by this value. Default: 0.25 - */ - #addSplineOffset( - point: Point, - direction: LinkDirection, - dist: number, - factor = 0.25 - ): void { - switch (direction) { - case LinkDirection.LEFT: - point[0] += dist * -factor - break - case LinkDirection.RIGHT: - point[0] += dist * factor - break - case LinkDirection.UP: - point[1] += dist * -factor - break - case LinkDirection.DOWN: - point[1] += dist * factor - break + ) } } diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 972a98326..d75181b63 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -1,4 +1,10 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties' +import { + type SlotPositionContext, + calculateInputSlotPos, + calculateInputSlotPosFromSlot, + calculateOutputSlotPos +} from '@/utils/slotCalculations' import type { DragAndScale } from './DragAndScale' import type { LGraph } from './LGraph' @@ -3238,67 +3244,22 @@ export class LGraphNode } /** - * Calculate slot position using Vue node dimensions. - * This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering. - * @param isInput Whether this is an input slot (true) or output slot (false) - * @param slot The slot object (for widget detection) - * @param slotIndex The index of the slot in the appropriate array - * @returns The [x, y] position of the slot center in graph coordinates + * Get the context needed for slot position calculations + * @internal */ - #calculateVueSlotPosition( - isInput: boolean, - slot: INodeSlot, - slotIndex: number - ): Point { - const { - pos: [nodeX, nodeY], - size: [width] - } = this - const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components - const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing - - let slotCenterY: number - - // IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header) - // The header is rendered ABOVE this position at negative Y coordinates - // So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px) - const headerDifference = - dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT - - if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) { - // Widget input slot - calculate based on widget position - // Count regular (non-widget) input slots - const regularInputCount = this.#defaultVerticalInputs.length - - // Find widget index - const widgetIndex = - this.widgets?.findIndex( - (w) => w.name === (slot as INodeInputSlot).widget?.name - ) ?? 0 - - // Y position relative to the node body top (not the header) - slotCenterY = - headerDifference + - regularInputCount * dimensions.SLOT_HEIGHT + - (regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) + - widgetIndex * - (dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) + - dimensions.STANDARD_WIDGET_HEIGHT / 2 - } else { - // Regular slot (input or output) - // Slots start at the top of the body, but we need to account for Vue's larger header - slotCenterY = - headerDifference + - slotIndex * dimensions.SLOT_HEIGHT + - dimensions.SLOT_HEIGHT / 2 + #getSlotPositionContext(): SlotPositionContext { + return { + nodeX: this.pos[0], + nodeY: this.pos[1], + nodeWidth: this.size[0], + nodeHeight: this.size[1], + collapsed: this.flags.collapsed ?? false, + collapsedWidth: this._collapsed_width, + slotStartY: this.constructor.slot_start_y, + inputs: this.inputs, + outputs: this.outputs, + widgets: this.widgets } - - // Calculate X position - // Input slots: 10px from left edge (center of 20x20 connector) - // Output slots: 10px from right edge (center of 20x20 connector) - const slotCenterX = isInput ? 10 : width - 10 - - return [nodeX + slotCenterX, nodeY + slotCenterY] } /** @@ -3309,7 +3270,7 @@ export class LGraphNode * @returns Position of the input slot */ getInputPos(slot: number): Point { - return this.getInputSlotPos(this.inputs[slot]) + return calculateInputSlotPos(this.#getSlotPositionContext(), slot) } /** @@ -3318,39 +3279,7 @@ export class LGraphNode * @returns Position of the centre of the input slot in graph co-ordinates. */ getInputSlotPos(input: INodeInputSlot): Point { - const { - pos: [nodeX, nodeY] - } = this - - if (this.flags.collapsed) { - const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 - return [nodeX, nodeY - halfTitle] - } - - const { pos } = input - if (pos) return [nodeX + pos[0], nodeY + pos[1]] - - // Check if we should use Vue positioning - if (LiteGraph.vueNodesMode) { - if (isWidgetInputSlot(input)) { - // Widget slot - pass the slot object - return this.#calculateVueSlotPosition(true, input, -1) - } else { - // Regular slot - find its index in default vertical inputs - const slotIndex = this.#defaultVerticalInputs.indexOf(input) - if (slotIndex !== -1) { - return this.#calculateVueSlotPosition(true, input, slotIndex) - } - } - } - - // default vertical slots - const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 - const nodeOffsetY = this.constructor.slot_start_y || 0 - const slotIndex = this.#defaultVerticalInputs.indexOf(input) - const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT - - return [nodeX + offsetX, nodeY + slotY + nodeOffsetY] + return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input) } /** @@ -3361,38 +3290,7 @@ export class LGraphNode * @returns Position of the output slot */ getOutputPos(slot: number): Point { - const { - pos: [nodeX, nodeY], - outputs, - size: [width] - } = this - - if (this.flags.collapsed) { - const width = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH - const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 - return [nodeX + width, nodeY - halfTitle] - } - - const outputPos = outputs?.[slot]?.pos - if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]] - - // Check if we should use Vue positioning - if (LiteGraph.vueNodesMode) { - const outputSlot = this.outputs[slot] - const slotIndex = this.#defaultVerticalOutputs.indexOf(outputSlot) - if (slotIndex !== -1) { - return this.#calculateVueSlotPosition(false, outputSlot, slotIndex) - } - } - - // default vertical slots - const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 - const nodeOffsetY = this.constructor.slot_start_y || 0 - const slotIndex = this.#defaultVerticalOutputs.indexOf(this.outputs[slot]) - const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT - - // TODO: Why +1? - return [nodeX + width + 1 - offsetX, nodeY + slotY + nodeOffsetY] + return calculateOutputSlotPos(this.#getSlotPositionContext(), slot) } /** @inheritdoc */ diff --git a/src/rendering/adapters/LitegraphLinkAdapter.ts b/src/rendering/adapters/LitegraphLinkAdapter.ts new file mode 100644 index 000000000..997e89a93 --- /dev/null +++ b/src/rendering/adapters/LitegraphLinkAdapter.ts @@ -0,0 +1,525 @@ +/** + * Litegraph Link Adapter + * + * Bridges the gap between litegraph's data model and the pure canvas renderer. + * Converts litegraph-specific types (LLink, LGraphNode, slots) into generic + * rendering data that can be consumed by the PathRenderer. + * Maintains backward compatibility with existing litegraph integration. + */ +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { LLink } from '@/lib/litegraph/src/LLink' +import type { Reroute } from '@/lib/litegraph/src/Reroute' +import type { + CanvasColour, + INodeInputSlot, + INodeOutputSlot, + ReadOnlyPoint +} from '@/lib/litegraph/src/interfaces' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { + LinkDirection, + LinkMarkerShape, + LinkRenderType +} from '@/lib/litegraph/src/types/globalEnums' +import { + type ArrowShape, + CanvasPathRenderer, + type Direction, + type DragLinkData, + type LinkRenderData, + type RenderContext as PathRenderContext, + type Point, + type RenderMode +} from '@/rendering/canvas/PathRenderer' +import { layoutStore } from '@/stores/layoutStore' +import { + type SlotPositionContext, + calculateInputSlotPos, + calculateOutputSlotPos +} from '@/utils/slotCalculations' + +export interface LinkRenderContext { + // Canvas settings + renderMode: LinkRenderType + connectionWidth: number + renderBorder: boolean + lowQuality: boolean + highQualityRender: boolean + scale: number + linkMarkerShape: LinkMarkerShape + renderConnectionArrows: boolean + + // State + highlightedLinks: Set + + // Colors + defaultLinkColor: CanvasColour + linkTypeColors: Record + + // Pattern for disabled links (optional) + disabledPattern?: CanvasPattern | null +} + +export interface LinkRenderOptions { + color?: CanvasColour + flow?: boolean + skipBorder?: boolean + disabled?: boolean +} + +export class LitegraphLinkAdapter { + private graph: LGraph + private pathRenderer: CanvasPathRenderer + + constructor(graph: LGraph) { + this.graph = graph + this.pathRenderer = new CanvasPathRenderer() + } + + /** + * Render a single link with all necessary data properly fetched + * Populates link.path for hit detection + */ + renderLink( + ctx: CanvasRenderingContext2D, + link: LLink, + context: LinkRenderContext, + options: LinkRenderOptions = {} + ): void { + // Get nodes from graph + const sourceNode = this.graph.getNodeById(link.origin_id) + const targetNode = this.graph.getNodeById(link.target_id) + + if (!sourceNode || !targetNode) { + console.warn(`Cannot render link ${link.id}: missing nodes`) + return + } + + // Get slots from nodes + const sourceSlot = sourceNode.outputs?.[link.origin_slot] + const targetSlot = targetNode.inputs?.[link.target_slot] + + if (!sourceSlot || !targetSlot) { + console.warn(`Cannot render link ${link.id}: missing slots`) + return + } + + // Get positions using layout tree data if available + const startPos = this.getSlotPosition( + sourceNode, + link.origin_slot, + false // output + ) + const endPos = this.getSlotPosition( + targetNode, + link.target_slot, + true // input + ) + + // Get directions from slots + const startDir = sourceSlot.dir || LinkDirection.RIGHT + const endDir = targetSlot.dir || LinkDirection.LEFT + + // Convert to pure render data + const linkData = this.convertToLinkRenderData( + link, + { x: startPos[0], y: startPos[1] }, + { x: endPos[0], y: endPos[1] }, + startDir, + endDir, + options + ) + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Render using pure renderer + const path = this.pathRenderer.drawLink(ctx, linkData, pathContext) + + // Store path for hit detection + link.path = path + } + + /** + * Convert litegraph link data to pure render format + */ + private convertToLinkRenderData( + link: LLink, + startPoint: Point, + endPoint: Point, + startDir: LinkDirection, + endDir: LinkDirection, + options: LinkRenderOptions + ): LinkRenderData { + return { + id: String(link.id), + startPoint, + endPoint, + startDirection: this.convertDirection(startDir), + endDirection: this.convertDirection(endDir), + color: options.color + ? String(options.color) + : link.color + ? String(link.color) + : undefined, + type: link.type !== undefined ? String(link.type) : undefined, + flow: options.flow || false, + disabled: options.disabled || false + } + } + + /** + * Convert LinkDirection enum to Direction string + */ + private convertDirection(dir: LinkDirection): Direction { + switch (dir) { + case LinkDirection.LEFT: + return 'left' + case LinkDirection.RIGHT: + return 'right' + case LinkDirection.UP: + return 'up' + case LinkDirection.DOWN: + return 'down' + default: + return 'right' + } + } + + /** + * Convert LinkRenderContext to PathRenderContext + */ + private convertToPathRenderContext( + context: LinkRenderContext + ): PathRenderContext { + // Match original arrow rendering conditions: + // Arrows only render when scale >= 0.6 AND highquality_render AND render_connection_arrows + const shouldShowArrows = + context.scale >= 0.6 && + context.highQualityRender && + context.renderConnectionArrows + + // Only show center marker when not set to None + const shouldShowCenterMarker = + context.linkMarkerShape !== LinkMarkerShape.None + + return { + style: { + mode: this.convertRenderMode(context.renderMode), + connectionWidth: context.connectionWidth, + borderWidth: context.renderBorder ? 4 : undefined, + arrowShape: this.convertArrowShape(context.linkMarkerShape), + showArrows: shouldShowArrows, + lowQuality: context.lowQuality, + // Center marker settings (matches original litegraph behavior) + showCenterMarker: shouldShowCenterMarker, + centerMarkerShape: + context.linkMarkerShape === LinkMarkerShape.Arrow + ? 'arrow' + : 'circle', + highQuality: context.highQualityRender + }, + colors: { + default: String(context.defaultLinkColor), + byType: this.convertColorMap(context.linkTypeColors), + highlighted: '#FFF' + }, + patterns: { + disabled: context.disabledPattern + }, + animation: { + time: LiteGraph.getTime() * 0.001 + }, + scale: context.scale, + highlightedIds: new Set(Array.from(context.highlightedLinks).map(String)) + } + } + + /** + * Convert LinkRenderType to RenderMode + */ + private convertRenderMode(mode: LinkRenderType): RenderMode { + switch (mode) { + case LinkRenderType.LINEAR_LINK: + return 'linear' + case LinkRenderType.STRAIGHT_LINK: + return 'straight' + case LinkRenderType.SPLINE_LINK: + default: + return 'spline' + } + } + + /** + * Convert LinkMarkerShape to ArrowShape + */ + private convertArrowShape(shape: LinkMarkerShape): ArrowShape { + switch (shape) { + case LinkMarkerShape.Circle: + return 'circle' + case LinkMarkerShape.Arrow: + default: + return 'triangle' + } + } + + /** + * Convert color map to ensure all values are strings + */ + private convertColorMap( + colors: Record + ): Record { + const result: Record = {} + for (const [key, value] of Object.entries(colors)) { + result[key] = String(value) + } + return result + } + + /** + * Apply spline offset to a point, mimicking original #addSplineOffset behavior + * Critically: does nothing for CENTER/NONE directions (no case for them) + */ + private applySplineOffset( + point: Point, + direction: LinkDirection, + distance: number + ): void { + switch (direction) { + case LinkDirection.LEFT: + point.x -= distance + break + case LinkDirection.RIGHT: + point.x += distance + break + case LinkDirection.UP: + point.y -= distance + break + case LinkDirection.DOWN: + point.y += distance + break + // CENTER and NONE: no offset applied (original behavior) + } + } + + /** + * Direct rendering method compatible with LGraphCanvas + * Converts data and delegates to pure renderer + */ + renderLinkDirect( + ctx: CanvasRenderingContext2D, + a: ReadOnlyPoint, + b: ReadOnlyPoint, + link: LLink | null, + skip_border: boolean, + flow: number | boolean | null, + color: CanvasColour | null, + start_dir: LinkDirection, + end_dir: LinkDirection, + context: LinkRenderContext, + extras: { + reroute?: Reroute + startControl?: ReadOnlyPoint + endControl?: ReadOnlyPoint + num_sublines?: number + disabled?: boolean + } = {} + ): void { + // Apply same defaults as original renderLink + const startDir = start_dir || LinkDirection.RIGHT + const endDir = end_dir || LinkDirection.LEFT + + // Convert flow to boolean + const flowBool = flow === true || (typeof flow === 'number' && flow > 0) + + // Create LinkRenderData from direct parameters + const linkData: LinkRenderData = { + id: link ? String(link.id) : 'temp', + startPoint: { x: a[0], y: a[1] }, + endPoint: { x: b[0], y: b[1] }, + startDirection: this.convertDirection(startDir), + endDirection: this.convertDirection(endDir), + color: color !== null && color !== undefined ? String(color) : undefined, + type: link?.type !== undefined ? String(link.type) : undefined, + flow: flowBool, + disabled: extras.disabled || false + } + + // Control points handling (spline mode): + // - Pre-refactor, the old renderLink honored a single provided control and + // derived the missing side via #addSplineOffset (CENTER => no offset). + // - Restore that behavior here so reroute segments render identically. + if (context.renderMode === LinkRenderType.SPLINE_LINK) { + const hasStartCtrl = !!extras.startControl + const hasEndCtrl = !!extras.endControl + + // Compute distance once for offsets + const dist = Math.sqrt( + (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]) + ) + const factor = 0.25 + + const cps: Point[] = [] + + if (hasStartCtrl && hasEndCtrl) { + // Both provided explicitly + cps.push( + { + x: a[0] + (extras.startControl![0] || 0), + y: a[1] + (extras.startControl![1] || 0) + }, + { + x: b[0] + (extras.endControl![0] || 0), + y: b[1] + (extras.endControl![1] || 0) + } + ) + linkData.controlPoints = cps + } else if (hasStartCtrl && !hasEndCtrl) { + // Start provided, derive end via direction offset (CENTER => no offset) + const start = { + x: a[0] + (extras.startControl![0] || 0), + y: a[1] + (extras.startControl![1] || 0) + } + const end = { x: b[0], y: b[1] } + this.applySplineOffset(end, endDir, dist * factor) + cps.push(start, end) + linkData.controlPoints = cps + } else if (!hasStartCtrl && hasEndCtrl) { + // End provided, derive start via direction offset (CENTER => no offset) + const start = { x: a[0], y: a[1] } + this.applySplineOffset(start, startDir, dist * factor) + const end = { + x: b[0] + (extras.endControl![0] || 0), + y: b[1] + (extras.endControl![1] || 0) + } + cps.push(start, end) + linkData.controlPoints = cps + } else { + // Neither provided: derive both from directions (CENTER => no offset) + const start = { x: a[0], y: a[1] } + const end = { x: b[0], y: b[1] } + this.applySplineOffset(start, startDir, dist * factor) + this.applySplineOffset(end, endDir, dist * factor) + cps.push(start, end) + linkData.controlPoints = cps + } + } + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Override skip_border if needed + if (skip_border) { + pathContext.style.borderWidth = undefined + } + + // Render using pure renderer + const path = this.pathRenderer.drawLink(ctx, linkData, pathContext) + + // Store path for hit detection + const linkSegment = extras.reroute ?? link + if (linkSegment) { + linkSegment.path = path + + // Copy calculated center position back to litegraph object + // This is needed for hit detection and menu interaction + if (linkData.centerPos) { + linkSegment._pos = linkSegment._pos || new Float32Array(2) + linkSegment._pos[0] = linkData.centerPos.x + linkSegment._pos[1] = linkData.centerPos.y + + // Store center angle if calculated (for arrow markers) + if (linkData.centerAngle !== undefined) { + linkSegment._centreAngle = linkData.centerAngle + } + } + } + } + + /** + * Get slot position using layout tree if available, fallback to node's position + */ + private getSlotPosition( + node: LGraphNode, + slotIndex: number, + isInput: boolean + ): ReadOnlyPoint { + // Try to get position from layout tree + const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value + + if (nodeLayout) { + // Create context from layout tree data + const context: SlotPositionContext = { + nodeX: nodeLayout.position.x, + nodeY: nodeLayout.position.y, + nodeWidth: nodeLayout.size.width, + nodeHeight: nodeLayout.size.height, + collapsed: node.flags.collapsed || false, + collapsedWidth: node._collapsed_width, + slotStartY: node.constructor.slot_start_y, + inputs: node.inputs, + outputs: node.outputs, + widgets: node.widgets + } + + // Use helper to calculate position + return isInput + ? calculateInputSlotPos(context, slotIndex) + : calculateOutputSlotPos(context, slotIndex) + } + + // Fallback to node's own methods if layout not available + return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex) + } + + /** + * Render a link being dragged from a slot to mouse position + * Used during link creation/reconnection + */ + renderDraggingLink( + ctx: CanvasRenderingContext2D, + fromNode: LGraphNode | null, + fromSlot: INodeOutputSlot | INodeInputSlot, + fromSlotIndex: number, + toPosition: ReadOnlyPoint, + context: LinkRenderContext, + options: { + fromInput?: boolean + color?: CanvasColour + disabled?: boolean + } = {} + ): void { + if (!fromNode) return + + // Get slot position using layout tree if available + const slotPos = this.getSlotPosition( + fromNode, + fromSlotIndex, + options.fromInput || false + ) + if (!slotPos) return + + // Get slot direction + const slotDir = + fromSlot.dir || + (options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT) + + // Create drag data + const dragData: DragLinkData = { + fixedPoint: { x: slotPos[0], y: slotPos[1] }, + fixedDirection: this.convertDirection(slotDir), + dragPoint: { x: toPosition[0], y: toPosition[1] }, + color: options.color ? String(options.color) : undefined, + type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined, + disabled: options.disabled || false, + fromInput: options.fromInput || false + } + + // Convert context + const pathContext = this.convertToPathRenderContext(context) + + // Render using pure renderer + this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext) + } +} diff --git a/src/rendering/canvas/PathRenderer.ts b/src/rendering/canvas/PathRenderer.ts new file mode 100644 index 000000000..a2ee8817c --- /dev/null +++ b/src/rendering/canvas/PathRenderer.ts @@ -0,0 +1,820 @@ +/** + * Path Renderer + * + * Pure canvas2D rendering utility with no framework dependencies. + * Renders bezier curves, straight lines, and linear connections between points. + * Supports arrows, flow animations, and returns Path2D objects for hit detection. + * Can be reused in any canvas-based project without modification. + */ + +export interface Point { + x: number + y: number +} + +export type Direction = 'left' | 'right' | 'up' | 'down' +export type RenderMode = 'spline' | 'straight' | 'linear' +export type ArrowShape = 'triangle' | 'circle' | 'square' + +export interface LinkRenderData { + id: string + startPoint: Point + endPoint: Point + startDirection: Direction + endDirection: Direction + color?: string + type?: string + controlPoints?: Point[] + flow?: boolean + disabled?: boolean + // Optional multi-segment support + segments?: Array<{ + start: Point + end: Point + controlPoints?: Point[] + }> + // Center point storage (for hit detection and menu) + centerPos?: Point + centerAngle?: number +} + +export interface RenderStyle { + mode: RenderMode + connectionWidth: number + borderWidth?: number + arrowShape?: ArrowShape + showArrows?: boolean + lowQuality?: boolean + // Center marker properties + showCenterMarker?: boolean + centerMarkerShape?: 'circle' | 'arrow' + highQuality?: boolean +} + +export interface RenderColors { + default: string + byType: Record + highlighted: string +} + +export interface RenderContext { + style: RenderStyle + colors: RenderColors + patterns?: { + disabled?: CanvasPattern | null + } + animation?: { + time: number // Seconds for flow animation + } + scale?: number // Canvas scale for quality adjustments + highlightedIds?: Set +} + +export interface DragLinkData { + /** Fixed end - the slot being dragged from */ + fixedPoint: Point + fixedDirection: Direction + /** Moving end - follows mouse */ + dragPoint: Point + dragDirection?: Direction + /** Visual properties */ + color?: string + type?: string + disabled?: boolean + /** Whether dragging from input (reverse direction) */ + fromInput?: boolean +} + +export class CanvasPathRenderer { + /** + * Draw a link between two points + * Returns a Path2D object for hit detection + */ + drawLink( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext + ): Path2D { + const path = new Path2D() + + // Determine final color + const isHighlighted = context.highlightedIds?.has(link.id) ?? false + const color = this.determineLinkColor(link, context, isHighlighted) + + // Save context state + ctx.save() + + // Apply disabled pattern if needed + if (link.disabled && context.patterns?.disabled) { + ctx.strokeStyle = context.patterns.disabled + } else { + ctx.strokeStyle = color + } + + // Set line properties + ctx.lineWidth = context.style.connectionWidth + ctx.lineJoin = 'round' + + // Draw border if needed + if (context.style.borderWidth && !context.style.lowQuality) { + this.drawLinkPath( + ctx, + path, + link, + context, + context.style.connectionWidth + context.style.borderWidth, + 'rgba(0,0,0,0.5)' + ) + } + + // Draw main link + this.drawLinkPath( + ctx, + path, + link, + context, + context.style.connectionWidth, + color + ) + + // Calculate and store center position + this.calculateCenterPoint(link, context) + + // Draw arrows if needed + if (context.style.showArrows) { + this.drawArrows(ctx, link, context, color) + } + + // Draw center marker if needed (for link menu interaction) + if ( + context.style.showCenterMarker && + context.scale && + context.scale >= 0.6 && + context.style.highQuality + ) { + this.drawCenterMarker(ctx, link, context, color) + } + + // Draw flow animation if needed + if (link.flow && context.animation) { + this.drawFlowAnimation(ctx, path, link, context) + } + + ctx.restore() + + return path + } + + private determineLinkColor( + link: LinkRenderData, + context: RenderContext, + isHighlighted: boolean + ): string { + if (isHighlighted) { + return context.colors.highlighted + } + if (link.color) { + return link.color + } + if (link.type && context.colors.byType[link.type]) { + return context.colors.byType[link.type] + } + return context.colors.default + } + + private drawLinkPath( + ctx: CanvasRenderingContext2D, + path: Path2D, + link: LinkRenderData, + context: RenderContext, + lineWidth: number, + color: string + ): void { + ctx.strokeStyle = color + ctx.lineWidth = lineWidth + + const start = link.startPoint + const end = link.endPoint + + // Build the path based on render mode + if (context.style.mode === 'linear') { + this.buildLinearPath( + path, + start, + end, + link.startDirection, + link.endDirection + ) + } else if (context.style.mode === 'straight') { + this.buildStraightPath( + path, + start, + end, + link.startDirection, + link.endDirection + ) + } else { + // Spline mode (default) + this.buildSplinePath( + path, + start, + end, + link.startDirection, + link.endDirection, + link.controlPoints + ) + } + + ctx.stroke(path) + } + + private buildLinearPath( + path: Path2D, + start: Point, + end: Point, + startDir: Direction, + endDir: Direction + ): void { + // Match original litegraph LINEAR_LINK mode with 4-point path + const l = 15 // offset distance for control points + + const innerA = { x: start.x, y: start.y } + const innerB = { x: end.x, y: end.y } + + // Apply directional offsets to create control points + switch (startDir) { + case 'left': + innerA.x -= l + break + case 'right': + innerA.x += l + break + case 'up': + innerA.y -= l + break + case 'down': + innerA.y += l + break + } + + switch (endDir) { + case 'left': + innerB.x -= l + break + case 'right': + innerB.x += l + break + case 'up': + innerB.y -= l + break + case 'down': + innerB.y += l + break + } + + // Draw 4-point path: start -> innerA -> innerB -> end + path.moveTo(start.x, start.y) + path.lineTo(innerA.x, innerA.y) + path.lineTo(innerB.x, innerB.y) + path.lineTo(end.x, end.y) + } + + private buildStraightPath( + path: Path2D, + start: Point, + end: Point, + startDir: Direction, + endDir: Direction + ): void { + // Match original STRAIGHT_LINK implementation with l=10 offset + const l = 10 // offset distance matching original + + const innerA = { x: start.x, y: start.y } + const innerB = { x: end.x, y: end.y } + + // Apply directional offsets to match original behavior + switch (startDir) { + case 'left': + innerA.x -= l + break + case 'right': + innerA.x += l + break + case 'up': + innerA.y -= l + break + case 'down': + innerA.y += l + break + } + + switch (endDir) { + case 'left': + innerB.x -= l + break + case 'right': + innerB.x += l + break + case 'up': + innerB.y -= l + break + case 'down': + innerB.y += l + break + } + + // Calculate midpoint using innerA/innerB positions (matching original) + const midX = (innerA.x + innerB.x) * 0.5 + + // Build path: start -> innerA -> (midX, innerA.y) -> (midX, innerB.y) -> innerB -> end + path.moveTo(start.x, start.y) + path.lineTo(innerA.x, innerA.y) + path.lineTo(midX, innerA.y) + path.lineTo(midX, innerB.y) + path.lineTo(innerB.x, innerB.y) + path.lineTo(end.x, end.y) + } + + private buildSplinePath( + path: Path2D, + start: Point, + end: Point, + startDir: Direction, + endDir: Direction, + controlPoints?: Point[] + ): void { + path.moveTo(start.x, start.y) + + // Calculate control points if not provided + const controls = + controlPoints || this.calculateControlPoints(start, end, startDir, endDir) + + if (controls.length >= 2) { + // Cubic bezier + path.bezierCurveTo( + controls[0].x, + controls[0].y, + controls[1].x, + controls[1].y, + end.x, + end.y + ) + } else if (controls.length === 1) { + // Quadratic bezier + path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y) + } else { + // Fallback to linear + path.lineTo(end.x, end.y) + } + } + + private calculateControlPoints( + start: Point, + end: Point, + startDir: Direction, + endDir: Direction + ): Point[] { + const dist = Math.sqrt( + Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2) + ) + const controlDist = Math.max(30, dist * 0.25) + + // Calculate control point offsets based on direction + const startControl = this.getDirectionOffset(startDir, controlDist) + const endControl = this.getDirectionOffset(endDir, controlDist) + + return [ + { x: start.x + startControl.x, y: start.y + startControl.y }, + { x: end.x + endControl.x, y: end.y + endControl.y } + ] + } + + private getDirectionOffset(direction: Direction, distance: number): Point { + switch (direction) { + case 'left': + return { x: -distance, y: 0 } + case 'right': + return { x: distance, y: 0 } + case 'up': + return { x: 0, y: -distance } + case 'down': + return { x: 0, y: distance } + } + } + + private drawArrows( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext, + color: string + ): void { + if (!context.style.showArrows) return + + // Render arrows at 0.25 and 0.75 positions along the path (matching original) + const positions = [0.25, 0.75] + + for (const t of positions) { + // Compute arrow position and angle + const posA = this.computeConnectionPoint(link, t, context) + const posB = this.computeConnectionPoint(link, t + 0.01, context) // slightly ahead for angle + + const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x) + + // Draw arrow triangle (matching original shape) + const transform = ctx.getTransform() + ctx.translate(posA.x, posA.y) + ctx.rotate(angle) + ctx.fillStyle = color + ctx.beginPath() + ctx.moveTo(-5, -3) + ctx.lineTo(0, +7) + ctx.lineTo(+5, -3) + ctx.fill() + ctx.setTransform(transform) + } + } + + /** + * Compute a point along the link path at position t (0 to 1) + * For backward compatibility with original litegraph, this always uses + * bezier calculation with spline offsets, regardless of render mode. + * This ensures arrow positions match the original implementation. + */ + private computeConnectionPoint( + link: LinkRenderData, + t: number, + _context: RenderContext + ): Point { + const { startPoint, endPoint, startDirection, endDirection } = link + + // Match original behavior: always use bezier math with spline offsets + // regardless of render mode (for arrow position compatibility) + const dist = Math.sqrt( + Math.pow(endPoint.x - startPoint.x, 2) + + Math.pow(endPoint.y - startPoint.y, 2) + ) + const factor = 0.25 + + // Create control points with spline offsets (matching original #addSplineOffset) + const pa = { x: startPoint.x, y: startPoint.y } + const pb = { x: endPoint.x, y: endPoint.y } + + // Apply spline offsets based on direction + switch (startDirection) { + case 'left': + pa.x -= dist * factor + break + case 'right': + pa.x += dist * factor + break + case 'up': + pa.y -= dist * factor + break + case 'down': + pa.y += dist * factor + break + } + + switch (endDirection) { + case 'left': + pb.x -= dist * factor + break + case 'right': + pb.x += dist * factor + break + case 'up': + pb.y -= dist * factor + break + case 'down': + pb.y += dist * factor + break + } + + // Calculate bezier point (matching original computeConnectionPoint) + const c1 = (1 - t) * (1 - t) * (1 - t) + const c2 = 3 * ((1 - t) * (1 - t)) * t + const c3 = 3 * (1 - t) * (t * t) + const c4 = t * t * t + + return { + x: c1 * startPoint.x + c2 * pa.x + c3 * pb.x + c4 * endPoint.x, + y: c1 * startPoint.y + c2 * pa.y + c3 * pb.y + c4 * endPoint.y + } + } + + private drawFlowAnimation( + ctx: CanvasRenderingContext2D, + _path: Path2D, + link: LinkRenderData, + context: RenderContext + ): void { + if (!context.animation) return + + // Match original implementation: render 5 moving circles along the path + const time = context.animation.time + const linkColor = this.determineLinkColor(link, context, false) + + ctx.save() + ctx.fillStyle = linkColor + + // Draw 5 circles at different positions along the path + for (let i = 0; i < 5; ++i) { + // Calculate position along path (0 to 1), with time-based animation + const f = (time + i * 0.2) % 1 + const flowPos = this.computeConnectionPoint(link, f, context) + + // Draw circle at this position + ctx.beginPath() + ctx.arc(flowPos.x, flowPos.y, 5, 0, 2 * Math.PI) + ctx.fill() + } + + ctx.restore() + } + + /** + * Utility to find a point on a bezier curve (for hit detection) + */ + findPointOnBezier( + t: number, + p0: Point, + p1: Point, + p2: Point, + p3: Point + ): Point { + const mt = 1 - t + const mt2 = mt * mt + const mt3 = mt2 * mt + const t2 = t * t + const t3 = t2 * t + + return { + x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x, + y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + } + } + + /** + * Draw a link being dragged from a slot to the mouse position + * Returns a Path2D object for potential hit detection + */ + drawDraggingLink( + ctx: CanvasRenderingContext2D, + dragData: DragLinkData, + context: RenderContext + ): Path2D { + // Create LinkRenderData from drag data + // When dragging from input, swap the points/directions + const linkData: LinkRenderData = dragData.fromInput + ? { + id: 'dragging', + startPoint: dragData.dragPoint, + endPoint: dragData.fixedPoint, + startDirection: + dragData.dragDirection || + this.getOppositeDirection(dragData.fixedDirection), + endDirection: dragData.fixedDirection, + color: dragData.color, + type: dragData.type, + disabled: dragData.disabled + } + : { + id: 'dragging', + startPoint: dragData.fixedPoint, + endPoint: dragData.dragPoint, + startDirection: dragData.fixedDirection, + endDirection: + dragData.dragDirection || + this.getOppositeDirection(dragData.fixedDirection), + color: dragData.color, + type: dragData.type, + disabled: dragData.disabled + } + + // Use standard link drawing + return this.drawLink(ctx, linkData, context) + } + + /** + * Get the opposite direction (for drag preview) + */ + private getOppositeDirection(direction: Direction): Direction { + switch (direction) { + case 'left': + return 'right' + case 'right': + return 'left' + case 'up': + return 'down' + case 'down': + return 'up' + } + } + + /** + * Get the center point of a link (useful for labels, debugging) + */ + getLinkCenter(link: LinkRenderData): Point { + // For now, simple midpoint + // Could be enhanced to find actual curve midpoint + return { + x: (link.startPoint.x + link.endPoint.x) / 2, + y: (link.startPoint.y + link.endPoint.y) / 2 + } + } + + /** + * Calculate and store the center point and angle of a link + * Mimics the original litegraph center point calculation + */ + private calculateCenterPoint( + link: LinkRenderData, + context: RenderContext + ): void { + const { startPoint, endPoint, controlPoints } = link + + if ( + context.style.mode === 'spline' && + controlPoints && + controlPoints.length >= 2 + ) { + // For spline mode, find point at t=0.5 on the bezier curve + const centerPos = this.findPointOnBezier( + 0.5, + startPoint, + controlPoints[0], + controlPoints[1], + endPoint + ) + link.centerPos = centerPos + + // Calculate angle for arrow marker (point slightly past center) + if (context.style.centerMarkerShape === 'arrow') { + const justPastCenter = this.findPointOnBezier( + 0.51, + startPoint, + controlPoints[0], + controlPoints[1], + endPoint + ) + link.centerAngle = Math.atan2( + justPastCenter.y - centerPos.y, + justPastCenter.x - centerPos.x + ) + } + } else if (context.style.mode === 'linear') { + // For linear mode, calculate midpoint between control points (matching original) + const l = 15 // Same offset as buildLinearPath + const innerA = { x: startPoint.x, y: startPoint.y } + const innerB = { x: endPoint.x, y: endPoint.y } + + // Apply same directional offsets as buildLinearPath + switch (link.startDirection) { + case 'left': + innerA.x -= l + break + case 'right': + innerA.x += l + break + case 'up': + innerA.y -= l + break + case 'down': + innerA.y += l + break + } + + switch (link.endDirection) { + case 'left': + innerB.x -= l + break + case 'right': + innerB.x += l + break + case 'up': + innerB.y -= l + break + case 'down': + innerB.y += l + break + } + + link.centerPos = { + x: (innerA.x + innerB.x) * 0.5, + y: (innerA.y + innerB.y) * 0.5 + } + + if (context.style.centerMarkerShape === 'arrow') { + link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x) + } + } else if (context.style.mode === 'straight') { + // For straight mode, match original STRAIGHT_LINK center calculation + const l = 10 // Same offset as buildStraightPath + const innerA = { x: startPoint.x, y: startPoint.y } + const innerB = { x: endPoint.x, y: endPoint.y } + + // Apply same directional offsets as buildStraightPath + switch (link.startDirection) { + case 'left': + innerA.x -= l + break + case 'right': + innerA.x += l + break + case 'up': + innerA.y -= l + break + case 'down': + innerA.y += l + break + } + + switch (link.endDirection) { + case 'left': + innerB.x -= l + break + case 'right': + innerB.x += l + break + case 'up': + innerB.y -= l + break + case 'down': + innerB.y += l + break + } + + // Calculate center using midX and average of innerA/innerB y positions + const midX = (innerA.x + innerB.x) * 0.5 + link.centerPos = { + x: midX, + y: (innerA.y + innerB.y) * 0.5 + } + + if (context.style.centerMarkerShape === 'arrow') { + const diff = innerB.y - innerA.y + if (Math.abs(diff) < 4) { + link.centerAngle = 0 + } else if (diff > 0) { + link.centerAngle = Math.PI * 0.5 + } else { + link.centerAngle = -(Math.PI * 0.5) + } + } + } else { + // Fallback to simple midpoint + link.centerPos = this.getLinkCenter(link) + if (context.style.centerMarkerShape === 'arrow') { + link.centerAngle = Math.atan2( + endPoint.y - startPoint.y, + endPoint.x - startPoint.x + ) + } + } + } + + /** + * Draw the center marker on a link (for menu interaction) + * Matches the original litegraph center marker rendering + */ + private drawCenterMarker( + ctx: CanvasRenderingContext2D, + link: LinkRenderData, + context: RenderContext, + color: string + ): void { + if (!link.centerPos) return + + ctx.beginPath() + + if ( + context.style.centerMarkerShape === 'arrow' && + link.centerAngle !== undefined + ) { + const transform = ctx.getTransform() + ctx.translate(link.centerPos.x, link.centerPos.y) + ctx.rotate(link.centerAngle) + // The math is off, but it currently looks better in chromium (from original) + ctx.moveTo(-3.2, -5) + ctx.lineTo(7, 0) + ctx.lineTo(-3.2, 5) + ctx.setTransform(transform) + } else { + // Default to circle + ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2) + } + + // Apply disabled pattern or color + if (link.disabled && context.patterns?.disabled) { + const { fillStyle, globalAlpha } = ctx + ctx.fillStyle = context.patterns.disabled + ctx.globalAlpha = 0.75 + ctx.fill() + ctx.globalAlpha = globalAlpha + ctx.fillStyle = fillStyle + } else { + ctx.fillStyle = color + ctx.fill() + } + } +} diff --git a/src/utils/slotCalculations.ts b/src/utils/slotCalculations.ts new file mode 100644 index 000000000..5084028c5 --- /dev/null +++ b/src/utils/slotCalculations.ts @@ -0,0 +1,231 @@ +/** + * Slot Position Calculations + * + * Centralized utility for calculating input/output slot positions on nodes. + * This allows both litegraph nodes and the layout system to use the same + * calculation logic while providing their own position data. + */ +import type { + INodeInputSlot, + INodeOutputSlot, + INodeSlot, + Point +} from '@/lib/litegraph/src/interfaces' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils' + +export interface SlotPositionContext { + /** Node's X position in graph coordinates */ + nodeX: number + /** Node's Y position in graph coordinates */ + nodeY: number + /** Node's width */ + nodeWidth: number + /** Node's height */ + nodeHeight: number + /** Whether the node is collapsed */ + collapsed: boolean + /** Collapsed width (if applicable) */ + collapsedWidth?: number + /** Node constructor's slot_start_y offset */ + slotStartY?: number + /** Node's input slots */ + inputs: INodeInputSlot[] + /** Node's output slots */ + outputs: INodeOutputSlot[] + /** Node's widgets (for widget slot detection) */ + widgets?: Array<{ name?: string }> +} + +/** + * Calculate the position of an input slot in graph coordinates + * @param context Node context containing position and slot data + * @param slot The input slot index + * @returns Position of the input slot center in graph coordinates + */ +export function calculateInputSlotPos( + context: SlotPositionContext, + slot: number +): Point { + const input = context.inputs[slot] + if (!input) return [context.nodeX, context.nodeY] + + return calculateInputSlotPosFromSlot(context, input) +} + +/** + * Calculate the position of an input slot in graph coordinates + * @param context Node context containing position and slot data + * @param input The input slot object + * @returns Position of the input slot center in graph coordinates + */ +export function calculateInputSlotPosFromSlot( + context: SlotPositionContext, + input: INodeInputSlot +): Point { + const { nodeX, nodeY, collapsed } = context + + // Handle collapsed nodes + if (collapsed) { + const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 + return [nodeX, nodeY - halfTitle] + } + + // Handle hard-coded positions + const { pos } = input + if (pos) return [nodeX + pos[0], nodeY + pos[1]] + + // Check if we should use Vue positioning + if (LiteGraph.vueNodesMode) { + if (isWidgetInputSlot(input)) { + // Widget slot - pass the slot object + return calculateVueSlotPosition(context, true, input, -1) + } else { + // Regular slot - find its index in default vertical inputs + const defaultVerticalInputs = getDefaultVerticalInputs(context) + const slotIndex = defaultVerticalInputs.indexOf(input) + if (slotIndex !== -1) { + return calculateVueSlotPosition(context, true, input, slotIndex) + } + } + } + + // Default vertical slots + const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + const nodeOffsetY = context.slotStartY || 0 + const defaultVerticalInputs = getDefaultVerticalInputs(context) + const slotIndex = defaultVerticalInputs.indexOf(input) + const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + + return [nodeX + offsetX, nodeY + slotY + nodeOffsetY] +} + +/** + * Calculate the position of an output slot in graph coordinates + * @param context Node context containing position and slot data + * @param slot The output slot index + * @returns Position of the output slot center in graph coordinates + */ +export function calculateOutputSlotPos( + context: SlotPositionContext, + slot: number +): Point { + const { nodeX, nodeY, nodeWidth, collapsed, collapsedWidth, outputs } = + context + + // Handle collapsed nodes + if (collapsed) { + const width = collapsedWidth || LiteGraph.NODE_COLLAPSED_WIDTH + const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5 + return [nodeX + width, nodeY - halfTitle] + } + + const outputSlot = outputs[slot] + if (!outputSlot) return [nodeX + nodeWidth, nodeY] + + // Handle hard-coded positions + const outputPos = outputSlot.pos + if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]] + + // Check if we should use Vue positioning + if (LiteGraph.vueNodesMode) { + const defaultVerticalOutputs = getDefaultVerticalOutputs(context) + const slotIndex = defaultVerticalOutputs.indexOf(outputSlot) + if (slotIndex !== -1) { + return calculateVueSlotPosition(context, false, outputSlot, slotIndex) + } + } + + // Default vertical slots + const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + const nodeOffsetY = context.slotStartY || 0 + const defaultVerticalOutputs = getDefaultVerticalOutputs(context) + const slotIndex = defaultVerticalOutputs.indexOf(outputSlot) + const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + + // TODO: Why +1? + return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY] +} + +/** + * Get the inputs that are not positioned with absolute coordinates + */ +function getDefaultVerticalInputs( + context: SlotPositionContext +): INodeInputSlot[] { + return context.inputs.filter( + (slot) => !slot.pos && !(context.widgets?.length && isWidgetInputSlot(slot)) + ) +} + +/** + * Get the outputs that are not positioned with absolute coordinates + */ +function getDefaultVerticalOutputs( + context: SlotPositionContext +): INodeOutputSlot[] { + return context.outputs.filter((slot) => !slot.pos) +} + +/** + * Calculate slot position using Vue node dimensions. + * This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering. + * @param context Node context + * @param isInput Whether this is an input slot (true) or output slot (false) + * @param slot The slot object (for widget detection) + * @param slotIndex The index of the slot in the appropriate array + * @returns The [x, y] position of the slot center in graph coordinates + */ +function calculateVueSlotPosition( + context: SlotPositionContext, + isInput: boolean, + slot: INodeSlot, + slotIndex: number +): Point { + const { nodeX, nodeY, nodeWidth, widgets } = context + const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components + const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing + + let slotCenterY: number + + // IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header) + // The header is rendered ABOVE this position at negative Y coordinates + // So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px) + const headerDifference = + dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT + + if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) { + // Widget input slot - calculate based on widget position + // Count regular (non-widget) input slots + const regularInputCount = getDefaultVerticalInputs(context).length + + // Find widget index + const widgetIndex = + widgets?.findIndex( + (w) => w.name === (slot as INodeInputSlot).widget?.name + ) ?? 0 + + // Y position relative to the node body top (not the header) + slotCenterY = + headerDifference + + regularInputCount * dimensions.SLOT_HEIGHT + + (regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) + + widgetIndex * + (dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) + + dimensions.STANDARD_WIDGET_HEIGHT / 2 + } else { + // Regular slot (input or output) + // Slots start at the top of the body, but we need to account for Vue's larger header + slotCenterY = + headerDifference + + slotIndex * dimensions.SLOT_HEIGHT + + dimensions.SLOT_HEIGHT / 2 + } + + // Calculate X position + // Input slots: 10px from left edge (center of 20x20 connector) + // Output slots: 10px from right edge (center of 20x20 connector) + const slotCenterX = isInput ? 10 : nodeWidth - 10 + + return [nodeX + slotCenterX, nodeY + slotCenterY] +}