diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index 7c0cd4c1d..ebc09cf2e 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -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 +) { + 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,507 @@ 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(() => {}) + } }) }) diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png new file mode 100644 index 000000000..685637384 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png new file mode 100644 index 000000000..0f660f23e Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png new file mode 100644 index 000000000..22f5c69f4 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png new file mode 100644 index 000000000..7eeef693d Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png new file mode 100644 index 000000000..64d140449 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 000000000..90df443ab Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png new file mode 100644 index 000000000..a5e4e35c3 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png new file mode 100644 index 000000000..80b96815e Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png new file mode 100644 index 000000000..3be98b50f Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png new file mode 100644 index 000000000..de003b4ed Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png new file mode 100644 index 000000000..05a22b8d2 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/src/lib/litegraph/src/canvas/MovingLinkBase.ts b/src/lib/litegraph/src/canvas/MovingLinkBase.ts index b06c238b8..825a26856 100644 --- a/src/lib/litegraph/src/canvas/MovingLinkBase.ts +++ b/src/lib/litegraph/src/canvas/MovingLinkBase.ts @@ -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, diff --git a/src/lib/litegraph/src/canvas/RenderLink.ts b/src/lib/litegraph/src/canvas/RenderLink.ts index 88a8d3abf..1c8f73bb7 100644 --- a/src/lib/litegraph/src/canvas/RenderLink.ts +++ b/src/lib/litegraph/src/canvas/RenderLink.ts @@ -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, diff --git a/src/renderer/core/canvas/links/linkConnectorAdapter.ts b/src/renderer/core/canvas/links/linkConnectorAdapter.ts new file mode 100644 index 000000000..7a0ec3c2f --- /dev/null +++ b/src/renderer/core/canvas/links/linkConnectorAdapter.ts @@ -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() + +/** + * 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 { + 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 +} diff --git a/src/renderer/core/canvas/links/slotLinkCompatibility.ts b/src/renderer/core/canvas/links/slotLinkCompatibility.ts deleted file mode 100644 index dd00e8c90..000000000 --- a/src/renderer/core/canvas/links/slotLinkCompatibility.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragState.ts index 5d2bbcfc4..33c47f0f5 100644 --- a/src/renderer/core/canvas/links/slotLinkDragState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragState.ts @@ -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 + linkId?: number + movingExistingOutput?: boolean } export interface SlotDropCandidate { diff --git a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts index b69cd9b7a..281c9b42b 100644 --- a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts +++ b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts @@ -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,73 @@ 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 renderLinks = createLinkConnectorAdapter()?.renderLinks + if (!renderLinks || renderLinks.length === 0) return + const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y] - - const startDir = source.direction ?? LinkDirection.RIGHT - const endDir = LinkDirection.CENTER - - const colour = resolveConnectingLinkColor(sourceSlot?.type) - 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 } diff --git a/src/renderer/core/layout/utils/geometry.ts b/src/renderer/core/layout/utils/geometry.ts index 176cb0c98..131223dfa 100644 --- a/src/renderer/core/layout/utils/geometry.ts +++ b/src/renderer/core/layout/utils/geometry.ts @@ -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 } diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index f82deab7d..57f6fa21f 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -2,15 +2,26 @@ 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 { 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 { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' -import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility' +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 { app } from '@/scripts/app' interface SlotInteractionOptions { @@ -92,10 +103,22 @@ export function useSlotLinkInteraction({ const candidate: SlotDropCandidate = { layout, compatible: false } if (state.source) { - candidate.compatible = evaluateCompatibility( - state.source, - candidate - ).allowable + const canvas = app.canvas + const graph = canvas?.graph + const adapter = ensureActiveAdapter() + if (graph && adapter) { + if (layout.type === 'input') { + candidate.compatible = adapter.isInputValidDrop( + layout.nodeId, + layout.index + ) + } else if (layout.type === 'output') { + candidate.compatible = adapter.isOutputValidDrop( + layout.nodeId, + layout.index + ) + } + } } return candidate @@ -104,10 +127,138 @@ export function useSlotLinkInteraction({ const conversion = useSharedCanvasPositionConversion() const pointerSession = createPointerSession() + let activeAdapter: LinkConnectorAdapter | null = 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, + 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, + 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 } const updatePointerState = (event: PointerEvent) => { @@ -127,44 +278,108 @@ export function useSlotLinkInteraction({ app.canvas?.setDirty(true) } - const connectSlots = (slotLayout: SlotLayout) => { - const canvas = app.canvas - const graph = canvas?.graph - const source = state.source - if (!canvas || !graph || !source) 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 - const sourceNode = graph.getNodeById(Number(source.nodeId)) - const targetNode = graph.getNodeById(Number(slotLayout.nodeId)) - if (!sourceNode || !targetNode) return + const nodeId = Number(candidate.layout.nodeId) + const targetNode = graph.getNodeById(nodeId) + if (!targetNode) 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 + 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) - } + if (!state.source) { + cleanupInteraction() + app.canvas?.setDirty(true) + return + } + + const candidate = candidateFromTarget(event.target) + let connected = tryConnectToCandidate(candidate) + if (!connected) connected = tryConnectViaRerouteAtPointer() || connected + + // Drop on canvas: disconnect moving input link(s) + if (!connected && !candidate && state.source.type === 'input') { + ensureActiveAdapter()?.disconnectMovingLinks() } cleanupInteraction() @@ -190,19 +405,80 @@ export function useSlotLinkInteraction({ const graph = canvas?.graph if (!canvas || !graph) return + ensureActiveAdapter() + 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 +486,11 @@ export function useSlotLinkInteraction({ slotIndex: index, type, direction, - position: layout.position + position: startPosition, + linkId: !shouldBreakExistingInputLink + ? existingInputLink?.id + : undefined, + movingExistingOutput: shouldMoveExistingOutput }, event.pointerId )