Compare commits

...

3 Commits

Author SHA1 Message Date
pythongosssss
3ed88fbe68 Autopan canvas when dragging nodes/links to edges (#8773)
## Summary

Adds autopanning so the canvas moves when you drag a node/link to the
side of the canvas

## Changes

- **What**: 
- adds autopan controller that runs on animation frame timer to check
autopan speed
- extracts updateNodePositions for reuse
- specific handling for vue vs litegraph modes
- adds max speed setting, allowing user to set 0 for disabling

## Screenshots (if applicable)

https://github.com/user-attachments/assets/1290ae6d-b2f0-4d63-8955-39b933526874

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8773-Autopan-canvas-when-dragging-nodes-links-to-edges-3036d73d365081869a58ca5978f15f80)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-19 04:12:06 -07:00
pythongosssss
77ddda9d3c fix: App mode - renaming widgets on subgraphs (#10245)
## Summary

Fixes renaming of widgets from subgraph nodes in app builder/app mode.

## Changes

- **What**: If the widget is from a subgraph node and no parents passed,
use the node as the subgraph parent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10245-fix-App-mode-renaming-widgets-on-subgraphs-3276d73d3650815bb131c840df43cdf2)
by [Unito](https://www.unito.io)
2026-03-19 04:00:31 -07:00
jaeone94
3591579141 fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10289)
## Summary

Fixes a regression introduced in v1.41.21 where
`_removeDuplicateLinks()` (added by #9120 / backport #10045) incorrectly
removes valid links during workflow loading when the target node has
widget-to-input conversions that shift slot indices.

- Fixes https://github.com/Comfy-Org/workflow_templates/issues/715

## Root Cause

The `_removeDuplicateLinks()` method added in #9120 uses
`node.inputs[link.target_slot]` to determine which duplicate link to
keep. However, `target_slot` is the slot index recorded at serialization
time. During `LGraphNode.configure()`, the `onConnectionsChange`
callback triggers widget-to-input conversions (e.g., KSamplerAdvanced
converting `steps`, `cfg`, `start_at_step`, etc.), which inserts new
entries into the `inputs` array. This shifts indices so that
`node.inputs[target_slot]` no longer points to the expected input.

**Concrete example with `video_wan2_2_14B_i2v.json`:**

The Wan2.2 Image-to-Video subgraph contains a Switch node (id=120)
connected to KSamplerAdvanced (id=85) cfg input. The serialized data has
two links with the same connection tuple `(origin_id=120, origin_slot=0,
target_id=85, target_slot=5)`:

| Link ID | Connection | Status |
|---------|-----------|--------|
| 257 | 120:0 → 85:5 (FLOAT) | Orphaned duplicate (not referenced by any
input.link) |
| 276 | 120:0 → 85:5 (FLOAT) | Valid (referenced by node 85
input.link=276) |

When `_removeDuplicateLinks()` runs after all nodes are configured:
1. KSamplerAdvanced is created with 4 default inputs, but after
`configure()` with widget conversions, it has **13 inputs** (shifted
indices)
2. The method checks `node.inputs[5].link` (target_slot=5 from the
LLink), but index 5 is now a different input due to the shift
3. `node.inputs[5].link === null` → the method incorrectly decides link
276 is not referenced
4. **Link 276 (valid) is removed, link 257 (orphan) is kept** →
connection lost

This worked correctly in v1.41.20 because `_removeDuplicateLinks()` did
not exist.

## Changes

Replace the `target_slot`-based positional lookup with a full scan of
the target node's inputs to find which duplicate link ID is actually
referenced by `input.link`. Also repair `input.link` if it still points
to a removed duplicate after cleanup.

## Test Plan

- [x] Added regression test: shifted slot index scenario
(widget-to-input conversion)
- [x] Added regression test: `input.link` repair when pointing to
removed duplicate
- [x] Existing `_removeDuplicateLinks` tests pass (45/45)
- [x] Full unit test suite passes (6885/6885)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes (0 errors)
- [x] Manual verification: loaded `video_wan2_2_14B_i2v.json` in clean
state — Switch→KSamplerAdvanced cfg link is now preserved
- [ ] E2E testing is difficult for this fix since it requires a workflow
with duplicate links in a subgraph containing nodes with widget-to-input
conversions (e.g., KSamplerAdvanced). The specific conditions —
duplicate LLink entries + slot index shift from widget conversion — are
hard to set up in Playwright without a pre-crafted fixture workflow and
backend node type registration. The unit tests cover the core logic
directly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10289-fix-_removeDuplicateLinks-incorrectly-removes-valid-link-when-slot-indices-shift-3286d73d36508140b053fd538163e383)
by [Unito](https://www.unito.io)
2026-03-19 15:45:24 +09:00
24 changed files with 1718 additions and 162 deletions

View File

@@ -33,6 +33,7 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
@@ -176,6 +177,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -221,6 +223,7 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)

View File

@@ -0,0 +1,128 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.nextFrame()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
}

View File

@@ -58,6 +58,10 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -84,6 +88,7 @@ export type TestIdValue =
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],

View File

@@ -0,0 +1,149 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
await page.mouse.click(seedPos.x, seedPos.y)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
const saveImagePos = await saveImageRef.getPosition()
// Click left edge — the right side is hidden by the builder panel
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

@@ -79,6 +79,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)

View File

@@ -123,6 +123,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
test('Can pin and unpin', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'

View File

@@ -191,7 +191,11 @@ function nodeToNodeData(node: LGraphNode) {
]"
>
<template #button>
<Button variant="textonly" size="icon">
<Button
variant="textonly"
size="icon"
data-testid="widget-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -63,7 +63,7 @@ const entries = computed(() => {
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<Button variant="muted-textonly" data-testid="widget-actions-menu">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -614,6 +614,87 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
// Connect source:0 -> target:0, establishing input.link on target
source.connect(0, target, 0)
const validLinkId = target.inputs[0].link!
expect(graph._links.has(validLinkId)).toBe(true)
// Simulate widget-to-input conversion shifting the slot: insert a new
// input BEFORE the connected one, moving it from index 0 to index 1.
target.addInput('extra_widget', 'number')
const connectedInput = target.inputs[0]
target.inputs[0] = target.inputs[1]
target.inputs[1] = connectedInput
// Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null
// Add a duplicate link with the same connection tuple (target_slot=0
// in the LLink, matching the original slot before the shift).
const dupLink = new LLink(
++graph.state.lastLinkId,
'number',
source.id,
0,
target.id,
0
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
expect(graph._links.size).toBe(2)
graph._removeDuplicateLinks()
// The valid link (referenced by an actual input) must survive
expect(graph._links.size).toBe(1)
expect(graph._links.has(validLinkId)).toBe(true)
expect(graph._links.has(dupLink.id)).toBe(false)
expect(target.inputs[1].link).toBe(validLinkId)
})
it('repairs input.link when it points to a removed duplicate', () => {
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
// Create a duplicate link
const dupLink = new LLink(
++graph.state.lastLinkId,
'number',
source.id,
0,
target.id,
0
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
// Point input.link to the duplicate (simulating corrupted state)
target.inputs[0].link = dupLink.id
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(1)
// input.link must point to whichever link survived
const survivingId = graph._links.keys().next().value!
expect(target.inputs[0].link).toBe(survivingId)
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
})
it('is a no-op when no duplicates exist', () => {
registerTestNodes()
const graph = new LGraph()

View File

@@ -1625,42 +1625,66 @@ export class LGraph
* output.links and the graph's _links map.
*/
_removeDuplicateLinks(): void {
const seen = new Map<string, LinkId>()
const toRemove: LinkId[] = []
// Group all link IDs by their connection tuple.
const groups = new Map<string, LinkId[]>()
for (const [id, link] of this._links) {
const key = LGraph._linkTupleKey(link)
if (seen.has(key)) {
const existingId = seen.get(key)!
// Keep the link that the input side references
const node = this.getNodeById(link.target_id)
const input = node?.inputs?.[link.target_slot]
if (input?.link === id) {
toRemove.push(existingId)
seen.set(key, id)
} else {
toRemove.push(id)
}
} else {
seen.set(key, id)
let group = groups.get(key)
if (!group) {
group = []
groups.set(key, group)
}
group.push(id)
}
for (const id of toRemove) {
const link = this._links.get(id)
if (!link) continue
for (const [, ids] of groups) {
if (ids.length <= 1) continue
// Remove from origin node's output.links array
const originNode = this.getNodeById(link.origin_id)
if (originNode) {
const output = originNode.outputs?.[link.origin_slot]
if (output?.links) {
const idx = output.links.indexOf(id)
if (idx !== -1) output.links.splice(idx, 1)
const sampleLink = this._links.get(ids[0])!
const node = this.getNodeById(sampleLink.target_id)
// Find which link ID is actually referenced by any input on the target
// node. Cannot rely on target_slot index because widget-to-input
// conversions during configure() can shift slot indices.
let keepId: LinkId | undefined
if (node) {
for (const input of node.inputs ?? []) {
const match = ids.find((id) => input.link === id)
if (match != null) {
keepId = match
break
}
}
}
keepId ??= ids[0]
this._links.delete(id)
for (const id of ids) {
if (id === keepId) continue
const link = this._links.get(id)
if (!link) continue
// Remove from origin node's output.links array
const originNode = this.getNodeById(link.origin_id)
if (originNode) {
const output = originNode.outputs?.[link.origin_slot]
if (output?.links) {
const idx = output.links.indexOf(id)
if (idx !== -1) output.links.splice(idx, 1)
}
}
this._links.delete(id)
}
// Ensure input.link points to the surviving link
if (node) {
for (const input of node.inputs ?? []) {
if (ids.includes(input.link as LinkId) && input.link !== keepId) {
input.link = keepId
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas link drag auto-pan', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.useFakeTimers()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
const graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
})
afterEach(() => {
canvas.pointer.finally?.()
vi.useRealTimers()
})
function startLinkDrag() {
canvas['_linkConnectorDrop']()
}
it('starts auto-pan when link drag begins', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
expect(canvas['_autoPan']).not.toBeNull()
})
it('keeps graph_mouse consistent with offset after auto-pan', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
vi.advanceTimersByTime(16)
const { scale } = canvas.ds
expect(canvas.graph_mouse[0]).toBeCloseTo(
canvas.mouse[0] / scale - canvas.ds.offset[0]
)
expect(canvas.graph_mouse[1]).toBeCloseTo(
canvas.mouse[1] / scale - canvas.ds.offset[1]
)
})
it('keeps graph_mouse consistent with zoom applied', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
canvas.ds.scale = 2
startLinkDrag()
vi.advanceTimersByTime(16)
expect(canvas.graph_mouse[0]).toBeCloseTo(
canvas.mouse[0] / 2 - canvas.ds.offset[0]
)
expect(canvas.graph_mouse[1]).toBeCloseTo(
canvas.mouse[1] / 2 - canvas.ds.offset[1]
)
})
it('pans the viewport when pointer is near edge', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
const offsetBefore = canvas.ds.offset[0]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).not.toBe(offsetBefore)
})
it('marks canvas dirty when auto-pan fires', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
canvas.dirty_canvas = false
canvas.dirty_bgcanvas = false
vi.advanceTimersByTime(16)
expect(canvas.dirty_canvas).toBe(true)
expect(canvas.dirty_bgcanvas).toBe(true)
})
it('stops auto-pan when pointer.finally fires', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
expect(canvas['_autoPan']).not.toBeNull()
canvas.pointer.finally!()
expect(canvas['_autoPan']).toBeNull()
})
it('does not pan when pointer is in the center', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
const offsetBefore = [...canvas.ds.offset]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
})
})

