Totally not scuffed renderer and adapter

This commit is contained in:
Benjamin Lu
2025-08-14 21:33:34 -04:00
parent 18f78ff786
commit 2b9d83efb8
3 changed files with 1244 additions and 341 deletions

View File

@@ -1,4 +1,9 @@
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/rendering/adapters/LitegraphLinkAdapter'
import { layoutStore } from '@/stores/layoutStore'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -47,7 +52,6 @@ import {
containsRect,
createBounds,
distance,
findPointOnCurve,
isInRect,
isInRectangle,
isPointInRect,
@@ -232,9 +236,6 @@ export class LGraphCanvas
static #tmp_area = new Float32Array(4)
static #margin_area = new Float32Array(4)
static #link_bounding = new Float32Array(4)
static #lTempA: Point = new Float32Array(2)
static #lTempB: Point = new Float32Array(2)
static #lTempC: Point = new Float32Array(2)
static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -636,6 +637,9 @@ export class LGraphCanvas
/** Set on keydown, keyup. @todo */
#shiftDown: boolean = false
/** Link rendering adapter for litegraph-to-canvas integration */
linkRenderer: LitegraphLinkAdapter | null = null
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
dragZoomEnabled: boolean = false
/** The start position of the drag zoom. */
@@ -697,6 +701,11 @@ export class LGraphCanvas
this.ds = new DragAndScale(canvas)
this.pointer = new CanvasPointer(canvas)
// Initialize link renderer if graph is available
if (graph) {
this.linkRenderer = new LitegraphLinkAdapter(layoutStore, graph)
}
this.linkConnector.events.addEventListener('link-created', () =>
this.#dirty()
)
@@ -1790,6 +1799,9 @@ export class LGraphCanvas
this.clear()
newGraph.attachCanvas(this)
// Re-initialize link renderer with new graph
this.linkRenderer = new LitegraphLinkAdapter(layoutStore, newGraph)
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()
}
@@ -4538,18 +4550,26 @@ export class LGraphCanvas
: LiteGraph.CONNECTING_LINK_COLOR
// the connection being dragged by the mouse
this.renderLink(
ctx,
pos,
highlightPos,
null,
false,
null,
colour,
fromDirection,
dragDirection
)
if (this.linkRenderer) {
const context = this.buildLinkRenderContext()
this.linkRenderer.renderLinkDirect(
ctx,
pos,
highlightPos,
null,
false,
null,
colour,
fromDirection,
dragDirection,
context,
{
disabled: false
}
)
}
ctx.fillStyle = colour
ctx.beginPath()
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
@@ -5660,6 +5680,34 @@ export class LGraphCanvas
}
}
/**
* Build LinkRenderContext from canvas properties
* Helper method for using LitegraphLinkAdapter
*/
private buildLinkRenderContext(): LinkRenderContext {
return {
// Canvas settings
renderMode: this.links_render_mode,
connectionWidth: this.connections_width,
renderBorder: this.render_connections_border,
lowQuality: this.low_quality,
highQualityRender: this.highquality_render,
scale: this.ds.scale,
linkMarkerShape: this.linkMarkerShape,
renderConnectionArrows: this.render_connection_arrows,
// State
highlightedLinks: new Set(Object.keys(this.highlighted_links)),
// Colors
defaultLinkColor: this.default_link_color,
linkTypeColors: LGraphCanvas.link_type_colors,
// Pattern for disabled links
disabledPattern: this._pattern
}
}
/**
* draws a link between two points
* @param ctx Canvas 2D rendering context
@@ -5701,333 +5749,27 @@ export class LGraphCanvas
disabled?: boolean
} = {}
): void {
const linkColour =
link != null && this.highlighted_links[link.id]
? '#FFF'
: color ||
link?.color ||
(link?.type != null && LGraphCanvas.link_type_colors[link.type]) ||
this.default_link_color
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
const dist =
this.links_render_mode == LinkRenderType.SPLINE_LINK &&
(!endControl || !startControl)
? distance(a, b)
: 0
// TODO: Subline code below was inserted in the wrong place - should be before this statement
if (this.render_connections_border && !this.low_quality) {
ctx.lineWidth = this.connections_width + 4
}
ctx.lineJoin = 'round'
num_sublines ||= 1
if (num_sublines > 1) ctx.lineWidth = 0.5
// begin line shape
const path = new Path2D()
/** The link or reroute we're currently rendering */
const linkSegment = reroute ?? link
if (linkSegment) linkSegment.path = path
const innerA = LGraphCanvas.#lTempA
const innerB = LGraphCanvas.#lTempB
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
const pos: Point = linkSegment?._pos ?? [0, 0]
for (let i = 0; i < num_sublines; i++) {
const offsety = (i - (num_sublines - 1) * 0.5) * 5
innerA[0] = a[0]
innerA[1] = a[1]
innerB[0] = b[0]
innerB[1] = b[1]
if (this.links_render_mode == LinkRenderType.SPLINE_LINK) {
if (endControl) {
innerB[0] = b[0] + endControl[0]
innerB[1] = b[1] + endControl[1]
} else {
this.#addSplineOffset(innerB, endDir, dist)
if (this.linkRenderer) {
const context = this.buildLinkRenderContext()
this.linkRenderer.renderLinkDirect(
ctx,
a,
b,
link,
skip_border,
flow,
color,
start_dir,
end_dir,
context,
{
reroute,
startControl,
endControl,
num_sublines,
disabled
}
if (startControl) {
innerA[0] = a[0] + startControl[0]
innerA[1] = a[1] + startControl[1]
} else {
this.#addSplineOffset(innerA, startDir, dist)
}
path.moveTo(a[0], a[1] + offsety)
path.bezierCurveTo(
innerA[0],
innerA[1] + offsety,
innerB[0],
innerB[1] + offsety,
b[0],
b[1] + offsety
)
// Calculate centre point
findPointOnCurve(pos, a, b, innerA, innerB, 0.5)
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
const justPastCentre = LGraphCanvas.#lTempC
findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51)
linkSegment._centreAngle = Math.atan2(
justPastCentre[1] - pos[1],
justPastCentre[0] - pos[0]
)
}
} else {
const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10
switch (startDir) {
case LinkDirection.LEFT:
innerA[0] += -l
break
case LinkDirection.RIGHT:
innerA[0] += l
break
case LinkDirection.UP:
innerA[1] += -l
break
case LinkDirection.DOWN:
innerA[1] += l
break
}
switch (endDir) {
case LinkDirection.LEFT:
innerB[0] += -l
break
case LinkDirection.RIGHT:
innerB[0] += l
break
case LinkDirection.UP:
innerB[1] += -l
break
case LinkDirection.DOWN:
innerB[1] += l
break
}
if (this.links_render_mode == LinkRenderType.LINEAR_LINK) {
path.moveTo(a[0], a[1] + offsety)
path.lineTo(innerA[0], innerA[1] + offsety)
path.lineTo(innerB[0], innerB[1] + offsety)
path.lineTo(b[0], b[1] + offsety)
// Calculate centre point
pos[0] = (innerA[0] + innerB[0]) * 0.5
pos[1] = (innerA[1] + innerB[1]) * 0.5
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
linkSegment._centreAngle = Math.atan2(
innerB[1] - innerA[1],
innerB[0] - innerA[0]
)
}
} else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) {
const midX = (innerA[0] + innerB[0]) * 0.5
path.moveTo(a[0], a[1])
path.lineTo(innerA[0], innerA[1])
path.lineTo(midX, innerA[1])
path.lineTo(midX, innerB[1])
path.lineTo(innerB[0], innerB[1])
path.lineTo(b[0], b[1])
// Calculate centre point
pos[0] = midX
pos[1] = (innerA[1] + innerB[1]) * 0.5
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
const diff = innerB[1] - innerA[1]
if (Math.abs(diff) < 4) linkSegment._centreAngle = 0
else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5
else linkSegment._centreAngle = -(Math.PI * 0.5)
}
} else {
return
}
}
}
// rendering the outline of the connection can be a little bit slow
if (this.render_connections_border && !this.low_quality && !skip_border) {
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
ctx.stroke(path)
}
ctx.lineWidth = this.connections_width
ctx.fillStyle = ctx.strokeStyle = linkColour
ctx.stroke(path)
// render arrow in the middle
if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) {
// render arrow
if (this.render_connection_arrows) {
// compute two points in the connection
const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir)
const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir)
const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir)
const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir)
// compute the angle between them so the arrow points in the right direction
let angleA = 0
let angleB = 0
if (this.render_curved_connections) {
angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1])
angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1])
} else {
angleB = angleA = b[1] > a[1] ? 0 : Math.PI
}
// render arrow
const transform = ctx.getTransform()
ctx.translate(posA[0], posA[1])
ctx.rotate(angleA)
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
ctx.translate(posC[0], posC[1])
ctx.rotate(angleB)
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
}
// Draw link centre marker
ctx.beginPath()
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
const transform = ctx.getTransform()
ctx.translate(pos[0], pos[1])
if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle)
// The math is off, but it currently looks better in chromium
ctx.moveTo(-3.2, -5)
ctx.lineTo(+7, 0)
ctx.lineTo(-3.2, +5)
ctx.setTransform(transform)
} else if (
this.linkMarkerShape == null ||
this.linkMarkerShape === LinkMarkerShape.Circle
) {
ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2)
}
if (disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = this._pattern ?? '#797979'
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
}
ctx.fill()
if (LLink._drawDebug) {
const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx
ctx.globalAlpha = 1
ctx.lineWidth = 4
ctx.fillStyle = 'white'
ctx.strokeStyle = 'black'
ctx.font = '16px Arial'
const text = String(linkSegment.id)
const { width, actualBoundingBoxAscent } = ctx.measureText(text)
const x = pos[0] - width * 0.5
const y = pos[1] + actualBoundingBoxAscent * 0.5
ctx.strokeText(text, x, y)
ctx.fillText(text, x, y)
ctx.font = font
ctx.globalAlpha = globalAlpha
ctx.lineWidth = lineWidth
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
}
}
// render flowing points
if (flow) {
ctx.fillStyle = linkColour
for (let i = 0; i < 5; ++i) {
const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1
const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir)
ctx.beginPath()
ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI)
ctx.fill()
}
}
}
/**
* Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir.
* @param a Start point
* @param b End point
* @param t Time: distance between points (e.g 0.25 is 25% along the line)
* @param start_dir Spline start direction
* @param end_dir Spline end direction
* @returns The point at {@link t} distance along the spline a-b.
*/
computeConnectionPoint(
a: ReadOnlyPoint,
b: ReadOnlyPoint,
t: number,
start_dir: LinkDirection,
end_dir: LinkDirection
): Point {
start_dir ||= LinkDirection.RIGHT
end_dir ||= LinkDirection.LEFT
const dist = distance(a, b)
const pa: Point = [a[0], a[1]]
const pb: Point = [b[0], b[1]]
this.#addSplineOffset(pa, start_dir, dist)
this.#addSplineOffset(pb, end_dir, dist)
const c1 = (1 - t) * (1 - t) * (1 - t)
const c2 = 3 * ((1 - t) * (1 - t)) * t
const c3 = 3 * (1 - t) * (t * t)
const c4 = t * t * t
const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0]
const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1]
return [x, y]
}
/**
* Modifies an existing point, adding a single-axis offset.
* @param point The point to add the offset to
* @param direction The direction to add the offset in
* @param dist Distance to offset
* @param factor Distance is mulitplied by this value. Default: 0.25
*/
#addSplineOffset(
point: Point,
direction: LinkDirection,
dist: number,
factor = 0.25
): void {
switch (direction) {
case LinkDirection.LEFT:
point[0] += dist * -factor
break
case LinkDirection.RIGHT:
point[0] += dist * factor
break
case LinkDirection.UP:
point[1] += dist * -factor
break
case LinkDirection.DOWN:
point[1] += dist * factor
break
)
}
}

