mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-13 03:01:33 +00:00
Compare commits
5 Commits
test/stand
...
fix/subgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f370700fbd | ||
|
|
3cee74a0c5 | ||
|
|
f5167ec744 | ||
|
|
180fed69d8 | ||
|
|
0d657402e7 |
@@ -162,6 +162,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSubgraphDragBridge } from '@/renderer/core/canvas/links/useSubgraphDragBridge'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
@@ -448,6 +449,7 @@ useContextMenuTranslation()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useSubgraphDragBridge()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
watch(
|
||||
|
||||
@@ -445,6 +445,7 @@ export class LinkConnector {
|
||||
this.state.connectingTo = 'input'
|
||||
|
||||
this._setLegacyLinks(false)
|
||||
this.events.dispatch('connecting', { connectingTo: 'input' })
|
||||
}
|
||||
|
||||
dragNewFromSubgraphOutput(
|
||||
@@ -466,6 +467,7 @@ export class LinkConnector {
|
||||
this.state.connectingTo = 'output'
|
||||
|
||||
this._setLegacyLinks(true)
|
||||
this.events.dispatch('connecting', { connectingTo: 'output' })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -878,7 +880,10 @@ export class LinkConnector {
|
||||
(link) => link instanceof MovingInputLink && link.disconnectOnDrop
|
||||
).forEach((link) => (link as MovingLinkBase).disconnect())
|
||||
if (this.renderLinks.length === 0) return
|
||||
// For external event only.
|
||||
|
||||
const intercepted = this.events.dispatch('before-drop-on-canvas', event)
|
||||
if (intercepted === false) return
|
||||
|
||||
const mayContinue = this.events.dispatch('dropped-on-canvas', event)
|
||||
if (mayContinue === false) return
|
||||
|
||||
|
||||
87
src/lib/litegraph/src/canvas/LinkConnectorDropEvents.test.ts
Normal file
87
src/lib/litegraph/src/canvas/LinkConnectorDropEvents.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createMockCanvasPointerEvent,
|
||||
createMockLGraphNode,
|
||||
createMockLinkNetwork,
|
||||
createMockNodeOutputSlot
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockSetConnectingLinks = vi.fn()
|
||||
|
||||
type RenderLinkItem = LinkConnector['renderLinks'][number]
|
||||
|
||||
function createMockRenderLink(): RenderLinkItem {
|
||||
const partial: Partial<RenderLinkItem> = {
|
||||
toType: 'input',
|
||||
fromPos: [0, 0],
|
||||
fromSlotIndex: 0,
|
||||
fromDirection: 0,
|
||||
network: createMockLinkNetwork(),
|
||||
node: createMockLGraphNode(),
|
||||
fromSlot: createMockNodeOutputSlot(),
|
||||
dragDirection: 0,
|
||||
canConnectToInput: vi.fn().mockReturnValue(false),
|
||||
canConnectToOutput: vi.fn().mockReturnValue(false),
|
||||
canConnectToReroute: vi.fn().mockReturnValue(false),
|
||||
connectToInput: vi.fn(),
|
||||
connectToOutput: vi.fn(),
|
||||
connectToSubgraphInput: vi.fn(),
|
||||
connectToRerouteOutput: vi.fn(),
|
||||
connectToSubgraphOutput: vi.fn(),
|
||||
connectToRerouteInput: vi.fn()
|
||||
}
|
||||
return partial as RenderLinkItem
|
||||
}
|
||||
|
||||
describe('LinkConnector.dropOnNothing event dispatch', () => {
|
||||
let connector: LinkConnector
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new LinkConnector(mockSetConnectingLinks)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('dispatches before-drop-on-canvas before dropped-on-canvas', () => {
|
||||
connector.renderLinks.push(createMockRenderLink())
|
||||
|
||||
const callOrder: string[] = []
|
||||
connector.events.addEventListener('before-drop-on-canvas', () => {
|
||||
callOrder.push('before-drop-on-canvas')
|
||||
})
|
||||
connector.events.addEventListener('dropped-on-canvas', () => {
|
||||
callOrder.push('dropped-on-canvas')
|
||||
})
|
||||
|
||||
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
|
||||
|
||||
expect(callOrder).toEqual(['before-drop-on-canvas', 'dropped-on-canvas'])
|
||||
})
|
||||
|
||||
test('skips dropped-on-canvas when before-drop-on-canvas is intercepted', () => {
|
||||
connector.renderLinks.push(createMockRenderLink())
|
||||
|
||||
const droppedListener = vi.fn()
|
||||
connector.events.addEventListener('before-drop-on-canvas', (e) => {
|
||||
e.preventDefault()
|
||||
})
|
||||
connector.events.addEventListener('dropped-on-canvas', droppedListener)
|
||||
|
||||
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
|
||||
|
||||
expect(droppedListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does not dispatch events when renderLinks is empty', () => {
|
||||
const beforeListener = vi.fn()
|
||||
const droppedListener = vi.fn()
|
||||
connector.events.addEventListener('before-drop-on-canvas', beforeListener)
|
||||
connector.events.addEventListener('dropped-on-canvas', droppedListener)
|
||||
|
||||
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
|
||||
|
||||
expect(beforeListener).not.toHaveBeenCalled()
|
||||
expect(droppedListener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,10 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
export interface LinkConnectorEventMap {
|
||||
reset: boolean
|
||||
|
||||
connecting: {
|
||||
connectingTo: 'input' | 'output'
|
||||
}
|
||||
|
||||
'before-drop-links': {
|
||||
renderLinks: RenderLink[]
|
||||
event: CanvasPointerEvent
|
||||
@@ -44,6 +48,7 @@ export interface LinkConnectorEventMap {
|
||||
node: SubgraphInputNode | SubgraphOutputNode
|
||||
event: CanvasPointerEvent
|
||||
}
|
||||
'before-drop-on-canvas': CanvasPointerEvent
|
||||
'dropped-on-canvas': CanvasPointerEvent
|
||||
|
||||
'dropped-on-widget': {
|
||||
|
||||
100
src/lib/litegraph/src/subgraph/SubgraphIOSlotLinks.test.ts
Normal file
100
src/lib/litegraph/src/subgraph/SubgraphIOSlotLinks.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
|
||||
describe('SubgraphInput.connect triggers node:slot-links:changed', () => {
|
||||
subgraphTest(
|
||||
'fires connected event when connecting to a widget input',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraph = simpleSubgraph
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('prompt', 'STRING')
|
||||
node.inputs[0].widget = { name: 'prompt' }
|
||||
subgraph.add(node)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: true,
|
||||
linkId: expect.any(Number)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'does not fire event when connecting to a non-widget input',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraph = simpleSubgraph
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
|
||||
'node:slot-links:changed'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphInputNode._disconnectNodeInput triggers node:slot-links:changed', () => {
|
||||
subgraphTest(
|
||||
'fires disconnected event when disconnecting a widget input',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraph = simpleSubgraph
|
||||
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('prompt', 'STRING')
|
||||
node.inputs[0].widget = { name: 'prompt' }
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
expect(link).toBeDefined()
|
||||
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
|
||||
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: link!.id
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'does not fire event when disconnecting a non-widget input',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraph = simpleSubgraph
|
||||
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
expect(link).toBeDefined()
|
||||
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
|
||||
|
||||
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
|
||||
'node:slot-links:changed'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -134,6 +134,16 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
}
|
||||
subgraph._version++
|
||||
|
||||
if (slot.widget) {
|
||||
subgraph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id
|
||||
})
|
||||
}
|
||||
|
||||
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
|
||||
|
||||
subgraph.afterChange()
|
||||
|
||||
@@ -177,7 +177,17 @@ export class SubgraphInputNode
|
||||
}
|
||||
}
|
||||
|
||||
const linkId = input.link
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
subgraph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: node.inputs.indexOf(input),
|
||||
connected: false,
|
||||
linkId: linkId
|
||||
})
|
||||
}
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
|
||||
if (!link) return
|
||||
|
||||
@@ -140,7 +140,6 @@ export class LinkConnectorAdapter {
|
||||
|
||||
/** Drops moving links onto the canvas (no target). */
|
||||
dropOnCanvas(event: CanvasPointerEvent): void {
|
||||
//Add extra check for connection to subgraphInput/subgraphOutput
|
||||
if (isSubgraph(this.network)) {
|
||||
const { canvasX, canvasY } = event
|
||||
const ioNode = this.network.getIoNodeOnPos?.(canvasX, canvasY)
|
||||
|
||||
355
src/renderer/core/canvas/links/useSubgraphDragBridge.ts
Normal file
355
src/renderer/core/canvas/links/useSubgraphDragBridge.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { tryOnScopeDispose, whenever } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import {
|
||||
resolveNodeSurfaceSlotCandidate,
|
||||
resolveSlotTargetCandidate
|
||||
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
|
||||
import { resolvePointerTarget } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
const SNAP_CLASS = 'lg-slot--snap-target'
|
||||
|
||||
/**
|
||||
* Bridges canvas-initiated subgraph IO drags to Vue slot drag state.
|
||||
*
|
||||
* When a drag starts from a SubgraphInput or SubgraphOutput node
|
||||
* (canvas-drawn), the Vue slot components need to know about it so they
|
||||
* can dim incompatible slots, snap links, and highlight compatible targets.
|
||||
*/
|
||||
export function useSubgraphDragBridge() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { canvas } = storeToRefs(canvasStore)
|
||||
const {
|
||||
state: dragState,
|
||||
beginDrag,
|
||||
endDrag,
|
||||
setCandidate,
|
||||
setCompatibleForKey
|
||||
} = useSlotLinkDragUIState()
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
whenever(canvas, (lgCanvas) => {
|
||||
cleanup?.()
|
||||
cleanup = setupBridge(lgCanvas)
|
||||
})
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
cleanup?.()
|
||||
cleanup = undefined
|
||||
})
|
||||
|
||||
/** Wires up LinkConnector event listeners and returns a cleanup function. */
|
||||
function setupBridge(lgCanvas: LGraphCanvas): () => void {
|
||||
const linkConnector: LinkConnector = lgCanvas.linkConnector
|
||||
let teardownDrag: (() => void) | undefined
|
||||
let isBridgeDrag = false
|
||||
|
||||
const onConnecting = (
|
||||
event: CustomEvent<{ connectingTo: 'input' | 'output' }>
|
||||
) => {
|
||||
teardownDrag?.()
|
||||
teardownDrag = undefined
|
||||
|
||||
const { connectingTo } = event.detail
|
||||
|
||||
const adapter = createLinkConnectorAdapter()
|
||||
if (!adapter) return
|
||||
|
||||
const renderLink = adapter.renderLinks[0]
|
||||
if (!renderLink) return
|
||||
|
||||
const sourceType: 'input' | 'output' =
|
||||
connectingTo === 'input' ? 'output' : 'input'
|
||||
|
||||
isBridgeDrag = true
|
||||
beginDrag(
|
||||
{
|
||||
nodeId: String(renderLink.node.id),
|
||||
slotIndex: renderLink.fromSlotIndex,
|
||||
type: sourceType,
|
||||
direction: renderLink.fromDirection ?? LinkDirection.RIGHT,
|
||||
position: {
|
||||
x: renderLink.fromPos[0],
|
||||
y: renderLink.fromPos[1]
|
||||
}
|
||||
},
|
||||
-1
|
||||
)
|
||||
|
||||
const allKeys = layoutStore.getAllSlotKeys()
|
||||
for (const key of allKeys) {
|
||||
const slotLayout = layoutStore.getSlotLayout(key)
|
||||
if (!slotLayout) continue
|
||||
if (slotLayout.type !== connectingTo) continue
|
||||
|
||||
const ok =
|
||||
connectingTo === 'input'
|
||||
? adapter.isInputValidDrop(slotLayout.nodeId, slotLayout.index)
|
||||
: adapter.isOutputValidDrop(slotLayout.nodeId, slotLayout.index)
|
||||
|
||||
setCompatibleForKey(key, ok)
|
||||
}
|
||||
|
||||
teardownDrag = startPointerTracking(lgCanvas, linkConnector)
|
||||
}
|
||||
|
||||
const onBeforeDropOnCanvas = (event: CustomEvent) => {
|
||||
if (!isBridgeDrag) return
|
||||
|
||||
const candidate = dragState.candidate
|
||||
if (!candidate?.compatible) return
|
||||
|
||||
const adapter = createLinkConnectorAdapter()
|
||||
if (!adapter) return
|
||||
|
||||
const connected = connectToCandidate(
|
||||
adapter.renderLinks,
|
||||
adapter.network,
|
||||
candidate,
|
||||
linkConnector
|
||||
)
|
||||
if (connected) event.preventDefault()
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
if (!isBridgeDrag) return
|
||||
teardownDrag?.()
|
||||
teardownDrag = undefined
|
||||
isBridgeDrag = false
|
||||
endDrag()
|
||||
}
|
||||
|
||||
linkConnector.events.addEventListener('connecting', onConnecting)
|
||||
linkConnector.events.addEventListener(
|
||||
'before-drop-on-canvas',
|
||||
onBeforeDropOnCanvas
|
||||
)
|
||||
linkConnector.events.addEventListener('reset', onReset)
|
||||
|
||||
return () => {
|
||||
linkConnector.events.removeEventListener('connecting', onConnecting)
|
||||
linkConnector.events.removeEventListener(
|
||||
'before-drop-on-canvas',
|
||||
onBeforeDropOnCanvas
|
||||
)
|
||||
linkConnector.events.removeEventListener('reset', onReset)
|
||||
teardownDrag?.()
|
||||
teardownDrag = undefined
|
||||
if (isBridgeDrag) {
|
||||
isBridgeDrag = false
|
||||
endDrag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks pointer movement during a bridge drag to resolve Vue slot
|
||||
* candidates, update snap positions, and toggle snap-target highlights.
|
||||
* Returns a cleanup function that removes the listener and RAF batch.
|
||||
*/
|
||||
function startPointerTracking(
|
||||
lgCanvas: LGraphCanvas,
|
||||
linkConnector: LinkConnector
|
||||
): () => void {
|
||||
const ownerDoc = lgCanvas.getCanvasWindow().document
|
||||
const session = createSlotLinkDragContext()
|
||||
const slotRegistry = useNodeSlotRegistryStore()
|
||||
let pendingMove: { clientX: number; clientY: number } | null = null
|
||||
let highlightedSlotEl: HTMLElement | null = null
|
||||
|
||||
/** Resolves the Vue slot under the pointer and updates snap/highlight state. */
|
||||
const processFrame = () => {
|
||||
const data = pendingMove
|
||||
if (!data) return
|
||||
pendingMove = null
|
||||
|
||||
const adapter = createLinkConnectorAdapter()
|
||||
if (!adapter) return
|
||||
const graph = adapter.network
|
||||
|
||||
const target = resolvePointerTarget(data.clientX, data.clientY, null)
|
||||
|
||||
let hoveredSlotKey: string | null = null
|
||||
let hoveredNodeId: NodeId | null = null
|
||||
if (target === session.lastPointerEventTarget) {
|
||||
hoveredSlotKey = session.lastPointerTargetSlotKey
|
||||
hoveredNodeId = session.lastPointerTargetNodeId
|
||||
} else if (target instanceof HTMLElement) {
|
||||
const elWithSlot = target
|
||||
.closest('.lg-slot, .lg-node-widget')
|
||||
?.querySelector<HTMLElement>('[data-slot-key]')
|
||||
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
|
||||
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
|
||||
hoveredNodeId = elWithNode?.dataset['nodeId'] ?? null
|
||||
session.lastPointerEventTarget = target
|
||||
session.lastPointerTargetSlotKey = hoveredSlotKey
|
||||
session.lastPointerTargetNodeId = hoveredNodeId
|
||||
}
|
||||
|
||||
const hoverChanged =
|
||||
hoveredSlotKey !== session.lastHoverSlotKey ||
|
||||
hoveredNodeId !== session.lastHoverNodeId
|
||||
|
||||
let candidate = dragState.candidate
|
||||
|
||||
if (hoverChanged) {
|
||||
const context = { adapter, graph, session }
|
||||
const slotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const nodeCandidate = resolveNodeSurfaceSlotCandidate(target, context)
|
||||
candidate = slotCandidate?.compatible ? slotCandidate : nodeCandidate
|
||||
session.lastHoverSlotKey = hoveredSlotKey
|
||||
session.lastHoverNodeId = hoveredNodeId
|
||||
|
||||
if (slotCandidate) {
|
||||
const key = getSlotKey(
|
||||
slotCandidate.layout.nodeId,
|
||||
slotCandidate.layout.index,
|
||||
slotCandidate.layout.type === 'input'
|
||||
)
|
||||
setCompatibleForKey(key, !!slotCandidate.compatible)
|
||||
}
|
||||
if (nodeCandidate && !slotCandidate?.compatible) {
|
||||
const key = getSlotKey(
|
||||
nodeCandidate.layout.nodeId,
|
||||
nodeCandidate.layout.index,
|
||||
nodeCandidate.layout.type === 'input'
|
||||
)
|
||||
setCompatibleForKey(key, !!nodeCandidate.compatible)
|
||||
}
|
||||
}
|
||||
|
||||
const newCandidate = candidate?.compatible ? candidate : null
|
||||
const newCandidateKey = newCandidate
|
||||
? getSlotKey(
|
||||
newCandidate.layout.nodeId,
|
||||
newCandidate.layout.index,
|
||||
newCandidate.layout.type === 'input'
|
||||
)
|
||||
: null
|
||||
|
||||
const candidateChanged = newCandidateKey !== session.lastCandidateKey
|
||||
if (candidateChanged) {
|
||||
setCandidate(newCandidate)
|
||||
session.lastCandidateKey = newCandidateKey
|
||||
updateSnapTargetHighlight(newCandidate)
|
||||
}
|
||||
|
||||
const snapPos = newCandidate
|
||||
? ([newCandidate.layout.position.x, newCandidate.layout.position.y] as [
|
||||
number,
|
||||
number
|
||||
])
|
||||
: undefined
|
||||
const currentSnap = linkConnector.state.snapLinksPos
|
||||
const snapPosChanged = snapPos
|
||||
? !currentSnap ||
|
||||
currentSnap[0] !== snapPos[0] ||
|
||||
currentSnap[1] !== snapPos[1]
|
||||
: !!currentSnap
|
||||
if (snapPosChanged) {
|
||||
linkConnector.state.snapLinksPos = snapPos
|
||||
}
|
||||
|
||||
if (candidateChanged || snapPosChanged) {
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggles the `lg-slot--snap-target` CSS class on the candidate's slot element. */
|
||||
function updateSnapTargetHighlight(candidate: SlotDropCandidate | null) {
|
||||
if (highlightedSlotEl) {
|
||||
highlightedSlotEl.classList.remove(SNAP_CLASS)
|
||||
highlightedSlotEl = null
|
||||
}
|
||||
if (!candidate) return
|
||||
const key = getSlotKey(
|
||||
candidate.layout.nodeId,
|
||||
candidate.layout.index,
|
||||
candidate.layout.type === 'input'
|
||||
)
|
||||
const entry = slotRegistry
|
||||
.getNode(candidate.layout.nodeId)
|
||||
?.slots.get(key)
|
||||
const groupEl = entry?.el?.parentElement
|
||||
if (groupEl) {
|
||||
groupEl.classList.add(SNAP_CLASS)
|
||||
highlightedSlotEl = groupEl
|
||||
}
|
||||
}
|
||||
|
||||
const raf = createRafBatch(processFrame)
|
||||
|
||||
/** Buffers the latest pointer position and schedules a RAF frame. */
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
pendingMove = { clientX: e.clientX, clientY: e.clientY }
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
ownerDoc.addEventListener('pointermove', onPointerMove, { capture: true })
|
||||
|
||||
return () => {
|
||||
ownerDoc.removeEventListener('pointermove', onPointerMove, {
|
||||
capture: true
|
||||
})
|
||||
raf.cancel()
|
||||
if (highlightedSlotEl) {
|
||||
highlightedSlotEl.classList.remove(SNAP_CLASS)
|
||||
highlightedSlotEl = null
|
||||
}
|
||||
session.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects render links to the snapped Vue slot candidate.
|
||||
* Returns `true` if at least one link was connected.
|
||||
*/
|
||||
function connectToCandidate(
|
||||
links: ReadonlyArray<RenderLink>,
|
||||
network: { getNodeById(id: NodeId): LGraphNode | null },
|
||||
candidate: SlotDropCandidate,
|
||||
linkConnector: LinkConnector
|
||||
): boolean {
|
||||
const node = network.getNodeById(candidate.layout.nodeId)
|
||||
if (!node) return false
|
||||
|
||||
let connected = false
|
||||
|
||||
if (candidate.layout.type === 'input') {
|
||||
const input = node.inputs?.[candidate.layout.index]
|
||||
if (!input) return false
|
||||
for (const link of links) {
|
||||
if (link.toType !== 'input') continue
|
||||
if (!link.canConnectToInput(node, input)) continue
|
||||
link.connectToInput(node, input, linkConnector.events)
|
||||
connected = true
|
||||
}
|
||||
} else {
|
||||
const output = node.outputs?.[candidate.layout.index]
|
||||
if (!output) return false
|
||||
for (const link of links) {
|
||||
if (link.toType !== 'output') continue
|
||||
if (!link.canConnectToOutput(node, output)) continue
|
||||
link.connectToOutput(node, output, linkConnector.events)
|
||||
connected = true
|
||||
}
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
@@ -52,7 +52,7 @@ const slotClass = computed(() =>
|
||||
'border border-solid border-node-component-slot-dot-outline',
|
||||
props.multi
|
||||
? 'h-6 w-3'
|
||||
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
|
||||
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-[.lg-slot--snap-target]/slot:scale-125 group-[.lg-slot--snap-target]/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 {
|
||||
clearCanvasPointerHistory,
|
||||
@@ -32,6 +33,7 @@ import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/compos
|
||||
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
interface SlotInteractionOptions {
|
||||
nodeId: string
|
||||
@@ -386,14 +388,44 @@ export function useSlotLinkInteraction({
|
||||
dragContext.lastCandidateKey = newCandidateKey
|
||||
}
|
||||
|
||||
let subgraphIOSnapPos: [number, number] | null = null
|
||||
let subgraphIOHoverChanged = false
|
||||
|
||||
const graph = app.canvas?.graph
|
||||
if (isSubgraph(graph)) {
|
||||
const pointerEvent = { canvasX, canvasY } as CanvasPointerEvent
|
||||
|
||||
for (const node of [graph.inputNode, graph.outputNode]) {
|
||||
if (!node) continue
|
||||
|
||||
const wasPointerOver = node.isPointerOver
|
||||
node.onPointerMove(pointerEvent)
|
||||
|
||||
if (wasPointerOver !== node.isPointerOver) {
|
||||
subgraphIOHoverChanged = true
|
||||
}
|
||||
|
||||
if (node.isPointerOver) {
|
||||
const slot = node.getSlotInPosition(canvasX, canvasY)
|
||||
if (slot && slot.isPointerOver) {
|
||||
subgraphIOSnapPos = slot.pos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snapPosChanged = false
|
||||
if (activeAdapter) {
|
||||
const snapX = newCandidate
|
||||
? newCandidate.layout.position.x
|
||||
: state.pointer.canvas.x
|
||||
const snapY = newCandidate
|
||||
? newCandidate.layout.position.y
|
||||
: state.pointer.canvas.y
|
||||
const snapX = subgraphIOSnapPos
|
||||
? subgraphIOSnapPos[0]
|
||||
: newCandidate
|
||||
? newCandidate.layout.position.x
|
||||
: state.pointer.canvas.x
|
||||
const snapY = subgraphIOSnapPos
|
||||
? subgraphIOSnapPos[1]
|
||||
: newCandidate
|
||||
? newCandidate.layout.position.y
|
||||
: state.pointer.canvas.y
|
||||
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
|
||||
snapPosChanged =
|
||||
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
|
||||
@@ -402,7 +434,8 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRedraw = candidateChanged || snapPosChanged
|
||||
const shouldRedraw =
|
||||
candidateChanged || snapPosChanged || subgraphIOHoverChanged
|
||||
if (shouldRedraw) app.canvas?.setDirty(true, true)
|
||||
}
|
||||
const raf = createRafBatch(processPointerMoveFrame)
|
||||
@@ -511,8 +544,6 @@ export function useSlotLinkInteraction({
|
||||
|
||||
raf.flush()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true, true)
|
||||
|
||||
Reference in New Issue
Block a user