Compare commits

...

3 Commits

Author SHA1 Message Date
shrimbly
763c1e63f5 test: cover hide/show link menu actions
- use ctx save/restore in the badge connector stub
- note the single-canvas assumption for module-level badge state
2026-07-01 19:24:19 +12:00
shrimbly
3af08e8750 feat: attach a revealed link's curve to its label tip
Split badge layout from painting so each badge's position is known while
the curve is drawn. A revealed link's curve now attaches to the far edge
of each label instead of the socket, so it appears to flow out of the
label. Painting stays deferred, keeping labels above the curves.
2026-07-01 11:31:53 +12:00
shrimbly
202a51124c feat: hideable links with renamable end badges
Hide a link's curve and show a small renamable badge at each end instead.

- Right-click a link to Hide Link; right-click or double-click a badge to
  Show Link / Rename.
- Badges stack to avoid overlapping each other or nearby sockets' labels and
  are painted above the links.
- Hovering a badge, or a socket it connects to, reveals the link.
- hidden/label persist through serialization, so existing workflows are
  unaffected.

Hide/Show/Rename run through the graph change lifecycle so they are
undo/version aware.
2026-07-01 10:30:22 +12:00
13 changed files with 1112 additions and 39 deletions

View File

@@ -145,7 +145,12 @@ export interface GraphAddOptions {
export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[]
linkExtensions?: { id: LinkId; parentId: RerouteId | undefined }[]
linkExtensions?: {
id: LinkId
parentId: RerouteId | undefined
hidden?: boolean
label?: string
}[]
ds?: DragAndScaleState
workflowRendererVersion?: RendererType
}
@@ -2280,12 +2285,19 @@ export class LGraph
const linkArray = [...this._links.values()]
const links = linkArray.map((x) => x.serialize())
if (reroutes?.length) {
// Link parent IDs cannot go in 0.4 schema arrays
extra.linkExtensions = linkArray
.filter((x) => x.parentId !== undefined)
.map((x) => ({ id: x.id, parentId: x.parentId }))
}
// Per-link data that cannot fit in 0.4 schema arrays (parent reroute id,
// hidden state, badge label) rides along in extra.linkExtensions.
const linkExtensions = linkArray
.filter(
(x) => x.parentId !== undefined || x.hidden || x.label !== undefined
)
.map((x) => ({
id: x.id,
parentId: x.parentId,
hidden: x.hidden,
label: x.label
}))
extra.linkExtensions = linkExtensions.length ? linkExtensions : undefined
extra.reroutes = reroutes?.length ? reroutes : undefined
return {
@@ -2442,7 +2454,10 @@ export class LGraph
if (Array.isArray(extra?.linkExtensions)) {
for (const linkEx of extra.linkExtensions) {
const link = this._links.get(linkEx.id)
if (link) link.parentId = linkEx.parentId
if (!link) continue
link.parentId = linkEx.parentId
if (linkEx.hidden !== undefined) link.hidden = linkEx.hidden
if (linkEx.label !== undefined) link.label = linkEx.label
}
}

View File

@@ -8,7 +8,12 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import {
queryLinkBadgeAtPoint,
setRevealedLinks
} from '@/lib/litegraph/src/canvas/linkBadges'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
@@ -204,4 +209,108 @@ describe('drawConnections widget-input slot positioning', () => {
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
it('draws hidden links as end badges instead of a curve', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [150, 60]
targetNode.addInput('in', 'STRING')
graph.add(targetNode)
const link = createTestLink(graph, sourceNode, 0, targetNode, 0)
link.hidden = true
// Hidden, not revealed: a revealed link would fall through to the curve.
setRevealedLinks([])
// Viewport must cover the nodes so the link passes the on-screen cull.
canvas.visible_area.set([0, 0, 800, 600])
canvas.drawConnections(createMockCtx())
// The badge branch returns before the link is recorded as a drawn curve...
expect(canvas.renderedPaths.has(link)).toBe(false)
// ...and registers a badge hit area at the output socket instead.
const [outputX, outputY] = sourceNode.getOutputPos(0)
expect(queryLinkBadgeAtPoint(outputX + 20, outputY)).toBe(link.id)
})
})
describe('showLinkMenu link visibility actions', () => {
let graph: LGraph
let canvas: LGraphCanvas
const originalContextMenu = LiteGraph.ContextMenu
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
canvasElement.getBoundingClientRect = vi
.fn()
.mockReturnValue({ left: 0, top: 0, width: 800, height: 600 })
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, { skip_render: true })
LiteGraph.vueNodesMode = false
})
afterEach(() => {
LiteGraph.ContextMenu = originalContextMenu
LiteGraph.vueNodesMode = false
})
function linkBetweenNodes(): LLink {
const source = new LGraphNode('Source')
source.addOutput('out', 'STRING')
graph.add(source)
const target = new LGraphNode('Target')
target.addInput('in', 'STRING')
graph.add(target)
return createTestLink(graph, source, 0, target, 0)
}
function clickMenuItem(link: LLink, item: string): void {
let callback: ((v: string, o: unknown, e: MouseEvent) => void) | undefined
LiteGraph.ContextMenu = vi
.fn<typeof LiteGraph.ContextMenu>()
.mockImplementation(function (_values, options) {
callback = options.callback
}) as Partial<
typeof LiteGraph.ContextMenu
> as typeof LiteGraph.ContextMenu
canvas.showLinkMenu(link, {} as CanvasPointerEvent)
callback?.(item, null, {} as MouseEvent)
}
it('hides a visible link and brackets it with the change lifecycle', () => {
const link = linkBetweenNodes()
const before = vi.spyOn(canvas, 'emitBeforeChange')
const dirty = vi.spyOn(canvas, 'setDirty')
const after = vi.spyOn(canvas, 'emitAfterChange')
clickMenuItem(link, 'Hide Link')
expect(link.hidden).toBe(true)
expect(before).toHaveBeenCalled()
expect(dirty).toHaveBeenCalledWith(false, true)
expect(after).toHaveBeenCalled()
})
it('shows a hidden link again', () => {
const link = linkBetweenNodes()
link.hidden = true
clickMenuItem(link, 'Show Link')
expect(link.hidden).toBe(false)
})
})

