Add virtual slots to Reroutes (#970)

### Virtual helper "slots"

Adds a virtual input and output slot to native reroutes, allowing links
to be dragged from them to other reroutes or nodes.


https://github.com/user-attachments/assets/67d308c4-4732-4b04-a2b9-0a2b0c79b413

### Notes

- Reroute slots automatically show an outline as the pointer gets close
- When the slot is clickable, it will highlight in the same colour as
the reroute
- Enables opposite direction connecting: from reroute to node outputs
- Floating reroutes only show one slot - to whichever side is not
connected
This commit is contained in:
filtered
2025-04-27 03:00:01 +10:00
committed by GitHub
parent de0f0ebac1
commit 5c41e4e09c
6 changed files with 399 additions and 73 deletions

View File

@@ -24,7 +24,6 @@ import { LGraphNode, type NodeId } from "./LGraphNode"
import { LiteGraph } from "./litegraph"
import { type LinkId, LLink } from "./LLink"
import { MapProxyHandler } from "./MapProxyHandler"
import { isSortaInsideOctagon } from "./measure"
import { Reroute, RerouteId } from "./Reroute"
import { stringOrEmpty } from "./strings"
import { LGraphEventMode } from "./types/globalEnums"
@@ -1001,12 +1000,9 @@ export class LGraph implements LinkNetwork, Serialisable<SerialisableGraph> {
* @param y Y co-ordinate in graph space
* @returns The first reroute under the given co-ordinates, or undefined
*/
getRerouteOnPos(x: number, y: number): Reroute | undefined {
for (const reroute of this.reroutes.values()) {
const { pos } = reroute
if (isSortaInsideOctagon(x - pos[0], y - pos[1], 2 * Reroute.radius))
return reroute
getRerouteOnPos(x: number, y: number, reroutes?: Iterable<Reroute>): Reroute | undefined {
for (const reroute of reroutes ?? this.reroutes.values()) {
if (reroute.containsPoint([x, y])) return reroute
}
}

View File

@@ -272,6 +272,10 @@ export class LGraphCanvas implements ConnectionColorContext {
cursor = "se-resize"
} else if (this.state.hoveringOver & CanvasItem.Node) {
cursor = "crosshair"
} else if (this.state.hoveringOver & CanvasItem.Reroute) {
cursor = "grab"
} else if (this.state.hoveringOver & CanvasItem.RerouteSlot) {
cursor = "crosshair"
}
this.canvas.style.cursor = cursor
@@ -488,6 +492,8 @@ export class LGraphCanvas implements ConnectionColorContext {
node_capturing_input?: LGraphNode | null
highlighted_links: Dictionary<boolean> = {}
#visibleReroutes: Set<Reroute> = new Set()
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
/** A map of nodes that require selective-redraw */
@@ -1907,7 +1913,7 @@ export class LGraphCanvas implements ConnectionColorContext {
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY)
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes)
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
@@ -1970,7 +1976,7 @@ export class LGraphCanvas implements ConnectionColorContext {
pointer.onClick = (eUp) => {
// Click, not drag
const clickedItem = node ??
graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) ??
graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY, this.#visibleReroutes) ??
graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY)
this.processSelect(clickedItem, eUp)
}
@@ -2020,20 +2026,30 @@ export class LGraphCanvas implements ConnectionColorContext {
} else {
// Reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
const reroute = graph.getRerouteOnPos(x, y)
if (reroute) {
if (e.shiftKey) {
for (const reroute of this.#visibleReroutes) {
const overReroute = reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue
if (overReroute) {
pointer.onClick = () => this.processSelect(reroute, e)
if (!e.shiftKey) {
pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true)
pointer.onDragEnd = e => this.#processDraggedItems(e)
}
}
if (reroute.isOutputHovered || (overReroute && e.shiftKey)) {
linkConnector.dragFromReroute(graph, reroute)
this.#linkConnectorDrop()
this.dirty_bgcanvas = true
}
pointer.onClick = () => this.processSelect(reroute, e)
if (!pointer.onDragEnd) {
pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true)
pointer.onDragEnd = e => this.#processDraggedItems(e)
if (reroute.isInputHovered) {
linkConnector.dragFromRerouteToOutput(graph, reroute)
this.#linkConnectorDrop()
}
reroute.hideSlots()
this.dirty_bgcanvas = true
return
}
}
@@ -2612,6 +2628,10 @@ export class LGraphCanvas implements ConnectionColorContext {
this.node_over = node
this.dirty_canvas = true
for (const reroute of this.#visibleReroutes) {
reroute.hideSlots()
this.dirty_bgcanvas = true
}
node.onMouseEnter?.(e)
}
@@ -2690,19 +2710,8 @@ export class LGraphCanvas implements ConnectionColorContext {
underPointer |= CanvasItem.ResizeSe
}
} else {
// Reroute
const reroute = graph.getRerouteOnPos(e.canvasX, e.canvasY)
if (reroute) {
underPointer |= CanvasItem.Reroute
linkConnector.overReroute = reroute
if (linkConnector.isConnecting && linkConnector.isRerouteValidDrop(reroute)) {
this._highlight_pos = reroute.pos
}
} else {
this._highlight_pos &&= undefined
linkConnector.overReroute &&= undefined
}
// Reroutes
underPointer = this.#updateReroutes(underPointer)
// Not over a node
const segment = this.#getLinkCentreOnPos(e)
@@ -2760,6 +2769,42 @@ export class LGraphCanvas implements ConnectionColorContext {
return
}
/**
* Updates the hover / snap state of all visible reroutes.
* @returns The original value of {@link underPointer}, with any found reroute items added.
*/
#updateReroutes(underPointer: CanvasItem): CanvasItem {
const { graph, pointer, linkConnector } = this
if (!graph) throw new NullGraphError()
// Update reroute hover state
if (!pointer.isDown) {
let anyChanges = false
for (const reroute of this.#visibleReroutes) {
anyChanges ||= reroute.updateVisibility(this.graph_mouse)
if (reroute.isSlotHovered) underPointer |= CanvasItem.RerouteSlot
}
if (anyChanges) this.dirty_bgcanvas = true
} else if (linkConnector.isConnecting) {
// Highlight the reroute that the mouse is over
for (const reroute of this.#visibleReroutes) {
if (reroute.containsPoint(this.graph_mouse)) {
if (linkConnector.isRerouteValidDrop(reroute)) {
linkConnector.overReroute = reroute
this._highlight_pos = reroute.pos
}
return underPointer |= CanvasItem.RerouteSlot
}
}
}
this._highlight_pos &&= undefined
linkConnector.overReroute &&= undefined
return underPointer
}
/**
* Start dragging an item, optionally including all other selected items.
*
@@ -4645,9 +4690,14 @@ export class LGraphCanvas implements ConnectionColorContext {
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now)
}
const rerouteSet = this.#visibleReroutes
rerouteSet.clear()
// Render reroutes, ordered by number of non-floating links
visibleReroutes.sort((a, b) => a.linkIds.size - b.linkIds.size)
for (const reroute of visibleReroutes) {
rerouteSet.add(reroute)
if (
this.#snapToGrid &&
this.isDragging &&
@@ -4656,6 +4706,9 @@ export class LGraphCanvas implements ConnectionColorContext {
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
}
reroute.draw(ctx, this._pattern)
// Never draw slots when the pointer is down
if (!this.pointer.isDown) reroute.drawSlots(ctx)
}
ctx.globalAlpha = 1
}
@@ -7143,7 +7196,7 @@ export class LGraphCanvas implements ConnectionColorContext {
// Check for reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY)
const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY, this.#visibleReroutes)
if (reroute) {
menu_info.unshift({
content: "Delete Reroute",

View File

@@ -14,7 +14,7 @@ import type { Serialisable, SerialisableReroute } from "./types/serialisation"
import { LGraphBadge } from "./LGraphBadge"
import { type LinkId, LLink } from "./LLink"
import { distance } from "./measure"
import { distance, isPointInRect } from "./measure"
export type RerouteId = number
@@ -36,6 +36,12 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
/** Maximum distance from reroutes to their bezier curve control points. */
static maxSplineOffset: number = 80
static drawIdBadge: boolean = false
static slotRadius: number = 5
/** Distance from reroute centre to slot centre. */
static get slotOffset(): number {
const gap = Reroute.slotRadius * 0.33
return Reroute.radius + gap + Reroute.slotRadius
}
#malloc = new Float32Array(8)
@@ -81,6 +87,18 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
return [x - radius, y - radius, 2 * radius, 2 * radius]
}
/**
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
* Eliminates most hover positions using an extremely cheap check.
*/
get #hoverArea(): ReadOnlyRect {
const xOffset = 2 * Reroute.slotOffset
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
const [x, y] = this.#pos
return [x - xOffset, y - yOffset, 2 * xOffset, 2 * yOffset]
}
/** The total number of links & floating links using this reroute */
get totalLinks(): number {
return this.linkIds.size + this.floatingLinkIds.size
@@ -115,12 +133,32 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
/** Colour of the first link that rendered this reroute */
_colour?: CanvasColour
/** Colour of the first link that rendered this reroute */
get colour(): CanvasColour {
return this._colour ?? "#18184d"
}
/**
* Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead.
*/
#lastRenderTime: number = -Infinity
#inputSlot = new RerouteSlot(this, true)
#outputSlot = new RerouteSlot(this, false)
get isSlotHovered(): boolean {
return this.isInputHovered || this.isOutputHovered
}
get isInputHovered(): boolean {
return this.#inputSlot.hovering
}
get isOutputHovered(): boolean {
return this.#outputSlot.hovering
}
get firstLink(): LLink | undefined {
const linkId = this.linkIds.values().next().value
return linkId === undefined
@@ -499,7 +537,7 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
ctx.globalAlpha = globalAlpha * 0.33
}
ctx.fillStyle = this._colour ?? "#18184d"
ctx.fillStyle = this.colour
ctx.lineWidth = Reroute.radius * 0.1
ctx.strokeStyle = "rgb(0,0,0,0.5)"
ctx.fill()
@@ -529,13 +567,19 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
ctx.globalAlpha = globalAlpha
}
/**
* Draws the input and output slots on the canvas, if the slots are visible.
* @param ctx The canvas context to draw on.
*/
drawSlots(ctx: CanvasRenderingContext2D): void {
this.#inputSlot.draw(ctx)
this.#outputSlot.draw(ctx)
}
drawHighlight(ctx: CanvasRenderingContext2D, colour: CanvasColour): void {
const { pos } = this
const { strokeStyle, lineWidth } = ctx
ctx.strokeStyle = strokeStyle
ctx.lineWidth = lineWidth
ctx.strokeStyle = colour
ctx.lineWidth = 1
@@ -547,6 +591,56 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
ctx.lineWidth = lineWidth
}
/**
* Updates visibility of the input and output slots, based on the position of the pointer.
* @param pos The position of the pointer.
* @returns `true` if any changes require a redraw.
*/
updateVisibility(pos: Point): boolean {
const input = this.#inputSlot
const output = this.#outputSlot
input.dirty = false
output.dirty = false
const { firstFloatingLink } = this
const hasLink = !!this.firstLink
const showInput = hasLink || firstFloatingLink?.isFloatingOutput
const showOutput = hasLink || firstFloatingLink?.isFloatingInput
const showEither = showInput || showOutput
// Check if even in the vicinity
if (showEither && isPointInRect(pos, this.#hoverArea)) {
const outlineOnly = this.#contains(pos)
if (showInput) input.update(pos, outlineOnly)
if (showOutput) output.update(pos, outlineOnly)
} else {
this.hideSlots()
}
return input.dirty || output.dirty
}
/** Prevents rendering of the input and output slots. */
hideSlots() {
this.#inputSlot.hide()
this.#outputSlot.hide()
}
/**
* Precisely determines if {@link pos} is inside this reroute.
* @param pos The position to check (canvas space)
* @returns `true` if {@link pos} is within the reroute's radius.
*/
containsPoint(pos: Point): boolean {
return isPointInRect(pos, this.#hoverArea) && this.#contains(pos)
}
#contains(pos: Point): boolean {
return distance(this.pos, pos) <= Reroute.radius
}
/** @inheritdoc */
asSerialisable(): SerialisableReroute {
const { id, parentId, pos, linkIds } = this
@@ -560,6 +654,105 @@ export class Reroute implements Positionable, LinkSegment, Serialisable<Serialis
}
}
/**
* Represents a slot on a reroute.
* @private Designed for internal use within this module.
*/
class RerouteSlot {
/** The reroute that the slot belongs to. */
readonly #reroute: Reroute
readonly #offsetMultiplier: 1 | -1
/** Centre point of this slot. */
get pos(): Point {
const [x, y] = this.#reroute.pos
return [x + Reroute.slotOffset * this.#offsetMultiplier, y]
}
/** Whether any changes require a redraw. */
dirty: boolean = false
#hovering = false
/** Whether the pointer is hovering over the slot itself. */
get hovering() {
return this.#hovering
}
set hovering(value) {
if (!Object.is(this.#hovering, value)) {
this.#hovering = value
this.dirty = true
}
}
#showOutline = false
/** Whether the slot outline / faint background is visible. */
get showOutline() {
return this.#showOutline
}
set showOutline(value) {
if (!Object.is(this.#showOutline, value)) {
this.#showOutline = value
this.dirty = true
}
}
constructor(reroute: Reroute, isInput: boolean) {
this.#reroute = reroute
this.#offsetMultiplier = isInput ? -1 : 1
}
/**
* Updates the slot's visibility based on the position of the pointer.
* @param pos The position of the pointer.
* @param outlineOnly If `true`, slot will display with the faded outline only ({@link showOutline}).
*/
update(pos: Point, outlineOnly?: boolean) {
if (outlineOnly) {
this.hovering = false
this.showOutline = true
} else {
const dist = distance(this.pos, pos)
this.hovering = dist <= 2 * Reroute.slotRadius
this.showOutline = dist <= 5 * Reroute.slotRadius
}
}
/** Hides the slot. */
hide() {
this.hovering = false
this.showOutline = false
}
/**
* Draws the slot on the canvas.
* @param ctx The canvas context to draw on.
*/
draw(ctx: CanvasRenderingContext2D): void {
const { fillStyle, strokeStyle, lineWidth } = ctx
const { showOutline, hovering, pos: [x, y] } = this
if (!showOutline) return
try {
ctx.fillStyle = hovering
? this.#reroute.colour
: "rgba(127,127,127,0.3)"
ctx.strokeStyle = "rgb(0,0,0,0.5)"
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(x, y, Reroute.slotRadius, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
} finally {
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
ctx.lineWidth = lineWidth
}
}
}
/**
* Retrieves the position of the next reroute in the chain, or the destination input slot on this link.
* @param network The network of links

View File

@@ -15,6 +15,7 @@ import { MovingInputLink } from "./MovingInputLink"
import { MovingLinkBase } from "./MovingLinkBase"
import { MovingOutputLink } from "./MovingOutputLink"
import { ToInputRenderLink } from "./ToInputRenderLink"
import { ToOutputFromRerouteLink } from "./ToOutputFromRerouteLink"
import { ToOutputRenderLink } from "./ToOutputRenderLink"
/**
@@ -268,13 +269,22 @@ export class LinkConnector {
if (this.isConnecting) throw new Error("Already dragging links.")
const link = reroute.firstLink ?? reroute.firstFloatingLink
if (!link) return
if (!link) {
console.warn("No link found for reroute.")
return
}
const outputNode = network.getNodeById(link.origin_id)
if (!outputNode) return
if (!outputNode) {
console.warn("No output node found for link.", link)
return
}
const outputSlot = outputNode.outputs.at(link.origin_slot)
if (!outputSlot) return
if (!outputSlot) {
console.warn("No output slot found for link.", link)
return
}
const renderLink = new ToInputRenderLink(network, outputNode, outputSlot, reroute)
renderLink.fromDirection = LinkDirection.NONE
@@ -285,6 +295,41 @@ export class LinkConnector {
this.#setLegacyLinks(false)
}
/**
* Drags a new link from a reroute to an output slot.
* @param network The network that the link being connected belongs to
* @param reroute The reroute that the link is being dragged from
*/
dragFromRerouteToOutput(network: LinkNetwork, reroute: Reroute): void {
if (this.isConnecting) throw new Error("Already dragging links.")
const link = reroute.firstLink ?? reroute.firstFloatingLink
if (!link) {
console.warn("No link found for reroute.")
return
}
const inputNode = network.getNodeById(link.target_id)
if (!inputNode) {
console.warn("No input node found for link.", link)
return
}
const inputSlot = inputNode.inputs.at(link.target_slot)
if (!inputSlot) {
console.warn("No input slot found for link.", link)
return
}
const renderLink = new ToOutputFromRerouteLink(network, inputNode, inputSlot, reroute, this)
renderLink.fromDirection = LinkDirection.LEFT
this.renderLinks.push(renderLink)
this.state.connectingTo = "output"
this.#setLegacyLinks(true)
}
dragFromLinkSegment(network: LinkNetwork, linkSegment: LinkSegment): void {
if (this.isConnecting) throw new Error("Already dragging links.")
@@ -387,39 +432,7 @@ export class LinkConnector {
if (this.renderLinks.length !== 1) throw new Error(`Attempted to connect ${this.renderLinks.length} input links to a reroute.`)
const renderLink = this.renderLinks[0]
const results = reroute.findTargetInputs()
if (!results?.length) return
const maybeReroutes = reroute.getReroutes()
if (maybeReroutes === null) throw new Error("Reroute loop detected.")
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
// From reroute to reroute
if (renderLink instanceof ToInputRenderLink) {
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
// Clean floating link IDs from reroutes about to be removed from the chain
if (fromReroute != null) {
for (const originalReroute of originalReroutes) {
if (originalReroute.id === fromReroute.id) break
for (const linkId of reroute.floatingLinkIds) {
originalReroute.floatingLinkIds.delete(linkId)
}
}
}
}
// Filter before any connections are re-created
const filtered = results.filter(result => renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute))
for (const result of filtered) {
renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes)
}
this._connectOutputToReroute(reroute, renderLink)
return
}
@@ -438,6 +451,44 @@ export class LinkConnector {
}
}
/** @internal Temporary workaround - requires refactor. */
_connectOutputToReroute(reroute: Reroute, renderLink: RenderLinkUnion): void {
const results = reroute.findTargetInputs()
if (!results?.length) return
const maybeReroutes = reroute.getReroutes()
if (maybeReroutes === null) throw new Error("Reroute loop detected.")
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
// From reroute to reroute
if (renderLink instanceof ToInputRenderLink) {
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
// Clean floating link IDs from reroutes about to be removed from the chain
if (fromReroute != null) {
for (const originalReroute of originalReroutes) {
if (originalReroute.id === fromReroute.id) break
for (const linkId of reroute.floatingLinkIds) {
originalReroute.floatingLinkIds.delete(linkId)
}
}
}
}
// Filter before any connections are re-created
const filtered = results.filter(result => renderLink.toType === "input" && canConnectInputLinkToReroute(renderLink, result.node, result.input, reroute))
for (const result of filtered) {
renderLink.connectToRerouteInput(reroute, result, this.events, originalReroutes)
}
return
}
dropOnNothing(event: CanvasPointerEvent): void {
// For external event only.
const mayContinue = this.events.dispatch("dropped-on-canvas", event)

View File

@@ -0,0 +1,31 @@
import type { LinkConnector } from "./LinkConnector"
import type { LGraphNode } from "@/LGraphNode"
import type { INodeInputSlot, INodeOutputSlot, LinkNetwork } from "@/litegraph"
import type { Reroute } from "@/Reroute"
import { ToInputRenderLink } from "./ToInputRenderLink"
import { ToOutputRenderLink } from "./ToOutputRenderLink"
/**
* @internal A workaround class to support connecting to reroutes to node outputs.
*/
export class ToOutputFromRerouteLink extends ToOutputRenderLink {
constructor(
network: LinkNetwork,
node: LGraphNode,
fromSlot: INodeInputSlot,
readonly fromReroute: Reroute,
readonly linkConnector: LinkConnector,
) {
super(network, node, fromSlot, fromReroute)
}
override canConnectToReroute(): false {
return false
}
override connectToOutput(node: LGraphNode, output: INodeOutputSlot) {
const nuRenderLink = new ToInputRenderLink(this.network, node, output)
this.linkConnector._connectOutputToReroute(this.fromReroute, nuRenderLink)
}
}

View File

@@ -36,6 +36,8 @@ export enum CanvasItem {
Link = 1 << 3,
/** A resize in the bottom-right corner */
ResizeSe = 1 << 4,
/** A reroute slot */
RerouteSlot = 1 << 5,
}
/** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */