diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index a95f9cf19b..8989dc6329 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -788,4 +788,171 @@ test.describe('Vue Node Link Interaction', () => { targetSlot: 2 }) }) + + test.describe('Release actions (Shift-drop)', () => { + test('Context menu opens and endpoint is pinned on Shift-drop', async ({ + comfyPage, + comfyMouse + }) => { + await comfyPage.setSetting( + 'Comfy.LinkRelease.ActionShift', + 'context menu' + ) + + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(samplerNode).toBeTruthy() + + const outputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + + const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 } + + await comfyMouse.move(outputCenter) + await comfyPage.page.keyboard.down('Shift') + try { + await comfyMouse.drag(dropPos) + await comfyMouse.drop() + } finally { + await comfyPage.page.keyboard.up('Shift').catch(() => {}) + } + + // Context menu should be visible + const contextMenu = comfyPage.page.locator('.litecontextmenu') + await expect(contextMenu).toBeVisible() + + // Pinned endpoint should not change with mouse movement while menu is open + const before = await comfyPage.page.evaluate(() => { + const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos + return Array.isArray(snap) ? [snap[0], snap[1]] : null + }) + expect(before).not.toBeNull() + + // Move mouse elsewhere and verify snap position is unchanged + await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 }) + const after = await comfyPage.page.evaluate(() => { + const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos + return Array.isArray(snap) ? [snap[0], snap[1]] : null + }) + expect(after).toEqual(before) + }) + + test('Context menu -> Search pre-filters by link type and connects after selection', async ({ + comfyPage, + comfyMouse + }) => { + await comfyPage.setSetting( + 'Comfy.LinkRelease.ActionShift', + 'context menu' + ) + await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default') + + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(samplerNode).toBeTruthy() + + const outputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 } + + await comfyMouse.move(outputCenter) + await comfyPage.page.keyboard.down('Shift') + try { + await comfyMouse.drag(dropPos) + await comfyMouse.drop() + } finally { + await comfyPage.page.keyboard.up('Shift').catch(() => {}) + } + + // Open Search from the context menu + await comfyPage.clickContextMenuItem('Search') + + // Search box opens with prefilled type filter based on link type (LATENT) + await expect(comfyPage.searchBox.input).toBeVisible() + const chips = comfyPage.searchBox.filterChips + // Ensure at least one filter chip exists and it matches the link type + const chipCount = await chips.count() + expect(chipCount).toBeGreaterThan(0) + await expect(chips.first()).toContainText('LATENT') + + // Choose a compatible node and verify it auto-connects + await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode') + await comfyPage.nextFrame() + + // KSampler output should now have an outgoing link + const samplerOutput = await samplerNode.getOutput(0) + expect(await samplerOutput.getLinkCount()).toBe(1) + + // One of the VAEDecode nodes should have an incoming link on input[0] + const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode') + let linked = false + for (const vae of vaeNodes) { + const details = await getInputLinkDetails(comfyPage.page, vae.id, 0) + if (details) { + expect(details.originId).toBe(samplerNode.id) + linked = true + break + } + } + expect(linked).toBe(true) + }) + + test('Search box opens on Shift-drop and connects after selection', async ({ + comfyPage, + comfyMouse + }) => { + await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box') + + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(samplerNode).toBeTruthy() + + const outputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 } + + await comfyMouse.move(outputCenter) + await comfyPage.page.keyboard.down('Shift') + try { + await comfyMouse.drag(dropPos) + await comfyMouse.drop() + } finally { + await comfyPage.page.keyboard.up('Shift').catch(() => {}) + } + + // Search box should open directly + await expect(comfyPage.searchBox.input).toBeVisible() + await expect(comfyPage.searchBox.filterChips.first()).toContainText( + 'LATENT' + ) + + // Select a compatible node and verify connection + await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode') + await comfyPage.nextFrame() + + const samplerOutput = await samplerNode.getOutput(0) + expect(await samplerOutput.getLinkCount()).toBe(1) + + const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode') + let linked = false + for (const vae of vaeNodes) { + const details = await getInputLinkDetails(comfyPage.page, vae.id, 0) + if (details) { + expect(details.originId).toBe(samplerNode.id) + linked = true + break + } + } + expect(linked).toBe(true) + }) + }) }) diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png index eb3725e5c6..17176f65c9 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ 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 index ff85a019bb..8cfa8aa971 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png 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 index b69b3d3993..84a6ed8c07 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png 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 index 023d075adc..1ce6bc9a3b 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png 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 index d267102166..1651acb0b6 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png 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 index a0271f86b2..2fac48191f 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png 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/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png index 3f0d5f72a4..e1169b80a9 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png index f1dbcf18f9..968ade3b20 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png differ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 2d3b60ed35..d6c71c7343 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -113,7 +113,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave' import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' @@ -401,7 +400,6 @@ onMounted(async () => { // @ts-expect-error fixme ts strict error await comfyApp.setup(canvasRef.value) - attachSlotLinkPreviewRenderer(comfyApp.canvas) canvasStore.canvas = comfyApp.canvas canvasStore.canvas.render_canvas_border = false workspaceStore.spinner = false diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 66262ba59b..07d308e8fa 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -3318,7 +3318,15 @@ export class LGraphCanvas if (slot && linkConnector.isInputValidDrop(node, slot)) { highlightInput = slot - highlightPos = node.getInputSlotPos(slot) + if (LiteGraph.vueNodesMode) { + const idx = node.inputs.indexOf(slot) + highlightPos = + idx !== -1 + ? getSlotPosition(node, idx, true) + : node.getInputSlotPos(slot) + } else { + highlightPos = node.getInputSlotPos(slot) + } linkConnector.overWidget = overWidget } } @@ -3330,7 +3338,9 @@ export class LGraphCanvas const result = node.findInputByType(firstLink.fromSlot.type) if (result) { highlightInput = result.slot - highlightPos = node.getInputSlotPos(result.slot) + highlightPos = LiteGraph.vueNodesMode + ? getSlotPosition(node, result.index, true) + : node.getInputSlotPos(result.slot) } } else if ( inputId != -1 && @@ -3355,7 +3365,9 @@ export class LGraphCanvas if (inputId === -1 && outputId === -1) { const result = node.findOutputByType(firstLink.fromSlot.type) if (result) { - highlightPos = node.getOutputPos(result.index) + highlightPos = LiteGraph.vueNodesMode + ? getSlotPosition(node, result.index, false) + : node.getOutputPos(result.index) } } else { // check if I have a slot below de mouse @@ -5727,7 +5739,9 @@ export class LGraphCanvas if (!node) continue const startPos = firstReroute.pos - const endPos = node.getInputPos(link.target_slot) + const endPos: Point = LiteGraph.vueNodesMode + ? getSlotPosition(node, link.target_slot, true) + : node.getInputPos(link.target_slot) const endDirection = node.inputs[link.target_slot]?.dir firstReroute._dragging = true @@ -5746,7 +5760,9 @@ export class LGraphCanvas const node = graph.getNodeById(link.origin_id) if (!node) continue - const startPos = node.getOutputPos(link.origin_slot) + const startPos: Point = LiteGraph.vueNodesMode + ? getSlotPosition(node, link.origin_slot, false) + : node.getOutputPos(link.origin_slot) const endPos = reroute.pos const startDirection = node.outputs[link.origin_slot]?.dir diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 0bd20eab14..6490f94d81 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -3290,11 +3290,14 @@ export class LGraphNode * Gets the position of an output slot, in graph co-ordinates. * * This method is preferred over the legacy {@link getConnectionPos} method. - * @param slot Output slot index + * @param outputSlotIndex Output slot index * @returns Position of the output slot */ - getOutputPos(slot: number): Point { - return calculateOutputSlotPos(this.#getSlotPositionContext(), slot) + getOutputPos(outputSlotIndex: number): Point { + return calculateOutputSlotPos( + this.#getSlotPositionContext(), + outputSlotIndex + ) } /** @inheritdoc */ diff --git a/src/renderer/core/canvas/interaction/canvasPointerEvent.ts b/src/renderer/core/canvas/interaction/canvasPointerEvent.ts new file mode 100644 index 0000000000..76a71a0f7a --- /dev/null +++ b/src/renderer/core/canvas/interaction/canvasPointerEvent.ts @@ -0,0 +1,74 @@ +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import type { + CanvasPointerEvent, + CanvasPointerExtensions +} from '@/lib/litegraph/src/types/events' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' + +type PointerOffsets = { + x: number + y: number +} + +const pointerHistory = new Map() + +const defineEnhancements = ( + event: PointerEvent, + enhancement: CanvasPointerExtensions +) => { + Object.defineProperties(event, { + canvasX: { value: enhancement.canvasX, configurable: true, writable: true }, + canvasY: { value: enhancement.canvasY, configurable: true, writable: true }, + deltaX: { value: enhancement.deltaX, configurable: true, writable: true }, + deltaY: { value: enhancement.deltaY, configurable: true, writable: true }, + safeOffsetX: { + value: enhancement.safeOffsetX, + configurable: true, + writable: true + }, + safeOffsetY: { + value: enhancement.safeOffsetY, + configurable: true, + writable: true + } + }) +} + +const createEnhancement = (event: PointerEvent): CanvasPointerExtensions => { + const conversion = useSharedCanvasPositionConversion() + conversion.update() + + const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ + event.clientX, + event.clientY + ]) + + const canvas = useCanvasStore().getCanvas() + const { offset, scale } = canvas.ds + + const [originClientX, originClientY] = conversion.canvasPosToClientPos([0, 0]) + const left = originClientX - offset[0] * scale + const top = originClientY - offset[1] * scale + + const safeOffsetX = event.clientX - left + const safeOffsetY = event.clientY - top + + const previous = pointerHistory.get(event.pointerId) + const deltaX = previous ? safeOffsetX - previous.x : 0 + const deltaY = previous ? safeOffsetY - previous.y : 0 + pointerHistory.set(event.pointerId, { x: safeOffsetX, y: safeOffsetY }) + + return { canvasX, canvasY, deltaX, deltaY, safeOffsetX, safeOffsetY } +} + +export const toCanvasPointerEvent = ( + event: T +): T & CanvasPointerEvent => { + const enhancement = createEnhancement(event) + defineEnhancements(event, enhancement) + return event as T & CanvasPointerEvent +} + +export const clearCanvasPointerHistory = (pointerId: number) => { + pointerHistory.delete(pointerId) +} diff --git a/src/renderer/core/canvas/links/linkConnectorAdapter.ts b/src/renderer/core/canvas/links/linkConnectorAdapter.ts index 7a0ec3c2fe..0537bd91e8 100644 --- a/src/renderer/core/canvas/links/linkConnectorAdapter.ts +++ b/src/renderer/core/canvas/links/linkConnectorAdapter.ts @@ -1,9 +1,9 @@ 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 { 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 type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import { app } from '@/scripts/app' // Keep one adapter per graph so rendering and interaction share state. @@ -17,16 +17,11 @@ const adapterByGraph = new WeakMap() * - 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) - } + readonly network: LGraph, + readonly linkConnector: LinkConnector + ) {} /** * The currently rendered/dragged links, typed for consumer use. @@ -133,6 +128,11 @@ export class LinkConnectorAdapter { this.linkConnector.disconnectLinks() } + /** Drops moving links onto the canvas (no target). */ + dropOnCanvas(event: CanvasPointerEvent): void { + this.linkConnector.dropOnNothing(event) + } + /** Resets connector state and clears any temporary flags. */ reset(): void { this.linkConnector.reset() @@ -141,11 +141,12 @@ export class LinkConnectorAdapter { /** 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 + const graph = app.canvas?.graph + const connector = app.canvas?.linkConnector + if (!graph || !connector) return null let adapter = adapterByGraph.get(graph) - if (!adapter) { - adapter = new LinkConnectorAdapter(graph) + if (!adapter || adapter.linkConnector !== connector) { + adapter = new LinkConnectorAdapter(graph, connector) adapterByGraph.set(graph, adapter) } return adapter diff --git a/src/renderer/core/canvas/links/linkDropOrchestrator.ts b/src/renderer/core/canvas/links/linkDropOrchestrator.ts new file mode 100644 index 0000000000..23bd046a71 --- /dev/null +++ b/src/renderer/core/canvas/links/linkDropOrchestrator.ts @@ -0,0 +1,119 @@ +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' +import { + type SlotDropCandidate, + useSlotLinkDragUIState +} from '@/renderer/core/canvas/links/slotLinkDragUIState' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext' + +interface DropResolutionContext { + adapter: LinkConnectorAdapter | null + graph: LGraph | null + session: SlotLinkDragContext +} + +export const resolveSlotTargetCandidate = ( + target: EventTarget | null, + { adapter, graph }: DropResolutionContext +): SlotDropCandidate | null => { + const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState() + if (!(target instanceof HTMLElement)) return null + + const elWithKey = target.closest('[data-slot-key]') + const key = elWithKey?.dataset['slotKey'] + if (!key) return null + + const layout = layoutStore.getSlotLayout(key) + if (!layout) return null + + const candidate: SlotDropCandidate = { layout, compatible: false } + + if (adapter && graph) { + const cached = dragState.compatible.get(key) + if (cached != null) { + candidate.compatible = cached + } else { + const nodeId: NodeId = layout.nodeId + const compatible = + layout.type === 'input' + ? adapter.isInputValidDrop(nodeId, layout.index) + : adapter.isOutputValidDrop(nodeId, layout.index) + + setCompatibleForKey(key, compatible) + candidate.compatible = compatible + } + } + + return candidate +} + +export const resolveNodeSurfaceSlotCandidate = ( + target: EventTarget | null, + { adapter, graph, session }: DropResolutionContext +): SlotDropCandidate | null => { + const { setCompatibleForKey } = useSlotLinkDragUIState() + if (!(target instanceof HTMLElement)) return null + + const elWithNode = target.closest('[data-node-id]') + const nodeIdAttr = elWithNode?.dataset['nodeId'] + if (!nodeIdAttr) return null + + if (!adapter || !graph) return null + + const nodeId: NodeId = nodeIdAttr + + const cachedPreferredSlotForNode = session.preferredSlotForNode.get(nodeId) + if (cachedPreferredSlotForNode !== undefined) { + return cachedPreferredSlotForNode + ? { layout: cachedPreferredSlotForNode.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 result = isInput + ? node.findInputByType(slotType) + : node.findOutputByType(slotType) + + const index = result?.index + if (index == null) { + session.preferredSlotForNode.set(nodeId, null) + return null + } + + const key = getSlotKey(String(nodeId), index, isInput) + const layout = layoutStore.getSlotLayout(key) + if (!layout) { + session.preferredSlotForNode.set(nodeId, null) + return null + } + + const compatible = isInput + ? adapter.isInputValidDrop(nodeId, index) + : adapter.isOutputValidDrop(nodeId, index) + + setCompatibleForKey(key, compatible) + + if (!compatible) { + session.preferredSlotForNode.set(nodeId, null) + return null + } + + const preferred = { index, key, layout } + session.preferredSlotForNode.set(nodeId, preferred) + + return { layout, compatible: true } +} diff --git a/src/renderer/core/canvas/links/slotLinkDragState.ts b/src/renderer/core/canvas/links/slotLinkDragUIState.ts similarity index 71% rename from src/renderer/core/canvas/links/slotLinkDragState.ts rename to src/renderer/core/canvas/links/slotLinkDragUIState.ts index 33c47f0f50..838da1103a 100644 --- a/src/renderer/core/canvas/links/slotLinkDragState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragUIState.ts @@ -5,6 +5,14 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { Point, SlotLayout } from '@/renderer/core/layout/types' +/** + * Slot link drag UI state + * + * Reactive, shared state for a single drag interaction that UI components subscribe to. + * Tracks pointer position, source slot, and resolved drop candidate. Also exposes + * a compatibility map used to dim incompatible slots during drag. + */ + type SlotDragType = 'input' | 'output' interface SlotDragSource { @@ -33,6 +41,7 @@ interface SlotDragState { source: SlotDragSource | null pointer: PointerPosition candidate: SlotDropCandidate | null + compatible: Map } const state = reactive({ @@ -43,7 +52,8 @@ const state = reactive({ client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }, - candidate: null + candidate: null, + compatible: new Map() }) function updatePointerPosition( @@ -67,6 +77,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) { state.source = source state.pointerId = pointerId state.candidate = null + state.compatible.clear() } function endDrag() { @@ -78,6 +89,7 @@ function endDrag() { state.pointer.canvas.x = 0 state.pointer.canvas.y = 0 state.candidate = null + state.compatible.clear() } function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { @@ -85,13 +97,21 @@ function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { return layoutStore.getSlotLayout(slotKey) } -export function useSlotLinkDragState() { +export function useSlotLinkDragUIState() { return { state: readonly(state), beginDrag, endDrag, updatePointerPosition, setCandidate, - getSlotLayout + getSlotLayout, + setCompatibleMap: (entries: Iterable<[string, boolean]>) => { + state.compatible.clear() + for (const [key, value] of entries) state.compatible.set(key, value) + }, + setCompatibleForKey: (key: string, value: boolean) => { + state.compatible.set(key, value) + }, + clearCompatible: () => state.compatible.clear() } } diff --git a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts b/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts deleted file mode 100644 index cb4c43c5ec..0000000000 --- a/src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' -import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink' -import type { Point } from '@/lib/litegraph/src/interfaces' -import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' -import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors' -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 { - renderMode: canvas.links_render_mode, - connectionWidth: canvas.connections_width, - renderBorder: canvas.render_connections_border, - lowQuality: canvas.low_quality, - highQualityRender: canvas.highquality_render, - scale: canvas.ds.scale, - linkMarkerShape: canvas.linkMarkerShape, - renderConnectionArrows: canvas.render_connection_arrows, - highlightedLinks: new Set(Object.keys(canvas.highlighted_links)), - defaultLinkColor: canvas.default_link_color, - linkTypeColors: (canvas.constructor as typeof LGraphCanvas) - .link_type_colors, - disabledPattern: canvas._pattern - } -} - -export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) { - const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas) - const patched = ( - ctx: CanvasRenderingContext2D, - area: LGraphCanvas['visible_area'] - ) => { - 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 } = state - - const linkRenderer = canvas.linkRenderer - if (!linkRenderer) return - const context = buildContext(canvas) - - const renderLinks = createLinkConnectorAdapter()?.renderLinks - if (!renderLinks || renderLinks.length === 0) return - - const to: Readonly = 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) - - const fromPoint = resolveRenderLinkOrigin(link) - - linkRenderer.renderDraggingLink( - ctx, - fromPoint, - to, - colour, - startDir, - endDir, - context - ) - } - ctx.restore() - } - - canvas.onDrawForeground = patched -} - -function resolveRenderLinkOrigin(link: RenderLink): Readonly { - if (link.fromReroute) { - const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id) - if (rerouteLayout) { - return [rerouteLayout.position.x, rerouteLayout.position.y] - } - - const [x, y] = link.fromReroute.pos - return [x, y] - } - - 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 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/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 15d4128142..45d1122e82 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -578,6 +578,14 @@ class LayoutStoreImpl implements LayoutStore { return this.rerouteLayouts.get(rerouteId) || null } + /** + * Returns all slot layout keys currently tracked by the store. + * Useful for global passes without relying on spatial queries. + */ + getAllSlotKeys(): string[] { + return Array.from(this.slotLayouts.keys()) + } + /** * Update link segment layout data */ diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index ae2b761398..176b64396b 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -309,6 +309,9 @@ export interface LayoutStore { getSlotLayout(key: string): SlotLayout | null getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null + // Returns all slot layout keys currently tracked by the store + getAllSlotKeys(): string[] + // Direct mutation API (CRDT-ready) applyOperation(operation: LayoutOperation): void diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index c106f5e79f..b600569c12 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -36,6 +36,8 @@ import { import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot } from '@/lib/litegraph/src/litegraph' +import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' @@ -108,6 +110,15 @@ const slotColor = computed(() => { return getSlotColor(props.slotData.type) }) +const { state: dragState } = useSlotLinkDragUIState() +const slotKey = computed(() => + getSlotKey(props.nodeId ?? '', props.index, true) +) +const shouldDim = computed(() => { + if (!dragState.active) return false + return !dragState.compatible.get(slotKey.value) +}) + const slotWrapperClass = computed(() => cn( 'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6', @@ -117,7 +128,8 @@ const slotWrapperClass = computed(() => : 'pr-6 hover:bg-black/5 hover:dark:bg-white/5', { 'lg-slot--connected': props.connected, - 'lg-slot--compatible': props.compatible + 'lg-slot--compatible': props.compatible, + 'opacity-40': shouldDim.value } ) ) diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue index 4847e13bc0..dad2297823 100644 --- a/src/renderer/extensions/vueNodes/components/OutputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue @@ -33,6 +33,8 @@ import { import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot } from '@/lib/litegraph/src/litegraph' +import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' @@ -78,6 +80,15 @@ onErrorCaptured((error) => { // Get slot color based on type const slotColor = computed(() => getSlotColor(props.slotData.type)) +const { state: dragState } = useSlotLinkDragUIState() +const slotKey = computed(() => + getSlotKey(props.nodeId ?? '', props.index, false) +) +const shouldDim = computed(() => { + if (!dragState.active) return false + return !dragState.compatible.get(slotKey.value) +}) + const slotWrapperClass = computed(() => cn( 'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6', @@ -87,7 +98,8 @@ const slotWrapperClass = computed(() => : 'pl-6 hover:bg-black/5 hover:dark:bg-white/5', { 'lg-slot--connected': props.connected, - 'lg-slot--compatible': props.compatible + 'lg-slot--compatible': props.compatible, + 'opacity-40': shouldDim.value } ) ) diff --git a/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts b/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts new file mode 100644 index 0000000000..9f39865210 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts @@ -0,0 +1,59 @@ +import type { NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { SlotLayout } from '@/renderer/core/layout/types' + +/** + * Slot link drag context + * + * Non-reactive, per-drag ephemeral caches and RAF batching used during + * link drag interactions. Keeps high-churn data out of the reactive UI state. + */ + +interface PendingPointerMoveData { + clientX: number + clientY: number + target: EventTarget | null +} + +export interface SlotLinkDragContext { + preferredSlotForNode: Map< + NodeId, + { index: number; key: string; layout: SlotLayout } | null + > + lastHoverSlotKey: string | null + lastHoverNodeId: NodeId | null + lastCandidateKey: string | null + pendingPointerMove: PendingPointerMoveData | null + lastPointerEventTarget: EventTarget | null + lastPointerTargetSlotKey: string | null + lastPointerTargetNodeId: NodeId | null + reset: () => void + dispose: () => void +} + +export function createSlotLinkDragContext(): SlotLinkDragContext { + const state: SlotLinkDragContext = { + preferredSlotForNode: new Map(), + lastHoverSlotKey: null, + lastHoverNodeId: null, + lastCandidateKey: null, + pendingPointerMove: null, + lastPointerEventTarget: null, + lastPointerTargetSlotKey: null, + lastPointerTargetNodeId: null, + reset: () => { + state.preferredSlotForNode = new Map() + state.lastHoverSlotKey = null + state.lastHoverNodeId = null + state.lastCandidateKey = null + state.pendingPointerMove = null + state.lastPointerEventTarget = null + state.lastPointerTargetSlotKey = null + state.lastPointerTargetNodeId = null + }, + dispose: () => { + state.reset() + } + } + + return state +} diff --git a/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts b/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts deleted file mode 100644 index 6e12a03a5b..0000000000 --- a/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SlotLayout } from '@/renderer/core/layout/types' - -interface PendingMoveData { - clientX: number - clientY: number - target: EventTarget | null -} - -interface SlotLinkDragSession { - compatCache: Map - 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 -} diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 12f94f5c43..d6764fe661 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -4,6 +4,7 @@ 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 type { NodeId } 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' @@ -12,17 +13,25 @@ import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces' import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { + clearCanvasPointerHistory, + toCanvasPointerEvent +} from '@/renderer/core/canvas/interaction/canvasPointerEvent' import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' +import { + resolveNodeSurfaceSlotCandidate, + resolveSlotTargetCandidate +} from '@/renderer/core/canvas/links/linkDropOrchestrator' import { type SlotDropCandidate, - useSlotLinkDragState -} from '@/renderer/core/canvas/links/slotLinkDragState' + useSlotLinkDragUIState +} from '@/renderer/core/canvas/links/slotLinkDragUIState' import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' 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 { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext' import { app } from '@/scripts/app' import { createRafBatch } from '@/utils/rafBatch' @@ -81,111 +90,54 @@ export function useSlotLinkInteraction({ index, type }: SlotInteractionOptions): SlotInteractionHandlers { - const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } = - useSlotLinkDragState() + const { + state, + beginDrag, + endDrag, + updatePointerPosition, + setCandidate, + setCompatibleForKey, + clearCompatible + } = useSlotLinkDragUIState() const conversion = useSharedCanvasPositionConversion() const pointerSession = createPointerSession() let activeAdapter: LinkConnectorAdapter | null = null - // Per-drag drag-state cache - const dragSession = createSlotLinkDragSession() - - function candidateFromTarget( - target: EventTarget | null - ): SlotDropCandidate | null { - if (!(target instanceof HTMLElement)) return null - const elWithKey = target.closest('[data-slot-key]') - const key = elWithKey?.dataset['slotKey'] - if (!key) return null - - const layout = layoutStore.getSlotLayout(key) - if (!layout) return null - - const candidate: SlotDropCandidate = { layout, compatible: false } + // Per-drag drag-state context (non-reactive caches + RAF batching) + const dragContext = createSlotLinkDragContext() - 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 - } + const resolveRenderLinkSource = (link: RenderLink): Point | null => { + if (link.fromReroute) { + const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id) + if (rerouteLayout) return rerouteLayout.position + const [x, y] = link.fromReroute.pos + return toPoint(x, y) } - return candidate - } - - function candidateFromNodeTarget( - target: EventTarget | null - ): SlotDropCandidate | null { - if (!(target instanceof HTMLElement)) return null - const elWithNode = target.closest('[data-node-id]') - const nodeIdStr = elWithNode?.dataset['nodeId'] - if (!nodeIdStr) return null - - 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 nodeId = link.node.id + 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 } - 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 pos = link.fromPos + return toPoint(pos[0], pos[1]) } - const ensureActiveAdapter = (): LinkConnectorAdapter | null => { - if (!activeAdapter) activeAdapter = createLinkConnectorAdapter() - return activeAdapter + const syncRenderLinkOrigins = () => { + if (!activeAdapter) return + for (const link of activeAdapter.renderLinks) { + const origin = resolveRenderLinkSource(link) + if (!origin) continue + const x = origin.x + const y = origin.y + if (link.fromPos[0] !== x || link.fromPos[1] !== y) { + link.fromPos[0] = x + link.fromPos[1] = y + } + } } function hasCanConnectToReroute( @@ -309,12 +261,16 @@ export function useSlotLinkInteraction({ } const cleanupInteraction = () => { + if (state.pointerId != null) { + clearCanvasPointerHistory(state.pointerId) + } activeAdapter?.reset() pointerSession.clear() endDrag() activeAdapter = null raf.cancel() - dragSession.dispose() + dragContext.dispose() + clearCompatible() } const updatePointerState = (event: PointerEvent) => { @@ -329,9 +285,9 @@ export function useSlotLinkInteraction({ } const processPointerMoveFrame = () => { - const data = dragSession.pendingMove + const data = dragContext.pendingPointerMove if (!data) return - dragSession.pendingMove = null + dragContext.pendingPointerMove = null const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ data.clientX, @@ -339,34 +295,61 @@ export function useSlotLinkInteraction({ ]) updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY) + syncRenderLinkOrigins() + let hoveredSlotKey: string | null = null - let hoveredNodeId: number | null = null + let hoveredNodeId: NodeId | null = null const target = data.target - if (target instanceof HTMLElement) { - hoveredSlotKey = - target.closest('[data-slot-key]')?.dataset['slotKey'] ?? - null - if (!hoveredSlotKey) { - const nodeIdStr = - target.closest('[data-node-id]')?.dataset['nodeId'] - hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null - } + if (target === dragContext.lastPointerEventTarget) { + hoveredSlotKey = dragContext.lastPointerTargetSlotKey + hoveredNodeId = dragContext.lastPointerTargetNodeId + } else if (target instanceof HTMLElement) { + const elWithSlot = target.closest('[data-slot-key]') + const elWithNode = elWithSlot + ? null + : target.closest('[data-node-id]') + hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null + hoveredNodeId = hoveredSlotKey + ? null + : elWithNode?.dataset['nodeId'] ?? null + dragContext.lastPointerEventTarget = target + dragContext.lastPointerTargetSlotKey = hoveredSlotKey + dragContext.lastPointerTargetNodeId = hoveredNodeId } const hoverChanged = - hoveredSlotKey !== dragSession.lastHoverSlotKey || - hoveredNodeId !== dragSession.lastHoverNodeId + hoveredSlotKey !== dragContext.lastHoverSlotKey || + hoveredNodeId !== dragContext.lastHoverNodeId let candidate: SlotDropCandidate | null = state.candidate if (hoverChanged) { - const slotCandidate = candidateFromTarget(target) + const adapter = activeAdapter + const graph = app.canvas?.graph ?? null + const context = { adapter, graph, session: dragContext } + const slotCandidate = resolveSlotTargetCandidate(target, context) const nodeCandidate = slotCandidate ? null - : candidateFromNodeTarget(target) + : resolveNodeSurfaceSlotCandidate(target, context) candidate = slotCandidate ?? nodeCandidate - dragSession.lastHoverSlotKey = hoveredSlotKey - dragSession.lastHoverNodeId = hoveredNodeId + dragContext.lastHoverSlotKey = hoveredSlotKey + dragContext.lastHoverNodeId = hoveredNodeId + + if (slotCandidate) { + const key = getSlotKey( + slotCandidate.layout.nodeId, + slotCandidate.layout.index, + slotCandidate.layout.type === 'input' + ) + setCompatibleForKey(key, !!slotCandidate.compatible) + } else if (nodeCandidate) { + const key = getSlotKey( + nodeCandidate.layout.nodeId, + nodeCandidate.layout.index, + nodeCandidate.layout.type === 'input' + ) + setCompatibleForKey(key, !!nodeCandidate.compatible) + } } const newCandidate = candidate?.compatible ? candidate : null @@ -378,18 +361,36 @@ export function useSlotLinkInteraction({ ) : null - if (newCandidateKey !== dragSession.lastCandidateKey) { + const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey + if (candidateChanged) { setCandidate(newCandidate) - dragSession.lastCandidateKey = newCandidateKey + dragContext.lastCandidateKey = newCandidateKey + } + + let snapPosChanged = false + if (activeAdapter) { + const snapX = newCandidate + ? newCandidate.layout.position.x + : state.pointer.canvas.x + const snapY = newCandidate + ? newCandidate.layout.position.y + : state.pointer.canvas.y + const currentSnap = activeAdapter.linkConnector.state.snapLinksPos + snapPosChanged = + !currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY + if (snapPosChanged) { + activeAdapter.linkConnector.state.snapLinksPos = [snapX, snapY] + } } - app.canvas?.setDirty(true) + const shouldRedraw = candidateChanged || snapPosChanged + if (shouldRedraw) app.canvas?.setDirty(true, true) } const raf = createRafBatch(processPointerMoveFrame) const handlePointerMove = (event: PointerEvent) => { if (!pointerSession.matches(event)) return - dragSession.pendingMove = { + dragContext.pendingPointerMove = { clientX: event.clientX, clientY: event.clientY, target: event.target @@ -403,10 +404,10 @@ export function useSlotLinkInteraction({ ): boolean => { if (!candidate?.compatible) return false const graph = app.canvas?.graph - const adapter = ensureActiveAdapter() + const adapter = activeAdapter if (!graph || !adapter) return false - const nodeId = Number(candidate.layout.nodeId) + const nodeId: NodeId = candidate.layout.nodeId const targetNode = graph.getNodeById(nodeId) if (!targetNode) return false @@ -436,7 +437,7 @@ export function useSlotLinkInteraction({ y: state.pointer.canvas.y }) const graph = app.canvas?.graph - const adapter = ensureActiveAdapter() + const adapter = activeAdapter if (!rerouteLayout || !graph || !adapter) return false const reroute = graph.getReroute(rerouteLayout.id) @@ -484,43 +485,31 @@ export function useSlotLinkInteraction({ const finishInteraction = (event: PointerEvent) => { if (!pointerSession.matches(event)) return - event.preventDefault() + const canvasEvent = toCanvasPointerEvent(event) + canvasEvent.preventDefault() + + raf.flush() raf.flush() if (!state.source) { cleanupInteraction() - app.canvas?.setDirty(true) + app.canvas?.setDirty(true, true) return } - // Prefer using the snapped candidate captured during hover for perf + consistency const snappedCandidate = state.candidate?.compatible ? state.candidate : null - let connected = tryConnectToCandidate(snappedCandidate) - - // Fallback to DOM slot under pointer (if any), then node fallback, then reroute - if (!connected) { - const domCandidate = candidateFromTarget(event.target) - connected = tryConnectToCandidate(domCandidate) - } - - if (!connected) { - const nodeCandidate = candidateFromNodeTarget(event.target) - connected = tryConnectToCandidate(nodeCandidate) - } - - if (!connected) connected = tryConnectViaRerouteAtPointer() || connected + const hasConnected = connectByPriority(canvasEvent.target, snappedCandidate) - // Drop on canvas: disconnect moving input link(s) - if (!connected && !snappedCandidate && state.source.type === 'input') { - ensureActiveAdapter()?.disconnectMovingLinks() + if (!hasConnected) { + activeAdapter?.dropOnCanvas(canvasEvent) } cleanupInteraction() - app.canvas?.setDirty(true) + app.canvas?.setDirty(true, true) } const handlePointerUp = (event: PointerEvent) => { @@ -531,8 +520,37 @@ export function useSlotLinkInteraction({ if (!pointerSession.matches(event)) return raf.flush() + toCanvasPointerEvent(event) cleanupInteraction() - app.canvas?.setDirty(true) + app.canvas?.setDirty(true, true) + } + + function connectByPriority( + target: EventTarget | null, + snappedCandidate: SlotDropCandidate | null + ): boolean { + const adapter = activeAdapter + const graph = app.canvas?.graph ?? null + const context = { adapter, graph, session: dragContext } + + const attemptSnapped = () => tryConnectToCandidate(snappedCandidate) + + const domSlotCandidate = resolveSlotTargetCandidate(target, context) + const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate) + + const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate( + target, + context + ) + const attemptNodeSurface = () => + tryConnectToCandidate(nodeSurfaceSlotCandidate) + const attemptReroute = () => tryConnectViaRerouteAtPointer() + + if (attemptSnapped()) return true + if (attemptDomSlot()) return true + if (attemptNodeSurface()) return true + if (attemptReroute()) return true + return false } const onPointerDown = (event: PointerEvent) => { @@ -544,20 +562,21 @@ export function useSlotLinkInteraction({ const graph = canvas?.graph if (!canvas || !graph) return - ensureActiveAdapter() + activeAdapter = createLinkConnectorAdapter() + if (!activeAdapter) return raf.cancel() - dragSession.reset() + dragContext.reset() const layout = layoutStore.getSlotLayout( getSlotKey(nodeId, index, type === 'input') ) if (!layout) return - const numericNodeId = Number(nodeId) + const localNodeId: NodeId = nodeId const isInputSlot = type === 'input' const isOutputSlot = type === 'output' - const resolvedNode = graph.getNodeById(numericNodeId) + const resolvedNode = graph.getNodeById(localNodeId) const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined @@ -602,19 +621,24 @@ export function useSlotLinkInteraction({ const shouldMoveExistingInput = isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink - const adapter = ensureActiveAdapter() - if (adapter) { + if (activeAdapter) { if (isOutputSlot) { - adapter.beginFromOutput(numericNodeId, index, { + activeAdapter.beginFromOutput(localNodeId, index, { moveExisting: shouldMoveExistingOutput }) } else { - adapter.beginFromInput(numericNodeId, index, { + activeAdapter.beginFromInput(localNodeId, index, { moveExisting: shouldMoveExistingInput }) } + + if (shouldMoveExistingInput && existingInputLink) { + existingInputLink._dragging = true + } } + syncRenderLinkOrigins() + const direction = existingAnchor?.direction ?? baseDirection const startPosition = existingAnchor?.position ?? { x: layout.position.x, @@ -638,8 +662,16 @@ export function useSlotLinkInteraction({ pointerSession.begin(event.pointerId) + toCanvasPointerEvent(event) updatePointerState(event) + if (activeAdapter) { + activeAdapter.linkConnector.state.snapLinksPos = [ + state.pointer.canvas.x, + state.pointer.canvas.y + ] + } + pointerSession.register( useEventListener(window, 'pointermove', handlePointerMove, { capture: true @@ -651,7 +683,21 @@ export function useSlotLinkInteraction({ capture: true }) ) - app.canvas?.setDirty(true) + const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input' + const allKeys = layoutStore.getAllSlotKeys() + clearCompatible() + for (const key of allKeys) { + const slotLayout = layoutStore.getSlotLayout(key) + if (!slotLayout) continue + if (slotLayout.type !== targetType) continue + const idx = slotLayout.index + const ok = + targetType === 'input' + ? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx) + : activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx) + setCompatibleForKey(key, ok) + } + app.canvas?.setDirty(true, true) event.preventDefault() event.stopPropagation() }