mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-06 13:40:25 +00:00
Increase vue slot/link functionality (#5710)
## Summary Increase functionality for slots and links, covered with playwright tests. ## Features - Allow for reroute anchors to work when dragging from input slot - Allow for dragging existing links from input slot - Allow for ctrl/command + alt to create new link from input slot - Allow shift to drag all connected links on output slot - Connect links with reroutes (only when dragged from vue slot) ## Tests Added ### Playwright - Dragging input to input drags existing link - Dropping an input link back on its slot restores the original connection - Ctrl+alt drag from an input starts a fresh link - Should reuse the existing origin when dragging an input link - Shift-dragging an output with multiple links should drag all links - Rerouted input drag preview remains anchored to reroute - Rerouted output shift-drag preview remains anchored to reroute ## Notes The double rendering system for links being dragged, it works right now, maybe they can be coalesced later. Edit: As in the adapter, can be removed in a followup PR Also, it's known that more features will arrive in smaller PRs, this PR actually should've been much smaller. The next ones coming up are drop on canvas support, snap to node, type compatibility highlighting, and working with subgraphs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5710-Increase-vue-slot-link-functionality-2756d73d3650814f8995f7782244803b) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
152
src/renderer/core/canvas/links/linkConnectorAdapter.ts
Normal file
152
src/renderer/core/canvas/links/linkConnectorAdapter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
// Keep one adapter per graph so rendering and interaction share state.
|
||||
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
|
||||
|
||||
/**
|
||||
* Renderer‑agnostic adapter around LiteGraph's LinkConnector.
|
||||
*
|
||||
* - Uses layoutStore for hit‑testing (nodes/reroutes).
|
||||
* - Exposes minimal, imperative APIs to begin link drags and query drop validity.
|
||||
* - Preserves existing Vue composable behavior.
|
||||
*/
|
||||
export class LinkConnectorAdapter {
|
||||
readonly linkConnector: LinkConnector
|
||||
|
||||
constructor(
|
||||
/** Network the links belong to (typically `app.canvas.graph`). */
|
||||
readonly network: LGraph
|
||||
) {
|
||||
// No-op legacy setter to avoid side effects when connectors update
|
||||
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
|
||||
this.linkConnector = new LinkConnector(setConnectingLinks)
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently rendered/dragged links, typed for consumer use.
|
||||
* Prefer this over accessing `linkConnector.renderLinks` directly.
|
||||
*/
|
||||
get renderLinks(): ReadonlyArray<RenderLink> {
|
||||
return this.linkConnector.renderLinks
|
||||
}
|
||||
|
||||
// Drag helpers
|
||||
|
||||
/**
|
||||
* Begin dragging from an output slot.
|
||||
* @param nodeId Output node id
|
||||
* @param outputIndex Output slot index
|
||||
* @param opts Optional: moveExisting (shift), fromRerouteId
|
||||
*/
|
||||
beginFromOutput(
|
||||
nodeId: NodeId,
|
||||
outputIndex: number,
|
||||
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
|
||||
): void {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const output = node?.outputs?.[outputIndex]
|
||||
if (!node || !output) return
|
||||
|
||||
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
|
||||
|
||||
if (opts?.moveExisting) {
|
||||
this.linkConnector.moveOutputLink(this.network, output)
|
||||
} else {
|
||||
this.linkConnector.dragNewFromOutput(
|
||||
this.network,
|
||||
node,
|
||||
output,
|
||||
fromReroute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin dragging from an input slot.
|
||||
* @param nodeId Input node id
|
||||
* @param inputIndex Input slot index
|
||||
* @param opts Optional: moveExisting (when a link/floating exists), fromRerouteId
|
||||
*/
|
||||
beginFromInput(
|
||||
nodeId: NodeId,
|
||||
inputIndex: number,
|
||||
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
|
||||
): void {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const input = node?.inputs?.[inputIndex]
|
||||
if (!node || !input) return
|
||||
|
||||
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
|
||||
|
||||
if (opts?.moveExisting) {
|
||||
this.linkConnector.moveInputLink(this.network, input)
|
||||
} else {
|
||||
this.linkConnector.dragNewFromInput(
|
||||
this.network,
|
||||
node,
|
||||
input,
|
||||
fromReroute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
isNodeValidDrop(nodeId: NodeId): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
return this.linkConnector.isNodeValidDrop(node)
|
||||
}
|
||||
|
||||
isInputValidDrop(nodeId: NodeId, inputIndex: number): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const input = node?.inputs?.[inputIndex]
|
||||
if (!node || !input) return false
|
||||
return this.linkConnector.isInputValidDrop(node, input)
|
||||
}
|
||||
|
||||
isOutputValidDrop(nodeId: NodeId, outputIndex: number): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const output = node?.outputs?.[outputIndex]
|
||||
if (!node || !output) return false
|
||||
return this.linkConnector.renderLinks.some((link) =>
|
||||
link.canConnectToOutput(node, output)
|
||||
)
|
||||
}
|
||||
|
||||
isRerouteValidDrop(rerouteId: RerouteId): boolean {
|
||||
const reroute = this.network.getReroute(rerouteId)
|
||||
if (!reroute) return false
|
||||
return this.linkConnector.isRerouteValidDrop(reroute)
|
||||
}
|
||||
|
||||
// Drop/cancel helpers for future flows
|
||||
|
||||
/** Disconnects moving links (drop on canvas/no target). */
|
||||
disconnectMovingLinks(): void {
|
||||
this.linkConnector.disconnectLinks()
|
||||
}
|
||||
|
||||
/** Resets connector state and clears any temporary flags. */
|
||||
reset(): void {
|
||||
this.linkConnector.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience creator using the current app canvas graph. */
|
||||
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
|
||||
const graph = app.canvas?.graph as LGraph | undefined
|
||||
if (!graph) return null
|
||||
let adapter = adapterByGraph.get(graph)
|
||||
if (!adapter) {
|
||||
adapter = new LinkConnectorAdapter(graph)
|
||||
adapterByGraph.set(graph, adapter)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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'
|
||||
|
||||
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
|
||||
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 }
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import type { Point, SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
type SlotDragType = 'input' | 'output'
|
||||
|
||||
export interface SlotDragSource {
|
||||
interface SlotDragSource {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
type: SlotDragType
|
||||
direction: LinkDirection
|
||||
position: Readonly<Point>
|
||||
linkId?: number
|
||||
movingExistingOutput?: boolean
|
||||
}
|
||||
|
||||
export interface SlotDropCandidate {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ReadOnlyPoint
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { 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 { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
|
||||
return {
|
||||
@@ -39,57 +36,73 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
||||
originalOnDrawForeground?.(ctx, area)
|
||||
|
||||
const { state } = useSlotLinkDragState()
|
||||
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
|
||||
if (canvas.linkConnector?.isConnecting) return
|
||||
if (!state.active || !state.source) return
|
||||
|
||||
const { pointer, source } = state
|
||||
const start = source.position
|
||||
const sourceSlot = resolveSourceSlot(canvas, source)
|
||||
const { pointer } = state
|
||||
|
||||
const linkRenderer = canvas.linkRenderer
|
||||
if (!linkRenderer) return
|
||||
|
||||
const context = buildContext(canvas)
|
||||
|
||||
const from: ReadOnlyPoint = [start.x, start.y]
|
||||
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
||||
if (!renderLinks || renderLinks.length === 0) return
|
||||
|
||||
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()
|
||||
for (const link of renderLinks) {
|
||||
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
||||
const endDir = link.dragDirection ?? LinkDirection.CENTER
|
||||
const colour = resolveConnectingLinkColor(link.fromSlot.type)
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
context
|
||||
)
|
||||
const fromPoint = resolveRenderLinkOrigin(link)
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
fromPoint,
|
||||
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
|
||||
function resolveRenderLinkOrigin(link: RenderLink): ReadOnlyPoint {
|
||||
if (link.fromReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
|
||||
if (rerouteLayout) {
|
||||
return [rerouteLayout.position.x, rerouteLayout.position.y]
|
||||
}
|
||||
|
||||
const nodeId = Number(source.nodeId)
|
||||
if (!Number.isFinite(nodeId)) return undefined
|
||||
const [x, y] = link.fromReroute.pos
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
const nodeId = getRenderLinkNodeId(link)
|
||||
if (nodeId != null) {
|
||||
const isInputFrom = link.toType === 'output'
|
||||
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (layout) {
|
||||
return [layout.position.x, layout.position.y]
|
||||
}
|
||||
}
|
||||
|
||||
return source.type === 'output'
|
||||
? node.outputs?.[source.slotIndex]
|
||||
: node.inputs?.[source.slotIndex]
|
||||
return link.fromPos
|
||||
}
|
||||
|
||||
function getRenderLinkNodeId(link: RenderLink): number | null {
|
||||
const node = link.node
|
||||
if (typeof node === 'object' && node !== null && 'id' in node) {
|
||||
const maybeId = node.id
|
||||
if (typeof maybeId === 'number') return maybeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
|
||||
|
||||
export function toPoint(x: number, y: number): Point {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function isPointEqual(a: Point, b: Point): boolean {
|
||||
return a.x === b.x && a.y === b.y
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user