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>
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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')
|
||||
})
|
||||
|
||||
43
src/components/graph/LinkOverlayCanvas.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||