mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-19 11:57:31 +00:00
Compare commits
3 Commits
refactor/m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed88fbe68 | ||
|
|
77ddda9d3c | ||
|
|
3591579141 |
@@ -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)
|
||||
|
||||
128
browser_tests/fixtures/helpers/AppModeHelper.ts
Normal file
128
browser_tests/fixtures/helpers/AppModeHelper.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
149
browser_tests/tests/appModeWidgetRename.spec.ts
Normal file
149
browser_tests/tests/appModeWidgetRename.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
175
src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts
Normal file
175
src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
213
src/renderer/core/canvas/useAutoPan.test.ts
Normal file
213
src/renderer/core/canvas/useAutoPan.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
103
src/renderer/core/canvas/useAutoPan.ts
Normal file
103
src/renderer/core/canvas/useAutoPan.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user