Compare commits

...

4 Commits

Author SHA1 Message Date
Benjamin Lu
0245d882ec fix(litegraph): restore link tooltips by resolving data via linkId
- Add linkId to LinkSegment/RenderedLinkSegment
- Set linkId on rendered segments; resolve model link in drawLinkTooltip
- Remove data copy on render objects to keep them ephemeral
2025-08-12 19:57:10 -04:00
github-actions
c785834776 Update locales [skip ci] 2025-08-12 19:25:22 +00:00
Benjamin Lu
1e20c1eb8f fix(canvas): resolve link id when deleting reroute leg
(cherry picked from commit 15fb657e650b6bc343c761fcf06ef2cd5ba7e2e2)
2025-08-12 15:19:21 -04:00
Benjamin Lu
40b92670a6 Squash: decouple link/reroute rendering changes
Range: c3b7053f..709570b8

Commits: - a2648349 fix(Reroute): remove unsafe this alias in computeRenderParams; capture id and use it inside helper
 - 89d6ae1a Lazy compute
 - e78326ac refactor(litegraph): decouple link dragging render state from model
 - 6d3a035a refactor(litegraph): remove reroute._dragging; track reroute hiding ephemerally
 - d0870533 refactor(litegraph): decouple reroute render geometry and respect hidden reroutes
 - 709570b8 refactor(litegraph): decouple reroute rendering state from models and canvas
(cherry picked from commit 23f0a7403117a71e99095446443a8f5acec8f582)
2025-08-12 15:19:19 -04:00
26 changed files with 721 additions and 491 deletions

View File

