feat: render dragging links above Vue nodes via overlay canvas (#8695)

## Summary
When vueNodesMode is enabled, the dragging link preview was rendered on
the background canvas behind DOM-based Vue nodes, making it invisible
when overlapping node bodies.

Add a new overlay canvas layer between TransformPane and
SelectionRectangle that renders the dragging link preview and snap
highlight above the Vue node DOM layer.

Static connections remain on the background canvas as before.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/8414
discussed with @DrJKL 

## Screenshots
before

https://github.com/user-attachments/assets/94508efa-570c-4e32-a373-360b72625fdd

after

https://github.com/user-attachments/assets/4b0f924c-66ce-4f49-97d7-51e6e923a1b9

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8695-feat-render-dragging-links-above-Vue-nodes-via-overlay-canvas-2ff6d73d365081599b2fe18b87f34b7a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Terry Jia
2026-02-06 21:19:18 -05:00
committed by GitHub
parent e7932f2fc2
commit d9ce4ff5e0
19 changed files with 155 additions and 72 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -76,6 +76,13 @@
/>
</TransformPane>
<LinkOverlayCanvas
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@ready="onLinkOverlayReady"
@dispose="onLinkOverlayDispose"
/>
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
<SelectionRectangle v-if="comfyAppReady" />
@@ -111,6 +118,7 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
@@ -241,6 +249,18 @@ const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = el
canvasStore.canvas.overlayCtx = el.getContext('2d')
}
function onLinkOverlayDispose() {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = null
canvasStore.canvas.overlayCtx = null
}
watchEffect(() => {
LiteGraph.nodeOpacity = settingStore.get('Comfy.Node.Opacity')
})

View File

@@ -0,0 +1,43 @@
<template>
<canvas
ref="canvasRef"
class="pointer-events-none absolute inset-0 size-full"
/>
</template>
<script setup lang="ts">
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useRafFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const { canvas } = defineProps<{
canvas: LGraphCanvas
}>()
const emit = defineEmits<{
ready: [canvas: HTMLCanvasElement]
dispose: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useRafFn(() => {
const el = canvasRef.value
const mainCanvas = canvas.canvas
if (!el || !mainCanvas) return
if (el.width !== mainCanvas.width || el.height !== mainCanvas.height) {
el.width = mainCanvas.width
el.height = mainCanvas.height
}
})
onMounted(() => {
if (canvasRef.value) emit('ready', canvasRef.value)
})
onBeforeUnmount(() => {
emit('dispose')
})
</script>

View File

@@ -688,6 +688,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
canvas: HTMLCanvasElement & ICustomEventTarget<LGraphCanvasEventMap>
bgcanvas: HTMLCanvasElement
overlayCanvas: HTMLCanvasElement | null = null
overlayCtx: CanvasRenderingContext2D | null = null
ctx: CanvasRenderingContext2D
_events_binded?: boolean
_mousedown_callback?(e: PointerEvent): void
@@ -4849,7 +4851,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
drawFrontCanvas(): void {
this.dirty_canvas = false
const { ctx, canvas, graph, linkConnector } = this
const { ctx, canvas, graph } = this
// @ts-expect-error start2D method not in standard CanvasRenderingContext2D
if (ctx.start2D && !this.viewport) {
@@ -4949,78 +4951,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.drawConnections(ctx)
}
if (linkConnector.isConnecting) {
// current connection (the one being dragged by the mouse)
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()
ctx.lineWidth = this.connections_width
for (const renderLink of renderLinks) {
const {
fromSlot,
fromPos: pos,
fromDirection,
dragDirection
} = renderLink
const connShape = fromSlot.shape
const connType = fromSlot.type
const colour = resolveConnectingLinkColor(connType)
// the connection being dragged by the mouse
if (this.linkRenderer) {
this.linkRenderer.renderDraggingLink(
ctx,
pos,
highlightPos,
colour,
fromDirection,
dragDirection,
{
...this.buildLinkRenderContext(),
linkMarkerShape: LinkMarkerShape.None
}
)
}
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)
ctx.rect(
highlightPos[0] - 6 + 0.5,
highlightPos[1] - 5 + 0.5,
14,
10
)
} else if (connShape === RenderShape.ARROW) {
ctx.moveTo(pos[0] + 8, pos[1] + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5)
ctx.closePath()
} else {
ctx.arc(pos[0], pos[1], 4, 0, Math.PI * 2)
ctx.arc(highlightPos[0], highlightPos[1], 4, 0, Math.PI * 2)
}
ctx.fill()
}
// Gradient half-border over target node
this._renderSnapHighlight(ctx, highlightPos)
}
// on top of link center
if (
!this.isDragging &&
this.over_link_center &&
this.render_link_tooltip
) {
this.drawLinkTooltip(ctx, this.over_link_center)
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
this._drawConnectingLinks(ctx)
} else {
this.onDrawLinkTooltip?.(ctx, null)
this._drawOverlayLinks()
}
// custom info
this._drawLinkTooltip(ctx)
this.onDrawForeground?.(ctx, this.visible_area)
ctx.restore()
@@ -5031,9 +4969,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (area) ctx.restore()
}
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
private _getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
// Skip hit detection if center markers are disabled
if (this.linkMarkerShape === LinkMarkerShape.None) {
return undefined
}
@@ -5050,6 +4986,90 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
private _drawConnectingLinks(ctx: CanvasRenderingContext2D): void {
const { linkConnector } = this
if (!linkConnector.isConnecting) return
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()
ctx.lineWidth = this.connections_width
for (const renderLink of renderLinks) {
const {
fromSlot,
fromPos: pos,
fromDirection,
dragDirection
} = renderLink
const connShape = fromSlot.shape
const connType = fromSlot.type
const colour = resolveConnectingLinkColor(connType)
if (this.linkRenderer) {
this.linkRenderer.renderDraggingLink(
ctx,
pos,
highlightPos,
colour,
fromDirection,
dragDirection,
{
...this.buildLinkRenderContext(),
linkMarkerShape: LinkMarkerShape.None
}
)
}
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)
ctx.rect(highlightPos[0] - 6 + 0.5, highlightPos[1] - 5 + 0.5, 14, 10)
} else if (connShape === RenderShape.ARROW) {
ctx.moveTo(pos[0] + 8, pos[1] + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5)
ctx.closePath()
} else {
ctx.arc(pos[0], pos[1], 4, 0, Math.PI * 2)
ctx.arc(highlightPos[0], highlightPos[1], 4, 0, Math.PI * 2)
}
ctx.fill()
}
this._renderSnapHighlight(ctx, highlightPos)
}
private _drawLinkTooltip(ctx: CanvasRenderingContext2D): void {
if (!this.isDragging && this.over_link_center && this.render_link_tooltip) {
this.drawLinkTooltip(ctx, this.over_link_center)
} else {
this.onDrawLinkTooltip?.(ctx, null)
}
}
private _drawOverlayLinks(): void {
const octx = this.overlayCtx
const overlayCanvas = this.overlayCanvas
if (!octx || !overlayCanvas) return
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
if (!this.linkConnector.isConnecting) return
octx.save()
const scale = window.devicePixelRatio
octx.setTransform(scale, 0, 0, scale, 0, 0)
this.ds.toCanvasContext(octx)
this._drawConnectingLinks(octx)
octx.restore()
}
/** Get the target snap / highlight point in graph space */
private _getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy