Compare commits
57 Commits
fix/load-a
...
bl-drop-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f338480e8b | ||
|
|
a71b99d6fc | ||
|
|
5e3c91faac | ||
|
|
066a755a5b | ||
|
|
21cc208e4b | ||
|
|
f13a45c781 | ||
|
|
1c11dcc5af | ||
|
|
247e3950eb | ||
|
|
8da5ae3af6 | ||
|
|
4b95ef94df | ||
|
|
18b4f56158 | ||
|
|
9de27adfca | ||
|
|
0e33672443 | ||
|
|
ecc5bed87d | ||
|
|
1ca3d75aaf | ||
|
|
0627a71fb6 | ||
|
|
23f3e17d52 | ||
|
|
0f46452b70 | ||
|
|
76c718e2ee | ||
|
|
4f6eaea257 | ||
|
|
839d8a5f47 | ||
|
|
c05011594d | ||
|
|
9b39835cd1 | ||
|
|
57810b9350 | ||
|
|
99aaa4e4cb | ||
|
|
e9ffce468d | ||
|
|
381d97a982 | ||
|
|
88cd60f0c5 | ||
|
|
a2be36a0bc | ||
|
|
65ec322100 | ||
|
|
f99d8c1a92 | ||
|
|
8eec7fb80e | ||
|
|
d78029697c | ||
|
|
f34890296b | ||
|
|
e879bd5290 | ||
|
|
9d32b4cf06 | ||
|
|
0aa971bed9 | ||
|
|
6685e004c0 | ||
|
|
e136b89fae | ||
|
|
3f4a8060de | ||
|
|
20d136dff3 | ||
|
|
e7f0ee40e4 | ||
|
|
bef712ed4f | ||
|
|
263b28097d | ||
|
|
19c538c36c | ||
|
|
b99d70d0f0 | ||
|
|
9d668a154e | ||
|
|
c227d60626 | ||
|
|
70651dcde0 | ||
|
|
22a1c61208 | ||
|
|
369da53743 | ||
|
|
48f5087116 | ||
|
|
f624940e16 | ||
|
|
5c6c21cdf2 | ||
|
|
e3e1d2e8e6 | ||
|
|
939cbe0899 | ||
|
|
9a18d37019 |
@@ -13,6 +13,13 @@ export class VueNodeHelpers {
|
||||
return this.page.locator('[data-node-id]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by its NodeId
|
||||
*/
|
||||
getNodeLocator(nodeId: string): Locator {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '../../../../helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
@@ -16,6 +18,87 @@ async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getInputLinkDetails(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number
|
||||
) {
|
||||
return await page.evaluate(
|
||||
([targetNodeId, targetSlot]) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) return null
|
||||
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) return null
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.getLink?.(linkId)
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
id: link.id,
|
||||
originId: link.origin_id,
|
||||
originSlot:
|
||||
typeof link.origin_slot === 'string'
|
||||
? Number.parseInt(link.origin_slot, 10)
|
||||
: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot:
|
||||
typeof link.target_slot === 'string'
|
||||
? Number.parseInt(link.target_slot, 10)
|
||||
: link.target_slot,
|
||||
parentId: link.parentId ?? null
|
||||
}
|
||||
},
|
||||
[nodeId, slotIndex] as const
|
||||
)
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition across cases
|
||||
function slotLocator(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const key = getSlotKey(String(nodeId), slotIndex, isInput)
|
||||
return page.locator(`[data-slot-key="${key}"]`)
|
||||
}
|
||||
|
||||
async function expectVisibleAll(...locators: Locator[]) {
|
||||
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
|
||||
}
|
||||
|
||||
async function getSlotCenter(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const locator = slotLocator(page, nodeId, slotIndex, isInput)
|
||||
await expect(locator).toBeVisible()
|
||||
return await getCenter(locator)
|
||||
}
|
||||
|
||||
async function connectSlots(
|
||||
page: Page,
|
||||
from: { nodeId: NodeId; index: number },
|
||||
to: { nodeId: NodeId; index: number },
|
||||
nextFrame: () => Promise<void>
|
||||
) {
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
await fromLoc.dragTo(toLoc)
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -30,21 +113,13 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const samplerNode = samplerNodes[0]
|
||||
const outputSlot = await samplerNode.getOutput(0)
|
||||
await outputSlot.removeLinks()
|
||||
await comfyPage.nextFrame()
|
||||
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
await expect(slot).toBeVisible()
|
||||
|
||||
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
|
||||
await expect(slotLocator).toBeVisible()
|
||||
|
||||
const start = await getCenter(slotLocator)
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas bounding box not available')
|
||||
const start = await getCenter(slot)
|
||||
|
||||
// Arbitrary value
|
||||
const dragTarget = {
|
||||
@@ -68,58 +143,24 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
test('should create a link when dropping on a compatible slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
expect(vaeNodes.length).toBeGreaterThan(0)
|
||||
const vaeNode = vaeNodes[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
|
||||
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return null
|
||||
|
||||
const linkId = source.outputs[0]?.links?.[0]
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.links[linkId]
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
originId: link.origin_id,
|
||||
originSlot: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}
|
||||
}, samplerNode.id)
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: samplerNode.id,
|
||||
@@ -132,29 +173,16 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
test('should not create a link when slot types are incompatible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(clipNodes.length).toBeGreaterThan(0)
|
||||
const clipNode = clipNodes[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
expect(samplerNode && clipNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const clipInput = await clipNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -162,60 +190,602 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await clipInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
const graphLinkDetails = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0
|
||||
)
|
||||
expect(graphLinkDetails).toBeNull()
|
||||
})
|
||||
|
||||
test('should not create a link when dropping onto a slot on the same node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const samplerInput = await samplerNode.getInput(3)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await samplerInput.getLinkCount()).toBe(0)
|
||||
})
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph
|
||||
if (!graph) return 0
|
||||
test('should reuse the existing origin when dragging an input link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 160,
|
||||
y: vaeInputCenter.y - 100
|
||||
}
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-input-drag-reuses-origin.png'
|
||||
)
|
||||
await comfyMouse.drop()
|
||||
})
|
||||
|
||||
test('ctrl+alt drag from an input starts a fresh link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 140,
|
||||
y: vaeInputCenter.y - 110
|
||||
}
|
||||
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
|
||||
try {
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-input-drag-ctrl-alt.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop().catch(() => {})
|
||||
await comfyPage.page.keyboard.up('Alt').catch(() => {})
|
||||
await comfyPage.page.keyboard.up('Control').catch(() => {})
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Tcehnically intended to disconnect existing as well
|
||||
expect(await vaeInput.getLinkCount()).toBe(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('dropping an input link back on its slot restores the original connection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
try {
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0
|
||||
)
|
||||
expect(originalLink).not.toBeNull()
|
||||
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 150,
|
||||
y: vaeInputCenter.y - 100
|
||||
}
|
||||
|
||||
// To prevent needing a screenshot expectation for whether the link's off
|
||||
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
|
||||
const inputBox = await vaeInputLocator.boundingBox()
|
||||
if (!inputBox) throw new Error('Input slot bounding box not available')
|
||||
const isOutsideX =
|
||||
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
|
||||
const isOutsideY =
|
||||
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
|
||||
expect(isOutsideX || isOutsideY).toBe(true)
|
||||
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const restoredLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0
|
||||
)
|
||||
|
||||
expect(restoredLink).not.toBeNull()
|
||||
if (!restoredLink || !originalLink) {
|
||||
throw new Error('Expected both original and restored links to exist')
|
||||
}
|
||||
expect(restoredLink).toMatchObject({
|
||||
originId: originalLink.originId,
|
||||
originSlot: originalLink.originSlot,
|
||||
targetId: originalLink.targetId,
|
||||
targetSlot: originalLink.targetSlot,
|
||||
parentId: originalLink.parentId
|
||||
})
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('rerouted input drag preview remains anchored to reroute', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
const outputPosition = await samplerOutput.getPosition()
|
||||
const inputPosition = await vaeInput.getPosition()
|
||||
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
|
||||
|
||||
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) throw new Error('Target node not found')
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) throw new Error('Target input slot not found')
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) throw new Error('Expected existing link on input')
|
||||
const link = graph.getLink(linkId)
|
||||
if (!link) throw new Error('Link not found')
|
||||
|
||||
// Convert the client/canvas pixel coordinates to graph space
|
||||
const pos = app.canvas.ds.convertCanvasToOffset([
|
||||
clientPoint.x,
|
||||
clientPoint.y
|
||||
])
|
||||
graph.createReroute(pos, link)
|
||||
},
|
||||
[vaeNode.id, 0, reroutePoint] as const
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 160,
|
||||
y: vaeInputCenter.y - 120
|
||||
}
|
||||
|
||||
let dropped = false
|
||||
try {
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-reroute-input-drag.png'
|
||||
)
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
dropped = true
|
||||
} finally {
|
||||
if (!dropped) {
|
||||
await comfyMouse.drop().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails?.originId).toBe(samplerNode.id)
|
||||
expect(linkDetails?.parentId).not.toBeNull()
|
||||
})
|
||||
|
||||
test('rerouted output shift-drag preview remains anchored to reroute', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
const outputPosition = await samplerOutput.getPosition()
|
||||
const inputPosition = await vaeInput.getPosition()
|
||||
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
|
||||
|
||||
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) throw new Error('Target node not found')
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) throw new Error('Target input slot not found')
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) throw new Error('Expected existing link on input')
|
||||
const link = graph.getLink(linkId)
|
||||
if (!link) throw new Error('Link not found')
|
||||
|
||||
// Convert the client/canvas pixel coordinates to graph space
|
||||
const pos = app.canvas.ds.convertCanvasToOffset([
|
||||
clientPoint.x,
|
||||
clientPoint.y
|
||||
])
|
||||
graph.createReroute(pos, link)
|
||||
},
|
||||
[vaeNode.id, 0, reroutePoint] as const
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dragTarget = {
|
||||
x: outputCenter.x + 150,
|
||||
y: outputCenter.y - 140
|
||||
}
|
||||
|
||||
let dropPending = false
|
||||
let shiftHeld = false
|
||||
try {
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
shiftHeld = true
|
||||
dropPending = true
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-reroute-output-shift-drag.png'
|
||||
)
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyMouse.drop()
|
||||
dropPending = false
|
||||
} finally {
|
||||
if (dropPending) await comfyMouse.drop().catch(() => {})
|
||||
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails?.originId).toBe(samplerNode.id)
|
||||
expect(linkDetails?.parentId).not.toBeNull()
|
||||
})
|
||||
|
||||
test('dragging input to input drags existing link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 1 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
// Verify initial link exists between CLIP -> KSampler input[1]
|
||||
const initialLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
expect(initialLink).not.toBeNull()
|
||||
expect(initialLink).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 1
|
||||
})
|
||||
|
||||
// Step 2: Drag from KSampler's second input to its third input (index 2)
|
||||
const input2Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1,
|
||||
true
|
||||
)
|
||||
const input3Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(input2Center)
|
||||
await comfyMouse.drag(input3Center)
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect old link removed from input[1]
|
||||
const afterSecondInput = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
expect(afterSecondInput).toBeNull()
|
||||
|
||||
// Expect new link exists at input[2] from CLIP
|
||||
const afterThirdInput = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
expect(afterThirdInput).not.toBeNull()
|
||||
expect(afterThirdInput).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
|
||||
test('shift-dragging an output with multiple links should drag all links', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
const clipOutput = await clipNode.getOutput(0)
|
||||
|
||||
// Connect output[0] -> inputs[1] and [2]
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 1 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 2 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
expect(await clipOutput.getLinkCount()).toBe(2)
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dragTarget = {
|
||||
x: outputCenter.x + 40,
|
||||
y: outputCenter.y - 140
|
||||
}
|
||||
|
||||
let dropPending = false
|
||||
let shiftHeld = false
|
||||
try {
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
shiftHeld = true
|
||||
await comfyMouse.drag(dragTarget)
|
||||
dropPending = true
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-shift-output-multi-link.png'
|
||||
)
|
||||
} finally {
|
||||
if (dropPending) await comfyMouse.drop().catch(() => {})
|
||||
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test('should snap to node center while dragging and link on drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Start drag from CLIP output[0]
|
||||
const clipOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
// Drag to the visual center of the KSampler Vue node (not a slot)
|
||||
const samplerVue = comfyPage.vueNodes.getNodeLocator(String(samplerNode.id))
|
||||
await expect(samplerVue).toBeVisible()
|
||||
const samplerCenter = await getCenter(samplerVue)
|
||||
|
||||
await comfyMouse.move(clipOutputCenter)
|
||||
await comfyMouse.drag(samplerCenter)
|
||||
|
||||
// During drag, the preview should snap/highlight a compatible input on KSampler
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-node.png')
|
||||
|
||||
// Drop to create the link
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Validate a link was created to one of KSampler's compatible inputs (1 or 2)
|
||||
const linkOnInput1 = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
const linkOnInput2 = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
|
||||
const linked = linkOnInput1 ?? linkOnInput2
|
||||
expect(linked).not.toBeNull()
|
||||
expect(linked?.originId).toBe(clipNode.id)
|
||||
expect(linked?.targetId).toBe(samplerNode.id)
|
||||
})
|
||||
|
||||
test('should snap to a specific compatible slot when targeting it', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Drag from CLIP output[0] to KSampler input[2] (third slot) which is the
|
||||
// second compatible input for CLIP
|
||||
const clipOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const samplerInput3Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(clipOutputCenter)
|
||||
await comfyMouse.drag(samplerInput3Center)
|
||||
|
||||
// Expect the preview to show snapping to the targeted slot
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-slot.png')
|
||||
|
||||
// Finish the connection
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import type { RenderLink } from './RenderLink'
|
||||
@@ -99,6 +100,14 @@ export abstract class MovingLinkBase implements RenderLink {
|
||||
this.inputPos = inputNode.getInputPos(inputIndex)
|
||||
}
|
||||
|
||||
abstract canConnectToInput(
|
||||
inputNode: NodeLike,
|
||||
input: INodeInputSlot
|
||||
): boolean
|
||||
abstract canConnectToOutput(
|
||||
outputNode: NodeLike,
|
||||
output: INodeOutputSlot
|
||||
): boolean
|
||||
abstract connectToInput(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
export interface RenderLink {
|
||||
@@ -38,6 +39,17 @@ export interface RenderLink {
|
||||
/** The reroute that the link is being connected from. */
|
||||
readonly fromReroute?: Reroute
|
||||
|
||||
/**
|
||||
* Capability checks used for hit-testing and validation during drag.
|
||||
* Implementations should return `false` when a connection is not possible
|
||||
* rather than throwing.
|
||||
*/
|
||||
canConnectToInput(node: NodeLike, input: INodeInputSlot): boolean
|
||||
canConnectToOutput(node: NodeLike, output: INodeOutputSlot): boolean
|
||||
/** Optional: only some links support validating subgraph IO or reroutes. */
|
||||
canConnectToSubgraphInput?(input: SubgraphInput): boolean
|
||||
canConnectToReroute?(reroute: Reroute): boolean
|
||||
|
||||
connectToInput(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
|
||||
152
src/renderer/core/canvas/links/linkConnectorAdapter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
// Keep one adapter per graph so rendering and interaction share state.
|
||||
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
|
||||
|
||||
/**
|
||||
* Renderer‑agnostic adapter around LiteGraph's LinkConnector.
|
||||
*
|
||||
* - Uses layoutStore for hit‑testing (nodes/reroutes).
|
||||
* - Exposes minimal, imperative APIs to begin link drags and query drop validity.
|
||||
* - Preserves existing Vue composable behavior.
|
||||
*/
|
||||
export class LinkConnectorAdapter {
|
||||
readonly linkConnector: LinkConnector
|
||||
|
||||
constructor(
|
||||
/** Network the links belong to (typically `app.canvas.graph`). */
|
||||
readonly network: LGraph
|
||||
) {
|
||||
// No-op legacy setter to avoid side effects when connectors update
|
||||
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
|
||||
this.linkConnector = new LinkConnector(setConnectingLinks)
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently rendered/dragged links, typed for consumer use.
|
||||
* Prefer this over accessing `linkConnector.renderLinks` directly.
|
||||
*/
|
||||
get renderLinks(): ReadonlyArray<RenderLink> {
|
||||
return this.linkConnector.renderLinks
|
||||
}
|
||||
|
||||
// Drag helpers
|
||||
|
||||
/**
|
||||
* Begin dragging from an output slot.
|
||||
* @param nodeId Output node id
|
||||
* @param outputIndex Output slot index
|
||||
* @param opts Optional: moveExisting (shift), fromRerouteId
|
||||
*/
|
||||
beginFromOutput(
|
||||
nodeId: NodeId,
|
||||
outputIndex: number,
|
||||
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
|
||||
): void {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const output = node?.outputs?.[outputIndex]
|
||||
if (!node || !output) return
|
||||
|
||||
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
|
||||
|
||||
if (opts?.moveExisting) {
|
||||
this.linkConnector.moveOutputLink(this.network, output)
|
||||
} else {
|
||||
this.linkConnector.dragNewFromOutput(
|
||||
this.network,
|
||||
node,
|
||||
output,
|
||||
fromReroute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin dragging from an input slot.
|
||||
* @param nodeId Input node id
|
||||
* @param inputIndex Input slot index
|
||||
* @param opts Optional: moveExisting (when a link/floating exists), fromRerouteId
|
||||
*/
|
||||
beginFromInput(
|
||||
nodeId: NodeId,
|
||||
inputIndex: number,
|
||||
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
|
||||
): void {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const input = node?.inputs?.[inputIndex]
|
||||
if (!node || !input) return
|
||||
|
||||
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
|
||||
|
||||
if (opts?.moveExisting) {
|
||||
this.linkConnector.moveInputLink(this.network, input)
|
||||
} else {
|
||||
this.linkConnector.dragNewFromInput(
|
||||
this.network,
|
||||
node,
|
||||
input,
|
||||
fromReroute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
isNodeValidDrop(nodeId: NodeId): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
return this.linkConnector.isNodeValidDrop(node)
|
||||
}
|
||||
|
||||
isInputValidDrop(nodeId: NodeId, inputIndex: number): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const input = node?.inputs?.[inputIndex]
|
||||
if (!node || !input) return false
|
||||
return this.linkConnector.isInputValidDrop(node, input)
|
||||
}
|
||||
|
||||
isOutputValidDrop(nodeId: NodeId, outputIndex: number): boolean {
|
||||
const node = this.network.getNodeById(nodeId)
|
||||
const output = node?.outputs?.[outputIndex]
|
||||
if (!node || !output) return false
|
||||
return this.linkConnector.renderLinks.some((link) =>
|
||||
link.canConnectToOutput(node, output)
|
||||
)
|
||||
}
|
||||
|
||||
isRerouteValidDrop(rerouteId: RerouteId): boolean {
|
||||
const reroute = this.network.getReroute(rerouteId)
|
||||
if (!reroute) return false
|
||||
return this.linkConnector.isRerouteValidDrop(reroute)
|
||||
}
|
||||
|
||||
// Drop/cancel helpers for future flows
|
||||
|
||||
/** Disconnects moving links (drop on canvas/no target). */
|
||||
disconnectMovingLinks(): void {
|
||||
this.linkConnector.disconnectLinks()
|
||||
}
|
||||
|
||||
/** Resets connector state and clears any temporary flags. */
|
||||
reset(): void {
|
||||
this.linkConnector.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience creator using the current app canvas graph. */
|
||||
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
|
||||
const graph = app.canvas?.graph as LGraph | undefined
|
||||
if (!graph) return null
|
||||
let adapter = adapterByGraph.get(graph)
|
||||
if (!adapter) {
|
||||
adapter = new LinkConnectorAdapter(graph)
|
||||
adapterByGraph.set(graph, adapter)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type {
|
||||
SlotDragSource,
|
||||
SlotDropCandidate
|
||||
} from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
|
||||
interface CompatibilityResult {
|
||||
allowable: boolean
|
||||
targetNode?: LGraphNode
|
||||
targetSlot?: INodeInputSlot | INodeOutputSlot
|
||||
}
|
||||
|
||||
function resolveNode(nodeId: NodeId) {
|
||||
const pinia = getActivePinia()
|
||||
const canvasStore = pinia ? useCanvasStore() : null
|
||||
const graph = canvasStore?.canvas?.graph
|
||||
if (!graph) return null
|
||||
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
|
||||
if (Number.isNaN(id)) return null
|
||||
return graph.getNodeById(id)
|
||||
}
|
||||
|
||||
export function evaluateCompatibility(
|
||||
source: SlotDragSource,
|
||||
candidate: SlotDropCandidate
|
||||
): CompatibilityResult {
|
||||
if (candidate.layout.nodeId === source.nodeId) {
|
||||
return { allowable: false }
|
||||
}
|
||||
|
||||
const isOutputToInput =
|
||||
source.type === 'output' && candidate.layout.type === 'input'
|
||||
const isInputToOutput =
|
||||
source.type === 'input' && candidate.layout.type === 'output'
|
||||
|
||||
if (!isOutputToInput && !isInputToOutput) {
|
||||
return { allowable: false }
|
||||
}
|
||||
|
||||
const sourceNode = resolveNode(source.nodeId)
|
||||
const targetNode = resolveNode(candidate.layout.nodeId)
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { allowable: false }
|
||||
}
|
||||
|
||||
if (isOutputToInput) {
|
||||
const outputSlot = sourceNode.outputs?.[source.slotIndex]
|
||||
const inputSlot = targetNode.inputs?.[candidate.layout.index]
|
||||
if (!outputSlot || !inputSlot) {
|
||||
return { allowable: false }
|
||||
}
|
||||
|
||||
const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot)
|
||||
return { allowable, targetNode, targetSlot: inputSlot }
|
||||
}
|
||||
|
||||
const inputSlot = sourceNode.inputs?.[source.slotIndex]
|
||||
const outputSlot = targetNode.outputs?.[candidate.layout.index]
|
||||
if (!inputSlot || !outputSlot) {
|
||||
return { allowable: false }
|
||||
}
|
||||
|
||||
const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot)
|
||||
return { allowable, targetNode, targetSlot: outputSlot }
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import type { Point, SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
type SlotDragType = 'input' | 'output'
|
||||
|
||||
export interface SlotDragSource {
|
||||
interface SlotDragSource {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
type: SlotDragType
|
||||
direction: LinkDirection
|
||||
position: Readonly<Point>
|
||||
linkId?: number
|
||||
movingExistingOutput?: boolean
|
||||
}
|
||||
|
||||
export interface SlotDropCandidate {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ReadOnlyPoint
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
|
||||
import {
|
||||
type SlotDragSource,
|
||||
useSlotLinkDragState
|
||||
} from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
|
||||
return {
|
||||
@@ -39,57 +36,75 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
||||
originalOnDrawForeground?.(ctx, area)
|
||||
|
||||
const { state } = useSlotLinkDragState()
|
||||
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
|
||||
if (canvas.linkConnector?.isConnecting) return
|
||||
if (!state.active || !state.source) return
|
||||
|
||||
const { pointer, source } = state
|
||||
const start = source.position
|
||||
const sourceSlot = resolveSourceSlot(canvas, source)
|
||||
const { pointer } = state
|
||||
|
||||
const linkRenderer = canvas.linkRenderer
|
||||
if (!linkRenderer) return
|
||||
|
||||
const context = buildContext(canvas)
|
||||
|
||||
const from: ReadOnlyPoint = [start.x, start.y]
|
||||
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
|
||||
|
||||
const startDir = source.direction ?? LinkDirection.RIGHT
|
||||
const endDir = LinkDirection.CENTER
|
||||
|
||||
const colour = resolveConnectingLinkColor(sourceSlot?.type)
|
||||
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
||||
if (!renderLinks || renderLinks.length === 0) return
|
||||
|
||||
const to: ReadOnlyPoint = state.candidate?.compatible
|
||||
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
|
||||
: [pointer.canvas.x, pointer.canvas.y]
|
||||
ctx.save()
|
||||
for (const link of renderLinks) {
|
||||
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
||||
const endDir = link.dragDirection ?? LinkDirection.CENTER
|
||||
const colour = resolveConnectingLinkColor(link.fromSlot.type)
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
context
|
||||
)
|
||||
const fromPoint = resolveRenderLinkOrigin(link)
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
fromPoint,
|
||||
to,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
context
|
||||
)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
canvas.onDrawForeground = patched
|
||||
}
|
||||
|
||||
function resolveSourceSlot(
|
||||
canvas: LGraphCanvas,
|
||||
source: SlotDragSource
|
||||
): INodeInputSlot | INodeOutputSlot | undefined {
|
||||
const graph = canvas.graph
|
||||
if (!graph) return undefined
|
||||
function resolveRenderLinkOrigin(link: RenderLink): ReadOnlyPoint {
|
||||
if (link.fromReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
|
||||
if (rerouteLayout) {
|
||||
return [rerouteLayout.position.x, rerouteLayout.position.y]
|
||||
}
|
||||
|
||||
const nodeId = Number(source.nodeId)
|
||||
if (!Number.isFinite(nodeId)) return undefined
|
||||
const [x, y] = link.fromReroute.pos
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
const nodeId = getRenderLinkNodeId(link)
|
||||
if (nodeId != null) {
|
||||
const isInputFrom = link.toType === 'output'
|
||||
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (layout) {
|
||||
return [layout.position.x, layout.position.y]
|
||||
}
|
||||
}
|
||||
|
||||
return source.type === 'output'
|
||||
? node.outputs?.[source.slotIndex]
|
||||
: node.inputs?.[source.slotIndex]
|
||||
return link.fromPos
|
||||
}
|
||||
|
||||
function getRenderLinkNodeId(link: RenderLink): number | null {
|
||||
const node = link.node
|
||||
if (typeof node === 'object' && node !== null && 'id' in node) {
|
||||
const maybeId = node.id
|
||||
if (typeof maybeId === 'number') return maybeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
|
||||
|
||||
export function toPoint(x: number, y: number): Point {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function isPointEqual(a: Point, b: Point): boolean {
|
||||
return a.x === b.x && a.y === b.y
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
interface PendingMoveData {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: EventTarget | null
|
||||
}
|
||||
|
||||
interface SlotLinkDragSession {
|
||||
compatCache: Map<string, boolean>
|
||||
nodePreferred: Map<
|
||||
number,
|
||||
{ index: number; key: string; layout: SlotLayout } | null
|
||||
>
|
||||
lastHoverSlotKey: string | null
|
||||
lastHoverNodeId: number | null
|
||||
lastCandidateKey: string | null
|
||||
pendingMove: PendingMoveData | null
|
||||
reset: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createSlotLinkDragSession(): SlotLinkDragSession {
|
||||
const state: SlotLinkDragSession = {
|
||||
compatCache: new Map(),
|
||||
nodePreferred: new Map(),
|
||||
lastHoverSlotKey: null,
|
||||
lastHoverNodeId: null,
|
||||
lastCandidateKey: null,
|
||||
pendingMove: null,
|
||||
reset: () => {
|
||||
state.compatCache = new Map()
|
||||
state.nodePreferred = new Map()
|
||||
state.lastHoverSlotKey = null
|
||||
state.lastHoverNodeId = null
|
||||
state.lastCandidateKey = null
|
||||
state.pendingMove = null
|
||||
},
|
||||
dispose: () => {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -17,19 +17,17 @@ import {
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
// RAF batching
|
||||
const pendingNodes = new Set<string>()
|
||||
let rafId: number | null = null
|
||||
const raf = createRafBatch(() => {
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
|
||||
function scheduleSlotLayoutSync(nodeId: string) {
|
||||
pendingNodes.add(nodeId)
|
||||
if (rafId == null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
}
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
function flushScheduledSlotLayoutSync() {
|
||||
|
||||
@@ -2,16 +2,34 @@ import { type Fn, useEventListener } from '@vueuse/core'
|
||||
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'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
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 { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
|
||||
import {
|
||||
type SlotDropCandidate,
|
||||
useSlotLinkDragState
|
||||
} from '@/renderer/core/canvas/links/slotLinkDragState'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
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 {
|
||||
nodeId: string
|
||||
@@ -76,14 +94,38 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
}
|
||||
|
||||
const { state, beginDrag, endDrag, updatePointerPosition } =
|
||||
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
|
||||
useSlotLinkDragState()
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
const pointerSession = createPointerSession()
|
||||
let activeAdapter: LinkConnectorAdapter | null = null
|
||||
|
||||
// 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 {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const key = target.dataset['slotKey']
|
||||
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
|
||||
const key = elWithKey?.dataset['slotKey']
|
||||
if (!key) return null
|
||||
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
@@ -91,23 +133,218 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||
|
||||
if (state.source) {
|
||||
candidate.compatible = evaluateCompatibility(
|
||||
state.source,
|
||||
candidate
|
||||
).allowable
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (graph && adapter) {
|
||||
const cached = dragSession.compatCache.get(key)
|
||||
if (cached != null) {
|
||||
candidate.compatible = cached
|
||||
} else {
|
||||
const compatible =
|
||||
layout.type === 'input'
|
||||
? adapter.isInputValidDrop(layout.nodeId, layout.index)
|
||||
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
|
||||
dragSession.compatCache.set(key, compatible)
|
||||
candidate.compatible = compatible
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
function candidateFromNodeTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
|
||||
const nodeIdStr = elWithNode?.dataset['nodeId']
|
||||
if (!nodeIdStr) return null
|
||||
|
||||
const pointerSession = createPointerSession()
|
||||
const adapter = ensureActiveAdapter()
|
||||
const graph = app.canvas?.graph
|
||||
if (!adapter || !graph) return null
|
||||
|
||||
const nodeId = Number(nodeIdStr)
|
||||
|
||||
// Cached preferred slot for this node within this drag
|
||||
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
|
||||
if (cachedPreferred !== undefined) {
|
||||
return cachedPreferred
|
||||
? { layout: cachedPreferred.layout, compatible: true }
|
||||
: null
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
|
||||
const firstLink = adapter.renderLinks[0]
|
||||
if (!firstLink) return null
|
||||
const connectingTo = adapter.linkConnector.state.connectingTo
|
||||
|
||||
if (connectingTo !== 'input' && connectingTo !== 'output') return null
|
||||
|
||||
const isInput = connectingTo === 'input'
|
||||
const slotType = firstLink.fromSlot.type
|
||||
|
||||
const res = isInput
|
||||
? node.findInputByType(slotType)
|
||||
: node.findOutputByType(slotType)
|
||||
|
||||
const index = res?.index
|
||||
if (index == null) return null
|
||||
|
||||
const key = getSlotKey(String(nodeId), index, isInput)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
|
||||
const compatible = isInput
|
||||
? adapter.isInputValidDrop(nodeId, index)
|
||||
: adapter.isOutputValidDrop(nodeId, index)
|
||||
|
||||
if (compatible) {
|
||||
dragSession.compatCache.set(key, true)
|
||||
const preferred = { index, key, layout }
|
||||
dragSession.nodePreferred.set(nodeId, preferred)
|
||||
return { layout, compatible: true }
|
||||
} else {
|
||||
dragSession.compatCache.set(key, false)
|
||||
dragSession.nodePreferred.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
|
||||
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
||||
return activeAdapter
|
||||
}
|
||||
|
||||
function hasCanConnectToReroute(
|
||||
link: RenderLink
|
||||
): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } {
|
||||
return 'canConnectToReroute' in link
|
||||
}
|
||||
|
||||
type ToInputLink = RenderLink & { toType: 'input' }
|
||||
type ToOutputLink = RenderLink & { toType: 'output' }
|
||||
const isToInputLink = (link: RenderLink): link is ToInputLink =>
|
||||
link.toType === 'input'
|
||||
const isToOutputLink = (link: RenderLink): link is ToOutputLink =>
|
||||
link.toType === 'output'
|
||||
|
||||
function connectLinksToInput(
|
||||
links: ReadonlyArray<RenderLink>,
|
||||
node: LGraphNode,
|
||||
inputSlot: INodeInputSlot
|
||||
): boolean {
|
||||
const validCandidates = links
|
||||
.filter(isToInputLink)
|
||||
.filter((link) => link.canConnectToInput(node, inputSlot))
|
||||
|
||||
for (const link of validCandidates) {
|
||||
link.connectToInput(node, inputSlot, activeAdapter?.linkConnector.events)
|
||||
}
|
||||
|
||||
return validCandidates.length > 0
|
||||
}
|
||||
|
||||
function connectLinksToOutput(
|
||||
links: ReadonlyArray<RenderLink>,
|
||||
node: LGraphNode,
|
||||
outputSlot: INodeOutputSlot
|
||||
): boolean {
|
||||
const validCandidates = links
|
||||
.filter(isToOutputLink)
|
||||
.filter((link) => link.canConnectToOutput(node, outputSlot))
|
||||
|
||||
for (const link of validCandidates) {
|
||||
link.connectToOutput(
|
||||
node,
|
||||
outputSlot,
|
||||
activeAdapter?.linkConnector.events
|
||||
)
|
||||
}
|
||||
|
||||
return validCandidates.length > 0
|
||||
}
|
||||
|
||||
const resolveLinkOrigin = (
|
||||
link: LLink | undefined
|
||||
): { position: Point; direction: LinkDirection } | null => {
|
||||
if (!link) return null
|
||||
|
||||
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
if (!layout) return null
|
||||
|
||||
return { position: { ...layout.position }, direction: LinkDirection.NONE }
|
||||
}
|
||||
|
||||
const resolveExistingInputLinkAnchor = (
|
||||
graph: LGraph,
|
||||
inputSlot: INodeInputSlot | undefined
|
||||
): { position: Point; direction: LinkDirection } | null => {
|
||||
if (!inputSlot) return null
|
||||
|
||||
const directLink = graph.getLink(inputSlot.link)
|
||||
if (directLink) {
|
||||
const reroutes = LLink.getReroutes(graph, directLink)
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(lastReroute.id)
|
||||
if (rerouteLayout) {
|
||||
return {
|
||||
position: { ...rerouteLayout.position },
|
||||
direction: LinkDirection.NONE
|
||||
}
|
||||
}
|
||||
|
||||
const pos = lastReroute.pos
|
||||
if (pos) {
|
||||
return {
|
||||
position: toPoint(pos[0], pos[1]),
|
||||
direction: LinkDirection.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directAnchor = resolveLinkOrigin(directLink)
|
||||
if (directAnchor) return directAnchor
|
||||
}
|
||||
|
||||
const floatingLinkIterator = inputSlot._floatingLinks?.values()
|
||||
const floatingLink = floatingLinkIterator
|
||||
? floatingLinkIterator.next().value
|
||||
: undefined
|
||||
if (!floatingLink) return null
|
||||
|
||||
if (floatingLink.parentId != null) {
|
||||
const rerouteLayout = layoutStore.getRerouteLayout(floatingLink.parentId)
|
||||
if (rerouteLayout) {
|
||||
return {
|
||||
position: { ...rerouteLayout.position },
|
||||
direction: LinkDirection.NONE
|
||||
}
|
||||
}
|
||||
|
||||
const reroute = graph.getReroute(floatingLink.parentId)
|
||||
if (reroute) {
|
||||
return {
|
||||
position: toPoint(reroute.pos[0], reroute.pos[1]),
|
||||
direction: LinkDirection.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const cleanupInteraction = () => {
|
||||
activeAdapter?.reset()
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
activeAdapter = null
|
||||
raf.cancel()
|
||||
dragSession.dispose()
|
||||
}
|
||||
|
||||
const updatePointerState = (event: PointerEvent) => {
|
||||
@@ -121,49 +358,211 @@ export function useSlotLinkInteraction({
|
||||
updatePointerPosition(clientX, clientY, canvasX, canvasY)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
updatePointerState(event)
|
||||
const processPointerMoveFrame = () => {
|
||||
const data = dragSession.pendingMove
|
||||
if (!data) return
|
||||
dragSession.pendingMove = null
|
||||
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
data.clientX,
|
||||
data.clientY
|
||||
])
|
||||
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
|
||||
|
||||
let hoveredSlotKey: string | null = null
|
||||
let hoveredNodeId: number | null = null
|
||||
const target = data.target
|
||||
if (target instanceof HTMLElement) {
|
||||
hoveredSlotKey =
|
||||
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
|
||||
null
|
||||
if (!hoveredSlotKey) {
|
||||
const nodeIdStr =
|
||||
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
|
||||
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
|
||||
}
|
||||
}
|
||||
|
||||
const hoverChanged =
|
||||
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
|
||||
hoveredNodeId !== dragSession.lastHoverNodeId
|
||||
|
||||
let candidate: SlotDropCandidate | null = state.candidate
|
||||
|
||||
if (hoverChanged) {
|
||||
const slotCandidate = candidateFromTarget(target)
|
||||
const nodeCandidate = slotCandidate
|
||||
? null
|
||||
: candidateFromNodeTarget(target)
|
||||
candidate = slotCandidate ?? nodeCandidate
|
||||
dragSession.lastHoverSlotKey = hoveredSlotKey
|
||||
dragSession.lastHoverNodeId = hoveredNodeId
|
||||
}
|
||||
|
||||
const newCandidate = candidate?.compatible ? candidate : null
|
||||
const newCandidateKey = newCandidate
|
||||
? getSlotKey(
|
||||
newCandidate.layout.nodeId,
|
||||
newCandidate.layout.index,
|
||||
newCandidate.layout.type === 'input'
|
||||
)
|
||||
: null
|
||||
|
||||
if (newCandidateKey !== dragSession.lastCandidateKey) {
|
||||
setCandidate(newCandidate)
|
||||
dragSession.lastCandidateKey = newCandidateKey
|
||||
}
|
||||
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
const raf = createRafBatch(processPointerMoveFrame)
|
||||
|
||||
const connectSlots = (slotLayout: SlotLayout) => {
|
||||
const canvas = app.canvas
|
||||
const graph = canvas?.graph
|
||||
const source = state.source
|
||||
if (!canvas || !graph || !source) return
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
dragSession.pendingMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
target: event.target
|
||||
}
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
const sourceNode = graph.getNodeById(Number(source.nodeId))
|
||||
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
|
||||
if (!sourceNode || !targetNode) return
|
||||
// Attempt to finalize by connecting to a DOM slot candidate
|
||||
const tryConnectToCandidate = (
|
||||
candidate: SlotDropCandidate | null
|
||||
): boolean => {
|
||||
if (!candidate?.compatible) return false
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (!graph || !adapter) return false
|
||||
|
||||
if (source.type === 'output' && slotLayout.type === 'input') {
|
||||
const outputSlot = sourceNode.outputs?.[source.slotIndex]
|
||||
const inputSlot = targetNode.inputs?.[slotLayout.index]
|
||||
if (!outputSlot || !inputSlot) return
|
||||
graph.beforeChange()
|
||||
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
|
||||
return
|
||||
const nodeId = Number(candidate.layout.nodeId)
|
||||
const targetNode = graph.getNodeById(nodeId)
|
||||
if (!targetNode) return false
|
||||
|
||||
if (candidate.layout.type === 'input') {
|
||||
const inputSlot = targetNode.inputs?.[candidate.layout.index]
|
||||
return (
|
||||
!!inputSlot &&
|
||||
connectLinksToInput(adapter.renderLinks, targetNode, inputSlot)
|
||||
)
|
||||
}
|
||||
|
||||
if (source.type === 'input' && slotLayout.type === 'output') {
|
||||
const inputSlot = sourceNode.inputs?.[source.slotIndex]
|
||||
const outputSlot = targetNode.outputs?.[slotLayout.index]
|
||||
if (!inputSlot || !outputSlot) return
|
||||
graph.beforeChange()
|
||||
sourceNode.disconnectInput(source.slotIndex, true)
|
||||
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
|
||||
if (candidate.layout.type === 'output') {
|
||||
const outputSlot = targetNode.outputs?.[candidate.layout.index]
|
||||
return (
|
||||
!!outputSlot &&
|
||||
connectLinksToOutput(adapter.renderLinks, targetNode, outputSlot)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Attempt to finalize by dropping on a reroute under the pointer
|
||||
const tryConnectViaRerouteAtPointer = (): boolean => {
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
||||
x: state.pointer.canvas.x,
|
||||
y: state.pointer.canvas.y
|
||||
})
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (!rerouteLayout || !graph || !adapter) return false
|
||||
|
||||
const reroute = graph.getReroute(rerouteLayout.id)
|
||||
if (!reroute || !adapter.isRerouteValidDrop(reroute.id)) return false
|
||||
|
||||
let didConnect = false
|
||||
|
||||
const results = reroute.findTargetInputs() ?? []
|
||||
const maybeReroutes = reroute.getReroutes()
|
||||
if (results.length && maybeReroutes !== null) {
|
||||
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
|
||||
for (const link of adapter.renderLinks) {
|
||||
if (!isToInputLink(link)) continue
|
||||
for (const result of results) {
|
||||
link.connectToRerouteInput(
|
||||
reroute,
|
||||
result,
|
||||
adapter.linkConnector.events,
|
||||
originalReroutes
|
||||
)
|
||||
didConnect = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceOutput = reroute.findSourceOutput()
|
||||
if (sourceOutput) {
|
||||
const { node, output } = sourceOutput
|
||||
for (const link of adapter.renderLinks) {
|
||||
if (!isToOutputLink(link)) continue
|
||||
if (hasCanConnectToReroute(link) && !link.canConnectToReroute(reroute))
|
||||
continue
|
||||
link.connectToRerouteOutput(
|
||||
reroute,
|
||||
node,
|
||||
output,
|
||||
adapter.linkConnector.events
|
||||
)
|
||||
didConnect = true
|
||||
}
|
||||
}
|
||||
|
||||
return didConnect
|
||||
}
|
||||
|
||||
const finishInteraction = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.preventDefault()
|
||||
|
||||
if (state.source) {
|
||||
const candidate = candidateFromTarget(event.target)
|
||||
if (candidate?.compatible) {
|
||||
connectSlots(candidate.layout)
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer using any snapped candidate captured during hover
|
||||
const snappedCandidate = state.candidate?.compatible
|
||||
? state.candidate
|
||||
: null
|
||||
|
||||
let connected = tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +576,8 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
|
||||
raf.flush()
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
@@ -190,19 +591,82 @@ export function useSlotLinkInteraction({
|
||||
const graph = canvas?.graph
|
||||
if (!canvas || !graph) return
|
||||
|
||||
ensureActiveAdapter()
|
||||
raf.cancel()
|
||||
dragSession.reset()
|
||||
|
||||
const layout = layoutStore.getSlotLayout(
|
||||
getSlotKey(nodeId, index, type === 'input')
|
||||
)
|
||||
if (!layout) return
|
||||
|
||||
const resolvedNode = graph.getNodeById(Number(nodeId))
|
||||
const slot =
|
||||
type === 'input'
|
||||
? resolvedNode?.inputs?.[index]
|
||||
: resolvedNode?.outputs?.[index]
|
||||
const numericNodeId = Number(nodeId)
|
||||
const isInputSlot = type === 'input'
|
||||
const isOutputSlot = type === 'output'
|
||||
|
||||
const direction =
|
||||
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
|
||||
const resolvedNode = graph.getNodeById(numericNodeId)
|
||||
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
|
||||
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
|
||||
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey
|
||||
|
||||
const inputLinkId = inputSlot?.link ?? null
|
||||
const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0
|
||||
const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0
|
||||
|
||||
const outputLinkCount = outputSlot?.links?.length ?? 0
|
||||
const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0
|
||||
const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0
|
||||
|
||||
const shouldBreakExistingInputLink =
|
||||
isInputSlot &&
|
||||
hasExistingInputLink &&
|
||||
ctrlOrMeta &&
|
||||
event.altKey &&
|
||||
!event.shiftKey
|
||||
|
||||
const existingInputLink =
|
||||
isInputSlot && inputLinkId != null
|
||||
? graph.getLink(inputLinkId)
|
||||
: undefined
|
||||
|
||||
if (shouldBreakExistingInputLink && resolvedNode) {
|
||||
resolvedNode.disconnectInput(index, true)
|
||||
}
|
||||
|
||||
const baseDirection = isInputSlot
|
||||
? inputSlot?.dir ?? LinkDirection.LEFT
|
||||
: outputSlot?.dir ?? LinkDirection.RIGHT
|
||||
|
||||
const existingAnchor =
|
||||
isInputSlot && !shouldBreakExistingInputLink
|
||||
? resolveExistingInputLinkAnchor(graph, inputSlot)
|
||||
: null
|
||||
|
||||
const shouldMoveExistingOutput =
|
||||
isOutputSlot && event.shiftKey && hasExistingOutputLink
|
||||
|
||||
const shouldMoveExistingInput =
|
||||
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
|
||||
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (adapter) {
|
||||
if (isOutputSlot) {
|
||||
adapter.beginFromOutput(numericNodeId, index, {
|
||||
moveExisting: shouldMoveExistingOutput
|
||||
})
|
||||
} else {
|
||||
adapter.beginFromInput(numericNodeId, index, {
|
||||
moveExisting: shouldMoveExistingInput
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const direction = existingAnchor?.direction ?? baseDirection
|
||||
const startPosition = existingAnchor?.position ?? {
|
||||
x: layout.position.x,
|
||||
y: layout.position.y
|
||||
}
|
||||
|
||||
beginDrag(
|
||||
{
|
||||
@@ -210,7 +674,11 @@ export function useSlotLinkInteraction({
|
||||
slotIndex: index,
|
||||
type,
|
||||
direction,
|
||||
position: layout.position
|
||||
position: startPosition,
|
||||
linkId: !shouldBreakExistingInputLink
|
||||
? existingInputLink?.id
|
||||
: undefined,
|
||||
movingExistingOutput: shouldMoveExistingOutput
|
||||
},
|
||||
event.pointerId
|
||||
)
|
||||
|
||||
@@ -903,6 +903,9 @@ export class ComfyApp {
|
||||
this.canvasContainer,
|
||||
this.canvas
|
||||
)
|
||||
|
||||
// Provide high-performance position converter to LGraphCanvas
|
||||
this.canvas.positionConverter = this.#positionConversion
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
29
src/utils/rafBatch.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function createRafBatch(run: () => void) {
|
||||
let rafId: number | null = null
|
||||
|
||||
const schedule = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
run()
|
||||
}
|
||||
|
||||
const isScheduled = () => rafId != null
|
||||
|
||||
return { schedule, cancel, flush, isScheduled }
|
||||
}
|
||||