mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 13:17:48 +00:00
Compare commits
3 Commits
v1.47.5
...
hide-noodl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763c1e63f5 | ||
|
|
3af08e8750 | ||
|
|
202a51124c |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
64
src/lib/litegraph/src/LLink.hidden.test.ts
Normal file
64
src/lib/litegraph/src/LLink.hidden.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
276
src/lib/litegraph/src/canvas/linkBadges.test.ts
Normal file
276
src/lib/litegraph/src/canvas/linkBadges.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
334
src/lib/litegraph/src/canvas/linkBadges.ts
Normal file
334
src/lib/litegraph/src/canvas/linkBadges.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user