From 2ff0d951edffbdfa46aca923d9a4415a8e023d04 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 18 Sep 2025 19:35:15 -0700 Subject: [PATCH] Slot functionality for vue nodes (#5628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows for simple slot functionality in vue nodes mode. Has: - Drag new link from slot - Connect new link from dropping on slot Now: - Tests After: - Drop on reroute - Correct link color on connect - Drop on node - Hover effects ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5628-Slot-functionality-for-vue-nodes-2716d73d365081c59a3cef7c8a5e539e) by [Unito](https://www.unito.io) --------- Co-authored-by: bymyself Co-authored-by: AustinMroz Co-authored-by: Claude --- src/components/graph/GraphCanvas.vue | 2 + src/lib/litegraph/src/LGraphCanvas.ts | 14 +- src/lib/litegraph/src/utils/linkColors.ts | 13 + .../canvas/links/slotLinkCompatibility.ts | 73 ++++++ .../core/canvas/links/slotLinkDragState.ts | 95 +++++++ .../canvas/links/slotLinkPreviewRenderer.ts | 95 +++++++ .../canvas/litegraph/litegraphLinkAdapter.ts | 75 ++---- src/renderer/core/canvas/pathRenderer.ts | 2 +- .../components/InputOutputSlot.test.ts | 89 ------- .../vueNodes/components/InputSlot.vue | 36 ++- .../vueNodes/components/OutputSlot.vue | 37 ++- .../composables/useSlotElementTracking.ts | 10 +- .../composables/useSlotLinkInteraction.ts | 247 ++++++++++++++++++ 13 files changed, 609 insertions(+), 179 deletions(-) create mode 100644 src/lib/litegraph/src/utils/linkColors.ts create mode 100644 src/renderer/core/canvas/links/slotLinkCompatibility.ts create mode 100644 src/renderer/core/canvas/links/slotLinkDragState.ts create mode 100644 src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts delete mode 100644 src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts create mode 100644 src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 91c91d704..3abf47813 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -117,6 +117,7 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' +import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' @@ -404,6 +405,7 @@ onMounted(async () => { // @ts-expect-error fixme ts strict error await comfyApp.setup(canvasRef.value) + attachSlotLinkPreviewRenderer(comfyApp.canvas) canvasStore.canvas = comfyApp.canvas canvasStore.canvas.render_canvas_border = false workspaceStore.spinner = false diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index c17fd0e32..83ce47660 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -87,6 +87,7 @@ import type { PickNevers } from './types/utility' import type { IBaseWidget } from './types/widgets' import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange' import { findFirstNode, getAllNestedItems } from './utils/collections' +import { resolveConnectingLinkColor } from './utils/linkColors' import type { UUID } from './utils/uuid' import { BaseWidget } from './widgets/BaseWidget' import { toConcreteWidget } from './widgets/widgetMap' @@ -4716,29 +4717,20 @@ export class LGraphCanvas const connShape = fromSlot.shape const connType = fromSlot.type - const colour = - connType === LiteGraph.EVENT - ? LiteGraph.EVENT_LINK_COLOR - : LiteGraph.CONNECTING_LINK_COLOR + const colour = resolveConnectingLinkColor(connType) // the connection being dragged by the mouse if (this.linkRenderer) { - this.linkRenderer.renderLinkDirect( + this.linkRenderer.renderDraggingLink( ctx, pos, highlightPos, - null, - false, - null, colour, fromDirection, dragDirection, { ...this.buildLinkRenderContext(), linkMarkerShape: LinkMarkerShape.None - }, - { - disabled: false } ) } diff --git a/src/lib/litegraph/src/utils/linkColors.ts b/src/lib/litegraph/src/utils/linkColors.ts new file mode 100644 index 000000000..e6e966133 --- /dev/null +++ b/src/lib/litegraph/src/utils/linkColors.ts @@ -0,0 +1,13 @@ +import type { CanvasColour, ISlotType } from '../interfaces' +import { LiteGraph } from '../litegraph' + +/** + * Resolve the colour used while rendering or previewing a connection of a given slot type. + */ +export function resolveConnectingLinkColor( + type: ISlotType | undefined +): CanvasColour { + return type === LiteGraph.EVENT + ? LiteGraph.EVENT_LINK_COLOR + : LiteGraph.CONNECTING_LINK_COLOR +} diff --git a/src/renderer/core/canvas/links/slotLinkCompatibility.ts b/src/renderer/core/canvas/links/slotLinkCompatibility.ts new file mode 100644 index 000000000..b8beffc38 --- /dev/null +++ b/src/renderer/core/canvas/links/slotLinkCompatibility.ts @@ -0,0 +1,73 @@ +import { getActivePinia } from 'pinia' + +import type { + INodeInputSlot, + INodeOutputSlot +} from '@/lib/litegraph/src/interfaces' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import type { + SlotDragSource, + SlotDropCandidate +} from '@/renderer/core/canvas/links/slotLinkDragState' +import { app } from '@/scripts/app' + +interface CompatibilityResult { + allowable: boolean + targetNode?: LGraphNode + targetSlot?: INodeInputSlot | INodeOutputSlot +} + +function resolveNode(nodeId: NodeId) { + const pinia = getActivePinia() + const canvasStore = pinia ? useCanvasStore() : null + const graph = canvasStore?.canvas?.graph ?? app.canvas?.graph + if (!graph) return null + const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId + if (Number.isNaN(id)) return null + return graph.getNodeById(id) +} + +export function evaluateCompatibility( + source: SlotDragSource, + candidate: SlotDropCandidate +): CompatibilityResult { + if (candidate.layout.nodeId === source.nodeId) { + return { allowable: false } + } + + const isOutputToInput = + source.type === 'output' && candidate.layout.type === 'input' + const isInputToOutput = + source.type === 'input' && candidate.layout.type === 'output' + + if (!isOutputToInput && !isInputToOutput) { + return { allowable: false } + } + + const sourceNode = resolveNode(source.nodeId) + const targetNode = resolveNode(candidate.layout.nodeId) + if (!sourceNode || !targetNode) { + return { allowable: false } + } + + if (isOutputToInput) { + const outputSlot = sourceNode.outputs?.[source.slotIndex] + const inputSlot = targetNode.inputs?.[candidate.layout.index] + if (!outputSlot || !inputSlot) { + return { allowable: false } + } + + const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot) + return { allowable, targetNode, targetSlot: inputSlot } + } + + const inputSlot = sourceNode.inputs?.[source.slotIndex] + const outputSlot = targetNode.outputs?.[candidate.layout.index] + if (!inputSlot || !outputSlot) { + return { allowable: false } + } + + const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot) + return { allowable, targetNode, targetSlot: outputSlot } +} diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragState.ts new file mode 100644 index 000000000..5d2bbcfc4 --- /dev/null +++ b/src/renderer/core/canvas/links/slotLinkDragState.ts @@ -0,0 +1,95 @@ +import { reactive, readonly } from 'vue' + +import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { Point, SlotLayout } from '@/renderer/core/layout/types' + +type SlotDragType = 'input' | 'output' + +export interface SlotDragSource { + nodeId: string + slotIndex: number + type: SlotDragType + direction: LinkDirection + position: Readonly +} + +export interface SlotDropCandidate { + layout: SlotLayout + compatible: boolean +} + +interface PointerPosition { + client: Point + canvas: Point +} + +interface SlotDragState { + active: boolean + pointerId: number | null + source: SlotDragSource | null + pointer: PointerPosition + candidate: SlotDropCandidate | null +} + +const state = reactive({ + active: false, + pointerId: null, + source: null, + pointer: { + client: { x: 0, y: 0 }, + canvas: { x: 0, y: 0 } + }, + candidate: null +}) + +function updatePointerPosition( + clientX: number, + clientY: number, + canvasX: number, + canvasY: number +) { + state.pointer.client.x = clientX + state.pointer.client.y = clientY + state.pointer.canvas.x = canvasX + state.pointer.canvas.y = canvasY +} + +function setCandidate(candidate: SlotDropCandidate | null) { + state.candidate = candidate +} + +function beginDrag(source: SlotDragSource, pointerId: number) { + state.active = true + state.source = source + state.pointerId = pointerId + state.candidate = null +} + +function endDrag() { + state.active = false + state.pointerId = null + state.source = null + state.pointer.client.x = 0 + state.pointer.client.y = 0 + state.pointer.canvas.x = 0 + state.pointer.canvas.y = 0 + state.candidate = null +} + +function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { + const slotKey = getSlotKey(nodeId, slotIndex, isInput) + return layoutStore.getSlotLayout(slotKey) +} + +export function useSlotLinkDragState() { + return { + state: readonly(state), + beginDrag, + endDrag, + updatePointerPosition, + setCandidate, + getSlotLayout + } +} diff --git a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts new file mode 100644 index 000000000..b69cd9b7a --- /dev/null +++ b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts @@ -0,0 +1,95 @@ +import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import type { + INodeInputSlot, + INodeOutputSlot, + ReadOnlyPoint +} from '@/lib/litegraph/src/interfaces' +import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors' +import { + type SlotDragSource, + useSlotLinkDragState +} from '@/renderer/core/canvas/links/slotLinkDragState' +import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' + +function buildContext(canvas: LGraphCanvas): LinkRenderContext { + return { + renderMode: canvas.links_render_mode, + connectionWidth: canvas.connections_width, + renderBorder: canvas.render_connections_border, + lowQuality: canvas.low_quality, + highQualityRender: canvas.highquality_render, + scale: canvas.ds.scale, + linkMarkerShape: canvas.linkMarkerShape, + renderConnectionArrows: canvas.render_connection_arrows, + highlightedLinks: new Set(Object.keys(canvas.highlighted_links)), + defaultLinkColor: canvas.default_link_color, + linkTypeColors: (canvas.constructor as typeof LGraphCanvas) + .link_type_colors, + disabledPattern: canvas._pattern + } +} + +export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) { + const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas) + const patched = ( + ctx: CanvasRenderingContext2D, + area: LGraphCanvas['visible_area'] + ) => { + originalOnDrawForeground?.(ctx, area) + + const { state } = useSlotLinkDragState() + if (!state.active || !state.source) return + + const { pointer, source } = state + const start = source.position + const sourceSlot = resolveSourceSlot(canvas, source) + + const linkRenderer = canvas.linkRenderer + if (!linkRenderer) return + + const context = buildContext(canvas) + + const from: ReadOnlyPoint = [start.x, start.y] + const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y] + + const startDir = source.direction ?? LinkDirection.RIGHT + const endDir = LinkDirection.CENTER + + const colour = resolveConnectingLinkColor(sourceSlot?.type) + + ctx.save() + + linkRenderer.renderDraggingLink( + ctx, + from, + to, + colour, + startDir, + endDir, + context + ) + + ctx.restore() + } + + canvas.onDrawForeground = patched +} + +function resolveSourceSlot( + canvas: LGraphCanvas, + source: SlotDragSource +): INodeInputSlot | INodeOutputSlot | undefined { + const graph = canvas.graph + if (!graph) return undefined + + const nodeId = Number(source.nodeId) + if (!Number.isFinite(nodeId)) return undefined + + const node = graph.getNodeById(nodeId) + if (!node) return undefined + + return source.type === 'output' + ? node.outputs?.[source.slotIndex] + : node.inputs?.[source.slotIndex] +} diff --git a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts index 492767488..1bb3f7dae 100644 --- a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts +++ b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts @@ -7,13 +7,10 @@ * Maintains backward compatibility with existing litegraph integration. */ import type { LGraph } from '@/lib/litegraph/src/LGraph' -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { 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' @@ -27,7 +24,6 @@ import { type ArrowShape, CanvasPathRenderer, type Direction, - type DragLinkData, type LinkRenderData, type RenderContext as PathRenderContext, type Point, @@ -209,7 +205,6 @@ export class LitegraphLinkAdapter { case LinkDirection.DOWN: return 'down' case LinkDirection.CENTER: - case LinkDirection.NONE: return 'none' default: return 'right' @@ -502,57 +497,33 @@ export class LitegraphLinkAdapter { } } - /** - * 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 - } = {} + from: ReadOnlyPoint, + to: ReadOnlyPoint, + colour: CanvasColour, + startDir: LinkDirection, + endDir: LinkDirection, + context: LinkRenderContext ): void { - if (!fromNode) return - - // Get slot position using layout tree if available - const slotPos = getSlotPosition( - fromNode, - fromSlotIndex, - options.fromInput || false + this.renderLinkDirect( + ctx, + from, + to, + null, + false, + null, + colour, + startDir, + endDir, + { + ...context, + linkMarkerShape: LinkMarkerShape.None + }, + { + disabled: 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) - - // Hide center marker when dragging links - pathContext.style.showCenterMarker = false - - // Render using pure renderer - this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext) } /** diff --git a/src/renderer/core/canvas/pathRenderer.ts b/src/renderer/core/canvas/pathRenderer.ts index 29f406cad..126b98e00 100644 --- a/src/renderer/core/canvas/pathRenderer.ts +++ b/src/renderer/core/canvas/pathRenderer.ts @@ -70,7 +70,7 @@ export interface RenderContext { highlightedIds?: Set } -export interface DragLinkData { +interface DragLinkData { /** Fixed end - the slot being dragged from */ fixedPoint: Point fixedDirection: Direction diff --git a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts deleted file mode 100644 index d1c886905..000000000 --- a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { type ComponentMountingOptions, mount } from '@vue/test-utils' -import { createPinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createI18n } from 'vue-i18n' - -import enMessages from '@/locales/en/main.json' -import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' - -import InputSlot from './InputSlot.vue' -import OutputSlot from './OutputSlot.vue' - -// Mock composable used by InputSlot/OutputSlot so we can assert call params -vi.mock( - '@/renderer/extensions/vueNodes/composables/useSlotElementTracking', - () => ({ - useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() })) - }) -) - -type InputSlotProps = ComponentMountingOptions['props'] -type OutputSlotProps = ComponentMountingOptions['props'] - -const mountInputSlot = (props: InputSlotProps) => - mount(InputSlot, { - global: { - plugins: [ - createI18n({ - legacy: false, - locale: 'en', - messages: { en: enMessages } - }), - createPinia() - ] - }, - props - }) - -const mountOutputSlot = (props: OutputSlotProps) => - mount(OutputSlot, { - global: { - plugins: [ - createI18n({ - legacy: false, - locale: 'en', - messages: { en: enMessages } - }), - createPinia() - ] - }, - props - }) - -describe('InputSlot/OutputSlot', () => { - beforeEach(() => { - vi.mocked(useSlotElementTracking).mockClear() - }) - - it('InputSlot registers with correct options', () => { - mountInputSlot({ - nodeId: 'node-1', - index: 3, - slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] } - }) - - expect(useSlotElementTracking).toHaveBeenLastCalledWith( - expect.objectContaining({ - nodeId: 'node-1', - index: 3, - type: 'input' - }) - ) - }) - - it('OutputSlot registers with correct options', () => { - mountOutputSlot({ - nodeId: 'node-2', - index: 1, - slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] } - }) - - expect(useSlotElementTracking).toHaveBeenLastCalledWith( - expect.objectContaining({ - nodeId: 'node-2', - index: 1, - type: 'output' - }) - ) - }) -}) diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index 87a161946..b97fd30cc 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -1,21 +1,12 @@