@@ -10,6 +10,13 @@ import { LGraphGroup } from './LGraphGroup'
import { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode' import { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
import { LLink, type LinkId } from './LLink' import { LLink, type LinkId } from './LLink'
import { Reroute, type RerouteId } from './Reroute' import { Reroute, type RerouteId } from './Reroute'
import { RenderedLinkSegment } from './canvas/RenderedLinkSegment'
import { computeRerouteHoverState } from './canvas/RerouteHover'
import {
drawReroute,
drawRerouteHighlight,
drawRerouteSlots
} from './canvas/RerouteRenderer'
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots' import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
import { strokeShape } from './draw' import { strokeShape } from './draw'
import type { import type {
@@ -287,6 +294,12 @@ export class LGraphCanvas
selectionChanged: false selectionChanged: false
} }
/** Ephemeral per-frame colours for reroutes */
// Render state kept per-frame; no persistent caches here.
/** Ephemeral hover/outline UI state for reroute slots. */
// Hover state computed on-demand; no persistent caches here.
#subgraph?: Subgraph #subgraph?: Subgraph
get subgraph(): Subgraph | undefined { get subgraph(): Subgraph | undefined {
return this.#subgraph return this.#subgraph
@@ -2356,7 +2369,9 @@ export class LGraphCanvas
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
for (const reroute of this.#visibleReroutes) { for (const reroute of this.#visibleReroutes) {
const overReroute = reroute.containsPoint([x, y]) const overReroute = reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue const hover = computeRerouteHoverState(reroute, [x, y])
const anySlotHovered = hover.inputHover || hover.outputHover
if (!anySlotHovered && !overReroute) continue
if (overReroute) { if (overReroute) {
pointer.onClick = () => this.processSelect(reroute, e) pointer.onClick = () => this.processSelect(reroute, e)
@@ -2367,17 +2382,16 @@ export class LGraphCanvas
} }
} }
if (reroute.isOutputHovered || (overReroute && e.shiftKey)) { if (hover.outputHover || (overReroute && e.shiftKey)) {
linkConnector.dragFromReroute(graph, reroute) linkConnector.dragFromReroute(graph, reroute)
this.#linkConnectorDrop() this.#linkConnectorDrop()
} }
if (reroute.isInputHovered) { if (hover.inputHover) {
linkConnector.dragFromRerouteToOutput(graph, reroute) linkConnector.dragFromRerouteToOutput(graph, reroute)
this.#linkConnectorDrop() this.#linkConnectorDrop()
} }
reroute.hideSlots()
this.dirty_bgcanvas = true this.dirty_bgcanvas = true
return return
} }
@@ -3105,10 +3119,8 @@ export class LGraphCanvas
this.node_over = node this.node_over = node
this.dirty_canvas = true this.dirty_canvas = true
for (const reroute of this.#visibleReroutes) { // invalidate background to ensure slot outlines update
reroute.hideSlots() this.dirty_bgcanvas = true
this.dirty_bgcanvas = true
}
node.onMouseEnter?.(e) node.onMouseEnter?.(e)
} }
@@ -3283,13 +3295,19 @@ export class LGraphCanvas
const { graph, pointer, linkConnector } = this const { graph, pointer, linkConnector } = this
if (!graph) throw new NullGraphError() if (!graph) throw new NullGraphError()
// Update reroute hover state // Update reroute hover state without caching
if (!pointer.isDown) { if (!pointer.isDown) {
let anyChanges = false let anyChanges = false
for (const reroute of this.#visibleReroutes) { for (const reroute of this.#visibleReroutes) {
anyChanges ||= reroute.updateVisibility(this.graph_mouse) const next = computeRerouteHoverState(reroute, this.graph_mouse)
if (next.inputHover || next.outputHover)
if (reroute.isSlotHovered) underPointer |= CanvasItem.RerouteSlot underPointer |= CanvasItem.RerouteSlot
// pointer movement can change outlines/hover; mark dirty if any visible
anyChanges ||=
next.inputOutline ||
next.outputOutline ||
next.inputHover ||
next.outputHover
} }
if (anyChanges) this.dirty_bgcanvas = true if (anyChanges) this.dirty_bgcanvas = true
} else if (linkConnector.isConnecting) { } else if (linkConnector.isConnecting) {
@@ -4682,7 +4700,7 @@ export class LGraphCanvas
return return
// Reroute highlight // Reroute highlight
overReroute?.drawHighlight(ctx, '#ffcc00aa') if (overReroute) drawRerouteHighlight(ctx, overReroute, '#ffcc00aa')
// Ensure we're mousing over a node and connecting a link // Ensure we're mousing over a node and connecting a link
const node = this.node_over const node = this.node_over
@@ -5090,13 +5108,14 @@ export class LGraphCanvas
} }
ctx.fill() ctx.fill()
// @ts-expect-error TODO: Better value typing // Resolve the underlying model link for data/overrides
const { data } = link const modelLink = this.graph?.getLink(
(link.linkId ?? (link.id as unknown)) as LinkId
)
const data = modelLink?.data
if (this.onDrawLinkTooltip?.(ctx, modelLink ?? null, this) === true) return
if (data == null) return if (data == null) return
// @ts-expect-error TODO: Better value typing
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return
let text: string | null = null let text: string | null = null
if (typeof data === 'number') text = data.toFixed(2) if (typeof data === 'number') text = data.toFixed(2)
@@ -5312,6 +5331,10 @@ export class LGraphCanvas
if (!graph) throw new NullGraphError() if (!graph) throw new NullGraphError()
const visibleReroutes: Reroute[] = [] const visibleReroutes: Reroute[] = []
const visibleRerouteIds = new Set<RerouteId>()
// Per-frame reroute colours computed while building segments
const rerouteColours = new Map<RerouteId, CanvasColour>()
// Colours are computed per render pass (stored in rerouteColours)
const now = LiteGraph.getTime() const now = LiteGraph.getTime()
const { visible_area } = this const { visible_area } = this
@@ -5363,7 +5386,9 @@ export class LGraphCanvas
visibleReroutes, visibleReroutes,
now, now,
output.dir, output.dir,
input.dir input.dir,
visibleRerouteIds,
rerouteColours
) )
} }
} }
@@ -5390,7 +5415,9 @@ export class LGraphCanvas
visibleReroutes, visibleReroutes,
now, now,
input.dir, input.dir,
input.dir input.dir,
visibleRerouteIds,
rerouteColours
) )
} }
} }
@@ -5415,13 +5442,22 @@ export class LGraphCanvas
visibleReroutes, visibleReroutes,
now, now,
output.dir, output.dir,
input.dir input.dir,
visibleRerouteIds,
rerouteColours
) )
} }
} }
if (graph.floatingLinks.size > 0) { if (graph.floatingLinks.size > 0) {
this.#renderFloatingLinks(ctx, graph, visibleReroutes, now) this.#renderFloatingLinks(
ctx,
graph,
visibleReroutes,
now,
visibleRerouteIds,
rerouteColours
)
} }
const rerouteSet = this.#visibleReroutes const rerouteSet = this.#visibleReroutes
@@ -5430,6 +5466,8 @@ export class LGraphCanvas
// Render reroutes, ordered by number of non-floating links // Render reroutes, ordered by number of non-floating links
visibleReroutes.sort((a, b) => a.linkIds.size - b.linkIds.size) visibleReroutes.sort((a, b) => a.linkIds.size - b.linkIds.size)
for (const reroute of visibleReroutes) { for (const reroute of visibleReroutes) {
// Respect hidden reroutes while dragging existing links
if (this.linkConnector?.hiddenReroutes.has(reroute)) continue
rerouteSet.add(reroute) rerouteSet.add(reroute)
if ( if (
@@ -5439,10 +5477,16 @@ export class LGraphCanvas
) { ) {
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE) this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
} }
reroute.draw(ctx, this._pattern) {
const colour = rerouteColours.get(reroute.id) ?? this.default_link_color
drawReroute(ctx, reroute, this._pattern, colour)
// Never draw slots when the pointer is down // Never draw slots when the pointer is down
if (!this.pointer.isDown) reroute.drawSlots(ctx) if (!this.pointer.isDown) {
const state = computeRerouteHoverState(reroute, this.graph_mouse)
drawRerouteSlots(ctx, reroute, state, colour)
}
}
} }
ctx.globalAlpha = 1 ctx.globalAlpha = 1
} }
@@ -5451,7 +5495,9 @@ export class LGraphCanvas
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
graph: LGraph, graph: LGraph,
visibleReroutes: Reroute[], visibleReroutes: Reroute[],
now: number now: number,
visibleRerouteIds: Set<RerouteId>,
rerouteColours: Map<RerouteId, CanvasColour>
) { ) {
// Render floating links with 3/4 current alpha // Render floating links with 3/4 current alpha
const { globalAlpha } = ctx const { globalAlpha } = ctx
@@ -5473,7 +5519,6 @@ export class LGraphCanvas
const endPos = node.getInputPos(link.target_slot) const endPos = node.getInputPos(link.target_slot)
const endDirection = node.inputs[link.target_slot]?.dir const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
this.#renderAllLinkSegments( this.#renderAllLinkSegments(
ctx, ctx,
link, link,
@@ -5483,6 +5528,8 @@ export class LGraphCanvas
now, now,
LinkDirection.CENTER, LinkDirection.CENTER,
endDirection, endDirection,
visibleRerouteIds,
rerouteColours,
true true
) )
} else { } else {
@@ -5493,7 +5540,6 @@ export class LGraphCanvas
const endPos = reroute.pos const endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir const startDirection = node.outputs[link.origin_slot]?.dir
link._dragging = true
this.#renderAllLinkSegments( this.#renderAllLinkSegments(
ctx, ctx,
link, link,
@@ -5503,6 +5549,8 @@ export class LGraphCanvas
now, now,
startDirection, startDirection,
LinkDirection.CENTER, LinkDirection.CENTER,
visibleRerouteIds,
rerouteColours,
true true
) )
} }
@@ -5519,6 +5567,8 @@ export class LGraphCanvas
now: number, now: number,
startDirection?: LinkDirection, startDirection?: LinkDirection,
endDirection?: LinkDirection, endDirection?: LinkDirection,
seenRerouteIds?: Set<RerouteId>,
rerouteColours?: Map<RerouteId, CanvasColour>,
disabled: boolean = false disabled: boolean = false
) { ) {
const { graph, renderedPaths } = this const { graph, renderedPaths } = this
@@ -5551,29 +5601,49 @@ export class LGraphCanvas
const start_dir = startDirection || LinkDirection.RIGHT const start_dir = startDirection || LinkDirection.RIGHT
const end_dir = endDirection || LinkDirection.LEFT const end_dir = endDirection || LinkDirection.LEFT
const baseColour =
link.color ||
LGraphCanvas.link_type_colors[link.type] ||
this.default_link_color
// Has reroutes // Has reroutes
if (reroutes.length) { if (reroutes.length) {
const lastReroute = reroutes[reroutes.length - 1]
const floatingType = lastReroute?.floating?.slotType
const skipFirstSegment = floatingType === 'input'
const skipLastSegment = floatingType === 'output'
let startControl: Point | undefined let startControl: Point | undefined
const l = reroutes.length const l = reroutes.length
for (let j = 0; j < l; j++) { for (let j = 0; j < l; j++) {
const reroute = reroutes[j] const reroute = reroutes[j]
// Only render once // Lazily compute render params only if needed, and reuse for both purposes
if (!renderedPaths.has(reroute)) { const prevReroute = graph.getReroute(reroute.parentId)
renderedPaths.add(reroute) const rerouteStartPos = prevReroute?.pos ?? startPos
visibleReroutes.push(reroute) let params:
reroute._colour = | { cos: number; sin: number; controlPoint: Point }
link.color || | undefined
LGraphCanvas.link_type_colors[link.type] || const getParams = () =>
this.default_link_color (params ??= reroute.computeRenderParams(graph, rerouteStartPos))
const prevReroute = graph.getReroute(reroute.parentId) // Only render once per reroute
const rerouteStartPos = prevReroute?.pos ?? startPos if (!seenRerouteIds?.has(reroute.id)) {
reroute.calculateAngle(this.last_draw_time, graph, rerouteStartPos) visibleReroutes.push(reroute)
seenRerouteIds?.add(reroute.id)
if (rerouteColours && !rerouteColours.has(reroute.id))
rerouteColours.set(reroute.id, baseColour)
// Skip the first segment if it is being dragged // Skip the first segment if it is being dragged
if (!reroute._dragging) { if (!(skipFirstSegment && j === 0)) {
const rendered = new RenderedLinkSegment({
id: reroute.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: reroute.parentId,
linkId: link.id
})
rendered.colour = baseColour
this.renderLink( this.renderLink(
ctx, ctx,
rerouteStartPos, rerouteStartPos,
@@ -5586,15 +5656,16 @@ export class LGraphCanvas
LinkDirection.CENTER, LinkDirection.CENTER,
{ {
startControl, startControl,
endControl: reroute.controlPoint, endControl: getParams().controlPoint,
reroute, disabled,
disabled renderTarget: rendered
} }
) )
renderedPaths.add(rendered)
} }
} }
if (!startControl && reroutes.at(-1)?.floating?.slotType === 'input') { if (!startControl && skipFirstSegment) {
// Floating link connected to an input // Floating link connected to an input
startControl = [0, 0] startControl = [0, 0]
} else { } else {
@@ -5604,17 +5675,26 @@ export class LGraphCanvas
Reroute.maxSplineOffset, Reroute.maxSplineOffset,
distance(reroute.pos, nextPos) * 0.25 distance(reroute.pos, nextPos) * 0.25
) )
startControl = [dist * reroute.cos, dist * reroute.sin] const p = getParams()
startControl = [dist * p.cos, dist * p.sin]
} }
} }
// Skip the last segment if it is being dragged // For floating links from output, skip the last segment
if (link._dragging) return if (skipLastSegment) return
// Use runtime fallback; TypeScript cannot evaluate this correctly. // Use runtime fallback; TypeScript cannot evaluate this correctly.
const segmentStartPos = points.at(-2) ?? startPos const segmentStartPos = points.at(-2) ?? startPos
// Render final link segment // Render final link segment
const rendered = new RenderedLinkSegment({
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: link.parentId,
linkId: link.id
})
rendered.colour = baseColour
this.renderLink( this.renderLink(
ctx, ctx,
segmentStartPos, segmentStartPos,
@@ -5625,10 +5705,19 @@ export class LGraphCanvas
null, null,
LinkDirection.CENTER, LinkDirection.CENTER,
end_dir, end_dir,
{ startControl, disabled } { startControl, disabled, renderTarget: rendered }
) )
renderedPaths.add(rendered)
// Skip normal render when link is being dragged // Skip normal render when link is being dragged
} else if (!link._dragging) { } else if (!this.linkConnector?.isLinkBeingDragged(link.id)) {
const rendered = new RenderedLinkSegment({
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
parentId: link.parentId,
linkId: link.id
})
rendered.colour = baseColour
this.renderLink( this.renderLink(
ctx, ctx,
startPos, startPos,
@@ -5638,10 +5727,11 @@ export class LGraphCanvas
0, 0,
null, null,
start_dir, start_dir,
end_dir end_dir,
{ renderTarget: rendered }
) )
renderedPaths.add(rendered)
} }
renderedPaths.add(link)
// event triggered rendered on top // event triggered rendered on top
if (link?._last_time && now - link._last_time < 1000) { if (link?._last_time && now - link._last_time < 1000) {
@@ -5688,12 +5778,10 @@ export class LGraphCanvas
{ {
startControl, startControl,
endControl, endControl,
reroute,
num_sublines = 1, num_sublines = 1,
disabled = false disabled = false,
renderTarget
}: { }: {
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
reroute?: Reroute
/** Offset of the bezier curve control point from {@link a point a} (output side) */ /** Offset of the bezier curve control point from {@link a point a} (output side) */
startControl?: ReadOnlyPoint startControl?: ReadOnlyPoint
/** Offset of the bezier curve control point from {@link b point b} (input side) */ /** Offset of the bezier curve control point from {@link b point b} (input side) */
@@ -5702,6 +5790,8 @@ export class LGraphCanvas
num_sublines?: number num_sublines?: number
/** Whether this is a floating link segment */ /** Whether this is a floating link segment */
disabled?: boolean disabled?: boolean
/** Where to store the drawn path for hit testing */
renderTarget?: RenderedLinkSegment
} = {} } = {}
): void { ): void {
const linkColour = const linkColour =
@@ -5731,14 +5821,14 @@ export class LGraphCanvas
// begin line shape // begin line shape
const path = new Path2D() const path = new Path2D()
/** The link or reroute we're currently rendering */ /** The segment we're currently rendering */
const linkSegment = reroute ?? link const linkSegment = renderTarget
if (linkSegment) linkSegment.path = path if (linkSegment) linkSegment.path = path
const innerA = LGraphCanvas.#lTempA const innerA = LGraphCanvas.#lTempA
const innerB = LGraphCanvas.#lTempB const innerB = LGraphCanvas.#lTempB
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */ /** Reference to render-time centre point of this segment. */
const pos: Point = linkSegment?._pos ?? [0, 0] const pos: Point = linkSegment?._pos ?? [0, 0]
for (let i = 0; i < num_sublines; i++) { for (let i = 0; i < num_sublines; i++) {
@@ -6230,14 +6320,16 @@ export class LGraphCanvas
} }
case 'Delete': { case 'Delete': {
// segment can be a Reroute object, in which case segment.id is the reroute id let linkId: LinkId | undefined
const linkId = if (segment instanceof Reroute) {
segment instanceof Reroute linkId = segment.linkIds.values().next().value
? segment.linkIds.values().next().value } else {
: segment.id const maybeReroute = graph.getReroute(Number(segment.id))
if (linkId !== undefined) { linkId = maybeReroute
graph.removeLink(linkId) ? maybeReroute.linkIds.values().next().value
: (segment.id as LinkId)
} }
if (linkId !== undefined) graph.removeLink(linkId)
break break
} }
default: default:

View File

@@ -34,9 +34,12 @@ import type {
} from './interfaces' } from './interfaces'
import { import {
type LGraphNodeConstructor, type LGraphNodeConstructor,
LabelPosition,
LiteGraph, LiteGraph,
type Subgraph, type Subgraph,
type SubgraphNode type SubgraphNode,
drawCollapsedSlot,
drawSlot
} from './litegraph' } from './litegraph'
import { import {
createBounds, createBounds,
@@ -2819,7 +2822,6 @@ export class LGraphNode
for (const reroute of reroutes) { for (const reroute of reroutes) {
reroute.linkIds.add(link.id) reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
} }
// If this is the terminus of a floating link, remove it // If this is the terminus of a floating link, remove it
@@ -3798,13 +3800,13 @@ export class LGraphNode
// Render the first connected slot only. // Render the first connected slot only.
for (const slot of this.#concreteInputs) { for (const slot of this.#concreteInputs) {
if (slot.link != null) { if (slot.link != null) {
slot.drawCollapsed(ctx) drawCollapsedSlot(ctx, slot, slot.collapsedPos)
break break
} }
} }
for (const slot of this.#concreteOutputs) { for (const slot of this.#concreteOutputs) {
if (slot.links?.length) { if (slot.links?.length) {
slot.drawCollapsed(ctx) drawCollapsedSlot(ctx, slot, slot.collapsedPos)
break break
} }
} }
@@ -3917,11 +3919,32 @@ export class LGraphNode
slot.isConnected slot.isConnected
) { ) {
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slot.draw(ctx, {
colorContext, // - Inputs: label on the right, no stroke, left textAlign
lowQuality, // - Outputs: label on the left, black stroke, right textAlign
highlight const isInput = slot instanceof NodeInputSlot
}) const labelPosition = isInput ? LabelPosition.Right : LabelPosition.Left
const { strokeStyle, textAlign } = ctx
if (isInput) {
ctx.textAlign = 'left'
} else {
ctx.textAlign = 'right'
ctx.strokeStyle = 'black'
}
try {
drawSlot(ctx, slot as any, {
colorContext,
lowQuality,
highlight,
labelPosition,
doStroke: !isInput
})
} finally {
ctx.strokeStyle = strokeStyle
ctx.textAlign = textAlign
}
} }
} }
} }