View File

@@ -28,6 +28,15 @@ import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
import { LinkConnector } from './canvas/LinkConnector'
import { getCanvasContextMenuTarget } from './canvas/getCanvasContextMenuTarget'
import {
clearLinkBadgeHitAreas,
drawPendingLinkBadges,
enqueueHiddenLinkBadges,
isLinkRevealed,
promptRenameLinkBadge,
queryLinkBadgeAtPoint,
setRevealedLinks
} from './canvas/linkBadges'
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
import { strokeShape } from './draw'
import {
@@ -2339,37 +2348,57 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (node) {
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: e.canvasX,
y: e.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
reroute = graph.getReroute(rerouteLayout.id)
// Badge of a hidden link → its rename / show-noodle menu.
// (Double-click rename is wired in _processPrimaryButton.)
const badgeLink = graph.getLink(
queryLinkBadgeAtPoint(e.canvasX, e.canvasY)
)
if (badgeLink) {
if (e.button === 2) {
pointer.onClick = () => this.showLinkMenu(badgeLink, e)
}
} else {
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this._visibleReroutes
)
}
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
if (upEvent.altKey) {
// Ensure deselected
if (reroute.selected) {
this.deselect(reroute)
this.onSelectionChange?.(this.selected_nodes)
// Reroutes
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: e.canvasX,
y: e.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
reroute = graph.getReroute(rerouteLayout.id)
} else {
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this._visibleReroutes
)
}
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
if (upEvent.altKey) {
// Ensure deselected
if (reroute.selected) {
this.deselect(reroute)
this.onSelectionChange?.(this.selected_nodes)
}
reroute.remove()
}
reroute.remove()
}
} else {
this.processSelect(reroute, e, true)
}
} else {
this.processSelect(reroute, e, true)
// Visible noodle → offer to hide it
const link = graph.getLink(
layoutStore.queryLinkAtPoint(
{ x: e.canvasX, y: e.canvasY },
this.ctx
)
)
if (link) pointer.onClick = () => this.showLinkMenu(link, e)
}
}
}
@@ -2483,6 +2512,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (node && (this.allow_interaction || node.flags.allow_interaction)) {
this._processNodeClick(e, ctrlOrMeta, node)
} else {
// Badge of a hidden link → double-click to rename
const badgeLink = graph.getLink(queryLinkBadgeAtPoint(x, y))
if (badgeLink) {
pointer.onDoubleClick = () => promptRenameLinkBadge(this, badgeLink, e)
return
}
// Subgraph IO nodes
if (subgraph) {
const { inputNode, outputNode } = subgraph
@@ -3284,6 +3320,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.graph_mouse[0] = x
this.graph_mouse[1] = y
// Hovering a hidden link's badge reveals its noodle (see drawConnection).
// Socket hover is wired from the Vue slot components (useSlotNoodlePreview),
// since those pointer events never reach this canvas handler.
const hoveredBadge = queryLinkBadgeAtPoint(x, y)
if (setRevealedLinks(hoveredBadge === undefined ? [] : [hoveredBadge])) {
this.dirty_bgcanvas = true
}
if (e.isPrimary) pointer.move(e)
/** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */
@@ -3894,6 +3938,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc.
this.adjustMouseEvent(e)
this.updateMouseOverNodes(null, e)
// Pointer left the canvas — stop revealing any hovered hidden-link noodles.
if (setRevealedLinks([])) this.dirty_bgcanvas = true
}
processMouseCancel(): void {
@@ -5989,6 +6035,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
drawConnections(ctx: CanvasRenderingContext2D): void {
this.renderedPaths.clear()
clearLinkBadgeHitAreas()
if (this.links_render_mode === LinkRenderType.HIDDEN_LINK) return
// Skip link rendering while waiting for slot positions to sync after reconfigure
@@ -6177,6 +6224,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
link.disconnectOnDrop = distSquared < radius ** 2
})
// Badges last, so hidden-link labels sit above every noodle.
drawPendingLinkBadges(ctx)
ctx.globalAlpha = 1
}
@@ -6289,6 +6339,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// skip links outside of the visible area of the canvas
if (!overlapBounding(link_bounding, margin_area)) return
// Hidden links: badges replace the curve and are painted on top of the
// noodles afterwards (see drawPendingLinkBadges). Hovering a badge or a
// connected socket reveals the noodle, so fall through to draw the curve —
// attached to the badge tips rather than the sockets.
if (link.hidden) {
const badgeColor =
(typeof link.color === 'string' && link.color) ||
LGraphCanvas.link_type_colors[link.type] ||
this.default_link_color
const tips = enqueueHiddenLinkBadges(
ctx,
link,
startPos,
endPos,
badgeColor
)
if (!isLinkRevealed(link.id)) return
if (tips) {
startPos = tips.outputTip
endPos = tips.inputTip
}
}
const start_dir = startDirection || LinkDirection.RIGHT
const end_dir = endDirection || LinkDirection.LEFT
@@ -6632,7 +6705,23 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const node_left = graph.getNodeById(origin_id)
const fromType = node_left?.outputs?.[origin_slot]?.type
const options = ['Add Node', 'Add Reroute', null, 'Delete', null]
const link = segment instanceof LLink ? segment : undefined
const badgeOptions: (string | null)[] = !link
? []
: link.hidden
? ['Rename', 'Show Link', null]
: ['Hide Link', null]
const options: (string | null)[] = [
...badgeOptions,
'Add Node',
// Hidden links return before renderLink, so segment._pos is unset — a
// reroute would be created at the wrong place. Only offer it when visible.
...(link?.hidden ? [] : ['Add Reroute']),
null,
'Delete',
null
]
const promptEvent = e
const menu = new LiteGraph.ContextMenu<string>(options, {
event: e,
@@ -6702,6 +6791,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
break
}
case 'Hide Link':
if (link) {
this.emitBeforeChange()
link.hidden = true
this.setDirty(false, true)
this.emitAfterChange()
}
break
case 'Show Link':
if (link) {
this.emitBeforeChange()
link.hidden = false
this.setDirty(false, true)
this.emitAfterChange()
}
break
case 'Rename':
if (link) promptRenameLinkBadge(this, link, promptEvent)
break
default:
}
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/LLink'
describe('LLink hidden/label', () => {
it('round-trips hidden and label through asSerialisable/create', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
link.hidden = true
link.label = 'Checkpoint'
const data = link.asSerialisable()
expect(data.hidden).toBe(true)
expect(data.label).toBe('Checkpoint')
const restored = LLink.create(data)
expect(restored.hidden).toBe(true)
expect(restored.label).toBe('Checkpoint')
})
it('omits hidden and label from serialization when unset', () => {
const data = new LLink(1, 'MODEL', 4, 0, 5, 0).asSerialisable()
expect(data.hidden).toBeUndefined()
expect(data.label).toBeUndefined()
})
it('copies hidden and label via configure', () => {
const source = new LLink(1, 'MODEL', 4, 0, 5, 0)
source.hidden = true
source.label = 'Latent'
const target = new LLink(2, 'INT', 0, 0, 0, 0)
target.configure(source)
expect(target.hidden).toBe(true)
expect(target.label).toBe('Latent')
})
it('survives a graph serialize → configure round-trip (v0.4 linkExtensions)', () => {
const graph = new LGraph()
const link = new LLink(1, 'MODEL', 10, 0, 20, 0)
link.hidden = true
link.label = 'Backbone'
graph._links.set(link.id, link)
const data = graph.serialize()
expect(data.extra?.linkExtensions).toContainEqual(
expect.objectContaining({ id: 1, hidden: true, label: 'Backbone' })
)
const restored = new LGraph()
restored.configure(data)
const restoredLink = restored._links.get(1)
expect(restoredLink?.hidden).toBe(true)
expect(restoredLink?.label).toBe('Backbone')
})
it('omits linkExtensions when no link has hidden/label/parent', () => {
const graph = new LGraph()
graph._links.set(1, new LLink(1, 'MODEL', 10, 0, 20, 0))
expect(graph.serialize().extra?.linkExtensions).toBeUndefined()
})
})

