diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue index 470853b08c..8bac4e774a 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.vue +++ b/src/components/searchbox/NodeSearchBoxPopover.vue @@ -262,17 +262,26 @@ function cancelNextReset(e: CustomEvent) { } function handleDroppedOnCanvas(e: CustomEvent) { - 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: diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 22b71c66a3..62e963cbd5 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -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( e: T & Partial ): 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 + ]) - e.safeOffsetX = clientX_rel - e.safeOffsetY = clientY_rel + // 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 - // TODO: Find a less brittle way to do this + e.canvasX = canvasX + e.canvasY = canvasY + e.safeOffsetX = safeX + e.safeOffsetY = safeY - // 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] + 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] = clientX_rel - this.last_mouse_position[1] = clientY_rel + 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 - e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0] - e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1] + 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] + } } /** diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index afebae4c5c..8d12d52d00 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -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() diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 49423436d3..069d0ff1c7 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -903,6 +903,9 @@ export class ComfyApp { this.canvasContainer, this.canvas ) + + // Provide high-performance position converter to LGraphCanvas + this.canvas.positionConverter = this.#positionConversion } resizeCanvas() { diff --git a/src/stores/workspace/searchBoxStore.ts b/src/stores/workspace/searchBoxStore.ts index f5acff936f..fcc42ba5f5 100644 --- a/src/stores/workspace/searchBoxStore.ts +++ b/src/stores/workspace/searchBoxStore.ts @@ -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(null) + function setPendingLinkDropAction(action: LinkReleaseTriggerAction | null) { + pendingLinkDropAction.value = action + } + return { newSearchBoxEnabled, setPopoverRef, toggleVisible, - visible + visible, + pendingLinkDropAction, + setPendingLinkDropAction } })