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,8 +4951,45 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.drawConnections(ctx)
}
if (linkConnector.isConnecting) {
// current connection (the one being dragged by the mouse)
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
this._drawConnectingLinks(ctx)
} else {
this._drawOverlayLinks()
}
this._drawLinkTooltip(ctx)
this.onDrawForeground?.(ctx, this.visible_area)
ctx.restore()
}
this.onDrawOverlay?.(ctx)
if (area) ctx.restore()
}
private _getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
if (this.linkMarkerShape === LinkMarkerShape.None) {
return undefined
}
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
if (
isInRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)
) {
return linkSegment
}
}
}
private _drawConnectingLinks(ctx: CanvasRenderingContext2D): void {
const { linkConnector } = this
if (!linkConnector.isConnecting) return
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()
ctx.lineWidth = this.connections_width
@@ -4967,7 +5006,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const colour = resolveConnectingLinkColor(connType)
// the connection being dragged by the mouse
if (this.linkRenderer) {
this.linkRenderer.renderDraggingLink(
ctx,
@@ -4987,12 +5025,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
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
)
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)
@@ -5005,49 +5038,36 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
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
) {
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)
}
// custom info
this.onDrawForeground?.(ctx, this.visible_area)
ctx.restore()
}
this.onDrawOverlay?.(ctx)
private _drawOverlayLinks(): void {
const octx = this.overlayCtx
const overlayCanvas = this.overlayCanvas
if (!octx || !overlayCanvas) return
if (area) ctx.restore()
}
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
/** @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
}
if (!this.linkConnector.isConnecting) return
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
octx.save()
if (
isInRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)
) {
return linkSegment
}
}
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 */