View File

@@ -11,7 +11,6 @@ import type {
INodeOutputSlot, INodeOutputSlot,
ISlotType, ISlotType,
LinkNetwork, LinkNetwork,
LinkSegment,
ReadonlyLinkNetwork ReadonlyLinkNetwork
} from './interfaces' } from './interfaces'
import { Subgraph } from './litegraph' import { Subgraph } from './litegraph'
@@ -87,7 +86,7 @@ type BasicReadonlyNetwork = Pick<
> >
// this is the class in charge of storing link information // this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> { export class LLink implements Serialisable<SerialisableLLink> {
static _drawDebug = false static _drawDebug = false
/** Link ID */ /** Link ID */
@@ -105,17 +104,11 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
data?: number | string | boolean | { toToolTip?(): string } data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown _data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
_pos: Float32Array
/** @todo Clean up - never implemented in comfy. */ /** @todo Clean up - never implemented in comfy. */
_last_time?: number _last_time?: number
/** The last canvas 2D path that was used to render this link */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */ /** @inheritdoc */
_dragging?: boolean // Note: Render-time dragging state is tracked externally (LinkConnector), not on the model.
#color?: CanvasColour | null #color?: CanvasColour | null
/** Custom colour for this link only */ /** Custom colour for this link only */
@@ -167,8 +160,6 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
this.parentId = parentId this.parentId = parentId
this._data = null this._data = null
// center
this._pos = new Float32Array(2)
} }
/** @deprecated Use {@link LLink.create} */ /** @deprecated Use {@link LLink.create} */
@@ -200,7 +191,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
*/ */
static getReroutes( static getReroutes(
network: Pick<ReadonlyLinkNetwork, 'reroutes'>, network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
linkSegment: LinkSegment linkSegment: { parentId?: RerouteId }
): Reroute[] { ): Reroute[] {
if (!linkSegment.parentId) return [] if (!linkSegment.parentId) return []
return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? [] return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? []
@@ -208,7 +199,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static getFirstReroute( static getFirstReroute(
network: Pick<ReadonlyLinkNetwork, 'reroutes'>, network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
linkSegment: LinkSegment linkSegment: { parentId?: RerouteId }
): Reroute | undefined { ): Reroute | undefined {
return LLink.getReroutes(network, linkSegment).at(0) return LLink.getReroutes(network, linkSegment).at(0)
} }
@@ -223,7 +214,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
*/ */
static findNextReroute( static findNextReroute(
network: Pick<ReadonlyLinkNetwork, 'reroutes'>, network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
linkSegment: LinkSegment, linkSegment: { parentId?: RerouteId },
rerouteId: RerouteId rerouteId: RerouteId
): Reroute | null | undefined { ): Reroute | null | undefined {
if (!linkSegment.parentId) return if (!linkSegment.parentId) return

View File

@@ -6,7 +6,6 @@ import type {
INodeInputSlot, INodeInputSlot,
INodeOutputSlot, INodeOutputSlot,
LinkNetwork, LinkNetwork,
LinkSegment,
Point, Point,
Positionable, Positionable,
ReadOnlyRect, ReadOnlyRect,
@@ -31,7 +30,7 @@ export interface FloatingRerouteSlot {
* and a `WeakRef` to a {@link LinkNetwork} to resolve them. * and a `WeakRef` to a {@link LinkNetwork} to resolve them.
*/ */
export class Reroute export class Reroute
implements Positionable, LinkSegment, Serialisable<SerialisableReroute> implements Positionable, Serialisable<SerialisableReroute>
{ {
static radius: number = 10 static radius: number = 10
/** Maximum distance from reroutes to their bezier curve control points. */ /** Maximum distance from reroutes to their bezier curve control points. */
@@ -123,24 +122,6 @@ export class Reroute
/** Bezier curve control point for the "target" (input) side of the link */ /** Bezier curve control point for the "target" (input) side of the link */
controlPoint: Point = this.#malloc.subarray(4, 6) controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: Float32Array = this.#malloc.subarray(6, 8)
/** @inheritdoc */
_dragging?: boolean
/** 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. * Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead. * @todo Calculate on change instead.
@@ -150,18 +131,6 @@ export class Reroute
#inputSlot = new RerouteSlot(this, true) #inputSlot = new RerouteSlot(this, true)
#outputSlot = new RerouteSlot(this, false) #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 { get firstLink(): LLink | undefined {
const linkId = this.linkIds.values().next().value const linkId = this.linkIds.values().next().value
return linkId === undefined return linkId === undefined
@@ -537,13 +506,75 @@ export class Reroute
} }
} }
/**
* Computes render-time parameters for this reroute without mutating the model.
* Returns the bezier end control-point offset and the direction cos/sin.
*/
computeRenderParams(
network: ReadonlyLinkNetwork,
linkStart: Point
): { cos: number; sin: number; controlPoint: Point } {
const thisPos = this.#pos
const { id } = this
const angles: number[] = []
let sum = 0
// Collect angles of all links passing through this reroute
addAngles(this.linkIds, network.links)
addAngles(this.floatingLinkIds, network.floatingLinks)
// Default values when invalid
if (!angles.length) {
return { cos: 0, sin: 0, controlPoint: [0, 0] }
}
sum /= angles.length
const originToReroute = Math.atan2(
thisPos[1] - linkStart[1],
thisPos[0] - linkStart[0]
)
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(
Reroute.maxSplineOffset,
distance(linkStart, thisPos) * 0.25
)
const originDiff = originToReroute - diff
const cos = Math.cos(originDiff)
const sin = Math.sin(originDiff)
const controlPoint: Point = [dist * -cos, dist * -sin]
return { cos, sin, controlPoint }
function addAngles(
linkIds: Iterable<LinkId>,
links: ReadonlyMap<LinkId, LLink>
) {
for (const linkId of linkIds) {
const link = links.get(linkId)
const pos = getNextPos(network, link, id)
if (!pos) continue
const angle = getDirection(thisPos, pos)
angles.push(angle)
sum += angle
}
}
}
/** /**
* Renders the reroute on the canvas. * Renders the reroute on the canvas.
* @param ctx Canvas context to draw on * @param ctx Canvas context to draw on.
* @param backgroundPattern The canvas background pattern; used to make floating reroutes appear washed out. * @param backgroundPattern Canvas background pattern; used to wash out floating reroutes.
* @param colour Fill/stroke colour to use for this reroute (provided by renderer per-frame).
* @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.). * @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.).
*/ */
draw(ctx: CanvasRenderingContext2D, backgroundPattern?: CanvasPattern): void { draw(
ctx: CanvasRenderingContext2D,
backgroundPattern: CanvasPattern | undefined,
colour: CanvasColour
): void {
const { globalAlpha } = ctx const { globalAlpha } = ctx
const { pos } = this const { pos } = this
@@ -556,7 +587,7 @@ export class Reroute
ctx.globalAlpha = globalAlpha * 0.33 ctx.globalAlpha = globalAlpha * 0.33
} }
ctx.fillStyle = this.colour ctx.fillStyle = colour
ctx.lineWidth = Reroute.radius * 0.1 ctx.lineWidth = Reroute.radius * 0.1
ctx.strokeStyle = 'rgb(0,0,0,0.5)' ctx.strokeStyle = 'rgb(0,0,0,0.5)'
ctx.fill() ctx.fill()
@@ -587,64 +618,23 @@ export class Reroute
} }
/** /**
* Draws the input and output slots on the canvas, if the slots are visible. * Draws the input and output slots for this reroute.
* @param ctx The canvas context to draw on. * @param ctx Canvas context to draw on.
* @param state Ephemeral UI state for this frame: slot hover/outline flags.
* @param colour Colour to use when a slot is hovered (renderer-provided).
*/ */
drawSlots(ctx: CanvasRenderingContext2D): void { drawSlots(
this.#inputSlot.draw(ctx) ctx: CanvasRenderingContext2D,
this.#outputSlot.draw(ctx) state: {
} inputHover: boolean
inputOutline: boolean
drawHighlight(ctx: CanvasRenderingContext2D, colour: CanvasColour): void { outputHover: boolean
const { pos } = this outputOutline: boolean
},
const { strokeStyle, lineWidth } = ctx colour: CanvasColour
ctx.strokeStyle = colour ): void {
ctx.lineWidth = 1 this.#inputSlot.draw(ctx, state.inputOutline, state.inputHover, colour)
this.#outputSlot.draw(ctx, state.outputOutline, state.outputHover, colour)
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius * 1.5, 0, 2 * Math.PI)
ctx.stroke()
ctx.strokeStyle = strokeStyle
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()
} }
/** /**
@@ -688,77 +678,30 @@ class RerouteSlot {
return [x + Reroute.slotOffset * this.#offsetMultiplier, y] 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) { constructor(reroute: Reroute, isInput: boolean) {
this.#reroute = reroute this.#reroute = reroute
this.#offsetMultiplier = isInput ? -1 : 1 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. * Draws the slot on the canvas.
* @param ctx The canvas context to draw on. * @param ctx Canvas 2D context to draw on.
* @param showOutline Whether to render the faint slot outline/background.
* @param hovering Whether the pointer is close enough to treat the slot as hovered.
* @param colour The colour to use when hovered (provided by the renderer per-frame).
*/ */
draw(ctx: CanvasRenderingContext2D): void { draw(
ctx: CanvasRenderingContext2D,
showOutline: boolean,
hovering: boolean,
colour: CanvasColour
): void {
const { fillStyle, strokeStyle, lineWidth } = ctx const { fillStyle, strokeStyle, lineWidth } = ctx
const { const [x, y] = this.pos
showOutline,
hovering,
pos: [x, y]
} = this
if (!showOutline) return if (!showOutline) return
try { try {
ctx.fillStyle = hovering ? this.#reroute.colour : 'rgba(127,127,127,0.3)' ctx.fillStyle = hovering ? colour : 'rgba(127,127,127,0.3)'
ctx.strokeStyle = 'rgb(0,0,0,0.5)' ctx.strokeStyle = 'rgb(0,0,0,0.5)'
ctx.lineWidth = 1 ctx.lineWidth = 1

View File

@@ -108,6 +108,8 @@ export class LinkConnector {
readonly floatingLinks: LLink[] = [] readonly floatingLinks: LLink[] = []
readonly hiddenReroutes: Set<Reroute> = new Set() readonly hiddenReroutes: Set<Reroute> = new Set()
/** IDs of existing links currently being dragged (for render suppression). */
readonly draggingLinkIds: Set<number> = new Set()
/** The widget beneath the pointer, if it is a valid connection target. */ /** The widget beneath the pointer, if it is a valid connection target. */
overWidget?: IBaseWidget overWidget?: IBaseWidget
@@ -131,6 +133,11 @@ export class LinkConnector {
return this.state.draggingExistingLinks return this.state.draggingExistingLinks
} }
/** Returns true if the given link id is being dragged (existing link relocation). */
isLinkBeingDragged(id: number | null | undefined): boolean {
return id != null && this.draggingLinkIds.has(id)
}
/** Drag an existing link to a different input. */ /** Drag an existing link to a different input. */
moveInputLink(network: LinkNetwork, input: INodeInputSlot): void { moveInputLink(network: LinkNetwork, input: INodeInputSlot): void {
if (this.isConnecting) throw new Error('Already dragging links.') if (this.isConnecting) throw new Error('Already dragging links.')
@@ -171,7 +178,8 @@ export class LinkConnector {
) )
} }
floatingLink._dragging = true // Track floating link being dragged (existing link relocation)
this.draggingLinkIds.add(floatingLink.id)
this.floatingLinks.push(floatingLink) this.floatingLinks.push(floatingLink)
} else { } else {
const link = network.links.get(linkId) const link = network.links.get(linkId)
@@ -217,7 +225,7 @@ export class LinkConnector {
return return
} }
link._dragging = true this.draggingLinkIds.add(link.id)
inputLinks.push(link) inputLinks.push(link)
} else { } else {
// Regular node links // Regular node links
@@ -247,7 +255,7 @@ export class LinkConnector {
return return
} }
link._dragging = true this.draggingLinkIds.add(link.id)
inputLinks.push(link) inputLinks.push(link)
} }
} }
@@ -288,6 +296,7 @@ export class LinkConnector {
renderLinks.push(renderLink) renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink) this.floatingLinks.push(floatingLink)
this.draggingLinkIds.add(floatingLink.id)
} catch (error) { } catch (error) {
console.warn( console.warn(
`Could not create render link for link id: [${floatingLink.id}].`, `Could not create render link for link id: [${floatingLink.id}].`,
@@ -306,10 +315,9 @@ export class LinkConnector {
const firstReroute = LLink.getFirstReroute(network, link) const firstReroute = LLink.getFirstReroute(network, link)
if (firstReroute) { if (firstReroute) {
firstReroute._dragging = true
this.hiddenReroutes.add(firstReroute) this.hiddenReroutes.add(firstReroute)
} else { } else {
link._dragging = true this.draggingLinkIds.add(link.id)
} }
this.outputLinks.push(link) this.outputLinks.push(link)
@@ -1053,10 +1061,10 @@ export class LinkConnector {
if (!force && state.connectingTo === undefined) return if (!force && state.connectingTo === undefined) return
state.connectingTo = undefined state.connectingTo = undefined
for (const link of outputLinks) delete link._dragging // Clear tracked dragging links
for (const link of inputLinks) delete link._dragging this.draggingLinkIds.clear()
for (const link of floatingLinks) delete link._dragging // Clear tracked reroutes hidden during drag
for (const reroute of hiddenReroutes) delete reroute._dragging hiddenReroutes.clear()
renderLinks.length = 0 renderLinks.length = 0
inputLinks.length = 0 inputLinks.length = 0

View File

@@ -0,0 +1,37 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { CanvasColour, LinkSegment } from '@/lib/litegraph/src/interfaces'
/**
* Lightweight, render-only representation of a link segment used for hit testing and tooltips.
* Decouples canvas state from the LLink data model.
*/
export class RenderedLinkSegment implements LinkSegment {
readonly id: LinkId | RerouteId
readonly origin_id: NodeId
readonly origin_slot: number
readonly parentId?: RerouteId
/** Source link id for resolving runtime data/tooltips. */
readonly linkId?: LinkId
path?: Path2D
readonly _pos: Float32Array = new Float32Array(2)
_centreAngle?: number
_dragging?: boolean
colour?: CanvasColour
constructor(args: {
id: LinkId | RerouteId
origin_id: NodeId
origin_slot: number
parentId?: RerouteId
linkId?: LinkId
}) {
this.id = args.id
this.origin_id = args.origin_id
this.origin_slot = args.origin_slot
this.parentId = args.parentId
this.linkId = args.linkId
}
}

View File

@@ -0,0 +1,55 @@
import { Reroute } from '../Reroute'
import type { Point } from '../interfaces'
import type { RerouteSlotUiState } from './RerouteRenderer'
export function computeRerouteHoverState(
reroute: Reroute,
mouse: Point
): RerouteSlotUiState {
const [mx, my] = mouse
const state: RerouteSlotUiState = {
inputHover: false,
inputOutline: false,
outputHover: false,
outputOutline: false
}
const hasLink = reroute.firstLink != null
const firstFloating = reroute.firstFloatingLink
const showInput = hasLink || !!firstFloating?.isFloatingOutput
const showOutput = hasLink || !!firstFloating?.isFloatingInput
if (!showInput && !showOutput) return state
const overBody = reroute.containsPoint([mx, my])
if (showInput) {
if (overBody) {
state.inputOutline = true
} else {
const ix = reroute.pos[0] - Reroute.slotOffset
const iy = reroute.pos[1]
const dx = mx - ix
const dy = my - iy
const dist = Math.hypot(dx, dy)
state.inputHover = dist <= 2 * Reroute.slotRadius
state.inputOutline = dist <= 5 * Reroute.slotRadius
}
}
if (showOutput) {
if (overBody) {
state.outputOutline = true
} else {
const ox = reroute.pos[0] + Reroute.slotOffset
const oy = reroute.pos[1]
const dx = mx - ox
const dy = my - oy
const dist = Math.hypot(dx, dy)
state.outputHover = dist <= 2 * Reroute.slotRadius
state.outputOutline = dist <= 5 * Reroute.slotRadius
}
}
return state
}

View File

@@ -0,0 +1,122 @@
import { Reroute } from '../Reroute'
import type { CanvasColour, Point } from '../interfaces'
export interface RerouteSlotUiState {
inputHover: boolean
inputOutline: boolean
outputHover: boolean
outputOutline: boolean
}
const DEFAULT_REROUTE_COLOUR: CanvasColour = '#18184d'
export function drawReroute(
ctx: CanvasRenderingContext2D,
reroute: Reroute,
backgroundPattern: CanvasPattern | undefined,
colour: CanvasColour | undefined
): void {
const { globalAlpha } = ctx
const { pos } = reroute
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI)
if (reroute.linkIds.size === 0) {
ctx.fillStyle = backgroundPattern ?? '#797979'
ctx.fill()
ctx.globalAlpha = globalAlpha * 0.33
}
ctx.fillStyle = colour ?? DEFAULT_REROUTE_COLOUR
ctx.lineWidth = Reroute.radius * 0.1
ctx.strokeStyle = 'rgb(0,0,0,0.5)'
ctx.fill()
ctx.stroke()
ctx.fillStyle = '#ffffff55'
ctx.strokeStyle = 'rgb(0,0,0,0.3)'
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius * 0.8, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
if (reroute.selected) {
ctx.strokeStyle = '#fff'
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius * 1.2, 0, 2 * Math.PI)
ctx.stroke()
}
ctx.globalAlpha = globalAlpha
}
export function drawRerouteHighlight(
ctx: CanvasRenderingContext2D,
reroute: Reroute,
colour: CanvasColour
): void {
const { pos } = reroute
const { strokeStyle, lineWidth } = ctx
ctx.strokeStyle = colour
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(pos[0], pos[1], Reroute.radius * 1.5, 0, 2 * Math.PI)
ctx.stroke()
ctx.strokeStyle = strokeStyle
ctx.lineWidth = lineWidth
}
export function drawRerouteSlots(
ctx: CanvasRenderingContext2D,
reroute: Reroute,
state: RerouteSlotUiState,
colour: CanvasColour | undefined
): void {
const c = colour ?? DEFAULT_REROUTE_COLOUR
drawSlot(ctx, getInputPos(reroute), state.inputOutline, state.inputHover, c)
drawSlot(
ctx,
getOutputPos(reroute),
state.outputOutline,
state.outputHover,
c
)
}
function drawSlot(
ctx: CanvasRenderingContext2D,
[x, y]: Point,
showOutline: boolean,
hovering: boolean,
colour: CanvasColour
) {
if (!showOutline) return
const { fillStyle, strokeStyle, lineWidth } = ctx
try {
ctx.fillStyle = hovering ? 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
}
}
export function getInputPos(reroute: Reroute): Point {
const [x, y] = reroute.pos
return [x - Reroute.slotOffset, y]
}
export function getOutputPos(reroute: Reroute): Point {
const [x, y] = reroute.pos
return [x + Reroute.slotOffset, y]
}

View File

@@ -0,0 +1,175 @@
import { LabelPosition } from '@/lib/litegraph/src/draw'
import type {
DefaultConnectionColors,
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { getCentre } from '@/lib/litegraph/src/measure'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
import type { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
export interface SlotDrawOptions {
colorContext: DefaultConnectionColors
labelPosition?: LabelPosition
lowQuality?: boolean
doStroke?: boolean
highlight?: boolean
}
/** Draw a node input or output slot without coupling to the model class. */
export function drawSlot(
ctx: CanvasRenderingContext2D,
slot: NodeSlot,
{
colorContext,
labelPosition = LabelPosition.Right,
lowQuality = false,
highlight = false,
doStroke = false
}: SlotDrawOptions
) {
// Save the current fillStyle and strokeStyle
const originalFillStyle = ctx.fillStyle
const originalStrokeStyle = ctx.strokeStyle
const originalLineWidth = ctx.lineWidth
const labelColor = highlight ? slot.highlightColor : LiteGraph.NODE_TEXT_COLOR
const nodePos = slot.node.pos
const { boundingRect } = slot
const diameter = boundingRect[3]
const [cx, cy] = getCentre([
boundingRect[0] - nodePos[0],
boundingRect[1] - nodePos[1],
diameter,
diameter
])
const slot_type = slot.type
const slot_shape = (slot_type === 'array' ? RenderShape.GRID : slot.shape) as
| RenderShape
| undefined
ctx.beginPath()
let doFill = true
ctx.fillStyle = slot.renderingColor(colorContext)
ctx.lineWidth = 1
if (slot_type === LiteGraph.EVENT || slot_shape === RenderShape.BOX) {
ctx.rect(cx - 6 + 0.5, cy - 5 + 0.5, 14, 10)
} else if (slot_shape === RenderShape.ARROW) {
ctx.moveTo(cx + 8, cy + 0.5)
ctx.lineTo(cx - 4, cy + 6 + 0.5)
ctx.lineTo(cx - 4, cy - 6 + 0.5)
ctx.closePath()
} else if (slot_shape === RenderShape.GRID) {
const gridSize = 3
const cellSize = 2
const spacing = 3
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
ctx.rect(cx - 4 + x * spacing, cy - 4 + y * spacing, cellSize, cellSize)
}
}
doStroke = false
} else {
// Default rendering for circle, hollow circle.
if (lowQuality) {
ctx.rect(cx - 4, cy - 4, 8, 8)
} else {
let radius: number
if (slot_shape === RenderShape.HollowCircle) {
doFill = false
doStroke = true
ctx.lineWidth = 3
ctx.strokeStyle = ctx.fillStyle
radius = highlight ? 4 : 3
} else {
// Normal circle
radius = highlight ? 5 : 4
}
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
}
}
if (doFill) ctx.fill()
if (!lowQuality && doStroke) ctx.stroke()
// render slot label
const hideLabel = lowQuality || slot.isWidgetInputSlot
if (!hideLabel) {
const text = slot.renderingLabel
if (text) {
ctx.fillStyle = labelColor
if (labelPosition === LabelPosition.Right) {
if (slot.dir == LiteGraph.UP) {
ctx.fillText(text, cx, cy - 10)
} else {
ctx.fillText(text, cx + 10, cy + 5)
}
} else {
if (slot.dir == LiteGraph.DOWN) {
ctx.fillText(text, cx, cy - 8)
} else {
ctx.fillText(text, cx - 10, cy + 5)
}
}
}
}
// Draw a red circle if the slot has errors.
if (slot.hasErrors) {
ctx.lineWidth = 2
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.arc(cx, cy, 12, 0, Math.PI * 2)
ctx.stroke()
}
// Restore the original fillStyle and strokeStyle
ctx.fillStyle = originalFillStyle
ctx.strokeStyle = originalStrokeStyle
ctx.lineWidth = originalLineWidth
}
/** Draw a minimal collapsed representation for the first connected slot. */
export function drawCollapsedSlot(
ctx: CanvasRenderingContext2D,
slot: INodeInputSlot | INodeOutputSlot,
collapsedPos: ReadOnlyPoint
) {
const x = collapsedPos[0]
const y = collapsedPos[1]
// Save original styles
const { fillStyle } = ctx
ctx.fillStyle = '#686'
ctx.beginPath()
if (slot.type === LiteGraph.EVENT || slot.shape === RenderShape.BOX) {
ctx.rect(x - 7 + 0.5, y - 4, 14, 8)
} else if (slot.shape === RenderShape.ARROW) {
const isInput = slot instanceof NodeInputSlot
if (isInput) {
ctx.moveTo(x + 8, y)
ctx.lineTo(x - 4, y - 4)
ctx.lineTo(x - 4, y + 4)
} else {
ctx.moveTo(x + 6, y)
ctx.lineTo(x - 6, y - 4)
ctx.lineTo(x - 6, y + 4)
}
ctx.closePath()
} else {
ctx.arc(x, y, 4, 0, Math.PI * 2)
}
ctx.fill()
// Restore original styles
ctx.fillStyle = fillStyle
}

View File

@@ -188,6 +188,8 @@ export interface LinkSegment {
readonly id: LinkId | RerouteId readonly id: LinkId | RerouteId
/** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */ /** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */
readonly parentId?: RerouteId readonly parentId?: RerouteId
/** The source link id (if this segment belongs to a link). */
readonly linkId?: LinkId
/** The last canvas 2D path that was used to render this segment */ /** The last canvas 2D path that was used to render this segment */
path?: Path2D path?: Path2D

View File

@@ -87,6 +87,7 @@ export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
export { InputIndicators } from './canvas/InputIndicators' export { InputIndicators } from './canvas/InputIndicators'
export { LinkConnector } from './canvas/LinkConnector' export { LinkConnector } from './canvas/LinkConnector'
export { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots' export { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
export { drawSlot, drawCollapsedSlot } from './canvas/SlotRenderer'
export { CanvasPointer } from './CanvasPointer' export { CanvasPointer } from './CanvasPointer'
export * as Constants from './constants' export * as Constants from './constants'
export { ContextMenu } from './ContextMenu' export { ContextMenu } from './ContextMenu'

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink' import type { LinkId } from '@/lib/litegraph/src/LLink'
import { LabelPosition } from '@/lib/litegraph/src/draw'
import type { import type {
INodeInputSlot, INodeInputSlot,
INodeOutputSlot, INodeOutputSlot,
@@ -8,7 +7,7 @@ import type {
ReadOnlyPoint ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot' import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils' import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
@@ -61,20 +60,4 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
return false return false
} }
override draw(
ctx: CanvasRenderingContext2D,
options: Omit<IDrawOptions, 'doStroke' | 'labelPosition'>
) {
const { textAlign } = ctx
ctx.textAlign = 'left'
super.draw(ctx, {
...options,
labelPosition: LabelPosition.Right,
doStroke: false
})
ctx.textAlign = textAlign
}
} }

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink' import type { LinkId } from '@/lib/litegraph/src/LLink'
import { LabelPosition } from '@/lib/litegraph/src/draw'
import type { import type {
INodeInputSlot, INodeInputSlot,
INodeOutputSlot, INodeOutputSlot,
@@ -8,7 +7,7 @@ import type {
ReadOnlyPoint ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot' import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils' import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
@@ -59,22 +58,4 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
override get isConnected(): boolean { override get isConnected(): boolean {
return this.links != null && this.links.length > 0 return this.links != null && this.links.length > 0
} }
override draw(
ctx: CanvasRenderingContext2D,
options: Omit<IDrawOptions, 'doStroke' | 'labelPosition'>
) {
const { textAlign, strokeStyle } = ctx
ctx.textAlign = 'right'
ctx.strokeStyle = 'black'
super.draw(ctx, {
...options,
labelPosition: LabelPosition.Left,
doStroke: true
})
ctx.textAlign = textAlign
ctx.strokeStyle = strokeStyle
}
} }

View File

@@ -1,8 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LabelPosition, SlotShape, SlotType } from '@/lib/litegraph/src/draw'
import type { import type {
CanvasColour, CanvasColour,
DefaultConnectionColors,
INodeInputSlot, INodeInputSlot,
INodeOutputSlot, INodeOutputSlot,
INodeSlot, INodeSlot,
@@ -12,45 +10,15 @@ import type {
ReadOnlyPoint ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import { LiteGraph, Rectangle } from '@/lib/litegraph/src/litegraph' import { LiteGraph, Rectangle } from '@/lib/litegraph/src/litegraph'
import { getCentre } from '@/lib/litegraph/src/measure'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { NodeInputSlot } from './NodeInputSlot'
import { SlotBase } from './SlotBase' import { SlotBase } from './SlotBase'
export interface IDrawOptions {
colorContext: DefaultConnectionColors
labelPosition?: LabelPosition
lowQuality?: boolean
doStroke?: boolean
highlight?: boolean
}
/** Shared base class for {@link LGraphNode} input and output slots. */ /** Shared base class for {@link LGraphNode} input and output slots. */
export abstract class NodeSlot extends SlotBase implements INodeSlot { export abstract class NodeSlot extends SlotBase implements INodeSlot {
pos?: Point pos?: Point
/** The offset from the parent node to the centre point of this slot. */
get #centreOffset(): ReadOnlyPoint {
const nodePos = this.node.pos
const { boundingRect } = this
// Use height; widget input slots may be thinner.
const diameter = boundingRect[3]
return getCentre([
boundingRect[0] - nodePos[0],
boundingRect[1] - nodePos[1],
diameter,
diameter
])
}
/** The center point of this slot when the node is collapsed. */ /** The center point of this slot when the node is collapsed. */
abstract get collapsedPos(): ReadOnlyPoint abstract get collapsedPos(): ReadOnlyPoint
@@ -105,152 +73,4 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
get renderingLabel(): string { get renderingLabel(): string {
return this.label || this.localized_name || this.name || '' return this.label || this.localized_name || this.name || ''
} }
draw(
ctx: CanvasRenderingContext2D,
{
colorContext,
labelPosition = LabelPosition.Right,
lowQuality = false,
highlight = false,
doStroke = false
}: IDrawOptions
) {
// Save the current fillStyle and strokeStyle
const originalFillStyle = ctx.fillStyle
const originalStrokeStyle = ctx.strokeStyle
const originalLineWidth = ctx.lineWidth
const labelColor = highlight
? this.highlightColor
: LiteGraph.NODE_TEXT_COLOR
const pos = this.#centreOffset
const slot_type = this.type
const slot_shape = (
slot_type === SlotType.Array ? SlotShape.Grid : this.shape
) as SlotShape
ctx.beginPath()
let doFill = true
ctx.fillStyle = this.renderingColor(colorContext)
ctx.lineWidth = 1
if (slot_type === SlotType.Event || slot_shape === SlotShape.Box) {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
} else if (slot_shape === SlotShape.Arrow) {
ctx.moveTo(pos[0] + 8, pos[1] + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5)
ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5)
ctx.closePath()
} else if (slot_shape === SlotShape.Grid) {
const gridSize = 3
const cellSize = 2
const spacing = 3
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
ctx.rect(
pos[0] - 4 + x * spacing,
pos[1] - 4 + y * spacing,
cellSize,
cellSize
)
}
}
doStroke = false
} else {
// Default rendering for circle, hollow circle.
if (lowQuality) {
ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8)
} else {
let radius: number
if (slot_shape === SlotShape.HollowCircle) {
doFill = false
doStroke = true
ctx.lineWidth = 3
ctx.strokeStyle = ctx.fillStyle
radius = highlight ? 4 : 3
} else {
// Normal circle
radius = highlight ? 5 : 4
}
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2)
}
}
if (doFill) ctx.fill()
if (!lowQuality && doStroke) ctx.stroke()
// render slot label
const hideLabel = lowQuality || this.isWidgetInputSlot
if (!hideLabel) {
const text = this.renderingLabel
if (text) {
// TODO: Finish impl. Highlight text on mouseover unless we're connecting links.
ctx.fillStyle = labelColor
if (labelPosition === LabelPosition.Right) {
if (this.dir == LinkDirection.UP) {
ctx.fillText(text, pos[0], pos[1] - 10)
} else {
ctx.fillText(text, pos[0] + 10, pos[1] + 5)
}
} else {
if (this.dir == LinkDirection.DOWN) {
ctx.fillText(text, pos[0], pos[1] - 8)
} else {
ctx.fillText(text, pos[0] - 10, pos[1] + 5)
}
}
}
}
// Draw a red circle if the slot has errors.
if (this.hasErrors) {
ctx.lineWidth = 2
ctx.strokeStyle = 'red'
ctx.beginPath()
ctx.arc(pos[0], pos[1], 12, 0, Math.PI * 2)
ctx.stroke()
}
// Restore the original fillStyle and strokeStyle
ctx.fillStyle = originalFillStyle
ctx.strokeStyle = originalStrokeStyle
ctx.lineWidth = originalLineWidth
}
drawCollapsed(ctx: CanvasRenderingContext2D) {
const [x, y] = this.collapsedPos
// Save original styles
const { fillStyle } = ctx
ctx.fillStyle = '#686'
ctx.beginPath()
if (this.type === SlotType.Event || this.shape === RenderShape.BOX) {
ctx.rect(x - 7 + 0.5, y - 4, 14, 8)
} else if (this.shape === RenderShape.ARROW) {
// Adjust arrow direction based on whether this is an input or output slot
const isInput = this instanceof NodeInputSlot
if (isInput) {
ctx.moveTo(x + 8, y)
ctx.lineTo(x - 4, y - 4)
ctx.lineTo(x - 4, y + 4)
} else {
ctx.moveTo(x + 6, y)
ctx.lineTo(x - 6, y - 4)
ctx.lineTo(x - 6, y + 4)
}
ctx.closePath()
} else {
ctx.arc(x, y, 4, 0, Math.PI * 2)
}
ctx.fill()
// Restore original styles
ctx.fillStyle = fillStyle
}
} }

View File

@@ -116,7 +116,6 @@ export class SubgraphInput extends SubgraphSlot {
for (const reroute of reroutes) { for (const reroute of reroutes) {
reroute.linkIds.add(link.id) reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
} }
// If this is the terminus of a floating link, remove it // If this is the terminus of a floating link, remove it

View File

@@ -86,7 +86,6 @@ export class SubgraphOutput extends SubgraphSlot {
for (const reroute of reroutes) { for (const reroute of reroutes) {
reroute.linkIds.add(link.id) reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
} }
// If this is the terminus of a floating link, remove it // If this is the terminus of a floating link, remove it

View File

@@ -124,7 +124,7 @@ describe('LinkConnector', () => {
expect(connector.state.connectingTo).toBe('input') expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true) expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.inputLinks).toContain(link) expect(connector.inputLinks).toContain(link)
expect(link._dragging).toBe(true) expect(connector.isLinkBeingDragged(link.id)).toBe(true)
}) })
test('should not move input link if already connecting', ({ test('should not move input link if already connecting', ({
@@ -162,7 +162,7 @@ describe('LinkConnector', () => {
expect(connector.state.draggingExistingLinks).toBe(true) expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.state.multi).toBe(true) expect(connector.state.multi).toBe(true)
expect(connector.outputLinks).toContain(link) expect(connector.outputLinks).toContain(link)
expect(link._dragging).toBe(true) expect(connector.isLinkBeingDragged(link.id)).toBe(true)
}) })
test('should not move output link if already connecting', ({ test('should not move output link if already connecting', ({
@@ -253,12 +253,11 @@ describe('LinkConnector', () => {
connector.state.draggingExistingLinks = true connector.state.draggingExistingLinks = true
const link = new LLink(1, 'number', 1, 0, 2, 0) const link = new LLink(1, 'number', 1, 0, 2, 0)
link._dragging = true
connector.inputLinks.push(link) connector.inputLinks.push(link)
connector.draggingLinkIds.add(link.id)
const reroute = new Reroute(1, network) const reroute = new Reroute(1, network)
reroute.pos = [0, 0] reroute.pos = [0, 0]
reroute._dragging = true
connector.hiddenReroutes.add(reroute) connector.hiddenReroutes.add(reroute)
connector.reset() connector.reset()
@@ -272,8 +271,7 @@ describe('LinkConnector', () => {
expect(connector.inputLinks).toEqual([]) expect(connector.inputLinks).toEqual([])
expect(connector.outputLinks).toEqual([]) expect(connector.outputLinks).toEqual([])
expect(connector.hiddenReroutes.size).toBe(0) expect(connector.hiddenReroutes.size).toBe(0)
expect(link._dragging).toBeUndefined() expect(connector.draggingLinkIds.size).toBe(0)
expect(reroute._dragging).toBeUndefined()
}) })
}) })

View File

@@ -159,8 +159,8 @@ describe('Subgraph slot connections', () => {
expect(connector.inputLinks).toHaveLength(1) expect(connector.inputLinks).toHaveLength(1)
expect(connector.inputLinks[0]).toBe(link) expect(connector.inputLinks[0]).toBe(link)
// Verify the link is marked as dragging // Verify the link is marked as dragging (via connector state)
expect(link!._dragging).toBe(true) expect(connector.isLinkBeingDragged(link!.id)).toBe(true)
}) })
}) })