View File

@@ -3,6 +3,7 @@ import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
@@ -532,6 +533,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
readonly pointer: CanvasPointer
zoom_modify_alpha: boolean
zoom_speed: number
auto_pan_speed: number
node_title_color: string
default_link_color: string
default_connection_color: {
@@ -679,6 +681,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
highlighted_links: Dictionary<boolean> = {}
private _visibleReroutes: Set<Reroute> = new Set()
private _autoPan: AutoPanController | null = null
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
@@ -834,6 +837,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @deprecated Workaround: Keep until connecting_links is removed.
this.linkConnector.events.addEventListener('reset', () => {
this._autoPan?.stop()
this._autoPan = null
this.connecting_links = null
this.dirty_bgcanvas = true
})
@@ -901,6 +906,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.zoom_modify_alpha = true
// in range (1.01, 2.5). Less than 1 will invert the zoom direction
this.zoom_speed = 1.1
this.auto_pan_speed = 15
this.node_title_color = LiteGraph.NODE_TITLE_COLOR
this.default_link_color = LiteGraph.LINK_COLOR
@@ -2070,7 +2076,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!graph) throw new NullGraphError()
pointer.onDragEnd = (upEvent) => linkConnector.dropLinks(graph, upEvent)
pointer.finally = () => this.linkConnector.reset(true)
pointer.finally = () => {
this._autoPan?.stop()
this._autoPan = null
this.linkConnector.reset(true)
}
this._autoPan = new AutoPanController({
canvas: this.canvas,
ds: this.ds,
maxPanSpeed: this.auto_pan_speed,
onPan: () => {
const rect = this.canvas.getBoundingClientRect()
const { scale } = this.ds
this.graph_mouse[0] =
(this.mouse[0] - rect.left) / scale - this.ds.offset[0]
this.graph_mouse[1] =
(this.mouse[1] - rect.top) / scale - this.ds.offset[1]
this._dirty()
}
})
this._autoPan.updatePointer(this.mouse[0], this.mouse[1])
this._autoPan.start()
}
/**
@@ -3282,7 +3309,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
(this.allow_interaction || node?.flags.allow_interaction) &&
!this.read_only
) {
if (linkConnector.isConnecting) this.dirty_canvas = true
if (linkConnector.isConnecting) {
this._autoPan?.updatePointer(e.clientX, e.clientY)
this.dirty_canvas = true
}
// remove mouseover flag
this.updateMouseOverNodes(node, e)
@@ -3488,6 +3518,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Items being dragged
if (this.isDragging) {
this._autoPan?.updatePointer(e.clientX, e.clientY)
const selected = this.selectedItems
const allItems = e.ctrlKey ? selected : getAllNestedItems(selected)
@@ -3566,12 +3598,36 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
this.isDragging = false
this._autoPan?.stop()
this._autoPan = null
this.graph?.afterChange()
this.emitAfterChange()
}
this.processSelect(item, pointer.eDown, sticky)
this.isDragging = true
this._autoPan = new AutoPanController({
canvas: this.canvas,
ds: this.ds,
maxPanSpeed: this.auto_pan_speed,
onPan: (panX, panY) => {
const selected = this.selectedItems
const allItems = getAllNestedItems(selected)
if (LiteGraph.vueNodesMode) {
this.moveChildNodesInGroupVueMode(allItems, panX, panY)
} else {
for (const item of allItems) {
item.move(panX, panY, true)
}
}
this._dirty()
}
})
this._autoPan.updatePointer(this.mouse[0], this.mouse[1])
this._autoPan.start()
}
/**

View File

@@ -120,6 +120,10 @@
"name": "Live selection",
"tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools."
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},

View File

@@ -31,6 +31,13 @@ export const useLitegraphSettings = () => {
}
})
watchEffect(() => {
const autoPanSpeed = settingStore.get('Comfy.Graph.AutoPanSpeed')
if (canvasStore.canvas) {
canvasStore.canvas.auto_pan_speed = autoPanSpeed
}
})
watchEffect(() => {
LiteGraph.snaps_for_comfy = settingStore.get(
'Comfy.Node.AutoSnapLinkToSlot'

View File

@@ -311,6 +311,20 @@ export const CORE_SETTINGS: SettingParams[] = [
step: 0.01
}
},
{
id: 'Comfy.Graph.AutoPanSpeed',
category: ['LiteGraph', 'Canvas', 'AutoPanSpeed'],
name: 'Auto-pan speed',
tooltip:
'Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning.',
type: 'slider',
defaultValue: 15,
attrs: {
min: 0,
max: 30,
step: 1
}
},
// Bookmarks are stored in the settings store.
{
id: 'Comfy.NodeLibrary.NewDesign',

View File

@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale'
import {
AutoPanController,
calculateEdgePanSpeed
} from '@/renderer/core/canvas/useAutoPan'
describe('calculateEdgePanSpeed', () => {
const MAX = 15
it('returns 0 when pointer is in the center', () => {
expect(calculateEdgePanSpeed(500, 0, 1000, 1, MAX)).toBe(0)
})
it('returns negative speed near the left/top edge', () => {
const speed = calculateEdgePanSpeed(10, 0, 1000, 1, MAX)
expect(speed).toBeLessThan(0)
})
it('returns positive speed near the right/bottom edge', () => {
const speed = calculateEdgePanSpeed(990, 0, 1000, 1, MAX)
expect(speed).toBeGreaterThan(0)
})
it('returns max speed at the exact edge', () => {
const speed = calculateEdgePanSpeed(0, 0, 1000, 1, MAX)
expect(speed).toBe(-15)
})
it('returns 0 at exactly the threshold boundary', () => {
const speed = calculateEdgePanSpeed(50, 0, 1000, 1, MAX)
expect(speed).toBe(0)
})
it('scales speed linearly with edge proximity', () => {
const halfwaySpeed = calculateEdgePanSpeed(25, 0, 1000, 1, MAX)
const quarterSpeed = calculateEdgePanSpeed(37.5, 0, 1000, 1, MAX)
expect(halfwaySpeed).toBeCloseTo(-15 * 0.5)
expect(quarterSpeed).toBeCloseTo(-15 * 0.25)
})
it('divides speed by scale (zoom level)', () => {
const speedAtScale1 = calculateEdgePanSpeed(0, 0, 1000, 1, MAX)
const speedAtScale2 = calculateEdgePanSpeed(0, 0, 1000, 2, MAX)
expect(speedAtScale2).toBe(speedAtScale1 / 2)
})
it('returns max speed when pointer is outside bounds', () => {
expect(calculateEdgePanSpeed(-10, 0, 1000, 1, MAX)).toBe(-15)
expect(calculateEdgePanSpeed(1010, 0, 1000, 1, MAX)).toBe(15)
})
it('returns 0 when maxPanSpeed is 0 (disabled)', () => {
expect(calculateEdgePanSpeed(0, 0, 1000, 1, 0)).toBe(0)
expect(calculateEdgePanSpeed(10, 0, 1000, 1, 0)).toBe(0)
})
it('uses custom maxPanSpeed', () => {
const speed = calculateEdgePanSpeed(0, 0, 1000, 1, 30)
expect(speed).toBe(-30)
})
})
describe('AutoPanController', () => {
let mockCanvas: HTMLCanvasElement
let mockDs: DragAndScale
let onPanMock: ReturnType<typeof vi.fn<(dx: number, dy: number) => void>>
let controller: AutoPanController
beforeEach(() => {
vi.useFakeTimers()
mockCanvas = {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
} as unknown as HTMLCanvasElement
mockDs = {
offset: [0, 0],
scale: 1
} as unknown as DragAndScale
onPanMock = vi.fn<(dx: number, dy: number) => void>()
controller = new AutoPanController({
canvas: mockCanvas,
ds: mockDs,
maxPanSpeed: 15,
onPan: onPanMock
})
})
afterEach(() => {
controller.stop()
vi.useRealTimers()
})
it('does not pan when pointer is in the center', () => {
controller.updatePointer(400, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).not.toHaveBeenCalled()
})
it('pans when pointer is near the right edge', () => {
controller.updatePointer(790, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBe(0)
})
it('pans when pointer is near the bottom edge', () => {
controller.updatePointer(400, 590)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBe(0)
expect(dy).toBeGreaterThan(0)
})
it('pans diagonally when pointer is near a corner', () => {
controller.updatePointer(790, 590)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBeGreaterThan(0)
})
it('updates ds.offset when panning', () => {
controller.updatePointer(0, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(mockDs.offset[0]).toBeGreaterThan(0)
expect(mockDs.offset[1]).toBe(0)
})
it('accounts for zoom level in offset changes', () => {
controller.updatePointer(0, 300)
controller.start()
vi.advanceTimersByTime(16)
const offsetAtScale1 = mockDs.offset[0]
controller.stop()
mockDs.offset[0] = 0
mockDs.scale = 2
controller.start()
vi.advanceTimersByTime(16)
const offsetAtScale2 = mockDs.offset[0]
expect(offsetAtScale2).toBeCloseTo(offsetAtScale1 / 2)
})
it('stops panning when stop() is called', () => {
controller.updatePointer(790, 300)
controller.start()
vi.advanceTimersByTime(16)
const callCount = onPanMock.mock.calls.length
expect(callCount).toBeGreaterThan(0)
controller.stop()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalledTimes(callCount)
})
it('does not pan when maxPanSpeed is 0', () => {
const disabledController = new AutoPanController({
canvas: mockCanvas,
ds: mockDs,
maxPanSpeed: 0,
onPan: onPanMock
})
disabledController.updatePointer(0, 0)
disabledController.start()
vi.advanceTimersByTime(16)
expect(onPanMock).not.toHaveBeenCalled()
disabledController.stop()
})
})

View File

@@ -0,0 +1,103 @@
import { useRafFn } from '@vueuse/core'
import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale'
const EDGE_THRESHOLD = 50
interface AutoPanOptions {
canvas: HTMLCanvasElement
ds: DragAndScale
maxPanSpeed: number
onPan: (canvasDeltaX: number, canvasDeltaY: number) => void
}
/**
* Calculates the pan speed for a single axis based on distance from the edge.
* Returns negative speed for left/top edges, positive for right/bottom edges,
* or 0 if the pointer is not near any edge. Pans at max speed when the
* pointer is outside the bounds (e.g. dragged outside the window).
*/
export function calculateEdgePanSpeed(
pointerPos: number,
minBound: number,
maxBound: number,
scale: number,
maxPanSpeed: number
): number {
if (maxPanSpeed <= 0) return 0
const distFromMin = pointerPos - minBound
const distFromMax = maxBound - pointerPos
if (distFromMin < 0) return -maxPanSpeed / scale
if (distFromMax < 0) return maxPanSpeed / scale
if (distFromMin < EDGE_THRESHOLD) {
return (-maxPanSpeed * (1 - distFromMin / EDGE_THRESHOLD)) / scale
}
if (distFromMax < EDGE_THRESHOLD) {
return (maxPanSpeed * (1 - distFromMax / EDGE_THRESHOLD)) / scale
}
return 0
}
export class AutoPanController {
private pointerX = 0
private pointerY = 0
private readonly canvas: HTMLCanvasElement
private readonly ds: DragAndScale
private readonly maxPanSpeed: number
private readonly onPan: (dx: number, dy: number) => void
private readonly raf: ReturnType<typeof useRafFn>
constructor(options: AutoPanOptions) {
this.canvas = options.canvas
this.ds = options.ds
this.maxPanSpeed = options.maxPanSpeed
this.onPan = options.onPan
this.raf = useRafFn(() => this.tick(), { immediate: false })
}
updatePointer(screenX: number, screenY: number) {
this.pointerX = screenX
this.pointerY = screenY
}
start() {
this.raf.resume()
}
stop() {
this.raf.pause()
}
private tick() {
const rect = this.canvas.getBoundingClientRect()
const scale = this.ds.scale
const panX = calculateEdgePanSpeed(
this.pointerX,
rect.left,
rect.right,
scale,
this.maxPanSpeed
)
const panY = calculateEdgePanSpeed(
this.pointerY,
rect.top,
rect.bottom,
scale,
this.maxPanSpeed
)
if (panX === 0 && panY === 0) return
this.ds.offset[0] -= panX
this.ds.offset[1] -= panY
this.onPan(panX, panY)
}
}