View File

@@ -119,6 +119,11 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/** @inheritdoc */
_dragging?: boolean
/** When `true`, the curve is not drawn; renamable end badges are shown instead. */
hidden?: boolean
/** Custom label shown on the link's end badges when hidden. Defaults to the link type. */
label?: string
private _color?: CanvasColour | null
/** Custom colour for this link only */
public get color(): CanvasColour | null | undefined {
@@ -184,7 +189,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
* @returns A new LLink
*/
static create(data: SerialisableLLink): LLink {
return new LLink(
const link = new LLink(
data.id,
data.type,
data.origin_id,
@@ -193,6 +198,9 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
data.target_slot,
data.parentId
)
link.hidden = data.hidden
link.label = data.label
return link
}
/**
@@ -364,6 +372,8 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
this.target_id = o.target_id
this.target_slot = o.target_slot
this.parentId = o.parentId
this.hidden = o.hidden
this.label = o.label
}
}
@@ -486,6 +496,8 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
type: this.type
}
if (this.parentId !== undefined) copy.parentId = this.parentId
if (this.hidden) copy.hidden = this.hidden
if (this.label !== undefined) copy.label = this.label
return copy
}
}

View File

@@ -0,0 +1,276 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
import {
clearLinkBadgeHitAreas,
drawHiddenLinkBadges,
drawPendingLinkBadges,
enqueueHiddenLinkBadges,
isLinkRevealed,
linkBadgeText,
promptRenameLinkBadge,
queryLinkBadgeAtPoint,
setRevealedLinks
} from './linkBadges'
function mockCtx(): CanvasRenderingContext2D {
return createMockCanvas2DContext({
font: '',
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
measureText: vi.fn().mockReturnValue({ width: 50 } as TextMetrics),
roundRect: vi.fn(),
fillText: vi.fn()
})
}
describe('linkBadgeText', () => {
it('uses the trimmed label when present', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
link.label = ' Checkpoint '
expect(linkBadgeText(link)).toBe('Checkpoint')
})
it('falls back to the link type when there is no label', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
expect(linkBadgeText(link)).toBe('MODEL')
})
})
describe('drawHiddenLinkBadges + queryLinkBadgeAtPoint', () => {
beforeEach(() => {
clearLinkBadgeHitAreas()
})
it('registers a hit area near each socket that resolves to the link id', () => {
const ctx = mockCtx()
const link = new LLink(7, 'MODEL', 4, 0, 5, 0)
drawHiddenLinkBadges(ctx, link, [100, 100], [400, 200], '#cab8ff')
// Output badge sits just right of the output socket (100, 100)
expect(queryLinkBadgeAtPoint(120, 100)).toBe(7)
// Input badge sits just left of the input socket (400, 200)
expect(queryLinkBadgeAtPoint(360, 200)).toBe(7)
// Empty space between the sockets is not a badge
expect(queryLinkBadgeAtPoint(250, 150)).toBeUndefined()
})
it('clears hit areas between frames', () => {
const ctx = mockCtx()
const link = new LLink(7, 'MODEL', 4, 0, 5, 0)
drawHiddenLinkBadges(ctx, link, [100, 100], [400, 200], '#cab8ff')
clearLinkBadgeHitAreas()
expect(queryLinkBadgeAtPoint(120, 100)).toBeUndefined()
})
it('draws nothing for an empty badge text', () => {
const ctx = mockCtx()
const link = new LLink(7, '', 4, 0, 5, 0)
drawHiddenLinkBadges(ctx, link, [100, 100], [400, 200], '#cab8ff')
expect(queryLinkBadgeAtPoint(120, 100)).toBeUndefined()
})
it('stacks badges that share an output socket so they do not overlap', () => {
const ctx = mockCtx()
// Both links leave the same output socket (100, 100) for different inputs.
drawHiddenLinkBadges(
ctx,
new LLink(1, 'IMAGE', 4, 0, 5, 0),
[100, 100],
[400, 200],
'#cab8ff'
)
drawHiddenLinkBadges(
ctx,
new LLink(2, 'IMAGE', 4, 0, 6, 0),
[100, 100],
[400, 300],
'#cab8ff'
)
// First link keeps the socket row; the second stacks onto a lower row.
expect(queryLinkBadgeAtPoint(120, 100)).toBe(1)
const secondBadgeBelow = Array.from({ length: 80 }, (_, i) => 101 + i).some(
(y) => queryLinkBadgeAtPoint(120, y) === 2
)
expect(secondBadgeBelow).toBe(true)
})
it('keeps badges of nearby sockets from overlapping', () => {
const ctx = mockCtx()
// The IMAGE socket (100, 100) fans to two inputs; the MASK socket sits just
// below it — close enough that naive downward stacking would collide.
drawHiddenLinkBadges(
ctx,
new LLink(1, 'IMAGE', 4, 0, 5, 0),
[100, 100],
[400, 200],
'#cab8ff'
)
drawHiddenLinkBadges(
ctx,
new LLink(2, 'IMAGE', 4, 0, 6, 0),
[100, 100],
[400, 300],
'#cab8ff'
)
drawHiddenLinkBadges(
ctx,
new LLink(3, 'MASK', 4, 1, 7, 0),
[100, 118],
[400, 400],
'#cab8ff'
)
const rows = Array.from({ length: 160 }, (_, i) => 80 + i)
const bandOf = (id: number) => {
const ys = rows.filter((y) => queryLinkBadgeAtPoint(120, y) === id)
return { lo: Math.min(...ys), hi: Math.max(...ys) }
}
const [a, b, c] = [bandOf(1), bandOf(2), bandOf(3)]
// Each badge occupies a real, hit-testable band...
for (const band of [a, b, c]) expect(band.lo).toBeLessThanOrEqual(band.hi)
// ...and the bands are disjoint, so no two badges overlap.
const disjoint = (p: typeof a, q: typeof a) => p.hi < q.lo || q.hi < p.lo
expect(disjoint(a, b) && disjoint(b, c) && disjoint(a, c)).toBe(true)
})
it('lays badges out at enqueue but defers painting until the flush', () => {
const ctx = mockCtx()
enqueueHiddenLinkBadges(
ctx,
new LLink(9, 'MODEL', 4, 0, 5, 0),
[100, 100],
[400, 200],
'#cab8ff'
)
// Hit-testable immediately, but the badge is not painted until the flush.
expect(queryLinkBadgeAtPoint(120, 100)).toBe(9)
expect(ctx.fillText).not.toHaveBeenCalled()
drawPendingLinkBadges(ctx)
expect(ctx.fillText).toHaveBeenCalled()
})
it('returns the badge tips a revealed noodle attaches to', () => {
const ctx = mockCtx()
const tips = enqueueHiddenLinkBadges(
ctx,
new LLink(9, 'MODEL', 4, 0, 5, 0),
[100, 100],
[400, 200],
'#cab8ff'
)
// Output tip is the right edge of the output badge; input tip its left edge.
expect(tips?.outputTip[0]).toBeGreaterThan(100)
expect(tips?.outputTip[1]).toBe(100)
expect(tips?.inputTip[0]).toBeLessThan(400)
expect(tips?.inputTip[1]).toBe(200)
})
})
type RenamePrompt = (
title: string,
value: string | number,
callback: (value: string) => void,
event: CanvasPointerEvent
) => unknown
describe('promptRenameLinkBadge', () => {
const event = {} as CanvasPointerEvent
const renameHost = (prompt: RenamePrompt, setDirty = vi.fn()) => ({
prompt,
setDirty,
emitBeforeChange: vi.fn(),
emitAfterChange: vi.fn()
})
it('sets the trimmed prompt value as the label and redraws', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
const setDirty = vi.fn()
const prompt = vi.fn(
(_t: string, _v: string | number, cb: (value: string) => void) =>
cb(' Backbone ')
)
promptRenameLinkBadge(renameHost(prompt, setDirty), link, event)
expect(link.label).toBe('Backbone')
expect(setDirty).toHaveBeenCalledWith(false, true)
})
it('clears the label when the prompt value is blank', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
link.label = 'Old'
const prompt = vi.fn(
(_t: string, _v: string | number, cb: (value: string) => void) => cb(' ')
)
promptRenameLinkBadge(renameHost(prompt), link, event)
expect(link.label).toBeUndefined()
})
it('seeds the editor with the current badge text', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
const prompt = vi.fn()
promptRenameLinkBadge(renameHost(prompt), link, event)
expect(prompt).toHaveBeenCalledWith(
'Rename',
'MODEL',
expect.any(Function),
event
)
})
it('brackets the label change with the graph change lifecycle', () => {
const link = new LLink(1, 'MODEL', 4, 0, 5, 0)
const host = renameHost(
vi.fn((_t: string, _v: string | number, cb: (value: string) => void) =>
cb('Backbone')
)
)
promptRenameLinkBadge(host, link, event)
expect(host.emitBeforeChange).toHaveBeenCalled()
expect(host.emitAfterChange).toHaveBeenCalled()
})
})
describe('setRevealedLinks / isLinkRevealed', () => {
beforeEach(() => {
setRevealedLinks([])
})
it('reveals a set of links and flags only real changes', () => {
expect(setRevealedLinks([5, 6])).toBe(true)
expect(isLinkRevealed(5)).toBe(true)
expect(isLinkRevealed(6)).toBe(true)
expect(isLinkRevealed(7)).toBe(false)
// Same membership, any order, is not a change.
expect(setRevealedLinks([6, 5])).toBe(false)
// Revealing another link (e.g. a second link on the hovered socket).
expect(setRevealedLinks([5, 6, 7])).toBe(true)
expect(isLinkRevealed(7)).toBe(true)
// Moving off clears every reveal.
expect(setRevealedLinks([])).toBe(true)
expect(isLinkRevealed(5)).toBe(false)
})
})

View File

@@ -0,0 +1,334 @@
import { textOnColor } from '@/utils/colorUtil'
import { LGraphBadge } from '../LGraphBadge'
import type { LinkId, LLink } from '../LLink'
import type { Point } from '../interfaces'
import type { CanvasPointerEvent } from '../types/events'
/** Gap in graph units between a socket and the start of its badge. */
const BADGE_GAP = 14
const BADGE_HEIGHT = 18
const BADGE_FONT_SIZE = 11
/** Width of the short stub that connects a socket to its badge. */
const CONNECTOR_WIDTH = 3
/** Vertical gap kept between badges that would otherwise overlap in a column. */
const BADGE_STACK_GAP = 4
/**
* How far the connector stub reaches past the badge's edge so the badge fill
* covers the join — just enough to stay clean when a stacked badge sits below
* its socket and the stub runs at an angle.
*/
const BADGE_CONNECT_INSET = 2
interface BadgeHitArea {
linkId: LinkId
x: number
y: number
width: number
height: number
}
/**
* Hit areas for the badges drawn this frame, in graph coordinates. Rebuilt every
* render pass (cleared at the start of `drawConnections`) and queried by pointer
* handlers. Module-level so the canvas god-object gains no new render state; safe
* because only the app canvas draws badges (offscreen/minimap canvases don't).
*/
const hitAreas: BadgeHitArea[] = []
interface BadgeLayout {
badge: LGraphBadge
color: string
width: number
outputSocket: Point
outputBadgeX: number
outputBadgeY: number
inputSocket: Point
inputBadgeX: number
inputBadgeY: number
}
/**
* Laid-out badges from the current link pass, painted after the noodles so the
* labels sit on top. Layout (positions + hit areas) happens at enqueue so a
* revealed link's noodle can attach to the badge tip; only painting is deferred.
*/
const pendingBadges: BadgeLayout[] = []
/** Links whose noodle is currently revealed (badge or socket hover). */
const revealedLinkIds = new Set<LinkId>()
export function clearLinkBadgeHitAreas(): void {
hitAreas.length = 0
pendingBadges.length = 0
}
/** Returns the id of a hidden link whose badge contains the point, if any. */
export function queryLinkBadgeAtPoint(
x: number,
y: number
): LinkId | undefined {
for (const area of hitAreas) {
if (
x >= area.x &&
x <= area.x + area.width &&
y >= area.y &&
y <= area.y + area.height
) {
return area.linkId
}
}
return undefined
}
/**
* Sets the links whose noodle should be revealed (the pointer is over a badge or
* a socket they connect to). Returns `true` only when the set changed, so
* callers can skip redundant redraws.
*/
export function setRevealedLinks(linkIds: Iterable<LinkId>): boolean {
const next = new Set(linkIds)
if (
next.size === revealedLinkIds.size &&
[...next].every((id) => revealedLinkIds.has(id))
) {
return false
}
revealedLinkIds.clear()
for (const id of next) revealedLinkIds.add(id)
return true
}
/** Whether the given hidden link's noodle is currently revealed. */
export function isLinkRevealed(linkId: LinkId): boolean {
return revealedLinkIds.has(linkId)
}
/** The text shown on a hidden link's badges: its custom label or its type. */
export function linkBadgeText(link: Pick<LLink, 'label' | 'type'>): string {
const label = link.label?.trim()
if (label) return label
return link.type != null ? String(link.type) : ''
}
/** Minimal canvas surface needed to rename a badge, to avoid a circular import. */
interface BadgeRenameHost {
prompt(
title: string,
value: string | number,
callback: (value: string) => void,
event: CanvasPointerEvent
): unknown
setDirty(fgcanvas: boolean, bgcanvas: boolean): void
emitBeforeChange(): void
emitAfterChange(): void
}
/** Opens the inline editor to rename a hidden link's badges, then redraws. */
export function promptRenameLinkBadge(
host: BadgeRenameHost,
link: LLink,
event: CanvasPointerEvent
): void {
host.prompt(
'Rename',
linkBadgeText(link),
(value) => {
const trimmed = value.trim()
host.emitBeforeChange()
link.label = trimmed.length ? trimmed : undefined
host.setDirty(false, true)
host.emitAfterChange()
},
event
)
}
function makeBadge(text: string, color: string): LGraphBadge {
return new LGraphBadge({
text,
bgColor: color,
fgColor: textOnColor(color),
fontSize: BADGE_FONT_SIZE,
height: BADGE_HEIGHT,
cornerRadius: BADGE_HEIGHT / 2
})
}
/** Strokes the short connector stub between a socket and its badge edge. */
function drawConnectorStub(
ctx: CanvasRenderingContext2D,
from: Point,
to: Point,
color: string
): void {
ctx.save()
ctx.strokeStyle = color
ctx.lineWidth = CONNECTOR_WIDTH
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(from[0], from[1])
ctx.lineTo(to[0], to[1])
ctx.stroke()
ctx.restore()
}
function overlapsBadge(
left: number,
top: number,
width: number,
area: BadgeHitArea
): boolean {
return (
left < area.x + area.width &&
left + width > area.x &&
top < area.y + area.height &&
top + BADGE_HEIGHT > area.y
)
}
/**
* Centre Y for a badge of `width` whose left edge sits at `left`, starting at its
* socket row (`desiredCentreY`) and sliding downward only as far as needed to
* clear every badge already placed this frame. This stacks badges that share a
* socket and keeps them off the labels of nearby sockets.
*/
function freeBadgeCentreY(
left: number,
desiredCentreY: number,
width: number
): number {
let centreY = desiredCentreY
let moved = true
while (moved) {
moved = false
const top = centreY - BADGE_HEIGHT / 2
for (const area of hitAreas) {
if (overlapsBadge(left, top, width, area)) {
centreY = area.y + area.height + BADGE_STACK_GAP + BADGE_HEIGHT / 2
moved = true
break
}
}
}
return centreY
}
function recordHitArea(
linkId: LinkId,
left: number,
centreY: number,
width: number
): void {
hitAreas.push({
linkId,
x: left,
y: centreY - BADGE_HEIGHT / 2,
width,
height: BADGE_HEIGHT
})
}
/**
* Lays out a hidden link's two end badges — one just past the output socket, one
* just before the input socket — recording their hit areas. Returns the geometry
* to paint later, or `undefined` if the link has no badge text.
*/
function layoutHiddenLinkBadges(
ctx: CanvasRenderingContext2D,
link: LLink,
startPos: Point,
endPos: Point,
color: string
): BadgeLayout | undefined {
const text = linkBadgeText(link)
if (!text) return undefined
const badge = makeBadge(text, color)
const width = badge.getWidth(ctx)
const [outputSocketX, outputSocketY] = startPos
const outputBadgeX = outputSocketX + BADGE_GAP
const outputBadgeY = freeBadgeCentreY(outputBadgeX, outputSocketY, width)
recordHitArea(link.id, outputBadgeX, outputBadgeY, width)
const [inputSocketX, inputSocketY] = endPos
const inputBadgeX = inputSocketX - BADGE_GAP - width
const inputBadgeY = freeBadgeCentreY(inputBadgeX, inputSocketY, width)
recordHitArea(link.id, inputBadgeX, inputBadgeY, width)
return {
badge,
color,
width,
outputSocket: startPos,
outputBadgeX,
outputBadgeY,
inputSocket: endPos,
inputBadgeX,
inputBadgeY
}
}
/** Paints a laid-out badge pair: each socket's connector stub and its badge. */
function drawBadgeLayout(ctx: CanvasRenderingContext2D, l: BadgeLayout): void {
drawConnectorStub(
ctx,
l.outputSocket,
[l.outputBadgeX + BADGE_CONNECT_INSET, l.outputBadgeY],
l.color
)
l.badge.draw(ctx, l.outputBadgeX, l.outputBadgeY - BADGE_HEIGHT / 2)
drawConnectorStub(
ctx,
l.inputSocket,
[l.inputBadgeX + l.width - BADGE_CONNECT_INSET, l.inputBadgeY],
l.color
)
l.badge.draw(ctx, l.inputBadgeX, l.inputBadgeY - BADGE_HEIGHT / 2)
}
/** The far edge of each badge, where a revealed link's noodle attaches. */
function badgeTips(l: BadgeLayout): { outputTip: Point; inputTip: Point } {
return {
outputTip: [l.outputBadgeX + l.width, l.outputBadgeY],
inputTip: [l.inputBadgeX, l.inputBadgeY]
}
}
/** Lays out and immediately paints a hidden link's end badges. */
export function drawHiddenLinkBadges(
ctx: CanvasRenderingContext2D,
link: LLink,
startPos: Point,
endPos: Point,
color: string
): void {
const layout = layoutHiddenLinkBadges(ctx, link, startPos, endPos, color)
if (layout) drawBadgeLayout(ctx, layout)
}
/**
* Lays out a hidden link's badges now — so its hit areas and tip positions are
* known — but defers painting to {@link drawPendingLinkBadges}, keeping labels
* above the noodles. Returns the badge tips a revealed link's noodle attaches to.
*/
export function enqueueHiddenLinkBadges(
ctx: CanvasRenderingContext2D,
link: LLink,
startPos: Point,
endPos: Point,
color: string
): { outputTip: Point; inputTip: Point } | undefined {
const layout = layoutHiddenLinkBadges(ctx, link, startPos, endPos, color)
if (!layout) return undefined
pendingBadges.push(layout)
return badgeTips(layout)
}
/** Paints every queued badge on top of the rendered links, then clears the queue. */
export function drawPendingLinkBadges(ctx: CanvasRenderingContext2D): void {
for (const layout of pendingBadges) drawBadgeLayout(ctx, layout)
pendingBadges.length = 0
}

View File

@@ -222,6 +222,10 @@ export interface SerialisableLLink {
type: ISlotType
/** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */
parentId?: RerouteId
/** When `true`, the link curve is not drawn; renamable end badges are shown instead. */
hidden?: boolean
/** Custom label shown on the link's badges when hidden. Defaults to the link type. */
label?: string
}
export interface ExportedSubgraphIONode {

View File

@@ -74,11 +74,13 @@ const zComfyLink = z.tuple([
zDataType // Data type
])
/** Extension to 0.4 schema (links as arrays): parent reroute ID */
/** Extension to 0.4 schema (links as arrays): parent reroute ID, hidden state, badge label */
const zComfyLinkExtension = z
.object({
id: z.number(),
parentId: z.number()
parentId: z.number().optional(),
hidden: z.boolean().optional(),
label: z.string().optional()
})
.passthrough()

View File

@@ -16,6 +16,8 @@
props.socketless && 'pointer-events-none invisible'
)
"
@pointerenter="revealNoodles"
@pointerleave="hideNoodles"
>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -65,6 +67,7 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useSlotNoodlePreview } from '@/renderer/extensions/vueNodes/composables/useSlotNoodlePreview'
import { cn } from '@comfyorg/tailwind-utils'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -142,4 +145,10 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'input'
})
const { revealNoodles, hideNoodles } = useSlotNoodlePreview({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input'
})
</script>