View File

@@ -299,6 +299,7 @@
"disabling": "جارٍ التعطيل", "disabling": "جارٍ التعطيل",
"dismiss": "تجاهل", "dismiss": "تجاهل",
"download": "تنزيل", "download": "تنزيل",
"duplicate": "تكرار",
"edit": "تعديل", "edit": "تعديل",
"empty": "فارغ", "empty": "فارغ",
"enableAll": "تمكين الكل", "enableAll": "تمكين الكل",

View File

@@ -299,6 +299,7 @@
"disabling": "Deshabilitando", "disabling": "Deshabilitando",
"dismiss": "Descartar", "dismiss": "Descartar",
"download": "Descargar", "download": "Descargar",
"duplicate": "Duplicar",
"edit": "Editar", "edit": "Editar",
"empty": "Vacío", "empty": "Vacío",
"enableAll": "Habilitar todo", "enableAll": "Habilitar todo",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "Advertencia de compatibilidad de versión", "versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.", "versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video", "videoFailedToLoad": "Falló la carga del video",
"workflow": "Flujo de trabajo", "workflow": "Flujo de trabajo"
"duplicate": "Duplicar"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "Ajustar vista", "fitView": "Ajustar vista",

View File

@@ -299,6 +299,7 @@
"disabling": "Désactivation", "disabling": "Désactivation",
"dismiss": "Fermer", "dismiss": "Fermer",
"download": "Télécharger", "download": "Télécharger",
"duplicate": "Dupliquer",
"edit": "Modifier", "edit": "Modifier",
"empty": "Vide", "empty": "Vide",
"enableAll": "Activer tout", "enableAll": "Activer tout",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "Avertissement de compatibilité de version", "versionMismatchWarning": "Avertissement de compatibilité de version",
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.", "versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
"videoFailedToLoad": "Échec du chargement de la vidéo", "videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail", "workflow": "Flux de travail"
"duplicate": "Dupliquer"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "Adapter la vue", "fitView": "Adapter la vue",