View File

@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
capturedOnPan,
capturedAutoPan,
capturedHandlers,
mockDs,
mockSetDirty,
mockLinkConnector,
mockAdapter
} = vi.hoisted(() => ({
capturedOnPan: { current: null as ((dx: number, dy: number) => void) | null },
capturedAutoPan: {
current: null as {
updatePointer: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} | null
},
capturedHandlers: {} as Record<string, (...args: unknown[]) => void>,
mockDs: { offset: [0, 0] as [number, number], scale: 1 },
mockSetDirty: vi.fn(),
mockLinkConnector: {
isConnecting: false,
state: { snapLinksPos: null as [number, number] | null },
events: {}
},
mockAdapter: {
beginFromOutput: vi.fn(),
beginFromInput: vi.fn(),
reset: vi.fn(),
renderLinks: [] as unknown[],
linkConnector: null as unknown,
isInputValidDrop: vi.fn(() => false),
isOutputValidDrop: vi.fn(() => false),
dropOnCanvas: vi.fn()
}
}))
mockAdapter.linkConnector = mockLinkConnector
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
constructor(opts: { onPan: (dx: number, dy: number) => void }) {
capturedOnPan.current = opts.onPan
capturedAutoPan.current = this as typeof capturedAutoPan.current
}
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
ds: mockDs,
graph: {
getNodeById: (id: string) => ({
id,
inputs: [],
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
}),
getLink: () => null,
getReroute: () => null
},
linkConnector: mockLinkConnector,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600
})
},
setDirty: mockSetDirty
}
}
}))
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
createLinkConnectorAdapter: () => mockAdapter
}))
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => {
const pointer = { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }
return {
useSlotLinkDragUIState: () => ({
state: {
active: false,
pointerId: null,
source: null,
pointer,
candidate: null,
compatible: new Map()
},
beginDrag: vi.fn(),
endDrag: vi.fn(),
updatePointerPosition: (
cx: number,
cy: number,
canX: number,
canY: number
) => {
pointer.client.x = cx
pointer.client.y = cy
pointer.canvas.x = canX
pointer.canvas.y = canY
},
setCandidate: vi.fn(),
setCompatibleForKey: vi.fn(),
clearCompatible: vi.fn()
})
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: (pos: [number, number]): [number, number] => [
pos[0] / (mockDs.scale || 1) - mockDs.offset[0],
pos[1] / (mockDs.scale || 1) - mockDs.offset[1]
]
})
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getSlotLayout: (_key: string) => ({
nodeId: 'node1',
index: 0,
type: 'output',
position: { x: 100, y: 200 }
}),
getAllSlotKeys: () => [],
getRerouteLayout: () => null,
queryRerouteAtPoint: () => null
}
}))
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
getSlotKey: (...args: unknown[]) => args.join('-')
}))
vi.mock('@/renderer/core/canvas/interaction/canvasPointerEvent', () => ({
toCanvasPointerEvent: (e: PointerEvent) => e,
clearCanvasPointerHistory: vi.fn()
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/slotLinkDragContext',
() => ({
createSlotLinkDragContext: () => ({
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotKey: null,
lastPointerTargetNodeId: null,
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
reset: vi.fn(),
dispose: vi.fn()
})
})
)
vi.mock('@/renderer/extensions/vueNodes/utils/eventUtils', () => ({
augmentToCanvasPointerEvent: vi.fn()
}))
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
resolveSlotTargetCandidate: () => null,
resolveNodeSurfaceSlotCandidate: () => null
}))
vi.mock('@vueuse/core', () => ({
useEventListener: (event: string, handler: (...args: unknown[]) => void) => {
capturedHandlers[event] = handler
return vi.fn()
},
tryOnScopeDispose: () => {}
}))
vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({
schedule: () => {},
cancel: () => {},
flush: fn
})
}))
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
function pointerEvent(
clientX: number,
clientY: number,
pointerId = 1
): PointerEvent {
return {
clientX,
clientY,
button: 0,
pointerId,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
target: document.createElement('div'),
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as unknown as PointerEvent
}
function startDrag() {
const { onPointerDown } = useSlotLinkInteraction({
nodeId: 'node1',
index: 0,
type: 'output'
})
onPointerDown(pointerEvent(400, 300))
}
describe('useSlotLinkInteraction auto-pan', () => {
beforeEach(() => {
capturedOnPan.current = null
capturedAutoPan.current = null
for (const k of Object.keys(capturedHandlers)) {
delete capturedHandlers[k]
}
mockDs.offset = [0, 0]
mockDs.scale = 1
mockSetDirty.mockClear()
mockAdapter.beginFromOutput.mockClear()
mockLinkConnector.state.snapLinksPos = null
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts auto-pan when link drag begins', () => {
startDrag()
expect(capturedAutoPan.current).not.toBeNull()
expect(capturedAutoPan.current!.start).toHaveBeenCalled()
})
it('updates snapLinksPos and marks dirty when onPan fires', () => {
startDrag()
mockSetDirty.mockClear()
mockDs.offset = [-10, -5]
capturedOnPan.current!(10, 5)
expect(mockLinkConnector.state.snapLinksPos).toEqual([410, 305])
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
})
it('forwards pointer position to auto-pan during drag', () => {
startDrag()
const moveHandler = capturedHandlers['pointermove']
moveHandler(pointerEvent(790, 300))
expect(capturedAutoPan.current!.updatePointer).toHaveBeenCalledWith(
790,
300
)
})
it('stops auto-pan on cleanup', () => {
startDrag()
const upHandler = capturedHandlers['pointerup']
upHandler(pointerEvent(400, 300))
expect(capturedAutoPan.current!.stop).toHaveBeenCalled()
})
})

View File

@@ -2,6 +2,7 @@ import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import type { Fn } from '@vueuse/core'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
@@ -124,6 +125,7 @@ export function useSlotLinkInteraction({
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
let autoPan: AutoPanController | null = null
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
@@ -283,6 +285,8 @@ export function useSlotLinkInteraction({
}
const cleanupInteraction = () => {
autoPan?.stop()
autoPan = null
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
@@ -411,6 +415,8 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return
event.stopPropagation()
autoPan?.updatePointer(event.clientX, event.clientY)
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,
@@ -738,6 +744,30 @@ export function useSlotLinkInteraction({
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
autoPan = new AutoPanController({
canvas: canvas.canvas,
ds: canvas.ds,
maxPanSpeed: canvas.auto_pan_speed,
onPan: () => {
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
state.pointer.client.x,
state.pointer.client.y
])
updatePointerPosition(
state.pointer.client.x,
state.pointer.client.y,
canvasX,
canvasY
)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [canvasX, canvasY]
}
canvas.setDirty(true, true)
}
})
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
canvas.setDirty(true, true)
}

View File

@@ -19,7 +19,18 @@ const testState = vi.hoisted(() => {
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos)
},
cancelAnimationFrame: vi.fn(),
requestAnimationFrameCallback: null as FrameRequestCallback | null
requestAnimationFrameCallback: null as FrameRequestCallback | null,
capturedOnPan: {
current: null as ((dx: number, dy: number) => void) | null
},
capturedAutoPanInstance: {
current: null as {
updatePointer: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} | null
},
mockDs: { offset: [0, 0] as [number, number], scale: 1 }
}
})
@@ -27,10 +38,34 @@ vi.mock('pinia', () => ({
storeToRefs: <T>(store: T) => store
}))
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
constructor(opts: { onPan: (dx: number, dy: number) => void }) {
testState.capturedOnPan.current = opts.onPan
testState.capturedAutoPanInstance.current = this
}
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
selectedNodeIds: testState.selectedNodeIds,
selectedItems: testState.selectedItems
selectedItems: testState.selectedItems,
canvas: {
ds: testState.mockDs,
auto_pan_speed: 10,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600
})
}
}
})
}))
@@ -58,7 +93,10 @@ vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
useTransformState: () => ({
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x, y })
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({
x: x / (testState.mockDs.scale || 1) - testState.mockDs.offset[0],
y: y / (testState.mockDs.scale || 1) - testState.mockDs.offset[1]
})
})
}))
@@ -66,8 +104,24 @@ vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: () => false
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: (fn: () => unknown) => fn
}))
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
function pointerEvent(clientX: number, clientY: number): PointerEvent {
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
return {
clientX,
clientY,
target,
pointerId: 1
} as unknown as PointerEvent
}
describe('useNodeDrag', () => {
beforeEach(() => {
testState.selectedNodeIds = ref(new Set<string>())
@@ -85,6 +139,10 @@ describe('useNodeDrag', () => {
)
testState.cancelAnimationFrame.mockReset()
testState.requestAnimationFrameCallback = null
testState.capturedOnPan.current = null
testState.capturedAutoPanInstance.current = null
testState.mockDs.offset = [0, 0]
testState.mockDs.scale = 1
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
testState.requestAnimationFrameCallback = cb
@@ -106,28 +164,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
{
clientX: 10,
clientY: 20
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 30,
clientY: 40,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(10, 20), '1')
handleDrag(pointerEvent(30, 40), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
@@ -147,28 +185,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
{
clientX: 5,
clientY: 10
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 25,
clientY: 30,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(5, 10), '1')
handleDrag(pointerEvent(25, 30), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
@@ -192,28 +210,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag, endDrag } = useNodeDrag()
startDrag(
{
clientX: 5,
clientY: 10
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 25,
clientY: 30,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(5, 10), '1')
handleDrag(pointerEvent(25, 30), '1')
endDrag({} as PointerEvent, '1')
expect(testState.cancelAnimationFrame).toHaveBeenCalledTimes(1)
@@ -232,3 +230,113 @@ describe('useNodeDrag', () => {
])
})
})
describe('useNodeDrag auto-pan', () => {
beforeEach(() => {
testState.selectedNodeIds = ref(new Set(['1']))
testState.selectedItems = ref<unknown[]>([])
testState.nodeLayouts.clear()
testState.nodeLayouts.set('1', {
position: { x: 100, y: 200 },
size: { width: 200, height: 100 }
})
testState.nodeLayouts.set('2', {
position: { x: 300, y: 400 },
size: { width: 200, height: 100 }
})
testState.mutationFns.setSource.mockReset()
testState.mutationFns.moveNode.mockReset()
testState.mutationFns.batchMoveNodes.mockReset()
testState.batchUpdateNodeBounds.mockReset()
testState.nodeSnap.shouldSnap.mockReset()
testState.nodeSnap.shouldSnap.mockReturnValue(false)
testState.nodeSnap.applySnapToPosition.mockReset()
testState.nodeSnap.applySnapToPosition.mockImplementation(
(pos: { x: number; y: number }) => pos
)
testState.cancelAnimationFrame.mockReset()
testState.requestAnimationFrameCallback = null
testState.capturedOnPan.current = null
testState.capturedAutoPanInstance.current = null
testState.mockDs.offset = [0, 0]
testState.mockDs.scale = 1
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
testState.requestAnimationFrameCallback = cb
return 1
})
vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame)
})
it('moves node when auto-pan shifts the canvas offset', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(750, 300), '1')
drag.handleDrag(pointerEvent(760, 300), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenLastCalledWith([
{ nodeId: '1', position: { x: 110, y: 200 } }
])
testState.mutationFns.batchMoveNodes.mockClear()
testState.mockDs.offset[0] -= 5
testState.capturedOnPan.current!(5, 0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 115, y: 200 } }
])
})
it('moves all selected nodes when auto-pan fires', () => {
testState.selectedNodeIds.value = new Set(['1', '2'])
const drag = useNodeDrag()
drag.startDrag(pointerEvent(750, 300), '1')
testState.mutationFns.batchMoveNodes.mockClear()
testState.mockDs.offset[0] -= 5
testState.capturedOnPan.current!(5, 0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
const calls = testState.mutationFns.batchMoveNodes.mock.calls[0][0]
const nodeIds = calls.map((u: { nodeId: string }) => u.nodeId)
expect(nodeIds).toContain('1')
expect(nodeIds).toContain('2')
})
it('updates auto-pan pointer on handleDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(790, 300), '1')
expect(
testState.capturedAutoPanInstance.current!.updatePointer
).toHaveBeenCalledWith(790, 300)
})
it('stops auto-pan on endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
expect(testState.capturedAutoPanInstance.current).not.toBeNull()
drag.endDrag(pointerEvent(400, 300), '1')
expect(testState.capturedAutoPanInstance.current!.stop).toHaveBeenCalled()
})
it('does not move nodes if onPan fires after endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
const onPan = testState.capturedOnPan.current!
drag.endDrag(pointerEvent(400, 300), '1')
testState.mutationFns.batchMoveNodes.mockClear()
onPan(5, 0)
expect(testState.mutationFns.batchMoveNodes).not.toHaveBeenCalled()
})
})