View File

@@ -1,6 +1,12 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div
v-else
v-tooltip.right="tooltipConfig"
:class="slotWrapperClass"
@pointerenter="revealNoodles"
@pointerleave="hideNoodles"
>
<div class="relative flex h-full min-w-0 items-center">
<!-- Slot Name -->
<span
@@ -37,6 +43,7 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useSlotNoodlePreview } from '@/renderer/extensions/vueNodes/composables/useSlotNoodlePreview'
import { cn } from '@comfyorg/tailwind-utils'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -132,4 +139,10 @@ const { onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'output'
})
const { revealNoodles, hideNoodles } = useSlotNoodlePreview({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output'
})
</script>

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
isLinkRevealed,
setRevealedLinks
} from '@/lib/litegraph/src/canvas/linkBadges'
import { useSlotNoodlePreview } from './useSlotNoodlePreview'
const mocks = vi.hoisted(() => ({
getNodeById: vi.fn(),
links: new Map<number, unknown>(),
setDirty: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: { getNodeById: mocks.getNodeById, links: mocks.links },
setDirty: mocks.setDirty
}
}
}))
beforeEach(() => {
setRevealedLinks([])
mocks.links.clear()
mocks.getNodeById.mockReturnValue({ id: 5 })
})
afterEach(() => {
vi.clearAllMocks()
})
describe('useSlotNoodlePreview', () => {
it('reveals the hidden links on a hovered output slot, ignoring others', () => {
mocks.links.set(1, { id: 1, hidden: true, origin_id: 5, origin_slot: 0 })
mocks.links.set(2, { id: 2, hidden: false, origin_id: 5, origin_slot: 0 })
mocks.links.set(3, { id: 3, hidden: true, origin_id: 5, origin_slot: 1 })
mocks.links.set(4, { id: 4, hidden: true, origin_id: 9, origin_slot: 0 })
useSlotNoodlePreview({
nodeId: '5',
index: 0,
type: 'output'
}).revealNoodles()
expect(isLinkRevealed(1)).toBe(true) // hidden, same slot
expect(isLinkRevealed(2)).toBe(false) // not hidden
expect(isLinkRevealed(3)).toBe(false) // other slot
expect(isLinkRevealed(4)).toBe(false) // other node
expect(mocks.setDirty).toHaveBeenCalledWith(false, true)
})
it('reveals the hidden link on a hovered input slot', () => {
mocks.links.set(7, { id: 7, hidden: true, target_id: 5, target_slot: 2 })
useSlotNoodlePreview({
nodeId: '5',
index: 2,
type: 'input'
}).revealNoodles()
expect(isLinkRevealed(7)).toBe(true)
})
it('clears the reveal on leave', () => {
setRevealedLinks([9])
useSlotNoodlePreview({
nodeId: '5',
index: 0,
type: 'input'
}).hideNoodles()
expect(isLinkRevealed(9)).toBe(false)
expect(mocks.setDirty).toHaveBeenCalledWith(false, true)
})
})