View File

@@ -299,6 +299,7 @@
"disabling": "無効化", "disabling": "無効化",
"dismiss": "閉じる", "dismiss": "閉じる",
"download": "ダウンロード", "download": "ダウンロード",
"duplicate": "複製",
"edit": "編集", "edit": "編集",
"empty": "空", "empty": "空",
"enableAll": "すべて有効にする", "enableAll": "すべて有効にする",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "バージョン互換性の警告", "versionMismatchWarning": "バージョン互換性の警告",
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。", "versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
"videoFailedToLoad": "ビデオの読み込みに失敗しました", "videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー", "workflow": "ワークフロー"
"duplicate": "複製"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "ビューに合わせる", "fitView": "ビューに合わせる",

View File

@@ -299,6 +299,7 @@
"disabling": "비활성화 중", "disabling": "비활성화 중",
"dismiss": "닫기", "dismiss": "닫기",
"download": "다운로드", "download": "다운로드",
"duplicate": "복제",
"edit": "편집", "edit": "편집",
"empty": "비어 있음", "empty": "비어 있음",
"enableAll": "모두 활성화", "enableAll": "모두 활성화",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "버전 호환성 경고", "versionMismatchWarning": "버전 호환성 경고",
"versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.", "versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.", "videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로", "workflow": "워크플로"
"duplicate": "복제"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "보기 맞춤", "fitView": "보기 맞춤",

