diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 5b7d6365a..b6375b664 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -36,6 +36,8 @@ import type { import type { ClipboardItems } from "./types/serialisation" import type { IWidget } from "./types/widgets" +import { LinkConnector } from "@/canvas/LinkConnector" + import { isOverNodeInput, isOverNodeOutput } from "./canvas/measureSlots" import { CanvasPointer } from "./CanvasPointer" import { DragAndScale } from "./DragAndScale" @@ -43,7 +45,7 @@ import { strokeShape } from "./draw" import { NullGraphError } from "./infrastructure/NullGraphError" import { LGraphGroup } from "./LGraphGroup" import { LGraphNode, type NodeId, type NodeProperty } from "./LGraphNode" -import { LinkReleaseContextExtended, LiteGraph } from "./litegraph" +import { LiteGraph } from "./litegraph" import { type LinkId, LLink } from "./LLink" import { containsRect, @@ -70,7 +72,7 @@ import { TitleMode, } from "./types/globalEnums" import { alignNodes, distributeNodes, getBoundaryNodes } from "./utils/arrange" -import { findFirstNode, getAllNestedItems, isDraggingLink } from "./utils/collections" +import { findFirstNode, getAllNestedItems } from "./utils/collections" import { toClass } from "./utils/type" import { WIDGET_TYPE_MAP } from "./widgets/widgetMap" @@ -448,7 +450,9 @@ export class LGraphCanvas implements ConnectionColorContext { /** Contains all links and reroutes that were rendered. Repopulated every render cycle. */ renderedPaths: Set = new Set() visible_links: LLink[] + /** @deprecated This array is populated and cleared to support legacy extensions. The contents are ignored by Litegraph. */ connecting_links: ConnectingLink[] | null + linkConnector = new LinkConnector(links => this.connecting_links = links) /** The viewport of this canvas. Tightly coupled with {@link ds}. */ readonly viewport?: Rect autoresize: boolean @@ -475,8 +479,6 @@ export class LGraphCanvas implements ConnectionColorContext { node_over?: LGraphNode node_capturing_input?: LGraphNode | null highlighted_links: Dictionary = {} - link_over_widget?: IWidget - link_over_widget_type?: string dirty_canvas: boolean = true dirty_bgcanvas: boolean = true @@ -601,6 +603,54 @@ export class LGraphCanvas implements ConnectionColorContext { this.ds = new DragAndScale() this.pointer = new CanvasPointer(canvas) + + // @deprecated Workaround: Keep until connecting_links is removed. + this.linkConnector.events.addEventListener("reset", () => { + this.connecting_links = null + }) + + // Dropped a link on the canvas + this.linkConnector.events.addEventListener("dropped-on-canvas", (customEvent) => { + if (!this.connecting_links) return + + const e = customEvent.detail + this.emitEvent({ + subType: "empty-release", + originalEvent: e, + linkReleaseContext: { links: this.connecting_links }, + }) + + const firstLink = this.linkConnector.renderLinks[0] + + // No longer in use + // add menu when releasing link in empty space + if (LiteGraph.release_link_on_empty_shows_menu) { + const linkReleaseContext = this.linkConnector.state.connectingTo === "input" + ? { + node_from: firstLink.node, + slot_from: firstLink.fromSlot, + type_filter_in: firstLink.fromSlot.type, + } + : { + node_to: firstLink.node, + slot_from: firstLink.fromSlot, + type_filter_out: firstLink.fromSlot.type, + } + + if ("shiftKey" in e && e.shiftKey) { + if (this.allow_searchbox) { + this.showSearchBox(e as unknown as MouseEvent, linkReleaseContext) + } + } else { + if (this.linkConnector.state.connectingTo === "input") { + this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.fromSlot, e: e as unknown as CanvasMouseEvent }) + } else { + this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.fromSlot, e: e as unknown as CanvasMouseEvent }) + } + } + } + }) + // otherwise it generates ugly patterns when scaling down too much this.zoom_modify_alpha = true // in range (1.01, 2.5). Less than 1 will invert the zoom direction @@ -1816,7 +1866,7 @@ export class LGraphCanvas implements ConnectionColorContext { otherNode.mouseOver = null this._highlight_input = undefined this._highlight_pos = undefined - this.link_over_widget = undefined + this.linkConnector.overWidget = undefined // Hover transitions // TODO: Implement single lerp ease factor for current progress on hover in/out. @@ -1910,7 +1960,7 @@ export class LGraphCanvas implements ConnectionColorContext { } #processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode | undefined) { - const { pointer, graph } = this + const { pointer, graph, linkConnector } = this if (!graph) throw new NullGraphError() const x = e.canvasX @@ -1983,33 +2033,16 @@ export class LGraphCanvas implements ConnectionColorContext { const reroute = graph.getRerouteOnPos(x, y) if (reroute) { if (e.shiftKey) { - // Connect new link from reroute - const linkId = reroute.linkIds.values().next().value - if (linkId == null) return + linkConnector.dragFromReroute(graph, reroute) - const link = graph._links.get(linkId) - if (!link) return - - const outputNode = graph.getNodeById(link.origin_id) - if (!outputNode) return - - const slot = link.origin_slot - const connecting: ConnectingLink = { - node: outputNode, - slot, - input: null, - pos: outputNode.getConnectionPos(false, slot), - afterRerouteId: reroute.id, - } - this.connecting_links = [connecting] - pointer.onDragStart = () => connecting.output = outputNode.outputs[slot] - // pointer.finally = () => this.connecting_links = null + pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent) + pointer.finally = () => linkConnector.reset(true) this.dirty_bgcanvas = true } pointer.onClick = () => this.processSelect(reroute, e) - if (!pointer.onDragStart) { + if (!pointer.onDragEnd) { pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true) pointer.onDragEnd = e => this.#processDraggedItems(e) } @@ -2036,22 +2069,10 @@ export class LGraphCanvas implements ConnectionColorContext { this.ctx.lineWidth = lineWidth if (e.shiftKey && !e.altKey) { - const slot = linkSegment.origin_slot - if (slot == null) return console.warn("Connecting link from corrupt link segment: `slot` null", linkSegment) - if (linkSegment.origin_id == null) return console.warn("Connecting link from corrupt link segment: `origin_id` null", linkSegment) + linkConnector.dragFromLinkSegment(graph, linkSegment) - const originNode = graph._nodes_by_id[linkSegment.origin_id] - - const connecting: ConnectingLink = { - node: originNode, - slot, - pos: originNode.getConnectionPos(false, slot), - } - this.connecting_links = [connecting] - if (linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId - - pointer.onDragStart = () => connecting.output = originNode.outputs[slot] - // pointer.finally = () => this.connecting_links = null + pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent) + pointer.finally = () => linkConnector.reset(true) return } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { @@ -2170,7 +2191,7 @@ export class LGraphCanvas implements ConnectionColorContext { ctrlOrMeta: boolean, node: LGraphNode, ): void { - const { pointer, graph } = this + const { pointer, graph, linkConnector } = this if (!graph) throw new NullGraphError() const x = e.canvasX @@ -2239,46 +2260,19 @@ export class LGraphCanvas implements ConnectionColorContext { if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { // Drag multiple output links if (e.shiftKey && output.links?.length) { - const connectingLinks: ConnectingLink[] = [] + linkConnector.moveOutputLink(graph, output) - for (const linkId of output.links) { - const link = graph._links.get(linkId) - if (!link) continue + pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent) + pointer.finally = () => linkConnector.reset(true) - const slot = link.target_slot - const otherNode = graph._nodes_by_id[link.target_id] - const input = otherNode.inputs[slot] - const pos = otherNode.getConnectionPos(true, slot) - const afterRerouteId = LLink.getReroutes(graph, link).at(-1)?.id - const firstRerouteId = LLink.getReroutes(graph, link).at(0)?.id - - connectingLinks.push({ - node: otherNode, - slot, - input, - output: null, - pos, - direction: LinkDirection.RIGHT, - afterRerouteId, - firstRerouteId, - link, - }) - } - - this.connecting_links = connectingLinks return } - output.slot_index = i - this.connecting_links = [ - { - node: node, - slot: i, - input: null, - output: output, - pos: link_pos, - }, - ] + // New output link + linkConnector.dragNewFromOutput(graph, node, output) + + pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent) + pointer.finally = () => linkConnector.reset(true) if (LiteGraph.shift_click_do_break_link_from) { if (e.shiftKey) { @@ -2308,12 +2302,6 @@ export class LGraphCanvas implements ConnectionColorContext { pointer.onClick = () => node.onInputClick?.(i, e) if (input.link !== null) { - // before disconnecting - const link_info = graph._links.get(input.link) - if (!link_info) throw new TypeError("Input link ID was invalid.") - - const slot = link_info.origin_slot - const linked_node = graph._nodes_by_id[link_info.origin_id] if ( LiteGraph.click_do_break_link_to || (LiteGraph.ctrl_alt_click_do_break_link && @@ -2323,50 +2311,18 @@ export class LGraphCanvas implements ConnectionColorContext { ) { node.disconnectInput(i) } else if (e.shiftKey || this.allow_reconnect_links) { - if (!linked_node) throw new TypeError("linked_node was null") - - const connecting: ConnectingLink = { - node: linked_node, - slot, - output: linked_node.outputs[slot], - pos: linked_node.getConnectionPos(false, slot), - afterRerouteId: link_info.parentId, - link: link_info, - } - const connectingLinks = [connecting] - this.connecting_links = connectingLinks - - pointer.onDragStart = () => { - connecting.output = linked_node.outputs[slot] - } - pointer.onDragEnd = (upEvent) => { - const shouldDisconnect = this.#processConnectingLinks(upEvent, connectingLinks) - - if (shouldDisconnect && this.allow_reconnect_links && !LiteGraph.click_do_break_link_to) { - node.disconnectInput(i) - } - connecting.output = linked_node.outputs[slot] - this.connecting_links = null - } - - this.dirty_bgcanvas = true + linkConnector.moveInputLink(graph, input) } } - if (!pointer.onDragStart) { - // Connect from input to output - const connecting: ConnectingLink = { - node, - slot: i, - output: null, - pos: link_pos, - } - this.connecting_links = [connecting] - pointer.onDragStart = () => connecting.input = input - this.dirty_bgcanvas = true + // Dragging a new link from input to output + if (!linkConnector.isConnecting) { + linkConnector.dragNewFromInput(graph, node, input) } + pointer.onDragEnd = upEvent => linkConnector.dropLinks(graph, upEvent) + pointer.finally = () => linkConnector.reset(true) + this.dirty_bgcanvas = true - // pointer.finally = () => this.connecting_links = null return } } @@ -2631,7 +2587,7 @@ export class LGraphCanvas implements ConnectionColorContext { e.canvasY, this.visible_nodes, ) - const { resizingGroup } = this + const { resizingGroup, linkConnector } = this const dragRect = this.dragging_rectangle if (dragRect) { @@ -2649,7 +2605,7 @@ export class LGraphCanvas implements ConnectionColorContext { (this.allow_interaction || (node && node.flags.allow_interaction)) && !this.read_only ) { - if (this.connecting_links) this.dirty_canvas = true + if (linkConnector.isConnecting) this.dirty_canvas = true // remove mouseover flag this.updateMouseOverNodes(node, e) @@ -2694,59 +2650,53 @@ export class LGraphCanvas implements ConnectionColorContext { node.mouseOver.overWidget = overWidget // Check if link is over anything it could connect to - record position of valid target for snap / highlight - if (this.connecting_links?.length) { - const firstLink = this.connecting_links[0] + if (linkConnector.isConnecting) { + const firstLink = linkConnector.renderLinks.at(0) // Default: nothing highlighted let highlightPos: Point | undefined let highlightInput: INodeInputSlot | undefined - let linkOverWidget: IWidget | undefined - if (firstLink.node === node) { - // Cannot connect link from a node to itself - } else if (firstLink.output) { - // Connecting from an output to an input + if (!firstLink || firstLink.node === node) { + // No link / node loopback + } else if (linkConnector.state.connectingTo === "input") { if (inputId === -1 && outputId === -1) { // Allow support for linking to widgets, handled externally to LiteGraph if (this.getWidgetLinkType && overWidget) { const widgetLinkType = this.getWidgetLinkType(overWidget, node) if ( widgetLinkType && - LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType) + LiteGraph.isValidConnection(linkConnector.renderLinks[0]?.fromSlot.type, widgetLinkType) && + firstLink.node.isValidWidgetLink?.(firstLink.fromSlotIndex, node, overWidget) !== false ) { - if (firstLink.output.slot_index == null) throw new TypeError("Connecting link output.slot_index was null.") - - if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { - linkOverWidget = overWidget - this.link_over_widget_type = widgetLinkType - } + linkConnector.overWidget = overWidget + linkConnector.overWidgetType = widgetLinkType } } // Node background / title under the pointer - if (!linkOverWidget) { - const targetSlotId = firstLink.node.findConnectByTypeSlot(true, node, firstLink.output.type) - if (targetSlotId !== undefined && targetSlotId >= 0) { - node.getConnectionPos(true, targetSlotId, pos) + if (!linkConnector.overWidget) { + const result = node.findInputByType(firstLink.fromSlot.type) + if (result) { + highlightInput = result.slot + node.getConnectionPos(true, result.index, pos) highlightPos = pos - highlightInput = node.inputs[targetSlotId] } } } else if ( inputId != -1 && node.inputs[inputId] && - LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type) + LiteGraph.isValidConnection(firstLink.fromSlot.type, node.inputs[inputId].type) ) { highlightPos = pos // XXX CHECK THIS highlightInput = node.inputs[inputId] } - } else if (firstLink.input) { + } else if (linkConnector.state.connectingTo === "output") { // Connecting from an input to an output if (inputId === -1 && outputId === -1) { - const targetSlotId = firstLink.node.findConnectByTypeSlot(false, node, firstLink.input.type) - - if (targetSlotId !== undefined && targetSlotId >= 0) { - node.getConnectionPos(false, targetSlotId, pos) + const result = node.findOutputByType(firstLink.fromSlot.type) + if (result) { + node.getConnectionPos(false, result.index, pos) highlightPos = pos } } else { @@ -2754,7 +2704,7 @@ export class LGraphCanvas implements ConnectionColorContext { if ( outputId != -1 && node.outputs[outputId] && - LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type) + LiteGraph.isValidConnection(firstLink.fromSlot.type, node.outputs[outputId].type) ) { highlightPos = pos } @@ -2762,7 +2712,6 @@ export class LGraphCanvas implements ConnectionColorContext { } this._highlight_pos = highlightPos this._highlight_input = highlightInput - this.link_over_widget = linkOverWidget } this.dirty_canvas = true @@ -2916,10 +2865,7 @@ export class LGraphCanvas implements ConnectionColorContext { const x = e.canvasX const y = e.canvasY - if (this.connecting_links?.length) { - // node below mouse - this.#processConnectingLinks(e, this.connecting_links) - } else { + if (!this.linkConnector.isConnecting) { this.dirty_canvas = true // @ts-expect-error Unused param @@ -2929,8 +2875,6 @@ export class LGraphCanvas implements ConnectionColorContext { y - this.node_capturing_input.pos[1], ]) } - - this.connecting_links = null } else if (e.button === 1) { // middle button this.dirty_canvas = true @@ -2950,112 +2894,6 @@ export class LGraphCanvas implements ConnectionColorContext { return } - #processConnectingLinks(e: CanvasPointerEvent, connecting_links: ConnectingLink[]): boolean | undefined { - const { graph } = this - if (!graph) throw new NullGraphError() - - const { canvasX: x, canvasY: y } = e - const node = graph.getNodeOnPos(x, y, this.visible_nodes) - const firstLink = connecting_links[0] - - if (node) { - let madeNewLink: boolean | undefined - - for (const link of connecting_links) { - // dragging a connection - this.#dirty() - - // One should avoid linking things to oneself - if (node === link.node) continue - - // slot below mouse? connect - if (link.output) { - const slot = isOverNodeInput(node, x, y) - if (slot != -1) { - // Trying to move link onto itself - if (link.link?.target_id === node.id && link.link?.target_slot === slot) return - - const newLink = link.node.connect(link.slot, node, slot, link.afterRerouteId) - madeNewLink ||= newLink !== null - } else if (this.link_over_widget) { - this.emitEvent({ - subType: "connectingWidgetLink", - link, - node, - widget: this.link_over_widget, - }) - this.link_over_widget = undefined - } else { - // not on top of an input - // look for a good slot - const slotIndex = link.node.findConnectByTypeSlot(true, node, link.output.type) - if (slotIndex !== undefined) { - // Trying to move link onto itself - if (link.link?.target_id === node.id && link.link?.target_slot === slotIndex) return - - const newLink = link.node.connect(link.slot, node, slotIndex, link.afterRerouteId) - madeNewLink ||= newLink !== null - } - } - } else if (link.input) { - const slot = isOverNodeOutput(node, x, y) - - const newLink = slot != -1 - // this is inverted has output-input nature like - ? node.connect(slot, link.node, link.slot, link.afterRerouteId) - // not on top of an input - // look for a good slot - : link.node.connectByTypeOutput( - link.slot, - node, - link.input.type, - { afterRerouteId: link.afterRerouteId }, - ) - madeNewLink ||= newLink !== null - } - } - return madeNewLink - } else if (firstLink.input || firstLink.output) { - // For external event only. - const linkReleaseContextExtended: LinkReleaseContextExtended = { - links: connecting_links, - } - this.emitEvent({ - subType: "empty-release", - originalEvent: e, - linkReleaseContext: linkReleaseContextExtended, - }) - // No longer in use - // add menu when releasing link in empty space - if (LiteGraph.release_link_on_empty_shows_menu) { - const linkReleaseContext = firstLink.output - ? { - node_from: firstLink.node, - slot_from: firstLink.output, - type_filter_in: firstLink.output.type, - } - : { - node_to: firstLink.node, - slot_from: firstLink.input, - type_filter_out: firstLink.input?.type, - } - - if (e.shiftKey) { - if (this.allow_searchbox) { - this.showSearchBox(e, linkReleaseContext) - } - } else { - if (firstLink.output) { - this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.output, e: e }) - } else if (firstLink.input) { - this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.input, e: e }) - } - } - } - return true - } - } - /** * Called when the mouse moves off the canvas. Clears all node hover states. * @param e @@ -3993,7 +3831,7 @@ export class LGraphCanvas implements ConnectionColorContext { drawFrontCanvas(): void { this.dirty_canvas = false - const { ctx, canvas } = this + const { ctx, canvas, linkConnector } = this // @ts-expect-error if (ctx.start2D && !this.viewport) { @@ -4081,33 +3919,21 @@ export class LGraphCanvas implements ConnectionColorContext { this.drawConnections(ctx) } - if (this.connecting_links?.length) { + if (linkConnector.isConnecting) { // current connection (the one being dragged by the mouse) + const { renderLinks } = linkConnector const highlightPos = this.#getHighlightPosition() ctx.lineWidth = this.connections_width - for (const link of this.connecting_links) { - const connInOrOut = link.output || link.input + for (const renderLink of renderLinks) { + const { fromSlot, fromPos: pos, fromDirection, dragDirection } = renderLink + const connShape = fromSlot.shape + const connType = fromSlot.type - const connType = connInOrOut?.type - let connDir = connInOrOut?.dir - if (connDir == null) { - if (link.output) - connDir = LinkDirection.RIGHT - else - connDir = LinkDirection.LEFT - } - const connShape = connInOrOut?.shape - - const link_color = connType === LiteGraph.EVENT + const colour = connType === LiteGraph.EVENT ? LiteGraph.EVENT_LINK_COLOR : LiteGraph.CONNECTING_LINK_COLOR - // If not using reroutes, link.afterRerouteId should be undefined. - const rerouteIdToConnectTo = link.firstRerouteId ?? link.afterRerouteId - const pos = rerouteIdToConnectTo == null - ? link.pos - : (this.graph.reroutes.get(rerouteIdToConnectTo)?.pos ?? link.pos) // the connection being dragged by the mouse this.renderLink( ctx, @@ -4116,9 +3942,9 @@ export class LGraphCanvas implements ConnectionColorContext { null, false, null, - link_color, - connDir, - link.direction ?? LinkDirection.CENTER, + colour, + fromDirection, + dragDirection, ) ctx.beginPath() @@ -4239,7 +4065,7 @@ export class LGraphCanvas implements ConnectionColorContext { // Ensure we're mousing over a node and connecting a link const node = this.node_over - if (!(node && this.connecting_links?.[0])) return + if (!(node && this.linkConnector.isConnecting)) return const { strokeStyle, lineWidth } = ctx @@ -4256,7 +4082,7 @@ export class LGraphCanvas implements ConnectionColorContext { ctx.roundRect(x, y, width, height, radius) // TODO: Currently works on LTR slots only. Add support for other directions. - const start = this.connecting_links[0].output === null ? 0 : 1 + const start = this.linkConnector.state.connectingTo === "output" ? 0 : 1 const inverter = start ? -1 : 1 // Radial highlight centred on highlight pos @@ -4547,7 +4373,7 @@ export class LGraphCanvas implements ConnectionColorContext { node.layoutWidgetInputSlots() node.drawSlots(ctx, { - connectingLink: this.connecting_links?.[0], + fromSlot: this.linkConnector.renderLinks[0]?.fromSlot, colorContext: this, editorAlpha: this.editor_alpha, lowQuality: this.low_quality, @@ -4858,8 +4684,6 @@ export class LGraphCanvas implements ConnectionColorContext { const link = this.graph._links.get(link_id) if (!link) continue - const draggingLink = isDraggingLink(link.id, this.connecting_links) - // find link info const start_node = this.graph.getNodeById(link.origin_id) if (start_node == null) continue @@ -4920,23 +4744,24 @@ export class LGraphCanvas implements ConnectionColorContext { reroute.calculateAngle(this.last_draw_time, this.graph, startPos) // Skip the first segment if it is being dragged - if (j === 0 && draggingLink?.input) continue - this.renderLink( - ctx, - startPos, - reroute.pos, - link, - false, - 0, - null, - start_dir, - end_dir, - { - startControl, - endControl: reroute.controlPoint, - reroute, - }, - ) + if (!reroute._dragging) { + this.renderLink( + ctx, + startPos, + reroute.pos, + link, + false, + 0, + null, + start_dir, + end_dir, + { + startControl, + endControl: reroute.controlPoint, + reroute, + }, + ) + } } // Calculate start control for the next iter control point @@ -4946,7 +4771,7 @@ export class LGraphCanvas implements ConnectionColorContext { } // Skip the last segment if it is being dragged - if (draggingLink?.output) continue + if (link._dragging) continue // Use runtime fallback; TypeScript cannot evaluate this correctly. const segmentStartPos = points.at(-2) ?? start_node_slotpos @@ -4965,7 +4790,7 @@ export class LGraphCanvas implements ConnectionColorContext { { startControl }, ) // Skip normal render when link is being dragged - } else if (!draggingLink) { + } else if (!link._dragging) { this.renderLink( ctx, start_node_slotpos, @@ -5413,11 +5238,12 @@ export class LGraphCanvas implements ConnectionColorContext { posY: number, ctx: CanvasRenderingContext2D, ): void { + const { linkConnector } = this + node.drawWidgets(ctx, { colorContext: this, - linkOverWidget: this.link_over_widget, - // @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616 - linkOverWidgetType: this.link_over_widget_type, + linkOverWidget: linkConnector.overWidget, + linkOverWidgetType: linkConnector.overWidgetType, lowQuality: this.low_quality, editorAlpha: this.editor_alpha, }) diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 9c01b69bf..4557de60d 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -2,7 +2,6 @@ import type { DragAndScale } from "./DragAndScale" import type { CanvasColour, ColorOption, - ConnectingLink, Dictionary, IColorable, IContextMenuValue, @@ -3256,7 +3255,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { drawWidgets(ctx: CanvasRenderingContext2D, options: { colorContext: ConnectionColorContext linkOverWidget: IWidget | null | undefined - linkOverWidgetType: ISlotType + linkOverWidgetType?: ISlotType lowQuality?: boolean editorAlpha?: number }): void { @@ -3281,6 +3280,7 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { // Manually draw a slot next to the widget simulating an input new NodeInputSlot({ name: "", + // @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616 type: linkOverWidgetType, link: 0, // @ts-expect-error https://github.com/Comfy-Org/litegraph.js/issues/616 @@ -3419,18 +3419,18 @@ export class LGraphNode implements Positionable, IPinnable, IColorable { * Draws the node's input and output slots. */ drawSlots(ctx: CanvasRenderingContext2D, options: { - connectingLink: ConnectingLink | undefined + fromSlot?: INodeInputSlot | INodeOutputSlot colorContext: ConnectionColorContext editorAlpha: number lowQuality: boolean }) { - const { connectingLink, colorContext, editorAlpha, lowQuality } = options + const { fromSlot, colorContext, editorAlpha, lowQuality } = options for (const slot of this.slots) { // change opacity of incompatible slots when dragging a connection const layoutElement = slot._layoutElement const slotInstance = toNodeSlotClass(slot) - const isValid = slotInstance.isValidTarget(connectingLink) + const isValid = !fromSlot || slotInstance.isValidTarget(fromSlot) const highlight = isValid && this.#isMouseOverSlot(slot) const labelColor = highlight ? this.highlightColor diff --git a/src/LLink.ts b/src/LLink.ts index 46b91ad35..9452d268e 100644 --- a/src/LLink.ts +++ b/src/LLink.ts @@ -45,6 +45,9 @@ export class LLink implements LinkSegment, Serialisable { /** @inheritdoc */ _centreAngle?: number + /** @inheritdoc */ + _dragging?: boolean + #color?: CanvasColour | null /** Custom colour for this link only */ public get color(): CanvasColour | null | undefined { @@ -114,6 +117,13 @@ export class LLink implements LinkSegment, Serialisable { ?.getReroutes() ?? [] } + static getFirstReroute( + network: LinkNetwork, + linkSegment: LinkSegment, + ): Reroute | undefined { + return LLink.getReroutes(network, linkSegment).at(0) + } + /** * Finds the reroute in the chain after the provided reroute ID. * @param network The network this link belongs to diff --git a/src/NodeSlot.ts b/src/NodeSlot.ts index 6945c7fc1..d69a89392 100644 --- a/src/NodeSlot.ts +++ b/src/NodeSlot.ts @@ -1,4 +1,4 @@ -import type { CanvasColour, ConnectingLink, Dictionary, INodeInputSlot, INodeOutputSlot, INodeSlot, ISlotType, IWidgetInputSlot, Point } from "./interfaces" +import type { CanvasColour, Dictionary, INodeInputSlot, INodeOutputSlot, INodeSlot, ISlotType, IWidgetInputSlot, Point } from "./interfaces" import type { LinkId } from "./LLink" import type { IWidget } from "./types/widgets" @@ -83,9 +83,9 @@ export abstract class NodeSlot implements INodeSlot { /** * Whether this slot is a valid target for a dragging link. - * @param link The link to check against. + * @param fromSlot The slot that the link is being connected from. */ - abstract isValidTarget(link: ConnectingLink | undefined): boolean + abstract isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean /** * The label to display in the UI. @@ -270,10 +270,8 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot { return this.link != null } - override isValidTarget(link: ConnectingLink | undefined): boolean { - if (!link) return true - - return !!link.output && LiteGraph.isValidConnection(this.type, link.output.type) + override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean { + return "links" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type) } override draw(ctx: CanvasRenderingContext2D, options: Omit) { @@ -306,10 +304,8 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot { this.slot_index = slot.slot_index } - override isValidTarget(link: ConnectingLink | undefined): boolean { - if (!link) return true - - return !!link.input && LiteGraph.isValidConnection(this.type, link.input.type) + override isValidTarget(fromSlot: INodeInputSlot | INodeOutputSlot): boolean { + return "link" in fromSlot && LiteGraph.isValidConnection(this.type, fromSlot.type) } override isConnected(): boolean { diff --git a/src/Reroute.ts b/src/Reroute.ts index 45b356966..5528e7433 100644 --- a/src/Reroute.ts +++ b/src/Reroute.ts @@ -84,6 +84,9 @@ export class Reroute implements Positionable, LinkSegment, Serialisable = new Set() + + /** The widget beneath the pointer, if it is a valid connection target. */ + overWidget?: IWidget + /** The type (returned by downstream callback) for {@link overWidget} */ + overWidgetType?: string + + readonly #setConnectingLinks: (value: ConnectingLink[]) => void + + constructor(setConnectingLinks: (value: ConnectingLink[]) => void) { + this.#setConnectingLinks = setConnectingLinks + } + + get isConnecting() { + return this.state.connectingTo !== undefined + } + + get draggingExistingLinks() { + return this.state.draggingExistingLinks + } + + /** Drag an existing link to a different input. */ + moveInputLink(network: LinkNetwork, input: INodeInputSlot, fromReroute?: Reroute): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state, inputLinks, renderLinks } = this + + const linkId = input.link + if (linkId == null) return + + const link = network.links.get(linkId) + if (!link) return + + try { + const renderLink = new MovingRenderLink(network, link, "input", fromReroute) + + const mayContinue = this.events.dispatch("before-move-input", renderLink) + if (mayContinue === false) return + + renderLinks.push(renderLink) + } catch (error) { + console.warn(`Could not create render link for link id: [${link.id}].`, link, error) + return + } + + link._dragging = true + inputLinks.push(link) + + state.connectingTo = "input" + state.draggingExistingLinks = true + + this.#setLegacyLinks(false) + } + + /** Drag all links from an output to a new output. */ + moveOutputLink(network: LinkNetwork, output: INodeOutputSlot): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state, renderLinks } = this + if (!output.links?.length) return + + for (const linkId of output.links) { + const link = network.links.get(linkId) + if (!link) continue + + const firstReroute = LLink.getFirstReroute(network, link) + if (firstReroute) { + firstReroute._dragging = true + this.hiddenReroutes.add(firstReroute) + } else { + link._dragging = true + } + this.outputLinks.push(link) + + try { + const renderLink = new MovingRenderLink(network, link, "output", firstReroute, LinkDirection.RIGHT) + + const mayContinue = this.events.dispatch("before-move-output", renderLink) + if (mayContinue === false) continue + + renderLinks.push(renderLink) + } catch (error) { + console.warn(`Could not create render link for link id: [${link.id}].`, link, error) + continue + } + } + + if (renderLinks.length === 0) return this.reset() + + state.draggingExistingLinks = true + state.multi = true + state.connectingTo = "output" + + this.#setLegacyLinks(true) + } + + /** + * Drags a new link from an output slot to an input slot. + * @param network The network that the link being connected belongs to + * @param node The node the link is being dragged from + * @param output The output slot that the link is being dragged from + */ + dragNewFromOutput(network: LinkNetwork, node: LGraphNode, output: INodeOutputSlot, fromReroute?: Reroute): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state } = this + const renderLink = new ToInputRenderLink(network, node, output, fromReroute) + this.renderLinks.push(renderLink) + + state.connectingTo = "input" + + this.#setLegacyLinks(false) + } + + /** + * Drags a new link from an input slot to an output slot. + * @param network The network that the link being connected belongs to + * @param node The node the link is being dragged from + * @param input The input slot that the link is being dragged from + */ + dragNewFromInput(network: LinkNetwork, node: LGraphNode, input: INodeInputSlot, fromReroute?: Reroute): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state } = this + const renderLink = new ToOutputRenderLink(network, node, input, fromReroute) + this.renderLinks.push(renderLink) + + state.connectingTo = "output" + + this.#setLegacyLinks(true) + } + + /** + * Drags a new link from a reroute to an input slot. + * @param network The network that the link being connected belongs to + * @param reroute The reroute that the link is being dragged from + */ + dragFromReroute(network: LinkNetwork, reroute: Reroute): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state } = this + + // Connect new link from reroute + const linkId = reroute.linkIds.values().next().value + if (linkId == null) return + + const link = network.links.get(linkId) + if (!link) return + + const outputNode = network.getNodeById(link.origin_id) + if (!outputNode) return + + const outputSlot = outputNode.outputs.at(link.origin_slot) + if (!outputSlot) return + + const renderLink = new ToInputRenderLink(network, outputNode, outputSlot, reroute) + renderLink.fromDirection = LinkDirection.NONE + this.renderLinks.push(renderLink) + + state.connectingTo = "input" + } + + dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void { + if (this.isConnecting) throw new Error("Already dragging links.") + + const { state } = this + if (linkSegment.origin_id == null || linkSegment.origin_slot == null) return + + const node = network.getNodeById(linkSegment.origin_id) + if (!node) return + + const slot = getNodeOutputOnPos(node, linkSegment._pos[0], linkSegment._pos[1])?.output + if (!slot) return + + const reroute = linkSegment.parentId ? network.reroutes.get(linkSegment.parentId) : undefined + if (!reroute) return + + const renderLink = new ToInputRenderLink(network, node, slot, reroute) + renderLink.fromDirection = LinkDirection.NONE + this.renderLinks.push(renderLink) + + state.connectingTo = "input" + } + + /** + * Connects the links being droppe + * @param event Contains the drop location, in canvas space + */ + dropLinks(locator: ItemLocator, event: CanvasPointerEvent): void { + if (!this.isConnecting) return this.reset() + + const { renderLinks, state } = this + const { connectingTo } = state + + const mayContinue = this.events.dispatch("before-drop-links", { renderLinks, event }) + if (mayContinue === false) return this.reset() + + const { canvasX, canvasY } = event + const node = locator.getNodeOnPos(canvasX, canvasY) ?? undefined + if (!node) return this.dropOnNothing(event) + + // To output + if (connectingTo === "output") { + const output = node.getOutputOnPos([canvasX, canvasY]) + + if (output) { + this.#dropOnOutput(node, output) + } else { + this.dropOnNode(node, event) + } + // To input + } else if (connectingTo === "input") { + const input = node.getInputOnPos([canvasX, canvasY]) + + // Input slot + if (input) { + this.#dropOnInput(node, input) + } else if (this.overWidget && this.renderLinks[0] instanceof ToInputRenderLink) { + // Widget + this.events.dispatch("dropped-on-widget", { + link: this.renderLinks[0], + node, + widget: this.overWidget, + }) + this.overWidget = undefined + } else { + // Node background / title + this.dropOnNode(node, event) + } + } + + this.events.dispatch("after-drop-links", { renderLinks, event }) + this.reset() + } + + /** + * Connects the links being dropped onto a node. + * @param node The node that the links are being dropped on + * @param event Contains the drop location, in canvas space + */ + dropOnNode(node: LGraphNode, event: CanvasPointerEvent): void { + const { state: { connectingTo } } = this + + const mayContinue = this.events.dispatch("dropped-on-node", { node, event }) + if (mayContinue === false) return + + // Assume all links are the same type, disallow loopback + const firstLink = this.renderLinks[0] + if (!firstLink || firstLink.node === node) return + + // Dragging output links + if (connectingTo === "output" && this.draggingExistingLinks) { + const output = node.findOutputByType(firstLink.fromSlot.type)?.slot + if (!output) { + console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) + return + } + this.#dropOnOutput(node, output) + return this.reset() + } + + // Dragging input links + if (connectingTo === "input" && this.draggingExistingLinks) { + const input = node.findInputByType(firstLink.fromSlot.type)?.slot + if (!input) { + console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) + return + } + this.#dropOnInput(node, input) + return this.reset() + } + + // Dropping new output link + if (connectingTo === "output") { + const output = node.findOutputByType(firstLink.fromSlot.type)?.slot + if (!output) { + console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) + return + } + + for (const link of this.renderLinks) { + if ("link" in link.fromSlot) { + node.connectSlots(output, link.node, link.fromSlot, link.fromReroute?.id) + } + } + // Dropping new input link + } else if (connectingTo === "input") { + const input = node.findInputByType(firstLink.fromSlot.type)?.slot + if (!input) { + console.warn(`Could not find slot for link type: [${firstLink.fromSlot.type}].`) + return + } + + for (const link of this.renderLinks) { + if ("links" in link.fromSlot) { + link.node.connectSlots(link.fromSlot, node, input, link.fromReroute?.id) + } + } + } + + this.reset() + } + + dropOnNothing(event: CanvasPointerEvent): void { + // For external event only. + if (this.state.connectingTo === "input") { + for (const link of this.renderLinks) { + if (link instanceof MovingRenderLink) { + link.inputNode.disconnectInput(link.inputIndex, true) + } + } + } + this.events.dispatch("dropped-on-canvas", event) + this.reset() + } + + #dropOnInput(node: LGraphNode, input: INodeInputSlot): void { + for (const link of this.renderLinks) { + if (link.toType !== "input") continue + + if (link instanceof MovingRenderLink) { + const { outputNode, inputSlot, outputSlot, fromReroute } = link + // Link is already connected here + if (inputSlot === input) continue + + outputNode.connectSlots(outputSlot, node, input, fromReroute?.id) + this.events.dispatch("input-moved", link) + } else { + const { node: outputNode, fromSlot, fromReroute } = link + const newLink = outputNode.connectSlots(fromSlot, node, input, fromReroute?.id) + this.events.dispatch("link-created", newLink) + } + } + } + + #dropOnOutput(node: LGraphNode, output: INodeOutputSlot): void { + for (const link of this.renderLinks) { + if (link.toType !== "output") continue + + if (link instanceof MovingRenderLink) { + const { inputNode, inputSlot, outputSlot } = link + // Link is already connected here + if (outputSlot === output) continue + + // Use the last reroute id on the link to retain all reroutes + node.connectSlots(output, inputNode, inputSlot, link.link.parentId) + this.events.dispatch("output-moved", link) + } else { + const { node: inputNode, fromSlot, fromReroute } = link + const newLink = node.connectSlots(output, inputNode, fromSlot, fromReroute?.id) + this.events.dispatch("link-created", newLink) + } + } + } + + /** Sets connecting_links, used by some extensions still. */ + #setLegacyLinks(fromSlotIsInput: boolean): void { + const links = this.renderLinks.map((link) => { + const input = fromSlotIsInput ? link.fromSlot as INodeInputSlot : null + const output = fromSlotIsInput ? null : link.fromSlot as INodeOutputSlot + + return { + node: link.node, + slot: link.fromSlotIndex, + input, + output, + pos: link.fromPos, + } + }) + this.#setConnectingLinks(links) + } + + /** + * Exports the current state of the link connector. + * @param network The network that the links being connected belong to. + * @returns A POJO with the state of the link connector, links being connected, and their network. + * @remarks Other than {@link network}, all properties are shallow cloned. + */ + export(network: LinkNetwork): LinkConnectorExport { + return { + renderLinks: [...this.renderLinks], + inputLinks: [...this.inputLinks], + outputLinks: [...this.outputLinks], + state: { ...this.state }, + network, + } + } + + /** + * Adds an event listener that will be automatically removed when the reset event is fired. + * @param eventName The event to listen for. + * @param listener The listener to call when the event is fired. + */ + listenUntilReset( + eventName: K, + listener: Parameters>[1], + options?: Parameters>[2], + ) { + this.events.addEventListener(eventName, listener, options) + this.events.addEventListener("reset", () => this.events.removeEventListener(eventName, listener), { once: true }) + } + + /** + * Resets everything to its initial state. + * + * Effectively cancels moving or connecting links. + */ + reset(force = false): void { + this.events.dispatch("reset", force) + + const { state, outputLinks, inputLinks, hiddenReroutes, renderLinks } = this + + if (!force && state.connectingTo === undefined) return + state.connectingTo = undefined + + for (const link of outputLinks) delete link._dragging + for (const link of inputLinks) delete link._dragging + for (const reroute of hiddenReroutes) delete reroute._dragging + + renderLinks.length = 0 + inputLinks.length = 0 + outputLinks.length = 0 + hiddenReroutes.clear() + state.multi = false + state.draggingExistingLinks = false + } +} diff --git a/src/canvas/MovingRenderLink.ts b/src/canvas/MovingRenderLink.ts new file mode 100644 index 000000000..4cb52bb5a --- /dev/null +++ b/src/canvas/MovingRenderLink.ts @@ -0,0 +1,87 @@ +import type { RenderLink } from "./RenderLink" +import type { INodeOutputSlot, LinkNetwork } from "@/interfaces" +import type { INodeInputSlot } from "@/interfaces" +import type { Point } from "@/interfaces" +import type { LGraphNode, NodeId } from "@/LGraphNode" +import type { LLink } from "@/LLink" +import type { Reroute } from "@/Reroute" + +import { LinkDirection } from "@/types/globalEnums" + +/** + * Represents an existing link that is currently being dragged by the user from one slot to another. + * + * This is a heavier, but short-lived convenience data structure. All refs to MovingRenderLinks should be discarded on drop. + * @remarks + * At time of writing, Litegraph is using several different styles and methods to handle link dragging. + * + * Once the library has undergone more substantial changes to the way links are managed, + * many properties of this class will be superfluous and removable. + */ +export class MovingRenderLink implements RenderLink { + readonly node: LGraphNode + readonly fromSlot: INodeOutputSlot | INodeInputSlot + readonly fromPos: Point + readonly fromDirection: LinkDirection + readonly fromSlotIndex: number + + readonly outputNodeId: NodeId + readonly outputNode: LGraphNode + readonly outputSlot: INodeOutputSlot + readonly outputIndex: number + readonly outputPos: Point + + readonly inputNodeId: NodeId + readonly inputNode: LGraphNode + readonly inputSlot: INodeInputSlot + readonly inputIndex: number + readonly inputPos: Point + + constructor( + readonly network: LinkNetwork, + readonly link: LLink, + readonly toType: "input" | "output", + readonly fromReroute?: Reroute, + readonly dragDirection: LinkDirection = LinkDirection.CENTER, + ) { + const { + origin_id: outputNodeId, + target_id: inputNodeId, + origin_slot: outputIndex, + target_slot: inputIndex, + } = link + + // Store output info + const outputNode = network.getNodeById(outputNodeId) ?? undefined + if (!outputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output node [${outputNodeId}] not found.`) + + const outputSlot = outputNode.outputs.at(outputIndex) + if (!outputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Output slot [${outputIndex}] not found.`) + + this.outputNodeId = outputNodeId + this.outputNode = outputNode + this.outputSlot = outputSlot + this.outputIndex = outputIndex + this.outputPos = outputNode.getConnectionPos(false, outputIndex) + + // Store input info + const inputNode = network.getNodeById(inputNodeId) ?? undefined + if (!inputNode) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input node [${inputNodeId}] not found.`) + + const inputSlot = inputNode.inputs.at(inputIndex) + if (!inputSlot) throw new Error(`Creating DraggingRenderLink for link [${link.id}] failed: Input slot [${inputIndex}] not found.`) + + this.inputNodeId = inputNodeId + this.inputNode = inputNode + this.inputSlot = inputSlot + this.inputIndex = inputIndex + this.inputPos = inputNode.getConnectionPos(true, inputIndex) + + // RenderLink props + this.node = this.toType === "input" ? outputNode : inputNode + this.fromSlot = this.toType === "input" ? outputSlot : inputSlot + this.fromPos = fromReroute?.pos ?? (this.toType === "input" ? this.outputPos : this.inputPos) + this.fromDirection = this.toType === "input" ? LinkDirection.NONE : LinkDirection.LEFT + this.fromSlotIndex = this.toType === "input" ? outputIndex : inputIndex + } +} diff --git a/src/canvas/RenderLink.ts b/src/canvas/RenderLink.ts new file mode 100644 index 000000000..754818bb6 --- /dev/null +++ b/src/canvas/RenderLink.ts @@ -0,0 +1,26 @@ +import type { LinkNetwork, Point } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" +import type { INodeInputSlot, INodeOutputSlot, Reroute } from "@/litegraph" +import type { LinkDirection } from "@/types/globalEnums" + +export interface RenderLink { + /** The type of link being connected. */ + readonly toType: "input" | "output" + /** The source {@link Point} of the link being connected. */ + readonly fromPos: Point + /** The direction the link starts off as. If {@link toType} is `output`, this will be the direction the link input faces. */ + readonly fromDirection: LinkDirection + /** If set, this will force a dragged link "point" from the cursor in the specified direction. */ + dragDirection: LinkDirection + + /** The network that the link belongs to. */ + readonly network: LinkNetwork + /** The node that the link is being connected from. */ + readonly node: LGraphNode + /** The slot that the link is being connected from. */ + readonly fromSlot: INodeOutputSlot | INodeInputSlot + /** The index of the slot that the link is being connected from. */ + readonly fromSlotIndex: number + /** The reroute that the link is being connected from. */ + readonly fromReroute?: Reroute +} diff --git a/src/canvas/ToInputRenderLink.ts b/src/canvas/ToInputRenderLink.ts new file mode 100644 index 000000000..5c04591e6 --- /dev/null +++ b/src/canvas/ToInputRenderLink.ts @@ -0,0 +1,32 @@ +import type { RenderLink } from "./RenderLink" +import type { LinkNetwork, Point } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" +import type { INodeOutputSlot } from "@/litegraph" +import type { Reroute } from "@/Reroute" + +import { LinkDirection } from "@/types/globalEnums" + +/** Connecting TO an input slot. */ + +export class ToInputRenderLink implements RenderLink { + readonly toType = "input" + readonly fromPos: Point + readonly fromSlotIndex: number + fromDirection: LinkDirection = LinkDirection.RIGHT + + constructor( + readonly network: LinkNetwork, + readonly node: LGraphNode, + readonly fromSlot: INodeOutputSlot, + readonly fromReroute?: Reroute, + public dragDirection: LinkDirection = LinkDirection.CENTER, + ) { + const outputIndex = node.outputs.indexOf(fromSlot) + if (outputIndex === -1) throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`) + + this.fromSlotIndex = outputIndex + this.fromPos = fromReroute + ? fromReroute.pos + : this.node.getConnectionPos(false, outputIndex) + } +} diff --git a/src/canvas/ToOutputRenderLink.ts b/src/canvas/ToOutputRenderLink.ts new file mode 100644 index 000000000..669b443af --- /dev/null +++ b/src/canvas/ToOutputRenderLink.ts @@ -0,0 +1,32 @@ +import type { RenderLink } from "./RenderLink" +import type { LinkNetwork, Point } from "@/interfaces" +import type { LGraphNode } from "@/LGraphNode" +import type { INodeInputSlot } from "@/litegraph" +import type { Reroute } from "@/Reroute" + +import { LinkDirection } from "@/types/globalEnums" + +/** Connecting TO an output slot. */ + +export class ToOutputRenderLink implements RenderLink { + readonly toType = "output" + readonly fromPos: Point + readonly fromSlotIndex: number + fromDirection: LinkDirection = LinkDirection.LEFT + + constructor( + readonly network: LinkNetwork, + readonly node: LGraphNode, + readonly fromSlot: INodeInputSlot, + readonly fromReroute?: Reroute, + public dragDirection: LinkDirection = LinkDirection.CENTER, + ) { + const inputIndex = node.inputs.indexOf(fromSlot) + if (inputIndex === -1) throw new Error(`Creating render link for node [${this.node.id}] failed: Slot index not found.`) + + this.fromSlotIndex = inputIndex + this.fromPos = fromReroute + ? fromReroute.pos + : this.node.getConnectionPos(true, inputIndex) + } +} diff --git a/src/infrastructure/LinkConnectorEventTarget.ts b/src/infrastructure/LinkConnectorEventTarget.ts new file mode 100644 index 000000000..cc439910f --- /dev/null +++ b/src/infrastructure/LinkConnectorEventTarget.ts @@ -0,0 +1,99 @@ +import type { MovingRenderLink } from "@/canvas/MovingRenderLink" +import type { RenderLink } from "@/canvas/RenderLink" +import type { ToInputRenderLink } from "@/canvas/ToInputRenderLink" +import type { LGraphNode } from "@/LGraphNode" +import type { LLink } from "@/LLink" +import type { CanvasPointerEvent } from "@/types/events" +import type { IWidget } from "@/types/widgets" + +export interface LinkConnectorEventMap { + "reset": boolean + + "before-drop-links": { + renderLinks: RenderLink[] + event: CanvasPointerEvent + } + "after-drop-links": { + renderLinks: RenderLink[] + event: CanvasPointerEvent + } + + "before-move-input": MovingRenderLink + "before-move-output": MovingRenderLink + + "input-moved": MovingRenderLink + "output-moved": MovingRenderLink + + "link-created": LLink | null | undefined + + "dropped-on-node": { + node: LGraphNode + event: CanvasPointerEvent + } + "dropped-on-canvas": CanvasPointerEvent + + "dropped-on-widget": { + link: ToInputRenderLink + node: LGraphNode + widget: IWidget + } +} + +/** {@link Omit} all properties that evaluate to `never`. */ +type NeverNever = { + [K in keyof T as T[K] extends never ? never : K]: T[K] +} + +/** {@link Pick} only properties that evaluate to `never`. */ +type PickNevers = { + [K in keyof T as T[K] extends never ? K : never]: T[K] +} + +type LinkConnectorEventListeners = { + readonly [K in keyof LinkConnectorEventMap]: ((this: EventTarget, ev: CustomEvent) => any) | EventListenerObject | null +} + +/** Events that _do not_ pass a {@link CustomEvent} `detail` object. */ +type SimpleEvents = keyof PickNevers + +/** Events that pass a {@link CustomEvent} `detail` object. */ +type ComplexEvents = keyof NeverNever + +export class LinkConnectorEventTarget extends EventTarget { + /** + * Type-safe event dispatching. + * @see {@link EventTarget.dispatchEvent} + * @param type Name of the event to dispatch + * @param detail A custom object to send with the event + * @returns `true` if the event was dispatched successfully, otherwise `false`. + */ + dispatch(type: T, detail: LinkConnectorEventMap[T]): boolean + dispatch(type: T): boolean + dispatch(type: T, detail?: LinkConnectorEventMap[T]) { + const event = new CustomEvent(type, { detail }) + return super.dispatchEvent(event) + } + + override addEventListener( + type: K, + listener: LinkConnectorEventListeners[K], + options?: boolean | AddEventListenerOptions, + ): void { + // Assertion: Contravariance on CustomEvent => Event + super.addEventListener(type, listener as EventListener, options) + } + + override removeEventListener( + type: K, + listener: LinkConnectorEventListeners[K], + options?: boolean | EventListenerOptions, + ): void { + // Assertion: Contravariance on CustomEvent => Event + super.removeEventListener(type, listener as EventListener, options) + } + + /** @deprecated Use {@link dispatch}. */ + override dispatchEvent(event: never): boolean { + return super.dispatchEvent(event) + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts index c0051fc3b..1c34304b2 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -113,6 +113,13 @@ export interface LinkNetwork { getNodeById(id: NodeId): LGraphNode | null } +/** + * Locates graph items. + */ +export interface ItemLocator { + getNodeOnPos(x: number, y: number, nodeList?: LGraphNode[]): LGraphNode | null +} + /** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */ export interface LinkSegment { /** Link / reroute ID */ @@ -131,6 +138,9 @@ export interface LinkSegment { */ _centreAngle?: number + /** Whether the link is currently being moved. @internal */ + _dragging?: boolean + /** Output node ID */ readonly origin_id: NodeId | undefined /** Output slot index */