diff --git a/browser_tests/fixtures/ComfyMouse.ts b/browser_tests/fixtures/ComfyMouse.ts index 67838695c48..b2009b4d9aa 100644 --- a/browser_tests/fixtures/ComfyMouse.ts +++ b/browser_tests/fixtures/ComfyMouse.ts @@ -113,6 +113,13 @@ export class ComfyMouse implements Omit { ) } + async hold(...args: Parameters) { + await this.mouse.down(...args) + const release = new AsyncDisposableStack() + release.defer(() => this.mouse.up(...args)) + return release + } + //#region Pass-through async click(...args: Parameters) { return await this.mouse.click(...args) diff --git a/browser_tests/fixtures/helpers/KeyboardHelper.ts b/browser_tests/fixtures/helpers/KeyboardHelper.ts index d1763675214..2e8d9f36885 100644 --- a/browser_tests/fixtures/helpers/KeyboardHelper.ts +++ b/browser_tests/fixtures/helpers/KeyboardHelper.ts @@ -14,6 +14,13 @@ export class KeyboardHelper { await nextFrame(this.page) } + async hold(key: string): Promise { + await this.page.keyboard.down(key) + const release = new AsyncDisposableStack() + release.defer(() => this.page.keyboard.up(key)) + return release + } + async delete(locator?: Locator | null): Promise { await this.press('Delete', locator) } diff --git a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts index beaf0d87c4a..1889a6694b4 100644 --- a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts @@ -27,37 +27,180 @@ test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => { } ) - test.describe('spacebar panning', () => { - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.settings.setSetting( - 'Comfy.Canvas.NavigationMode', - 'standard' - ) - await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple') - }) + test('spacebar panning', async ({ comfyPage, comfyMouse }) => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.NavigationMode', + 'standard' + ) + await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple') + const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler') + if (!nodeRef) throw new Error('KSampler is not rendered') + const softExpect = expect.configure({ soft: true }) - test('Space + left-drag on a Vue node pans canvas', async ({ - comfyPage, - comfyMouse - }) => { - const node = comfyPage.vueNodes.getNodeByTitle('KSampler') + await test.step('Space + click on a node starts a pan', async () => { const offsetBefore = await comfyPage.canvasOps.getOffset() await comfyPage.canvas.focus() - await comfyPage.page.keyboard.down('Space') - await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true) - try { - await comfyMouse.dragElementBy(node, { x: 140, y: 90 }) - } finally { - await comfyPage.page.keyboard.up('Space') - } + await using releaseSpace = await comfyPage.keyboard.hold('Space') + await softExpect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true) + await comfyMouse.dragElementBy(node.root, { x: -300, y: 0 }) + await releaseSpace.disposeAsync() - await expect + await softExpect .poll(() => comfyPage.canvasOps.getOffset()) .not.toEqual(offsetBefore) }) + + await test.step('Space switches node dragging to canvas panning', async () => { + await node.header.hover() + await using mouseRelease = await comfyMouse.hold() + await comfyPage.page.mouse.move(500, 500, { steps: 5 }) + const offsetBeforePan = await comfyPage.canvasOps.getOffset() + + await using spaceRelease = await comfyPage.keyboard.hold('Space') + await comfyPage.page.mouse.move(400, 400, { steps: 5 }) + await softExpect + .poll(() => comfyPage.canvasOps.getOffset()) + .not.toEqual(offsetBeforePan) + + await test.step('Releasing Space resumes node dragging', async () => { + await spaceRelease.disposeAsync() + const offsetAfterPan = await comfyPage.canvasOps.getOffset() + const positionBeforeResume = [ + ...(await nodeRef.getProperty<[number, number]>('pos')) + ] + await comfyPage.page.mouse.move(500, 500, { steps: 5 }) + await comfyPage.nextFrame() + + softExpect(await comfyPage.canvasOps.getOffset()).toEqual( + offsetAfterPan + ) + await softExpect + .poll(async () => [ + ...(await nodeRef.getProperty<[number, number]>('pos')) + ]) + .not.toEqual(positionBeforeResume) + await mouseRelease.disposeAsync() + }) + }) }) + test( + 'Space in a focused text widget does not start canvas panning', + { tag: ['@canvas', '@widget'] }, + async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('inputs/string_input') + const input = comfyPage.vueNodes + .getWidgetByName('Node With String Input', 'string_input') + .first() + + await input.focus() + await input.press('Space') + + await expect + .poll(async () => [ + await input.inputValue(), + await comfyPage.canvasOps.isReadOnly() + ]) + .toEqual([' ', false]) + } + ) + + test( + 'Space in a focused native select does not start canvas panning', + { tag: ['@canvas', '@widget'] }, + async ({ comfyPage, comfyMouse }) => { + await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple') + const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler') + if (!nodeRef) throw new Error('KSampler is not rendered') + const positionBeforeDrag = [ + ...(await nodeRef.getProperty<[number, number]>('pos')) + ] + await node.root.evaluate((element) => { + const select = document.createElement('select') + select.ariaLabel = 'Native select' + select.append(document.createElement('option')) + element.append(select) + }) + const select = node.root.getByRole('combobox', { + name: 'Native select' + }) + + await test.step('Hold and drag the node', async () => { + await node.header.hover() + await using mouseRelease = await comfyMouse.hold() + await comfyPage.page.mouse.move(500, 500, { steps: 5 }) + await expect + .poll(async () => [ + ...(await nodeRef.getProperty<[number, number]>('pos')) + ]) + .not.toEqual(positionBeforeDrag) + + await test.step('Press Space in the focused native select', async () => { + await select.focus() + await using spaceRelease = await comfyPage.keyboard.hold('Space') + + await expect(select).toBeFocused() + await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false) + await spaceRelease.disposeAsync() + }) + + await mouseRelease.disposeAsync() + }) + } + ) + + test( + 'releasing the pointer during Space-pan ends the node drag', + { tag: ['@canvas', '@node'] }, + async ({ comfyPage, comfyMouse }) => { + await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple') + const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler') + const headerBox = await node.header.boundingBox() + if (!nodeRef || !headerBox) throw new Error('KSampler is not rendered') + const start = { + x: headerBox.x + headerBox.width / 2, + y: headerBox.y + headerBox.height / 2 + } + + const positionAfterRelease = + await test.step('Release the pointer while Space-panning', async () => { + await comfyPage.page.mouse.move(start.x, start.y) + await using mouseRelease = await comfyMouse.hold() + await comfyPage.page.mouse.move(start.x + 40, start.y + 40, { + steps: 5 + }) + await using spaceRelease = await comfyPage.keyboard.hold('Space') + await comfyPage.page.mouse.move(start.x + 80, start.y + 80, { + steps: 5 + }) + await mouseRelease.disposeAsync() + await spaceRelease.disposeAsync() + + return [...(await nodeRef.getProperty<[number, number]>('pos'))] + }) + + await test.step('Further pointer movement leaves the node in place', async () => { + const headerAfterRelease = await node.header.boundingBox() + if (!headerAfterRelease) throw new Error('KSampler is not rendered') + await comfyPage.page.mouse.move( + headerAfterRelease.x + 5, + headerAfterRelease.y + 5 + ) + await comfyPage.nextFrame() + + await expect + .poll(async () => [ + ...(await nodeRef.getProperty<[number, number]>('pos')) + ]) + .toEqual(positionAfterRelease) + }) + } + ) + test( '@mobile Can pan with touch', { tag: '@screenshot' }, diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index efb98d51b13..be2daeb0810 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -70,6 +70,7 @@ @pointerdown.capture="forwardPointerDownPanEvent" @pointerup.capture="forwardPointerUpPanEvent" @pointermove.capture="forwardPointerMovePanEvent" + @keydown.space="forwardSpaceKeyEvent" > boolean diff --git a/src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts b/src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts index a3b60ddb27e..ac0195eb9ab 100644 --- a/src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts +++ b/src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts @@ -7,6 +7,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ layoutStore: { querySlotAtPoint: vi.fn(), queryRerouteAtPoint: vi.fn(), + queryLinkSegmentAtPoint: vi.fn(), getNodeLayoutRef: vi.fn(() => ({ value: null })), getSlotLayout: vi.fn() } @@ -58,6 +59,44 @@ describe('LGraphCanvas link drag auto-pan', () => { expect(canvas['_autoPan']).not.toBeNull() }) + it('resumes auto-pan after Space panning during a link drag', () => { + canvas.processMouseDown( + new PointerEvent('pointerdown', { + button: 0, + buttons: 1, + clientX: 400, + clientY: 300, + isPrimary: true + }) + ) + canvas.linkConnector.state.connectingTo = 'output' + startLinkDrag() + canvas.processMouseMove( + new PointerEvent('pointermove', { + buttons: 1, + clientX: 5, + clientY: 300, + isPrimary: true + }) + ) + const keydown = new KeyboardEvent('keydown', { key: ' ' }) + const keyup = new KeyboardEvent('keyup', { key: ' ' }) + Object.defineProperty(keydown, 'target', { value: canvasElement }) + Object.defineProperty(keyup, 'target', { value: canvasElement }) + + canvas.processKey(keydown) + const offsetWhileSpacePanning = [...canvas.ds.offset] + + vi.advanceTimersByTime(16) + + expect([...canvas.ds.offset]).toEqual(offsetWhileSpacePanning) + + canvas.processKey(keyup) + vi.advanceTimersByTime(16) + + expect([...canvas.ds.offset]).not.toEqual(offsetWhileSpacePanning) + }) + it('keeps graph_mouse consistent with offset after auto-pan', () => { canvas.mouse[0] = 5 canvas.mouse[1] = 300 diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 53562dae3f0..9c695e9aa9a 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -4008,6 +4008,7 @@ export class LGraphCanvas implements CustomEventDispatcher if (e.key === ' ') { // space this.read_only = true + this._autoPan?.stop() if (this._previously_dragging_canvas === null) { this._previously_dragging_canvas = this.dragging_canvas } @@ -4037,6 +4038,13 @@ export class LGraphCanvas implements CustomEventDispatcher this.dragging_canvas = (this._previously_dragging_canvas ?? false) && this.pointer.isDown this._previously_dragging_canvas = null + if ( + this.pointer.isDown && + (this.isDragging || this.linkConnector.isConnecting) + ) { + this._autoPan?.updatePointer(this.mouse[0], this.mouse[1]) + this._autoPan?.start() + } } for (const node of Object.values(this.selected_nodes)) { diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index 3558ec30118..1e5af8ce7f4 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -148,12 +148,12 @@ export function useCanvasInteractions() { return } - // Create new event with same properties - const EventConstructor = event.constructor as - | typeof MouseEvent - | typeof PointerEvent - const newEvent = new EventConstructor(event.type, event) - canvasEl.dispatchEvent(newEvent) + if (event instanceof PointerEvent) { + canvasEl.dispatchEvent(new PointerEvent(event.type, event)) + return + } + + canvasEl.dispatchEvent(new MouseEvent(event.type, event)) } return { diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index 82503fa4cff..4218059d0b8 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -142,6 +142,9 @@ export function useNodePointerInteractions( const canHandlePointer = shouldHandleNodePointerEvents.value if (!canHandlePointer) { forwardEventToCanvas(event) + if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) { + safeDragEnd(event) + } return } const wasDragging = layoutStore.isDraggingVueNodes.value diff --git a/src/renderer/extensions/vueNodes/layout/useNodeDrag.ts b/src/renderer/extensions/vueNodes/layout/useNodeDrag.ts index a079affe707..610a24b97cd 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeDrag.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeDrag.ts @@ -196,6 +196,20 @@ function useNodeDragIndividual() { if (!dragStartPos || !dragStartMouse) { return } + if (canvasStore.isReadOnly) { + autoPan?.stop() + const canvas = canvasStore.getCanvas() + const delta = [event.clientX - lastPointerX, event.clientY - lastPointerY] + + canvas.ds.offset[0] += delta[0] / canvas.ds.scale + canvas.ds.offset[1] += delta[1] / canvas.ds.scale + canvas.setDirty(true, true) + lastPointerX = event.clientX + lastPointerY = event.clientY + dragStartMouse.x += delta[0] + dragStartMouse.y += delta[1] + return + } // Throttle position updates using requestAnimationFrame for better performance if (rafId !== null) return // Skip if frame already scheduled