View File

@@ -0,0 +1,531 @@
/**
* Litegraph Link Adapter
*
* Bridges the gap between litegraph's data model and the pure canvas renderer.
* Converts litegraph-specific types (LLink, LGraphNode, slots) into generic
* rendering data that can be consumed by the PathRenderer.
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LinkDirection,
LinkMarkerShape,
LinkRenderType
} from '@/lib/litegraph/src/types/globalEnums'
import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type RenderMode
} from '@/rendering/canvas/PathRenderer'
import type { LayoutStore, Point } from '@/types/layoutTypes'
export interface LinkRenderContext {
// Canvas settings
renderMode: LinkRenderType
connectionWidth: number
renderBorder: boolean
lowQuality: boolean
highQualityRender: boolean
scale: number
linkMarkerShape: LinkMarkerShape
renderConnectionArrows: boolean
// State
highlightedLinks: Set<string | number>
// Colors
defaultLinkColor: CanvasColour
linkTypeColors: Record<string, CanvasColour>
// Pattern for disabled links (optional)
disabledPattern?: CanvasPattern | null
}
export interface LinkRenderOptions {
color?: CanvasColour
flow?: boolean
skipBorder?: boolean
disabled?: boolean
}
export class LitegraphLinkAdapter {
private layoutStore: LayoutStore
private graph: LGraph
private pathRenderer: CanvasPathRenderer
constructor(layoutStore: LayoutStore, graph: LGraph) {
this.layoutStore = layoutStore
this.graph = graph
this.pathRenderer = new CanvasPathRenderer()
}
/**
* Render a single link with all necessary data properly fetched
* Populates link.path for hit detection
*/
renderLink(
ctx: CanvasRenderingContext2D,
link: LLink,
context: LinkRenderContext,
options: LinkRenderOptions = {}
): void {
// Get nodes from graph
const sourceNode = this.graph.getNodeById(link.origin_id)
const targetNode = this.graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) {
console.warn(`Cannot render link ${link.id}: missing nodes`)
return
}
// Get slots from nodes
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) {
console.warn(`Cannot render link ${link.id}: missing slots`)
return
}
// Get positions from nodes
const startPos = sourceNode.getOutputPos(link.origin_slot)
const endPos = targetNode.getInputPos(link.target_slot)
// Get directions from slots
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Convert to pure render data
const linkData = this.convertToLinkRenderData(
link,
{ x: startPos[0], y: startPos[1] },
{ x: endPos[0], y: endPos[1] },
startDir,
endDir,
options
)
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
link.path = path
}
/**
* Convert litegraph link data to pure render format
*/
private convertToLinkRenderData(
link: LLink,
startPoint: Point,
endPoint: Point,
startDir: LinkDirection,
endDir: LinkDirection,
options: LinkRenderOptions
): LinkRenderData {
return {
id: String(link.id),
startPoint,
endPoint,
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: options.color
? String(options.color)
: link.color
? String(link.color)
: undefined,
type: link.type !== undefined ? String(link.type) : undefined,
flow: options.flow || false,
disabled: options.disabled || false
}
}
/**
* Convert LinkDirection enum to Direction string
*/
private convertDirection(dir: LinkDirection): Direction {
switch (dir) {
case LinkDirection.LEFT:
return 'left'
case LinkDirection.RIGHT:
return 'right'
case LinkDirection.UP:
return 'up'
case LinkDirection.DOWN:
return 'down'
default:
return 'right'
}
}
/**
* Convert LinkRenderContext to PathRenderContext
*/
private convertToPathRenderContext(
context: LinkRenderContext
): PathRenderContext {
return {
style: {
mode: this.convertRenderMode(context.renderMode),
connectionWidth: context.connectionWidth,
borderWidth: context.renderBorder ? 4 : undefined,
arrowShape: this.convertArrowShape(context.linkMarkerShape),
showArrows: context.renderConnectionArrows,
lowQuality: context.lowQuality,
// Center marker settings (matches original litegraph behavior)
showCenterMarker: true,
centerMarkerShape:
context.linkMarkerShape === LinkMarkerShape.Arrow
? 'arrow'
: 'circle',
highQuality: context.highQualityRender
},
colors: {
default: String(context.defaultLinkColor),
byType: this.convertColorMap(context.linkTypeColors),
highlighted: '#FFF'
},
patterns: {
disabled: context.disabledPattern
},
animation: {
time: LiteGraph.getTime() * 0.001
},
scale: context.scale,
highlightedIds: new Set(Array.from(context.highlightedLinks).map(String))
}
}
/**
* Convert LinkRenderType to RenderMode
*/
private convertRenderMode(mode: LinkRenderType): RenderMode {
switch (mode) {
case LinkRenderType.LINEAR_LINK:
return 'linear'
case LinkRenderType.STRAIGHT_LINK:
return 'straight'
case LinkRenderType.SPLINE_LINK:
default:
return 'spline'
}
}
/**
* Convert LinkMarkerShape to ArrowShape
*/
private convertArrowShape(shape: LinkMarkerShape): ArrowShape {
switch (shape) {
case LinkMarkerShape.Circle:
return 'circle'
case LinkMarkerShape.Arrow:
default:
return 'triangle'
}
}
/**
* Convert color map to ensure all values are strings
*/
private convertColorMap(
colors: Record<string, CanvasColour>
): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(colors)) {
result[key] = String(value)
}
return result
}
/**
* Apply spline offset to a point, mimicking original #addSplineOffset behavior
* Critically: does nothing for CENTER/NONE directions (no case for them)
*/
private applySplineOffset(
point: Point,
direction: LinkDirection,
distance: number
): void {
switch (direction) {
case LinkDirection.LEFT:
point.x -= distance
break
case LinkDirection.RIGHT:
point.x += distance
break
case LinkDirection.UP:
point.y -= distance
break
case LinkDirection.DOWN:
point.y += distance
break
// CENTER and NONE: no offset applied (original behavior)
}
}
/**
* Direct rendering method compatible with LGraphCanvas
* Converts data and delegates to pure renderer
*/
renderLinkDirect(
ctx: CanvasRenderingContext2D,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
link: LLink | null,
skip_border: boolean,
flow: number | boolean | null,
color: CanvasColour | null,
start_dir: LinkDirection,
end_dir: LinkDirection,
context: LinkRenderContext,
extras: {
reroute?: Reroute
startControl?: ReadOnlyPoint
endControl?: ReadOnlyPoint
num_sublines?: number
disabled?: boolean
} = {}
): void {
// Apply same defaults as original renderLink
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
// Convert flow to boolean
const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
// Create LinkRenderData from direct parameters
const linkData: LinkRenderData = {
id: link ? String(link.id) : 'temp',
startPoint: { x: a[0], y: a[1] },
endPoint: { x: b[0], y: b[1] },
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: color !== null && color !== undefined ? String(color) : undefined,
type: link?.type !== undefined ? String(link.type) : undefined,
flow: flowBool,
disabled: extras.disabled || false
}
// Control points handling (spline mode):
// - Pre-refactor, the old renderLink honored a single provided control and
// derived the missing side via #addSplineOffset (CENTER => no offset).
// - Restore that behavior here so reroute segments render identically.
if (context.renderMode === LinkRenderType.SPLINE_LINK) {
const hasStartCtrl = !!extras.startControl
const hasEndCtrl = !!extras.endControl
// Compute distance once for offsets
const dist = Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
)
const factor = 0.25
const cps: Point[] = []
if (hasStartCtrl && hasEndCtrl) {
// Both provided explicitly
cps.push(
{
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
},
{
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
)
linkData.controlPoints = cps
} else if (hasStartCtrl && !hasEndCtrl) {
// Start provided, derive end via direction offset (CENTER => no offset)
const start = {
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
}
const end = { x: b[0], y: b[1] }
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
} else if (!hasStartCtrl && hasEndCtrl) {
// End provided, derive start via direction offset (CENTER => no offset)
const start = { x: a[0], y: a[1] }
this.applySplineOffset(start, startDir, dist * factor)
const end = {
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
cps.push(start, end)
linkData.controlPoints = cps
} else {
// Neither provided: derive both from directions (CENTER => no offset)
const start = { x: a[0], y: a[1] }
const end = { x: b[0], y: b[1] }
this.applySplineOffset(start, startDir, dist * factor)
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
}
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Override skip_border if needed
if (skip_border) {
pathContext.style.borderWidth = undefined
}
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
const linkSegment = extras.reroute ?? link
if (linkSegment) {
linkSegment.path = path
// Copy calculated center position back to litegraph object
// This is needed for hit detection and menu interaction
if (linkData.centerPos) {
linkSegment._pos = linkSegment._pos || new Float32Array(2)
linkSegment._pos[0] = linkData.centerPos.x
linkSegment._pos[1] = linkData.centerPos.y
// Store center angle if calculated (for arrow markers)
if (linkData.centerAngle !== undefined) {
linkSegment._centreAngle = linkData.centerAngle
}
}
}
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
): void {
if (!fromNode) return
// Get slot position
const slotPos = options.fromInput
? fromNode.getInputPos(fromSlotIndex)
: fromNode.getOutputPos(fromSlotIndex)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**
* Render a dragging link with direct position data
* More flexible version for complex drag scenarios
*/
renderDraggingLinkDirect(
ctx: CanvasRenderingContext2D,
fromPos: ReadOnlyPoint,
fromDir: LinkDirection,
toPos: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
toDir?: LinkDirection
color?: CanvasColour
type?: ISlotType
disabled?: boolean
} = {}
): void {
// Use renderLinkDirect which already handles CENTER/NONE correctly
this.renderLinkDirect(
ctx,
fromPos,
toPos,
null, // no link
false, // skip_border
null, // flow
options.color || null,
fromDir,
options.toDir || LinkDirection.CENTER, // Default to CENTER for drag end
context,
{
disabled: options.disabled || false
}
)
}
/**
* Get slot position helper - may be useful for Vue components
*/
getSlotAbsolutePosition(
nodeId: string,
slotType: 'input' | 'output',
slotIndex: number
): Point | null {
// Get node from graph
const node = this.graph.getNodeById(nodeId)
if (!node) return null
// Get node position from layout tree
const nodeLayout = this.layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return null
// Get relative slot position from node
const relativePos =
slotType === 'input'
? node.getInputPos(slotIndex)
: node.getOutputPos(slotIndex)
if (!relativePos) return null
// Combine to get absolute position
return {
x: nodeLayout.position.x + relativePos[0],
y: nodeLayout.position.y + relativePos[1]
}
}
}

View File

@@ -0,0 +1,630 @@
/**
* Path Renderer
*
* Pure canvas2D rendering utility with no framework dependencies.
* Renders bezier curves, straight lines, and linear connections between points.
* Supports arrows, flow animations, and returns Path2D objects for hit detection.
* Can be reused in any canvas-based project without modification.
*/
export interface Point {
x: number
y: number
}
export type Direction = 'left' | 'right' | 'up' | 'down'
export type RenderMode = 'spline' | 'straight' | 'linear'
export type ArrowShape = 'triangle' | 'circle' | 'square'
export interface LinkRenderData {
id: string
startPoint: Point
endPoint: Point
startDirection: Direction
endDirection: Direction
color?: string
type?: string
controlPoints?: Point[]
flow?: boolean
disabled?: boolean
// Optional multi-segment support
segments?: Array<{
start: Point
end: Point
controlPoints?: Point[]
}>
// Center point storage (for hit detection and menu)
centerPos?: Point
centerAngle?: number
}
export interface RenderStyle {
mode: RenderMode
connectionWidth: number
borderWidth?: number
arrowShape?: ArrowShape
showArrows?: boolean
lowQuality?: boolean
// Center marker properties
showCenterMarker?: boolean
centerMarkerShape?: 'circle' | 'arrow'
highQuality?: boolean
}
export interface RenderColors {
default: string
byType: Record<string, string>
highlighted: string
}
export interface RenderContext {
style: RenderStyle
colors: RenderColors
patterns?: {
disabled?: CanvasPattern | null
}
animation?: {
time: number // Seconds for flow animation
}
scale?: number // Canvas scale for quality adjustments
highlightedIds?: Set<string>
}
export interface DragLinkData {
/** Fixed end - the slot being dragged from */
fixedPoint: Point
fixedDirection: Direction
/** Moving end - follows mouse */
dragPoint: Point
dragDirection?: Direction
/** Visual properties */
color?: string
type?: string
disabled?: boolean
/** Whether dragging from input (reverse direction) */
fromInput?: boolean
}
export class CanvasPathRenderer {
/**
* Draw a link between two points
* Returns a Path2D object for hit detection
*/
drawLink(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext
): Path2D {
const path = new Path2D()
// Determine final color
const isHighlighted = context.highlightedIds?.has(link.id) ?? false
const color = this.determineLinkColor(link, context, isHighlighted)
// Save context state
ctx.save()
// Apply disabled pattern if needed
if (link.disabled && context.patterns?.disabled) {
ctx.strokeStyle = context.patterns.disabled
} else {
ctx.strokeStyle = color
}
// Set line properties
ctx.lineWidth = context.style.connectionWidth
ctx.lineJoin = 'round'
// Draw border if needed
if (context.style.borderWidth && !context.style.lowQuality) {
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth + context.style.borderWidth,
'rgba(0,0,0,0.5)'
)
}
// Draw main link
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth,
color
)
// Calculate and store center position
this.calculateCenterPoint(link, context)
// Draw arrows if needed
if (context.style.showArrows) {
this.drawArrows(ctx, link, context, color)
}
// Draw center marker if needed (for link menu interaction)
if (
context.style.showCenterMarker &&
context.scale &&
context.scale >= 0.6 &&
context.style.highQuality
) {
this.drawCenterMarker(ctx, link, context, color)
}
// Draw flow animation if needed
if (link.flow && context.animation) {
this.drawFlowAnimation(ctx, path, link, context)
}
ctx.restore()
return path
}
private determineLinkColor(
link: LinkRenderData,
context: RenderContext,
isHighlighted: boolean
): string {
if (isHighlighted) {
return context.colors.highlighted
}
if (link.color) {
return link.color
}
if (link.type && context.colors.byType[link.type]) {
return context.colors.byType[link.type]
}
return context.colors.default
}
private drawLinkPath(
ctx: CanvasRenderingContext2D,
path: Path2D,
link: LinkRenderData,
context: RenderContext,
lineWidth: number,
color: string
): void {
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
const start = link.startPoint
const end = link.endPoint
// Build the path based on render mode
if (context.style.mode === 'linear') {
this.buildLinearPath(path, start, end)
} else if (context.style.mode === 'straight') {
this.buildStraightPath(
path,
start,
end,
link.startDirection,
link.endDirection
)
} else {
// Spline mode (default)
this.buildSplinePath(
path,
start,
end,
link.startDirection,
link.endDirection,
link.controlPoints
)
}
ctx.stroke(path)
}
private buildLinearPath(path: Path2D, start: Point, end: Point): void {
path.moveTo(start.x, start.y)
path.lineTo(end.x, end.y)
}
private buildStraightPath(
path: Path2D,
start: Point,
end: Point,
_startDir: Direction,
_endDir: Direction
): void {
path.moveTo(start.x, start.y)
const dx = end.x - start.x
const dy = end.y - start.y
if (Math.abs(dx) > Math.abs(dy)) {
const midX = start.x + dx * 0.5
path.lineTo(midX, start.y)
path.lineTo(midX, end.y)
} else {
const midY = start.y + dy * 0.5
path.lineTo(start.x, midY)
path.lineTo(end.x, midY)
}
path.lineTo(end.x, end.y)
}
private buildSplinePath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction,
controlPoints?: Point[]
): void {
path.moveTo(start.x, start.y)
// Calculate control points if not provided
const controls =
controlPoints || this.calculateControlPoints(start, end, startDir, endDir)
if (controls.length >= 2) {
// Cubic bezier
path.bezierCurveTo(
controls[0].x,
controls[0].y,
controls[1].x,
controls[1].y,
end.x,
end.y
)
} else if (controls.length === 1) {
// Quadratic bezier
path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y)
} else {
// Fallback to linear
path.lineTo(end.x, end.y)
}
}
private calculateControlPoints(
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): Point[] {
const dist = Math.sqrt(
Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
)
const controlDist = Math.max(30, dist * 0.25)
// Calculate control point offsets based on direction
const startControl = this.getDirectionOffset(startDir, controlDist)
const endControl = this.getDirectionOffset(endDir, controlDist)
return [
{ x: start.x + startControl.x, y: start.y + startControl.y },
{ x: end.x + endControl.x, y: end.y + endControl.y }
]
}
private getDirectionOffset(direction: Direction, distance: number): Point {
switch (direction) {
case 'left':
return { x: -distance, y: 0 }
case 'right':
return { x: distance, y: 0 }
case 'up':
return { x: 0, y: -distance }
case 'down':
return { x: 0, y: distance }
}
}
private drawArrows(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!context.style.showArrows) return
const arrowSize = 5 * (context.scale || 1)
const shape = context.style.arrowShape || 'triangle'
// Calculate arrow position (middle of link for now)
const mid = {
x: (link.startPoint.x + link.endPoint.x) / 2,
y: (link.startPoint.y + link.endPoint.y) / 2
}
// Calculate angle
const angle = Math.atan2(
link.endPoint.y - link.startPoint.y,
link.endPoint.x - link.startPoint.x
)
ctx.save()
ctx.translate(mid.x, mid.y)
ctx.rotate(angle)
ctx.fillStyle = color
if (shape === 'circle') {
ctx.beginPath()
ctx.arc(0, 0, arrowSize, 0, Math.PI * 2)
ctx.fill()
} else if (shape === 'square') {
ctx.fillRect(-arrowSize / 2, -arrowSize / 2, arrowSize, arrowSize)
} else {
// Triangle (default)
ctx.beginPath()
ctx.moveTo(arrowSize, 0)
ctx.lineTo(-arrowSize, -arrowSize)
ctx.lineTo(-arrowSize, arrowSize)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
private drawFlowAnimation(
ctx: CanvasRenderingContext2D,
path: Path2D,
_link: LinkRenderData,
context: RenderContext
): void {
if (!context.animation) return
const time = context.animation.time
const spacing = 24
const speed = 48
ctx.save()
ctx.strokeStyle = context.colors.highlighted
ctx.lineWidth = Math.max(1, context.style.connectionWidth * 0.5)
// Create dashed line effect for flow
const dashOffset = (time * speed) % spacing
ctx.setLineDash([4, spacing - 4])
ctx.lineDashOffset = -dashOffset
ctx.stroke(path)
ctx.restore()
}
/**
* Utility to find a point on a bezier curve (for hit detection)
*/
findPointOnBezier(
t: number,
p0: Point,
p1: Point,
p2: Point,
p3: Point
): Point {
const mt = 1 - t
const mt2 = mt * mt
const mt3 = mt2 * mt
const t2 = t * t
const t3 = t2 * t
return {
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
}
}
/**
* Draw a link being dragged from a slot to the mouse position
* Returns a Path2D object for potential hit detection
*/
drawDraggingLink(
ctx: CanvasRenderingContext2D,
dragData: DragLinkData,
context: RenderContext
): Path2D {
// Create LinkRenderData from drag data
// When dragging from input, swap the points/directions
const linkData: LinkRenderData = dragData.fromInput
? {
id: 'dragging',
startPoint: dragData.dragPoint,
endPoint: dragData.fixedPoint,
startDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
endDirection: dragData.fixedDirection,
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
: {
id: 'dragging',
startPoint: dragData.fixedPoint,
endPoint: dragData.dragPoint,
startDirection: dragData.fixedDirection,
endDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
// Use standard link drawing
return this.drawLink(ctx, linkData, context)
}
/**
* Get the opposite direction (for drag preview)
*/
private getOppositeDirection(direction: Direction): Direction {
switch (direction) {
case 'left':
return 'right'
case 'right':
return 'left'
case 'up':
return 'down'
case 'down':
return 'up'
}
}
/**
* Get the center point of a link (useful for labels, debugging)
*/
getLinkCenter(link: LinkRenderData): Point {
// For now, simple midpoint
// Could be enhanced to find actual curve midpoint
return {
x: (link.startPoint.x + link.endPoint.x) / 2,
y: (link.startPoint.y + link.endPoint.y) / 2
}
}
/**
* Calculate and store the center point and angle of a link
* Mimics the original litegraph center point calculation
*/
private calculateCenterPoint(
link: LinkRenderData,
context: RenderContext
): void {
const { startPoint, endPoint, controlPoints } = link
if (
context.style.mode === 'spline' &&
controlPoints &&
controlPoints.length >= 2
) {
// For spline mode, find point at t=0.5 on the bezier curve
const centerPos = this.findPointOnBezier(
0.5,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerPos = centerPos
// Calculate angle for arrow marker (point slightly past center)
if (context.style.centerMarkerShape === 'arrow') {
const justPastCenter = this.findPointOnBezier(
0.51,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerAngle = Math.atan2(
justPastCenter.y - centerPos.y,
justPastCenter.x - centerPos.x
)
}
} else if (context.style.mode === 'linear') {
// For linear mode, calculate midpoint between control points
const startControl = this.getDirectionOffset(link.startDirection, 15)
const endControl = this.getDirectionOffset(link.endDirection, 15)
const innerA = {
x: startPoint.x + startControl.x,
y: startPoint.y + startControl.y
}
const innerB = {
x: endPoint.x + endControl.x,
y: endPoint.y + endControl.y
}
link.centerPos = {
x: (innerA.x + innerB.x) * 0.5,
y: (innerA.y + innerB.y) * 0.5
}
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x)
}
} else if (context.style.mode === 'straight') {
// For straight mode, calculate midpoint
const dx = endPoint.x - startPoint.x
const dy = endPoint.y - startPoint.y
if (Math.abs(dx) > Math.abs(dy)) {
const midX = startPoint.x + dx * 0.5
link.centerPos = {
x: midX,
y: (startPoint.y + endPoint.y) * 0.5
}
} else {
const midY = startPoint.y + dy * 0.5
link.centerPos = {
x: (startPoint.x + endPoint.x) * 0.5,
y: midY
}
}
if (context.style.centerMarkerShape === 'arrow') {
const diff = endPoint.y - startPoint.y
if (Math.abs(diff) < 4) {
link.centerAngle = 0
} else if (diff > 0) {
link.centerAngle = Math.PI * 0.5
} else {
link.centerAngle = -(Math.PI * 0.5)
}
}
} else {
// Fallback to simple midpoint
link.centerPos = this.getLinkCenter(link)
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(
endPoint.y - startPoint.y,
endPoint.x - startPoint.x
)
}
}
}
/**
* Draw the center marker on a link (for menu interaction)
* Matches the original litegraph center marker rendering
*/
private drawCenterMarker(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!link.centerPos) return
ctx.beginPath()
if (
context.style.centerMarkerShape === 'arrow' &&
link.centerAngle !== undefined
) {
const transform = ctx.getTransform()
ctx.translate(link.centerPos.x, link.centerPos.y)
ctx.rotate(link.centerAngle)
// The math is off, but it currently looks better in chromium (from original)
ctx.moveTo(-3.2, -5)
ctx.lineTo(7, 0)
ctx.lineTo(-3.2, 5)
ctx.setTransform(transform)
} else {
// Default to circle
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
}
// Apply disabled pattern or color
if (link.disabled && context.patterns?.disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = context.patterns.disabled
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
} else {
ctx.fillStyle = color
ctx.fill()
}
}
}