View File

@@ -3,6 +3,7 @@ import { toValue } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -32,6 +33,8 @@ function useNodeDragIndividual() {
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const canvasStore = useCanvasStore()
// Drag state
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
@@ -43,6 +46,11 @@ function useNodeDragIndividual() {
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
// Auto-pan state
let autoPan: AutoPanController | null = null
let lastPointerX = 0
let lastPointerY = 0
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
@@ -53,6 +61,8 @@ function useNodeDragIndividual() {
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
lastPointerX = event.clientX
lastPointerY = event.clientY
const selectedNodes = toValue(selectedNodeIds)
@@ -87,6 +97,97 @@ function useNodeDragIndividual() {
}
mutations.setSource(LayoutSource.Vue)
// Start auto-pan
const lgCanvas = canvasStore.canvas
if (lgCanvas?.ds) {
autoPan = new AutoPanController({
canvas: lgCanvas.canvas,
ds: lgCanvas.ds,
maxPanSpeed: lgCanvas.auto_pan_speed,
onPan: (panX, panY) => {
if (dragStartPos) {
dragStartPos.x += panX
dragStartPos.y += panY
}
if (otherSelectedNodesStartPositions) {
for (const pos of otherSelectedNodesStartPositions.values()) {
pos.x += panX
pos.y += panY
}
}
if (selectedGroups) {
for (const group of selectedGroups) {
group.move(panX, panY, true)
}
}
updateNodePositions(nodeId)
}
})
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
}
}
/**
* Recalculates all dragged node positions based on the current mouse
* position and canvas transform.
*/
function updateNodePositions(nodeId: NodeId) {
if (!dragStartPos || !dragStartMouse) return
const mouseDelta = {
x: lastPointerX - dragStartMouse.x,
y: lastPointerY - dragStartMouse.y
}
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Move drag updates in one transaction to avoid per-node notify fan-out.
const updates = [
{
nodeId,
position: {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
}
]
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
updates.push({
nodeId: otherNodeId,
position: {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
})
}
}
mutations.batchMoveNodes(updates)
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
}
function handleDrag(event: PointerEvent, nodeId: NodeId) {
@@ -102,67 +203,14 @@ function useNodeDragIndividual() {
// Delay capture to drag to allow for the Node cloning
target.setPointerCapture(pointerId)
}
lastPointerX = event.clientX
lastPointerY = event.clientY
autoPan?.updatePointer(event.clientX, event.clientY)
rafId = requestAnimationFrame(() => {
rafId = null
if (!dragStartPos || !dragStartMouse) return
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Move drag updates in one transaction to avoid per-node notify fan-out.
const updates = [{ nodeId, position: newPosition }]
// Include other selected nodes so multi-drag stays in lockstep.
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
updates.push({ nodeId: otherNodeId, position: newOtherPosition })
}
}
mutations.batchMoveNodes(updates)
// Move selected groups using frame delta (difference from last frame)
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
updateNodePositions(nodeId)
})
}
@@ -234,6 +282,10 @@ function useNodeDragIndividual() {
selectedGroups = null
lastCanvasDelta = null
// Stop auto-pan
autoPan?.stop()
autoPan = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null

View File

@@ -312,6 +312,7 @@ const zSettings = z.object({
'Comfy.EnableTooltips': z.boolean(),
'Comfy.EnableWorkflowViewRestore': z.boolean(),
'Comfy.FloatRoundingPrecision': z.number(),
'Comfy.Graph.AutoPanSpeed': z.number(),
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),

View File

@@ -1,8 +1,19 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn()
}))
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
@@ -37,3 +48,98 @@ describe('getWidgetDefaultValue', () => {
expect(getWidgetDefaultValue(spec)).toBeUndefined()
})
})
function makeWidget(overrides: Record<string, unknown> = {}): IBaseWidget {
return {
name: 'myWidget',
type: 'number',
value: 0,
label: undefined,
options: {},
...overrides
} as unknown as IBaseWidget
}
function makeNode({
isSubgraph = false,
inputs = [] as INodeInputSlot[]
}: {
isSubgraph?: boolean
inputs?: INodeInputSlot[]
} = {}): LGraphNode {
return {
id: 1,
inputs,
isSubgraphNode: () => isSubgraph
} as unknown as LGraphNode
}
describe('renameWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renames a regular widget and its matching input', () => {
const widget = makeWidget({ name: 'seed' })
const input = { name: 'seed', widget: { name: 'seed' } } as INodeInputSlot
const node = makeNode({ inputs: [input] })
const result = renameWidget(widget, node, 'My Seed')
expect(result).toBe(true)
expect(widget.label).toBe('My Seed')
expect(input.label).toBe('My Seed')
})
it('clears label when given empty string', () => {
const widget = makeWidget({ name: 'seed', label: 'Old Label' })
const node = makeNode()
renameWidget(widget, node, '')
expect(widget.label).toBeUndefined()
})
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
const interiorInput = {
name: 'innerSeed',
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
})
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphNode = makeNode({ isSubgraph: true })
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
expect(result).toBe(true)
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
expect(promotedWidget.label).toBe('Renamed')
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
const result = renameWidget(promotedWidget, node, 'Renamed')
expect(result).toBe(true)
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
})
})

View File

@@ -48,7 +48,10 @@ export function renameWidget(
newLabel: string,
parents?: SubgraphNode[]
): boolean {
if (isPromotedWidgetView(widget) && parents?.length) {
if (
isPromotedWidgetView(widget) &&
(parents?.length || node.isSubgraphNode())
) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')