Add drop on canvas functionality

This commit is contained in:
Benjamin Lu
2025-09-29 14:33:33 -07:00
parent a71b99d6fc
commit f338480e8b
5 changed files with 135 additions and 35 deletions

View File

@@ -262,17 +262,26 @@ function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
}
function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
disconnectOnReset = true
const action = e.detail.shiftKey
const pendingAction = searchBoxStore.pendingLinkDropAction
searchBoxStore.setPendingLinkDropAction(null)
const fallbackAction = e.detail.shiftKey
? linkReleaseActionShift.value
: linkReleaseAction.value
const action =
pendingAction ?? fallbackAction ?? LinkReleaseTriggerAction.NO_ACTION
disconnectOnReset = action !== LinkReleaseTriggerAction.NO_ACTION
if (!disconnectOnReset) return
cancelNextReset(e)
switch (action) {
case LinkReleaseTriggerAction.SEARCH_BOX:
cancelNextReset(e)
showSearchBox(e.detail)
break
case LinkReleaseTriggerAction.CONTEXT_MENU:
cancelNextReset(e)
showContextMenu(e.detail)
break
case LinkReleaseTriggerAction.NO_ACTION:

View File

@@ -228,6 +228,16 @@ const cursors = {
NW: 'nwse-resize'
} as const
/** A lightweight converter for client<->canvas coordinate transforms. */
interface PositionConverter {
/** Convert a client/pointer position to canvas (graph) space. */
clientPosToCanvasPos(pos: Point): Point
/** Convert a canvas (graph) position to client space. */
canvasPosToClientPos(pos: Point): Point
/** Optional hook to refresh internal caches (e.g. bounding rect). */
update?(): void
}
/**
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
@@ -478,11 +488,19 @@ export class LGraphCanvas
return this._isLowQuality
}
/**
* Converts pointer/client positions to canvas positions without forcing layout reads.
* When present, {@link adjustMouseEvent} will use this instead of DOM queries.
*/
positionConverter?: PositionConverter
options: {
skip_events?: any
viewport?: any
skip_render?: any
autoresize?: any
/** Optional converter for client<->canvas position transforms. */
positionConverter?: PositionConverter
}
background_image: string
@@ -739,6 +757,8 @@ export class LGraphCanvas
) {
options ||= {}
this.options = options
if (options.positionConverter)
this.positionConverter = options.positionConverter
// if(graph === undefined)
// throw ("No graph assigned");
@@ -4453,33 +4473,54 @@ export class LGraphCanvas
adjustMouseEvent<T extends MouseEvent>(
e: T & Partial<CanvasPointerExtensions>
): asserts e is T & CanvasPointerEvent {
let clientX_rel = e.clientX
let clientY_rel = e.clientY
const { ds, positionConverter } = this
if (this.canvas) {
const b = this.canvas.getBoundingClientRect()
clientX_rel -= b.left
clientY_rel -= b.top
if (positionConverter) {
const [canvasX, canvasY] = positionConverter.clientPosToCanvasPos([
e.clientX,
e.clientY
])
// safeOffset is relative to the canvas element (like offsetX/Y), not page
const safeX = (canvasX + ds.offset[0]) * ds.scale
const safeY = (canvasY + ds.offset[1]) * ds.scale
e.canvasX = canvasX
e.canvasY = canvasY
e.safeOffsetX = safeX
e.safeOffsetY = safeY
if (e.deltaX === undefined) e.deltaX = safeX - this.last_mouse_position[0]
if (e.deltaY === undefined) e.deltaY = safeY - this.last_mouse_position[1]
this.last_mouse_position[0] = safeX
this.last_mouse_position[1] = safeY
} else {
// Fallback to DOM rect (legacy path)
let clientX_rel = e.clientX
let clientY_rel = e.clientY
if (this.canvas) {
const b = this.canvas.getBoundingClientRect()
clientX_rel -= b.left
clientY_rel -= b.top
}
e.safeOffsetX = clientX_rel
e.safeOffsetY = clientY_rel
// Only set deltaX and deltaY if not already set.
if (e.deltaX === undefined)
e.deltaX = clientX_rel - this.last_mouse_position[0]
if (e.deltaY === undefined)
e.deltaY = clientY_rel - this.last_mouse_position[1]
this.last_mouse_position[0] = clientX_rel
this.last_mouse_position[1] = clientY_rel
e.canvasX = clientX_rel / ds.scale - ds.offset[0]
e.canvasY = clientY_rel / ds.scale - ds.offset[1]
}
e.safeOffsetX = clientX_rel
e.safeOffsetY = clientY_rel
// TODO: Find a less brittle way to do this
// Only set deltaX and deltaY if not already set.
// If deltaX and deltaY are already present, they are read-only.
// Setting them would result browser error => zoom in/out feature broken.
if (e.deltaX === undefined)
e.deltaX = clientX_rel - this.last_mouse_position[0]
if (e.deltaY === undefined)
e.deltaY = clientY_rel - this.last_mouse_position[1]
this.last_mouse_position[0] = clientX_rel
this.last_mouse_position[1] = clientY_rel
e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0]
e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1]
}
/**

View File

@@ -3,6 +3,7 @@ import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
@@ -11,7 +12,9 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
@@ -24,6 +27,8 @@ import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
import { app } from '@/scripts/app'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { createRafBatch } from '@/utils/rafBatch'
interface SlotInteractionOptions {
@@ -98,6 +103,23 @@ export function useSlotLinkInteraction({
// Per-drag drag-state cache
const dragSession = createSlotLinkDragSession()
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const resolveDropAction = (event: PointerEvent): LinkReleaseTriggerAction => {
const baseAction =
(settingStore.get('Comfy.LinkRelease.Action') as
| LinkReleaseTriggerAction
| null
| undefined) ?? LinkReleaseTriggerAction.NO_ACTION
const shiftAction = settingStore.get('Comfy.LinkRelease.ActionShift') as
| LinkReleaseTriggerAction
| null
| undefined
return event.shiftKey ? shiftAction ?? baseAction : baseAction
}
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
@@ -502,29 +524,46 @@ export function useSlotLinkInteraction({
return
}
// Prefer using the snapped candidate captured during hover for perf + consistency
// Prefer using any snapped candidate captured during hover
const snappedCandidate = state.candidate?.compatible
? state.candidate
: null
let connected = tryConnectToCandidate(snappedCandidate)
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
// Then fallback to DOM slot under pointer
if (!connected) {
const domCandidate = candidateFromTarget(event.target)
connected = tryConnectToCandidate(domCandidate)
}
// Then fallback to node under pointer
if (!connected) {
const nodeCandidate = candidateFromNodeTarget(event.target)
connected = tryConnectToCandidate(nodeCandidate)
}
// Then fallback to reroute under pointer
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !snappedCandidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
// Then fallback to dropping on canvas under pointer
if (!connected && !snappedCandidate) {
const canvas: LGraphCanvas | null = app.canvas
const adapter = ensureActiveAdapter()
if (adapter && canvas) {
const action = resolveDropAction(event)
if (action === LinkReleaseTriggerAction.NO_ACTION)
adapter.disconnectMovingLinks()
const adjustMouseEvent: (
e: PointerEvent
) => asserts e is PointerEvent & CanvasPointerEvent =
canvas.adjustMouseEvent.bind(canvas)
adjustMouseEvent(event)
searchBoxStore.setPendingLinkDropAction(action)
canvas.linkConnector?.dropOnNothing(event)
}
}
cleanupInteraction()

View File

@@ -903,6 +903,9 @@ export class ComfyApp {
this.canvasContainer,
this.canvas
)
// Provide high-performance position converter to LGraphCanvas
this.canvas.positionConverter = this.#positionConversion
}
resizeCanvas() {

View File

@@ -5,6 +5,7 @@ import { computed, ref, shallowRef } from 'vue'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
export const useSearchBoxStore = defineStore('searchBox', () => {
const settingStore = useSettingStore()
@@ -41,10 +42,17 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
)
}
const pendingLinkDropAction = ref<LinkReleaseTriggerAction | null>(null)
function setPendingLinkDropAction(action: LinkReleaseTriggerAction | null) {
pendingLinkDropAction.value = action
}
return {
newSearchBoxEnabled,
setPopoverRef,
toggleVisible,
visible
visible,
pendingLinkDropAction,
setPendingLinkDropAction
}
})