View File

@@ -299,6 +299,7 @@
"disabling": "Отключение", "disabling": "Отключение",
"dismiss": "Закрыть", "dismiss": "Закрыть",
"download": "Скачать", "download": "Скачать",
"duplicate": "Дублировать",
"edit": "Редактировать", "edit": "Редактировать",
"empty": "Пусто", "empty": "Пусто",
"enableAll": "Включить все", "enableAll": "Включить все",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "Предупреждение о несовместимости версий", "versionMismatchWarning": "Предупреждение о несовместимости версий",
"versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.", "versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.",
"videoFailedToLoad": "Не удалось загрузить видео", "videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс", "workflow": "Рабочий процесс"
"duplicate": "Дублировать"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "Подгонять под выделенные", "fitView": "Подгонять под выделенные",

View File

@@ -299,6 +299,7 @@
"disabling": "停用中", "disabling": "停用中",
"dismiss": "關閉", "dismiss": "關閉",
"download": "下載", "download": "下載",
"duplicate": "複製",
"edit": "編輯", "edit": "編輯",
"empty": "空", "empty": "空",
"enableAll": "全部啟用", "enableAll": "全部啟用",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "版本相容性警告", "versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。", "versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "無法載入影片", "videoFailedToLoad": "無法載入影片",
"workflow": "工作流程", "workflow": "工作流程"
"duplicate": "複製"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "適合視窗", "fitView": "適合視窗",

View File

@@ -299,6 +299,7 @@
"disabling": "禁用中", "disabling": "禁用中",
"dismiss": "關閉", "dismiss": "關閉",
"download": "下载", "download": "下载",
"duplicate": "复制",
"edit": "编辑", "edit": "编辑",
"empty": "空", "empty": "空",
"enableAll": "启用全部", "enableAll": "启用全部",
@@ -402,8 +403,7 @@
"versionMismatchWarning": "版本相容性警告", "versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。", "versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "视频加载失败", "videoFailedToLoad": "视频加载失败",
"workflow": "工作流", "workflow": "工作流"
"duplicate": "复制"
}, },
"graphCanvasMenu": { "graphCanvasMenu": {
"fitView": "适应视图", "fitView": "适应视图",