const { app } = window.comfyAPI.app; import { getSlotPos, clientToCanvas, getNodeAtPoint, typesCompatible, chainCallback } from "./utility.js"; // Max age of the last keydown for it to count as the command's trigger. // Beyond this, treat the command as menu-fired (no release tracking). const KEYDOWN_MAX_AGE_MS = 100; const state = { pointerDown: false, hasMoved: false, insertKeyDown: false, activationKey: null, draggedNode: null, startPos: null, insertTargetLink: null, insertSlots: null, insertOriginNode: null, insertDestNode: null, lastScanTime: 0, animating: false, }; let lastKeyDown = null; let lastKeyDownTime = 0; function bezierAt(p0, p1, p2, p3, t) { const u = 1 - t; const uu = u * u, uuu = uu * u; const tt = t * t, ttt = tt * t; return [ uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0], uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1], ]; } // Horizontal control-point offset for the link bezier; must match what // LiteGraph draws so the hit-test region matches the visible curve. const bezierOffsetX = (from, to) => Math.max(Math.abs(to[0] - from[0]) * 0.5, 50); function findLinkUnderNode(graph, draggedNode) { const bounds = draggedNode.getBounding?.(); const nodeX = bounds ? bounds[0] : draggedNode.pos[0]; const nodeY = bounds ? bounds[1] : draggedNode.pos[1]; const nodeW = bounds ? bounds[2] : (draggedNode.size[0] || 100); const nodeH = bounds ? bounds[3] : (draggedNode.size[1] || 60); const nodeCx = nodeX + nodeW / 2; const nodeCy = nodeY + nodeH / 2; let bestLink = null; let bestDist = Infinity; const links = graph.links; if (!links) return null; for (const link of links.values()) { if (!link) continue; if (link.origin_id === draggedNode.id || link.target_id === draggedNode.id) continue; const originNode = graph.getNodeById(link.origin_id); const targetNode = graph.getNodeById(link.target_id); if (!originNode || !targetNode) continue; // Reject on node bounds before resolving slot positions — the latter // hits the DOM in Vue mode and is the expensive step. const oW = originNode.size?.[0] || 100, oH = originNode.size?.[1] || 60; const tW = targetNode.size?.[0] || 100, tH = targetNode.size?.[1] || 60; const cMinX = Math.min(originNode.pos[0], targetNode.pos[0]); const cMaxX = Math.max(originNode.pos[0] + oW, targetNode.pos[0] + tW); const cMinY = Math.min(originNode.pos[1], targetNode.pos[1]) - 50; const cMaxY = Math.max(originNode.pos[1] + oH, targetNode.pos[1] + tH) + 50; if (cMaxX < nodeX || cMinX > nodeX + nodeW || cMaxY < nodeY || cMinY > nodeY + nodeH) continue; const outPos = getSlotPos(originNode, false, link.origin_slot); const inPos = getSlotPos(targetNode, true, link.target_slot); const lMinX = Math.min(outPos[0], inPos[0]); const lMaxX = Math.max(outPos[0], inPos[0]); const lMinY = Math.min(outPos[1], inPos[1]) - 50; // bezier can sag const lMaxY = Math.max(outPos[1], inPos[1]) + 50; if (lMaxX < nodeX || lMinX > nodeX + nodeW || lMaxY < nodeY || lMinY > nodeY + nodeH) continue; const offsetX = bezierOffsetX(outPos, inPos); const p0 = outPos; const p1 = [outPos[0] + offsetX, outPos[1]]; const p2 = [inPos[0] - offsetX, inPos[1]]; const p3 = inPos; for (let i = 0; i <= 20; i++) { const pt = bezierAt(p0, p1, p2, p3, i / 20); if (pt[0] >= nodeX && pt[0] <= nodeX + nodeW && pt[1] >= nodeY && pt[1] <= nodeY + nodeH) { const d = Math.hypot(pt[0] - nodeCx, pt[1] - nodeCy); if (d < bestDist) { bestDist = d; bestLink = link; } break; } } } return bestLink; } function findInsertSlots(node, linkType) { const inputSlot = node.inputs?.findIndex(i => i.link == null && typesCompatible(linkType, i.type)) ?? -1; const outputSlot = node.outputs?.findIndex(o => typesCompatible(linkType, o.type)) ?? -1; if (inputSlot === -1 || outputSlot === -1) return null; return { inputSlot, outputSlot }; } function executeNodeInsert(canvas, node, link) { const graph = canvas.graph || app.graph; const originNode = graph.getNodeById(link.origin_id); const targetNode = graph.getNodeById(link.target_id); if (!originNode || !targetNode) return; const slots = findInsertSlots(node, link.type); if (!slots) return; const originSlot = link.origin_slot; const targetSlot = link.target_slot; targetNode.disconnectInput(targetSlot); originNode.connect(originSlot, node, slots.inputSlot); node.connect(slots.outputSlot, targetNode, targetSlot); graph.change(); canvas.setDirty(true, true); } function setInsertTarget(link, graph, slots = null) { if (link === state.insertTargetLink) return; // `_dragging` is LiteGraph's own runtime flag for hiding a link mid-rewire. // Not serialized, so save/copy/undo stay safe. if (state.insertTargetLink) delete state.insertTargetLink._dragging; if (link) { link._dragging = true; state.insertSlots = slots ?? findInsertSlots(state.draggedNode, link.type); state.insertOriginNode = graph.getNodeById(link.origin_id); state.insertDestNode = graph.getNodeById(link.target_id); } else { state.insertSlots = null; state.insertOriginNode = null; state.insertDestNode = null; } state.insertTargetLink = link; } function getTypeColor(lgCanvas, slotType) { if (slotType && LGraphCanvas.link_type_colors && LGraphCanvas.link_type_colors[slotType]) { return LGraphCanvas.link_type_colors[slotType]; } return lgCanvas.default_link_color || "#AAA"; } function drawGhostLink(ctx, from, to, color, alpha, drawTime) { const offsetX = bezierOffsetX(from, to); ctx.beginPath(); ctx.moveTo(from[0], from[1]); ctx.bezierCurveTo( from[0] + offsetX, from[1], to[0] - offsetX, to[1], to[0], to[1], ); ctx.strokeStyle = color; ctx.globalAlpha = alpha; ctx.lineWidth = 2.5; ctx.setLineDash([8, 4]); ctx.lineDashOffset = -(drawTime ?? performance.now()) / 50; ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; } function drawSlotHighlight(ctx, pos, color, alpha) { ctx.beginPath(); ctx.arc(pos[0], pos[1], 6, 0, Math.PI * 2); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fill(); ctx.globalAlpha = 1; } function drawGhostSegment(ctx, from, to, color, now) { drawGhostLink(ctx, from, to, color, 0.8, now); drawSlotHighlight(ctx, from, color, 0.6); drawSlotHighlight(ctx, to, color, 0.6); } function drawInsertPreview(ctx, lgCanvas) { const { insertTargetLink: link, insertSlots: slots, insertOriginNode, insertDestNode, draggedNode } = state; if (!link || !slots || !insertOriginNode || !insertDestNode || !draggedNode) return; const now = performance.now(); const color = getTypeColor(lgCanvas, link.type); drawGhostSegment(ctx, getSlotPos(insertOriginNode, false, link.origin_slot), getSlotPos(draggedNode, true, slots.inputSlot), color, now); drawGhostSegment(ctx, getSlotPos(draggedNode, false, slots.outputSlot), getSlotPos(insertDestNode, true, link.target_slot), color, now); } function startAnimLoop(lgCanvas) { if (state.animating) return; state.animating = true; function tick() { const link = state.insertTargetLink; if (!link) { state.animating = false; lgCanvas.setDirty(true, true); return; } // If the link was removed externally (e.g. undo) mid-preview, our // `_dragging` flag has nothing to land on — drop the preview. const graph = lgCanvas.graph || app.graph; const stored = graph?.links?.get(link.id); if (stored !== link) { setInsertTarget(null); state.animating = false; lgCanvas.setDirty(true, true); return; } // Steady-state: only the dash offset changes — front canvas only. lgCanvas.setDirty(true, false); requestAnimationFrame(tick); } requestAnimationFrame(tick); } function clearState() { setInsertTarget(null); state.pointerDown = false; state.hasMoved = false; state.draggedNode = null; state.startPos = null; } app.registerExtension({ name: "KJNodes.NodeInsert", settings: [ { id: "KJNodes.nodeInsertMode", name: "Node insert activation", category: ["KJNodes", "Node Insert", "Activation mode"], tooltip: "Always: dragging a compatible node onto a link previews insertion. Hotkey: only while the hotkey (default: D) is held. Disabled: feature off.", type: "combo", defaultValue: "hotkey", options: ["always", "hotkey", "disabled"], }, ], commands: [ { id: "KJNodes.NodeInsertMode", label: "Node insert mode (hold to activate)", active: () => state.insertKeyDown, function: () => { state.insertKeyDown = true; // Snapshot the key that triggered this so any rebind works. state.activationKey = (performance.now() - lastKeyDownTime < KEYDOWN_MAX_AGE_MS) ? lastKeyDown : null; if (state.draggedNode) { const graph = app.canvas?.graph || app.graph; if (graph) { const link = findLinkUnderNode(graph, state.draggedNode); if (link && findInsertSlots(state.draggedNode, link.type)) { setInsertTarget(link, graph); startAnimLoop(app.canvas); } } } }, }, ], keybindings: [ { commandId: "KJNodes.NodeInsertMode", // Default only — release detection captures the actually-triggering // key at command-fire time, so user rebinds work. combo: { key: "d" }, targetElementId: "graph-canvas", }, ], async setup() { const lgCanvas = app.canvas; const canvasEl = lgCanvas.canvas; // Capture phase so `lastKeyDown` is set before ComfyUI fires the command. document.addEventListener("keydown", (e) => { if (e.repeat) return; lastKeyDown = e.key?.toLowerCase() ?? null; lastKeyDownTime = performance.now(); }, true); document.addEventListener("keyup", (e) => { const key = e.key?.toLowerCase() ?? null; // Released key can't be a future trigger — invalidate so a stale // keydown can't be picked up by a later menu activation. if (key && key === lastKeyDown) lastKeyDownTime = 0; if (!state.insertKeyDown) return; // No activation key (menu-fired) → blur/pointercancel will clean up. if (!state.activationKey || key !== state.activationKey) return; state.insertKeyDown = false; state.activationKey = null; if (state.insertTargetLink) { setInsertTarget(null); lgCanvas.setDirty(false, true); } }); // Releases outside the window never reach our keyup/pointerup listeners, // so without these the `_dragging` flag and hotkey state can stick. const dropTransient = () => { state.insertKeyDown = false; state.activationKey = null; clearState(); lgCanvas.setDirty(false, true); }; window.addEventListener("blur", dropTransient); document.addEventListener("visibilitychange", () => { if (document.hidden) dropTransient(); }); document.addEventListener("pointercancel", dropTransient, true); // Scope the Vue-mode node lookup to the graph canvas's container so // stray `[data-node-id]` elements elsewhere can't activate this flow. const graphRoot = canvasEl.parentElement; document.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; const onCanvas = e.target === canvasEl; const vueNodeEl = graphRoot?.contains(e.target) ? e.target?.closest?.("[data-node-id]") : null; if (!onCanvas && !vueNodeEl) return; state.pointerDown = true; state.hasMoved = false; const graph = lgCanvas.graph || app.graph; if (graph) { let node = null; if (vueNodeEl) { const nodeId = parseInt(vueNodeEl.getAttribute("data-node-id")); if (Number.isFinite(nodeId)) node = graph.getNodeById(nodeId); } else { const [cx, cy] = clientToCanvas(lgCanvas, e.clientX, e.clientY); node = getNodeAtPoint(graph, cx, cy); } if (node && (node.inputs || node.outputs)) { state.draggedNode = node; state.startPos = node.pos ? [node.pos[0], node.pos[1]] : null; } } }, true); document.addEventListener("pointermove", () => { if (!state.pointerDown) return; if (lgCanvas.connecting_links?.length) return; if (lgCanvas.resizing_node) return; if (lgCanvas.node_widget) return; const mode = app.ui.settings.getSettingValue("KJNodes.nodeInsertMode") ?? "always"; if (mode === "disabled") return; state.hasMoved = true; const active = mode === "always" || state.insertKeyDown; if (!active) { setInsertTarget(null); return; } if (!state.draggedNode) return; // Multi-node drags move several nodes together — inserting one of // them into a link is almost never what the user wants. const selected = lgCanvas.selected_nodes; if (selected) { const count = selected instanceof Map ? selected.size : Object.keys(selected).length; if (count > 1) { setInsertTarget(null); return; } } // Vue mode: pointermove fires on the document during widget drags // (sliders etc.) too — require the node itself to have moved. const np = state.draggedNode.pos; if (!np || !state.startPos || (np[0] === state.startPos[0] && np[1] === state.startPos[1])) { setInsertTarget(null); return; } const graph = lgCanvas.graph || app.graph; if (!graph) return; const now = performance.now(); const nodeCount = graph._nodes?.length ?? 0; const throttle = nodeCount > 200 ? 50 : nodeCount > 100 ? 32 : 16; if (now - state.lastScanTime < throttle) return; state.lastScanTime = now; const link = findLinkUnderNode(graph, state.draggedNode); const slots = link ? findInsertSlots(state.draggedNode, link.type) : null; const wasIdle = !state.insertTargetLink; if (link && slots) { setInsertTarget(link, graph, slots); if (wasIdle) startAnimLoop(lgCanvas); } else { setInsertTarget(null); } }, true); document.addEventListener("pointerup", (e) => { if (e.button !== 0) return; if (!state.pointerDown) return; if (state.insertTargetLink && state.draggedNode && state.hasMoved && !state.draggedNode.flags?.pinned) { delete state.insertTargetLink._dragging; executeNodeInsert(lgCanvas, state.draggedNode, state.insertTargetLink); } clearState(); }, true); // onDrawForeground: ghost paints over the existing (hidden) link. chainCallback(lgCanvas, "onDrawForeground", function (ctx) { if (state.insertTargetLink) { drawInsertPreview(ctx, lgCanvas); } }); }, });