View File

@@ -0,0 +1,44 @@
import { setRevealedLinks } from '@/lib/litegraph/src/canvas/linkBadges'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { app } from '@/scripts/app'
interface SlotNoodlePreviewOptions {
nodeId: string
index: number
type: 'input' | 'output'
}
/**
* Reveals the noodles of a slot's hidden links while it is hovered. Socket hover
* lives in Vue (the dots are DOM, not canvas), so this is the slot-side analogue
* of the badge hover handled in `LGraphCanvas.processMouseMove`.
*/
export function useSlotNoodlePreview(options: SlotNoodlePreviewOptions) {
function hiddenLinkIds(): LinkId[] {
const graph = app.canvas?.graph
const node = graph?.getNodeById(options.nodeId)
if (!graph || !node) return []
// Derive from the links themselves rather than `slot.links`/`slot.link`,
// which can miss links created through dynamic/autogrow inputs.
const ids: LinkId[] = []
for (const link of graph.links.values()) {
if (!link.hidden) continue
const onSlot =
options.type === 'output'
? link.origin_id === node.id && link.origin_slot === options.index
: link.target_id === node.id && link.target_slot === options.index
if (onSlot) ids.push(link.id)
}
return ids
}
function revealNoodles(): void {
if (setRevealedLinks(hiddenLinkIds())) app.canvas?.setDirty(false, true)
}
function hideNoodles(): void {
if (setRevealedLinks([])) app.canvas?.setDirty(false, true)
}
return { revealNoodles, hideNoodles }
}