diff --git a/browser_tests/fixtures/ComfyMouse.ts b/browser_tests/fixtures/ComfyMouse.ts index 0b3b122928..d7f1ace0c9 100644 --- a/browser_tests/fixtures/ComfyMouse.ts +++ b/browser_tests/fixtures/ComfyMouse.ts @@ -66,6 +66,45 @@ export class ComfyMouse implements Omit { await this.drop(options) } + /** + * Middle mouse button drag-and-drop. Used by the MMB pan tests across the + * graph canvas, widget surfaces (textarea / markdown), and the mask editor + * canvas to verify the pan gesture forwards correctly from each surface. + */ + async mmbDrag( + from: Position, + to: Position, + options: Omit = {} + ) { + await this.dragAndDrop(from, to, { ...options, button: 'middle' }) + } + + /** + * Middle-button drag anchored at the center of a locator's bounding box. + * Asserts visibility, resolves the center, and delegates to {@link mmbDrag}. + * Collapses the `boundingBox()` + center-math + `mmbDrag` boilerplate that + * repeats across MMB pan tests. + */ + async mmbDragFromCenter( + locator: Locator, + delta: { dx: number; dy: number }, + options: Omit = {} + ) { + await locator.waitFor({ state: 'visible' }) + const box = await locator.boundingBox() + if (!box) throw new Error('mmbDragFromCenter: bounding box not found') + + const start = { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + } + await this.mmbDrag( + start, + { x: start.x + delta.dx, y: start.y + delta.dy }, + options + ) + } + /** @see {@link Mouse.move} */ async move(to: Position, options = ComfyMouse.defaultOptions) { await this.mouse.move(to.x, to.y, options) diff --git a/browser_tests/fixtures/components/MaskEditorDialog.ts b/browser_tests/fixtures/components/MaskEditorDialog.ts new file mode 100644 index 0000000000..a213a7ff2d --- /dev/null +++ b/browser_tests/fixtures/components/MaskEditorDialog.ts @@ -0,0 +1,89 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' + +/** + * Page object for the mask editor dialog. Encapsulates the structural + * locators that specs used to rebuild inline (undo/redo buttons, tool + * entries, brush setting labels, etc.) so tests consume a single typed + * surface instead of duplicating selectors. + */ +export class MaskEditorDialog { + public readonly root: Locator + public readonly heading: Locator + + // Canvas surface + public readonly canvasContainer: Locator + public readonly pointerZone: Locator + + // Header toolbar + public readonly undoButton: Locator + public readonly redoButton: Locator + public readonly saveButton: Locator + public readonly cancelButton: Locator + public readonly invertButton: Locator + public readonly clearButton: Locator + + // Tool panel + public readonly toolPanel: Locator + public readonly toolEntries: Locator + public readonly selectedTool: Locator + + // Brush settings side panel + public readonly thicknessLabel: Locator + public readonly opacityLabel: Locator + public readonly hardnessLabel: Locator + + constructor(public readonly comfyPage: ComfyPage) { + const { page } = comfyPage + this.root = page.locator('.mask-editor-dialog') + this.heading = this.root.getByRole('heading', { name: 'Mask Editor' }) + + this.canvasContainer = this.root.locator('#maskEditorCanvasContainer') + this.pointerZone = this.root.getByTestId('pointer-zone') + + this.undoButton = this.root.getByRole('button', { name: 'Undo' }) + this.redoButton = this.root.getByRole('button', { name: 'Redo' }) + this.saveButton = this.root.getByRole('button', { name: 'Save' }) + this.cancelButton = this.root.getByRole('button', { name: 'Cancel' }) + this.invertButton = this.root.getByRole('button', { name: 'Invert' }) + this.clearButton = this.root.getByRole('button', { name: 'Clear' }) + + this.toolPanel = this.root.locator('.maskEditor-ui-container') + this.toolEntries = this.root.locator('.maskEditor_toolPanelContainer') + this.selectedTool = this.root.locator( + '.maskEditor_toolPanelContainerSelected' + ) + + this.thicknessLabel = this.root.getByText('Thickness') + this.opacityLabel = this.root.getByText('Opacity').first() + this.hardnessLabel = this.root.getByText('Hardness') + } + + async waitForOpen(): Promise { + await expect(this.root).toBeVisible() + await expect(this.heading).toBeVisible() + await expect(this.canvasContainer).toBeVisible() + await expect(this.canvasContainer.locator('canvas')).toHaveCount(4) + } + + async getCanvasBoundingBox() { + await expect(this.canvasContainer).toBeVisible() + const box = await this.canvasContainer.boundingBox() + if (!box) + throw new Error('Mask editor canvas container bounding box not found') + return box + } + + /** + * Moves the cursor off the pointer zone so PointerZone's pointerleave + * clears store.brushVisible and the brush cursor overlay is removed from + * the next paint. Call this before taking a canvas screenshot to avoid + * flaky pixel diffs around the brush circle position. + */ + async hideBrushCursor() { + await this.comfyPage.page.mouse.move(0, 0) + await this.comfyPage.nextFrame() + } +} diff --git a/browser_tests/tests/maskEditor.spec.ts b/browser_tests/tests/maskEditor.spec.ts index 125e76076d..e24c016012 100644 --- a/browser_tests/tests/maskEditor.spec.ts +++ b/browser_tests/tests/maskEditor.spec.ts @@ -3,6 +3,9 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { MaskEditorDialog } from '@e2e/fixtures/components/MaskEditorDialog' + +const OPEN_MASK_EDITOR_LABEL = 'Edit or mask image' test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { async function loadImageOnNode(comfyPage: ComfyPage) { @@ -28,23 +31,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { } } - async function openMaskEditorDialog(comfyPage: ComfyPage) { + async function openMaskEditorDialog( + comfyPage: ComfyPage + ): Promise { const { imagePreview } = await loadImageOnNode(comfyPage) await imagePreview.getByRole('region').hover() - await comfyPage.page.getByLabel('Edit or mask image').click() + await comfyPage.page.getByLabel(OPEN_MASK_EDITOR_LABEL).click() - const dialog = comfyPage.page.locator('.mask-editor-dialog') - await expect(dialog).toBeVisible() - await expect( - dialog.getByRole('heading', { name: 'Mask Editor' }) - ).toBeVisible() - - const canvasContainer = dialog.locator('#maskEditorCanvasContainer') - await expect(canvasContainer).toBeVisible() - await expect(canvasContainer.locator('canvas')).toHaveCount(4) - - return dialog + const maskEditor = new MaskEditorDialog(comfyPage) + await maskEditor.waitForOpen() + return maskEditor } async function getMaskCanvasPixelData(page: Page) { @@ -72,16 +69,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { ) } - async function drawStrokeOnPointerZone( - page: Page, - dialog: ReturnType - ) { - const pointerZone = dialog.locator( - '.maskEditor-ui-container [class*="w-[calc"]' - ) - await expect(pointerZone).toBeVisible() + async function drawStrokeOnPointerZone(page: Page, dialog: MaskEditorDialog) { + await expect(dialog.pointerZone).toBeVisible() - const box = await pointerZone.boundingBox() + const box = await dialog.pointerZone.boundingBox() if (!box) throw new Error('Pointer zone bounding box not found') const startX = box.x + box.width * 0.3 @@ -99,7 +90,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { async function drawStrokeAndExpectPixels( comfyPage: ComfyPage, - dialog: ReturnType + dialog: MaskEditorDialog ) { await drawStrokeOnPointerZone(comfyPage.page, dialog) await expect @@ -115,24 +106,19 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { // Hover over the image panel to reveal action buttons await imagePreview.getByRole('region').hover() - await comfyPage.page.getByLabel('Edit or mask image').click() + await comfyPage.page.getByLabel(OPEN_MASK_EDITOR_LABEL).click() - const dialog = comfyPage.page.locator('.mask-editor-dialog') - await expect(dialog).toBeVisible() + const dialog = new MaskEditorDialog(comfyPage) + await dialog.waitForOpen() - await expect( - dialog.getByRole('heading', { name: 'Mask Editor' }) - ).toBeVisible() + await expect(dialog.toolPanel).toBeVisible() + await expect(dialog.saveButton).toBeVisible() + await expect(dialog.cancelButton).toBeVisible() - const canvasContainer = dialog.locator('#maskEditorCanvasContainer') - await expect(canvasContainer).toBeVisible() - await expect(canvasContainer.locator('canvas')).toHaveCount(4) - - await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible() - await expect(dialog.getByText('Save')).toBeVisible() - await expect(dialog.getByText('Cancel')).toBeVisible() - - await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png') + await comfyPage.expectScreenshot( + dialog.root, + 'mask-editor-dialog-open.png' + ) } ) @@ -153,14 +139,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await contextMenu.getByText('Open in Mask Editor').click() - const dialog = comfyPage.page.locator('.mask-editor-dialog') - await expect(dialog).toBeVisible() - await expect( - dialog.getByRole('heading', { name: 'Mask Editor' }) - ).toBeVisible() + const dialog = new MaskEditorDialog(comfyPage) + await dialog.waitForOpen() await comfyPage.expectScreenshot( - dialog, + dialog.root, 'mask-editor-dialog-from-context-menu.png' ) } @@ -178,29 +161,19 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { test( 'Middle-click drag should pan the mask editor canvas', - { tag: '@screenshot' }, - async ({ comfyPage }) => { + { tag: ['@screenshot', '@canvas'] }, + async ({ comfyPage, comfyMouse }) => { const dialog = await openMaskEditorDialog(comfyPage) - const canvasContainer = dialog.locator('#maskEditorCanvasContainer') - const box = await canvasContainer.boundingBox() - if (!box) throw new Error('Canvas container bounding box not found') - const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 } - await comfyPage.canvasOps.middleClickDrag(center, { - x: center.x + 140, - y: center.y + 90 - }) - await comfyPage.nextFrame() - - // Move cursor outside the pointer zone so the brush cursor overlay is - // hidden (PointerZone's pointerleave clears store.brushVisible). Without - // this the brush circle lands on slightly different pixels between runs - // and causes flaky screenshot diffs. - await comfyPage.page.mouse.move(0, 0) - await comfyPage.nextFrame() + await comfyMouse.mmbDragFromCenter( + dialog.canvasContainer, + { dx: 140, dy: 90 }, + { steps: 10 } + ) + await dialog.hideBrushCursor() await comfyPage.expectScreenshot( - canvasContainer, + dialog.canvasContainer, 'mask-editor-paned-with-mmb.png' ) } @@ -211,9 +184,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await drawStrokeAndExpectPixels(comfyPage, dialog) - const undoButton = dialog.locator('button[title="Undo"]') - await expect(undoButton).toBeVisible() - await undoButton.click() + await expect(dialog.undoButton).toBeVisible() + await dialog.undoButton.click() await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0) }) @@ -223,14 +195,12 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await drawStrokeAndExpectPixels(comfyPage, dialog) - const undoButton = dialog.locator('button[title="Undo"]') - await undoButton.click() + await dialog.undoButton.click() await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0) - const redoButton = dialog.locator('button[title="Redo"]') - await expect(redoButton).toBeVisible() - await redoButton.click() + await expect(dialog.redoButton).toBeVisible() + await dialog.redoButton.click() await expect .poll(() => pollMaskPixelCount(comfyPage.page)) @@ -242,9 +212,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await drawStrokeAndExpectPixels(comfyPage, dialog) - const clearButton = dialog.getByRole('button', { name: 'Clear' }) - await expect(clearButton).toBeVisible() - await clearButton.click() + await expect(dialog.clearButton).toBeVisible() + await dialog.clearButton.click() await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0) }) @@ -254,10 +223,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await drawStrokeAndExpectPixels(comfyPage, dialog) - const cancelButton = dialog.getByRole('button', { name: 'Cancel' }) - await cancelButton.click() + await dialog.cancelButton.click() - await expect(dialog).toBeHidden() + await expect(dialog.root).toBeHidden() }) test('invert button inverts the mask', async ({ comfyPage }) => { @@ -267,9 +235,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { expect(dataBefore).not.toBeNull() const pixelsBefore = dataBefore!.nonTransparentPixels - const invertButton = dialog.getByRole('button', { name: 'Invert' }) - await expect(invertButton).toBeVisible() - await invertButton.click() + await expect(dialog.invertButton).toBeVisible() + await dialog.invertButton.click() await expect .poll(() => pollMaskPixelCount(comfyPage.page)) @@ -281,8 +248,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await drawStrokeAndExpectPixels(comfyPage, dialog) - const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z' - await comfyPage.page.keyboard.press(modifier) + await comfyPage.page.keyboard.press('ControlOrMeta+z') await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0) }) @@ -293,18 +259,13 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { async ({ comfyPage }) => { const dialog = await openMaskEditorDialog(comfyPage) - const toolPanel = dialog.locator('.maskEditor-ui-container') - await expect(toolPanel).toBeVisible() + await expect(dialog.toolPanel).toBeVisible() // The tool panel should contain exactly 5 tool entries - const toolEntries = dialog.locator('.maskEditor_toolPanelContainer') - await expect(toolEntries).toHaveCount(5) + await expect(dialog.toolEntries).toHaveCount(5) // First tool (MaskPen) should be selected by default - const selectedTool = dialog.locator( - '.maskEditor_toolPanelContainerSelected' - ) - await expect(selectedTool).toHaveCount(1) + await expect(dialog.selectedTool).toHaveCount(1) } ) @@ -313,20 +274,16 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { }) => { const dialog = await openMaskEditorDialog(comfyPage) - const toolEntries = dialog.locator('.maskEditor_toolPanelContainer') - await expect(toolEntries).toHaveCount(5) + await expect(dialog.toolEntries).toHaveCount(5) // Click the third tool (Eraser, index 2) - await toolEntries.nth(2).click() + await dialog.toolEntries.nth(2).click() // The third tool should now be selected - const selectedTool = dialog.locator( - '.maskEditor_toolPanelContainerSelected' - ) - await expect(selectedTool).toHaveCount(1) + await expect(dialog.selectedTool).toHaveCount(1) // Verify it's the eraser (3rd entry) - await expect(toolEntries.nth(2)).toHaveClass(/Selected/) + await expect(dialog.toolEntries.nth(2)).toHaveClass(/Selected/) }) test('brush settings panel is visible with thickness controls', async ({ @@ -335,14 +292,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { const dialog = await openMaskEditorDialog(comfyPage) // The side panel should show brush settings by default - const thicknessLabel = dialog.getByText('Thickness') - await expect(thicknessLabel).toBeVisible() - - const opacityLabel = dialog.getByText('Opacity').first() - await expect(opacityLabel).toBeVisible() - - const hardnessLabel = dialog.getByText('Hardness') - await expect(hardnessLabel).toBeVisible() + await expect(dialog.thicknessLabel).toBeVisible() + await expect(dialog.opacityLabel).toBeVisible() + await expect(dialog.hardnessLabel).toBeVisible() }) test('save uploads all layers and closes dialog', async ({ comfyPage }) => { @@ -376,17 +328,21 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { }) }) - const saveButton = dialog.getByRole('button', { name: 'Save' }) - await expect(saveButton).toBeVisible() - await saveButton.click() + await expect(dialog.saveButton).toBeVisible() + await dialog.saveButton.click() - await expect(dialog).toBeHidden() + await expect(dialog.root).toBeHidden() - // The save pipeline uploads multiple layers (mask + image variants) - expect( - maskUploadCount + imageUploadCount, - 'save should trigger upload calls' - ).toBeGreaterThan(0) + // The save pipeline uploads the mask plus at least one image layer. + // Pinning >=1 of each catches regressions where either branch silently + // short-circuits. Poll because `dialog.root` can hide before both route + // handlers have actually incremented their counters on CI. + await expect + .poll(() => maskUploadCount, { message: 'mask upload should fire' }) + .toBeGreaterThanOrEqual(1) + await expect + .poll(() => imageUploadCount, { message: 'image upload should fire' }) + .toBeGreaterThanOrEqual(1) }) test('save failure keeps dialog open', async ({ comfyPage }) => { @@ -400,11 +356,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { route.fulfill({ status: 500 }) ) - const saveButton = dialog.getByRole('button', { name: 'Save' }) - await saveButton.click() + await dialog.saveButton.click() // Dialog should remain open when save fails - await expect(dialog).toBeVisible() + await expect(dialog.root).toBeVisible() }) test( @@ -419,8 +374,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page) // Switch to eraser tool (3rd tool, index 2) - const toolEntries = dialog.locator('.maskEditor_toolPanelContainer') - await toolEntries.nth(2).click() + await dialog.toolEntries.nth(2).click() // Draw over the same area with the eraser await drawStrokeOnPointerZone(comfyPage.page, dialog) diff --git a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts index 0e19335f44..94e06c4a62 100644 --- a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts @@ -20,20 +20,16 @@ test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => { test( 'Middle-click drag on node should pan canvas', - { tag: '@screenshot' }, - async ({ comfyPage }) => { + { tag: ['@screenshot', '@canvas'] }, + async ({ comfyPage, comfyMouse }) => { const node = comfyPage.vueNodes .getNodeByTitle('CLIP Text Encode (Prompt)') .first() - const box = await node.boundingBox() - if (!box) throw new Error('Node bounding box not found') - - const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 } - await comfyPage.canvasOps.middleClickDrag(center, { - x: center.x + 140, - y: center.y + 90 - }) - await comfyPage.nextFrame() + await comfyMouse.mmbDragFromCenter( + node, + { dx: 140, dy: 90 }, + { steps: 10 } + ) await expect(comfyPage.canvas).toHaveScreenshot( 'vue-nodes-paned-with-mmb-over-node.png' diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts index 3e16894937..a35cf9515a 100644 --- a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts @@ -57,19 +57,14 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { test( 'Middle-click drag on textarea should pan canvas', - { tag: '@screenshot' }, - async ({ comfyPage }) => { + { tag: ['@screenshot', '@canvas'] }, + async ({ comfyPage, comfyMouse }) => { const textarea = getFirstMultilineStringWidget(comfyPage) - await expect(textarea).toBeVisible() - const box = await textarea.boundingBox() - if (!box) throw new Error('Textarea bounding box not found') - - const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 } - await comfyPage.canvasOps.middleClickDrag(center, { - x: center.x + 120, - y: center.y + 80 - }) - await comfyPage.nextFrame() + await comfyMouse.mmbDragFromCenter( + textarea, + { dx: 120, dy: 80 }, + { steps: 10 } + ) await expect(comfyPage.canvas).toHaveScreenshot( 'vue-nodes-paned-with-mmb-over-textarea.png' @@ -79,22 +74,16 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { test( 'Middle-click drag on markdown widget should pan canvas', - { tag: '@screenshot' }, - async ({ comfyPage }) => { + { tag: ['@screenshot', '@canvas'] }, + async ({ comfyPage, comfyMouse }) => { await comfyPage.workflow.loadWorkflow('nodes/note_nodes') const markdownWidget = comfyPage.page.locator('.widget-markdown').first() - await expect(markdownWidget).toBeVisible() - - const box = await markdownWidget.boundingBox() - if (!box) throw new Error('Markdown widget bounding box not found') - - const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 } - await comfyPage.canvasOps.middleClickDrag(center, { - x: center.x + 120, - y: center.y + 80 - }) - await comfyPage.nextFrame() + await comfyMouse.mmbDragFromCenter( + markdownWidget, + { dx: 120, dy: 80 }, + { steps: 10 } + ) await expect(comfyPage.canvas).toHaveScreenshot( 'vue-nodes-paned-with-mmb-over-markdown.png' diff --git a/src/base/pointerUtils.test.ts b/src/base/pointerUtils.test.ts new file mode 100644 index 0000000000..ae85d0fa8b --- /dev/null +++ b/src/base/pointerUtils.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest' + +import { + isMiddleButtonEvent, + isMiddleButtonHeld, + isMiddleForPointerEvent, + isMiddlePointerInput +} from '@/base/pointerUtils' + +describe('isMiddlePointerInput', () => { + describe('MouseEvent.button semantics (down/up events)', () => { + it('returns true when button is 1 (middle)', () => { + const event = new MouseEvent('mousedown', { button: 1 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + + it('returns false when button is 0 (left)', () => { + const event = new MouseEvent('mousedown', { button: 0 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + + it('returns false when button is 2 (right)', () => { + const event = new MouseEvent('mousedown', { button: 2 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + }) + + describe('MouseEvent.buttons semantics (move events)', () => { + it('returns true when buttons bitmask is exactly 4 (middle only)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 4 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + + it('returns false when buttons bitmask is 0 (no buttons held)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 0 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + + it('returns false when buttons bitmask is 1 (left only)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 1 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + + it('returns false when buttons bitmask is 2 (right only)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 2 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + }) + + describe('chorded buttons (strict equality, not bitmask)', () => { + it('returns false when middle+left are held simultaneously (buttons=5)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 5 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + + it('returns false when middle+right are held simultaneously (buttons=6)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 6 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + + it('returns false when all three buttons are held (buttons=7)', () => { + const event = new MouseEvent('mousemove', { button: 0, buttons: 7 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + }) + + describe('PointerEvent', () => { + it('returns true for pointerdown with button === 1', () => { + const event = new PointerEvent('pointerdown', { button: 1 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + + it('returns true for pointermove with buttons === 4', () => { + const event = new PointerEvent('pointermove', { button: 0, buttons: 4 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + + it('returns false for pointerdown with button === 0', () => { + const event = new PointerEvent('pointerdown', { button: 0 }) + expect(isMiddlePointerInput(event)).toBe(false) + }) + }) + + describe('button takes precedence over buttons on down/up events', () => { + // On pointerdown with button===1, buttons typically also contains 4, but we + // want to confirm that the 'button' branch wins even when 'buttons' + // disagrees (e.g., synthetic events in tests, or quirky UA behavior). + it('returns true when button===1 even if buttons does not include middle', () => { + const event = new MouseEvent('mousedown', { button: 1, buttons: 0 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + + it('returns true when button===1 even if buttons reports a different chord', () => { + const event = new MouseEvent('mousedown', { button: 1, buttons: 2 }) + expect(isMiddlePointerInput(event)).toBe(true) + }) + }) +}) + +describe('isMiddleButtonHeld', () => { + it('returns true when middle is the only held button (buttons=4)', () => { + const event = new MouseEvent('mousemove', { buttons: 4 }) + expect(isMiddleButtonHeld(event)).toBe(true) + }) + + it('returns true when middle is held chorded with left (buttons=5)', () => { + const event = new MouseEvent('mousemove', { buttons: 5 }) + expect(isMiddleButtonHeld(event)).toBe(true) + }) + + it('returns true when middle is held chorded with right (buttons=6)', () => { + const event = new MouseEvent('mousemove', { buttons: 6 }) + expect(isMiddleButtonHeld(event)).toBe(true) + }) + + it('returns true when all three buttons are held (buttons=7)', () => { + const event = new MouseEvent('mousemove', { buttons: 7 }) + expect(isMiddleButtonHeld(event)).toBe(true) + }) + + it('returns false when only left is held (buttons=1)', () => { + const event = new MouseEvent('mousemove', { buttons: 1 }) + expect(isMiddleButtonHeld(event)).toBe(false) + }) + + it('returns false when only right is held (buttons=2)', () => { + const event = new MouseEvent('mousemove', { buttons: 2 }) + expect(isMiddleButtonHeld(event)).toBe(false) + }) + + it('returns false when no buttons are held (buttons=0)', () => { + const event = new MouseEvent('mousemove', { buttons: 0 }) + expect(isMiddleButtonHeld(event)).toBe(false) + }) + + it('ignores button field — only buttons (held) matters', () => { + // Synthetic: pointerdown with button===1 but buttons=0 (quirky UA). Held + // semantics say middle is NOT currently held, so false. + const event = new MouseEvent('mousedown', { button: 1, buttons: 0 }) + expect(isMiddleButtonHeld(event)).toBe(false) + }) + + it('works for PointerEvent with buttons=4', () => { + const event = new PointerEvent('pointermove', { buttons: 4 }) + expect(isMiddleButtonHeld(event)).toBe(true) + }) +}) + +describe('isMiddleButtonEvent', () => { + it('returns true when button is 1 (middle)', () => { + const event = new MouseEvent('mousedown', { button: 1 }) + expect(isMiddleButtonEvent(event)).toBe(true) + }) + + it('returns true on pointerup with button=1 even if buttons=0', () => { + // On middle pointerup the button just released, so buttons typically + // drops middle. Use the button field to identify middle-up events. + const event = new PointerEvent('pointerup', { button: 1, buttons: 0 }) + expect(isMiddleButtonEvent(event)).toBe(true) + }) + + it('returns false when button is 0 (left)', () => { + const event = new MouseEvent('mousedown', { button: 0 }) + expect(isMiddleButtonEvent(event)).toBe(false) + }) + + it('returns false when button is 2 (right)', () => { + const event = new MouseEvent('mousedown', { button: 2 }) + expect(isMiddleButtonEvent(event)).toBe(false) + }) + + it('ignores buttons bitmask — only button field matters', () => { + // buttons=5 (middle held while left press fires) but button=0 means this + // is a left-button event, not a middle-button event. + const event = new MouseEvent('mousedown', { button: 0, buttons: 5 }) + expect(isMiddleButtonEvent(event)).toBe(false) + }) +}) + +describe('isMiddleForPointerEvent', () => { + it('dispatches pointerdown through isMiddlePointerInput (strict buttons)', () => { + // Middle-only pointerdown → true + expect( + isMiddleForPointerEvent( + new PointerEvent('pointerdown', { button: 1, buttons: 4 }) + ) + ).toBe(true) + + // Chorded pointerdown (left pressed while middle is incidentally held) → + // strict semantics reject; must NOT forward as middle. + expect( + isMiddleForPointerEvent( + new PointerEvent('pointerdown', { button: 0, buttons: 5 }) + ) + ).toBe(false) + }) + + it('dispatches pointermove through isMiddleButtonHeld (bitmask)', () => { + // Middle-only move → true + expect( + isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 4 })) + ).toBe(true) + + // Chorded move (middle + left, middle + right, all three) → still held, + // forwarding must survive the chord. + expect( + isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 5 })) + ).toBe(true) + expect( + isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 6 })) + ).toBe(true) + expect( + isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 7 })) + ).toBe(true) + + // No middle bit → false + expect( + isMiddleForPointerEvent(new PointerEvent('pointermove', { buttons: 1 })) + ).toBe(false) + }) + + it('dispatches pointerup through isMiddleButtonEvent (button field)', () => { + // Middle released, buttons already dropped middle — must still identify + // this as a middle event via `button`. + expect( + isMiddleForPointerEvent( + new PointerEvent('pointerup', { button: 1, buttons: 0 }) + ) + ).toBe(true) + + // Non-middle pointerup → false + expect( + isMiddleForPointerEvent( + new PointerEvent('pointerup', { button: 0, buttons: 0 }) + ) + ).toBe(false) + }) + + it('falls back to isMiddleButtonEvent for other event types (e.g. auxclick)', () => { + expect( + isMiddleForPointerEvent(new MouseEvent('auxclick', { button: 1 })) + ).toBe(true) + expect( + isMiddleForPointerEvent(new MouseEvent('auxclick', { button: 2 })) + ).toBe(false) + }) + + it('dispatches pointercancel through isMiddleButtonHeld (button field is -1 per spec)', () => { + // Per the Pointer Events spec, pointercancel always carries + // `button === -1` because no button state changed. Identifying a + // middle-button cancel has to come from the `buttons` bitmask instead. + expect( + isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 4 })) + ).toBe(true) + + expect( + isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 5 })) + ).toBe(true) + + expect( + isMiddleForPointerEvent(new PointerEvent('pointercancel', { buttons: 1 })) + ).toBe(false) + }) +}) diff --git a/src/base/pointerUtils.ts b/src/base/pointerUtils.ts index 6bb2187807..22f9566a42 100644 --- a/src/base/pointerUtils.ts +++ b/src/base/pointerUtils.ts @@ -3,7 +3,14 @@ */ /** - * Checks if a pointer or mouse event is a middle button input + * Checks if a pointer or mouse event is a middle button input. + * + * Uses strict `buttons === 4` on the move branch so that chorded pointerdown + * events (e.g., left-click while middle is incidentally held) are not + * misclassified as middle-button clicks. For "is the middle button currently + * held regardless of other buttons" semantics (typical for pointermove panning + * or held-state indicators), use {@link isMiddleButtonHeld} instead. + * * @param event - The pointer or mouse event to check * @returns true if the event is from the middle button/wheel */ @@ -20,3 +27,62 @@ export function isMiddlePointerInput( return false } + +/** + * Checks if the middle button is currently held, using a bitmask so chorded + * states (middle + left, middle + right, etc.) still register as held. + * + * Use this on pointermove-style handlers that want to keep a middle-button + * gesture alive while other buttons transition. Do NOT use on pointerdown + * where a freshly-pressed left button while middle is held would otherwise be + * misclassified as middle input — use {@link isMiddlePointerInput} there. + */ +export function isMiddleButtonHeld(event: PointerEvent | MouseEvent): boolean { + if ('buttons' in event && typeof event.buttons === 'number') { + return (event.buttons & 4) === 4 + } + + return false +} + +/** + * Checks whether the event's `button` field identifies the middle button — + * i.e. the event was caused by a middle-button press/release/auxclick. Does + * not consult the `buttons` bitmask. + * + * Use this on state-transition handlers (pointerdown, pointerup, auxclick) + * where `button` is the authoritative source. pointerup in particular cannot + * use {@link isMiddleButtonHeld} because the button has just been released + * and no longer appears in `buttons`. + */ +export function isMiddleButtonEvent(event: PointerEvent | MouseEvent): boolean { + return 'button' in event && event.button === 1 +} + +/** + * Dispatches between the three middle-button predicates based on the event's + * type, so a single handler bound to multiple pointer events picks the right + * semantic per event: + * + * - pointerdown → {@link isMiddlePointerInput} (strict, rejects chorded + * pointerdowns where middle is only incidentally held) + * - pointermove and pointercancel → {@link isMiddleButtonHeld} (bitmask, + * keeps a chorded drag alive when the user adds/removes other buttons + * mid-gesture; pointercancel reports `button = -1` per spec so the + * `button` field cannot be used to identify a middle-button cancel) + * - pointerup and everything else → {@link isMiddleButtonEvent} (`button` + * field, the only reliable source on release) + * + * Use this at sites that wire the same callback to pointerdown, pointermove, + * and pointerup together (e.g. capture-phase forwarders). Handlers that only + * care about a single event type should call the specific helper directly. + */ +export function isMiddleForPointerEvent( + event: PointerEvent | MouseEvent +): boolean { + if (event.type === 'pointerdown') return isMiddlePointerInput(event) + if (event.type === 'pointermove' || event.type === 'pointercancel') { + return isMiddleButtonHeld(event) + } + return isMiddleButtonEvent(event) +} diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index cd4909417c..b0294379d2 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -8,6 +8,11 @@ v-if="workflowTabsPosition === 'Topbar'" class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full" > + +
@@ -119,7 +124,7 @@ import { } from 'vue' import { useI18n } from 'vue-i18n' -import { isMiddlePointerInput } from '@/base/pointerUtils' +import { isMiddleForPointerEvent } from '@/base/pointerUtils' import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue' import TopMenuSection from '@/components/TopMenuSection.vue' import BottomPanel from '@/components/bottomPanel/BottomPanel.vue' @@ -184,6 +189,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore' import { useAppMode } from '@/composables/useAppMode' import { useWorkspaceStore } from '@/stores/workspaceStore' +import { isNativeWindow } from '@/utils/envUtil' import { forEachNode } from '@/utils/graphTraversalUtil' import SelectionRectangle from './SelectionRectangle.vue' @@ -599,7 +605,11 @@ onUnmounted(() => { vueNodeLifecycle.cleanup() }) function forwardPanEvent(e: PointerEvent) { - if (!isMiddlePointerInput(e)) return + // Bound to pointerdown, pointerup, AND pointermove (see template capture + // handlers). isMiddleForPointerEvent picks the right helper per event type + // so the forwarder survives chorded moves without misclassifying chorded + // pointerdowns. + if (!isMiddleForPointerEvent(e)) return if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) return diff --git a/src/composables/maskeditor/useToolManager.test.ts b/src/composables/maskeditor/useToolManager.test.ts index 20246b62fc..59b516f3a9 100644 --- a/src/composables/maskeditor/useToolManager.test.ts +++ b/src/composables/maskeditor/useToolManager.test.ts @@ -440,6 +440,13 @@ describe('useToolManager', () => { expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled() }) + it('should not pan on chorded middle and left button drag', async () => { + const tm = setup() + await tm.handlePointerMove(pointerEvent({ buttons: 5 })) + + expect(mockPanZoom.handlePanMove).not.toHaveBeenCalled() + }) + it('should pan on left button + space drag', async () => { const tm = setup() mockKeyboard.isKeyDown.mockImplementation((k) => k === ' ') diff --git a/src/composables/maskeditor/useToolManager.ts b/src/composables/maskeditor/useToolManager.ts index 5c31122fe3..5204f95e81 100644 --- a/src/composables/maskeditor/useToolManager.ts +++ b/src/composables/maskeditor/useToolManager.ts @@ -111,6 +111,15 @@ export function useToolManager( } ) + // Pan gate shared by pointerdown and pointermove: middle-button (strict so + // chorded pointerdown on a non-middle button is not misclassified) OR + // space-held left-drag. Extracting keeps both handlers as a single + // early-exit against one named contract instead of a repeated boolean. + const shouldPan = (event: PointerEvent): boolean => { + if (isMiddlePointerInput(event)) return true + return event.buttons === 1 && keyboard.isKeyDown(' ') + } + const handlePointerDown = async (event: PointerEvent): Promise => { event.preventDefault() if (event.pointerType === 'touch') return @@ -119,21 +128,14 @@ export function useToolManager( panZoom.addPenPointerId(event.pointerId) } - const isSpacePressed = keyboard.isKeyDown(' ') - - if ( - isMiddlePointerInput(event) || - (event.buttons === 1 && isSpacePressed) - ) { + if (shouldPan(event)) { panZoom.handlePanStart(event) - store.brushVisible = false return } if (store.currentTool === Tools.PaintPen && event.button === 0) { await brushDrawing.startDrawing(event) - return } @@ -170,7 +172,6 @@ export function useToolManager( if ([0, 2].includes(event.button) && isDrawingTool) { await brushDrawing.startDrawing(event) - return } } @@ -181,12 +182,7 @@ export function useToolManager( const newCursorPoint = { x: event.clientX, y: event.clientY } panZoom.updateCursorPosition(newCursorPoint) - const isSpacePressed = keyboard.isKeyDown(' ') - - if ( - isMiddlePointerInput(event) || - (event.buttons === 1 && isSpacePressed) - ) { + if (shouldPan(event)) { await panZoom.handlePanMove(event) return } @@ -211,7 +207,6 @@ export function useToolManager( if (event.buttons === 1 || event.buttons === 2) { await brushDrawing.handleDrawing(event) - return } } diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index a42e274fd5..f7f0f54b4a 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1,7 +1,7 @@ import { toString } from 'es-toolkit/compat' import { toValue } from 'vue' -import { isMiddlePointerInput } from '@/base/pointerUtils' +import { isMiddleButtonEvent, isMiddlePointerInput } from '@/base/pointerUtils' import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink' import { AutoPanController } from '@/renderer/core/canvas/useAutoPan' @@ -684,7 +684,6 @@ export class LGraphCanvas implements CustomEventDispatcher private _visibleReroutes: Set = new Set() private _autoPan: AutoPanController | null = null private _ghostPointerHandler: ((e: PointerEvent) => void) | null = null - private _ghostKeyHandler: ((e: KeyboardEvent) => void) | null = null dirty_canvas: boolean = true dirty_bgcanvas: boolean = true @@ -1861,9 +1860,6 @@ export class LGraphCanvas implements CustomEventDispatcher const { graph } = this if (newGraph === graph) return - // Drop any in-flight ghost so listeners don't outlive the graph it belongs to - if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true) - this.clear() newGraph.attachCanvas(this) @@ -1974,7 +1970,11 @@ export class LGraphCanvas implements CustomEventDispatcher /** Prevents default for middle-click auxclick only. */ _preventMiddleAuxClick(e: MouseEvent): void { - if (isMiddlePointerInput(e)) e.preventDefault() + // Gate on the released button, not the held bitmask. On a non-middle + // auxclick (e.g. right-button release), `buttons` may still include the + // middle bit if middle is held, which would false-positive through + // isMiddlePointerInput and suppress defaults for unrelated auxclicks. + if (isMiddleButtonEvent(e)) e.preventDefault() } /** Captures an event and prevents default - returns true. */ @@ -3667,9 +3667,6 @@ export class LGraphCanvas implements CustomEventDispatcher * @param dragEvent Optional mouse event for positioning under cursor */ startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void { - // Cancel any in-flight ghost so we don't leak its listeners - if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true) - this.emitBeforeChange() this.graph?.beforeChange() @@ -3709,19 +3706,6 @@ export class LGraphCanvas implements CustomEventDispatcher 'pointerleave', this._ghostPointerHandler ) - - // Listen on document so cancellation works even when the canvas isnt focused - // e.g. the search dialog just closed. - // stopPropagation prevents window-level keybindings (like Comfy.Graph.ExitSubgraph on Escape) from firing alongside the cancel. - this._ghostKeyHandler = (e: KeyboardEvent) => { - if (e.key !== 'Escape' && e.key !== 'Delete' && e.key !== 'Backspace') { - return - } - this.finalizeGhostPlacement(true) - e.stopPropagation() - e.preventDefault() - } - document.addEventListener('keydown', this._ghostKeyHandler, true) } /** @@ -3750,11 +3734,6 @@ export class LGraphCanvas implements CustomEventDispatcher this._ghostPointerHandler = null } - if (this._ghostKeyHandler) { - document.removeEventListener('keydown', this._ghostKeyHandler, true) - this._ghostKeyHandler = null - } - const node = this.graph?.getNodeById(nodeId) if (!node) return @@ -3849,7 +3828,7 @@ export class LGraphCanvas implements CustomEventDispatcher this ) } - } else if (e.button === 1) { + } else if (isMiddleButtonEvent(e)) { this.dirty_canvas = true this.dragging_canvas = false } else if (e.button === 2) { @@ -3943,6 +3922,17 @@ export class LGraphCanvas implements CustomEventDispatcher const { graph } = this if (!graph) return + // Cancel ghost placement + if ( + (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') && + this.state.ghostNodeId != null + ) { + this.finalizeGhostPlacement(true) + e.stopPropagation() + e.preventDefault() + return + } + let block_default = false // @ts-expect-error EventTarget.localName is not in standard types if (e.target.localName == 'input') return diff --git a/src/lib/litegraph/src/canvas/InputIndicators.test.ts b/src/lib/litegraph/src/canvas/InputIndicators.test.ts new file mode 100644 index 0000000000..17aef5dd77 --- /dev/null +++ b/src/lib/litegraph/src/canvas/InputIndicators.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import { InputIndicators } from '@/lib/litegraph/src/canvas/InputIndicators' + +// Minimal LGraphCanvas-shaped fake good enough for InputIndicators' constructor +// and the handlers we exercise. The class touches `canvas.canvas` (DOM element), +// `canvas.drawFrontCanvas`, and `canvas.setDirty`; nothing else on the handler +// paths under test. +function createFakeCanvas() { + const element = document.createElement('canvas') + const origDrawFrontCanvas = vi.fn() + return { + canvas: element, + drawFrontCanvas: origDrawFrontCanvas, + setDirty: vi.fn() + } as unknown as LGraphCanvas +} + +describe('InputIndicators.onPointerDownOrMove', () => { + let canvas: LGraphCanvas + let indicators: InputIndicators + + beforeEach(() => { + canvas = createFakeCanvas() + indicators = new InputIndicators(canvas) + }) + + it('flags mouse1Down when only middle is held (buttons=4)', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 4 }) + ) + + expect(indicators.mouse0Down).toBe(false) + expect(indicators.mouse1Down).toBe(true) + expect(indicators.mouse2Down).toBe(false) + }) + + it('keeps mouse1Down while middle is chorded with left (buttons=5)', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 5 }) + ) + + expect(indicators.mouse0Down).toBe(true) + expect(indicators.mouse1Down).toBe(true) + expect(indicators.mouse2Down).toBe(false) + }) + + it('keeps mouse1Down while middle is chorded with right (buttons=6)', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 6 }) + ) + + expect(indicators.mouse0Down).toBe(false) + expect(indicators.mouse1Down).toBe(true) + expect(indicators.mouse2Down).toBe(true) + }) + + it('keeps mouse1Down while all three buttons are held (buttons=7)', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 7 }) + ) + + expect(indicators.mouse0Down).toBe(true) + expect(indicators.mouse1Down).toBe(true) + expect(indicators.mouse2Down).toBe(true) + }) + + it('clears mouse1Down when middle is not in buttons (left only, buttons=1)', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 1 }) + ) + + expect(indicators.mouse0Down).toBe(true) + expect(indicators.mouse1Down).toBe(false) + expect(indicators.mouse2Down).toBe(false) + }) + + it('clears all flags when no buttons are held (buttons=0)', () => { + // Prime with middle held, then send a no-buttons event (e.g., after release). + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 4 }) + ) + expect(indicators.mouse1Down).toBe(true) + + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 0 }) + ) + expect(indicators.mouse0Down).toBe(false) + expect(indicators.mouse1Down).toBe(false) + expect(indicators.mouse2Down).toBe(false) + }) + + it('captures the pointer position and marks canvas dirty', () => { + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { clientX: 123, clientY: 456, buttons: 4 }) + ) + + expect(indicators.x).toBe(123) + expect(indicators.y).toBe(456) + expect(canvas.setDirty).toHaveBeenCalledWith(true) + }) +}) + +describe('InputIndicators.onPointerUp', () => { + it('clears all mouse-down flags', () => { + const canvas = createFakeCanvas() + const indicators = new InputIndicators(canvas) + + indicators.onPointerDownOrMove( + new MouseEvent('pointermove', { buttons: 7 }) + ) + expect(indicators.mouse1Down).toBe(true) + + indicators.onPointerUp() + + expect(indicators.mouse0Down).toBe(false) + expect(indicators.mouse1Down).toBe(false) + expect(indicators.mouse2Down).toBe(false) + }) +}) diff --git a/src/lib/litegraph/src/canvas/InputIndicators.ts b/src/lib/litegraph/src/canvas/InputIndicators.ts index 3e0cda3ac0..7a88eea285 100644 --- a/src/lib/litegraph/src/canvas/InputIndicators.ts +++ b/src/lib/litegraph/src/canvas/InputIndicators.ts @@ -1,4 +1,4 @@ -import { isMiddlePointerInput } from '@/base/pointerUtils' +import { isMiddleButtonHeld } from '@/base/pointerUtils' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' /** @@ -6,10 +6,12 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' * * Used to create videos of feature changes. * - * Example usage with ComfyUI_frontend, via console / devtools: + * Example usage with ComfyUI_frontend, via console / devtools. The class is + * reachable as `LiteGraph.Classes.InputIndicators`, not at the top level of + * `LiteGraph`: * * ```ts - * const inputIndicators = new InputIndicators(canvas) + * const inputIndicators = new LiteGraph.Classes.InputIndicators(window.app.canvas) * // Dispose: * inputIndicators.dispose() * ``` @@ -72,7 +74,7 @@ export class InputIndicators implements Disposable { private _onPointerDownOrMove = this.onPointerDownOrMove.bind(this) onPointerDownOrMove(e: MouseEvent): void { this.mouse0Down = (e.buttons & 1) === 1 - this.mouse1Down = isMiddlePointerInput(e) + this.mouse1Down = isMiddleButtonHeld(e) this.mouse2Down = (e.buttons & 2) === 2 this.x = e.clientX diff --git a/src/renderer/core/canvas/useCanvasInteractions.test.ts b/src/renderer/core/canvas/useCanvasInteractions.test.ts index f73d717b70..3799aaca80 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.test.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.test.ts @@ -36,9 +36,12 @@ function createMockLGraphCanvas(read_only = true): LGraphCanvas { } function createMockPointerEvent( - buttons: PointerEvent['buttons'] = 1 + buttons: PointerEvent['buttons'] = 1, + { type = 'pointerdown', button = 0 }: { type?: string; button?: number } = {} ): PointerEvent { const mockEvent: Partial = { + type, + button, buttons, preventDefault: vi.fn(), stopPropagation: vi.fn() @@ -76,13 +79,30 @@ describe('useCanvasInteractions', () => { expect(mockEvent.stopPropagation).toHaveBeenCalled() }) - it('should forward middle mouse button events to canvas', () => { + it('should forward middle-button pointerdown to canvas', () => { const { getCanvas } = useCanvasStore() const mockCanvas = createMockLGraphCanvas(false) vi.mocked(getCanvas).mockReturnValue(mockCanvas) const { handlePointer } = useCanvasInteractions() - const mockEvent = createMockPointerEvent(4) // Middle mouse button + const mockEvent = createMockPointerEvent(4, { + type: 'pointerdown', + button: 1 + }) + handlePointer(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockEvent.stopPropagation).toHaveBeenCalled() + }) + + it('should forward middle-held pointermove to canvas even when chorded with left', () => { + const { getCanvas } = useCanvasStore() + const mockCanvas = createMockLGraphCanvas(false) + vi.mocked(getCanvas).mockReturnValue(mockCanvas) + const { handlePointer } = useCanvasInteractions() + + // buttons=5 = middle + left held simultaneously. + const mockEvent = createMockPointerEvent(5, { type: 'pointermove' }) handlePointer(mockEvent) expect(mockEvent.preventDefault).toHaveBeenCalled() diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index bf23799ab6..a972120123 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -1,6 +1,6 @@ import { computed } from 'vue' -import { isMiddlePointerInput } from '@/base/pointerUtils' +import { isMiddleForPointerEvent } from '@/base/pointerUtils' import { useSettingStore } from '@/platform/settings/settingStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' @@ -72,7 +72,11 @@ export function useCanvasInteractions() { * be forwarded to canvas (e.g., space+drag for panning) */ const handlePointer = (event: PointerEvent) => { - if (isMiddlePointerInput(event)) { + // Route through the shared type-dispatcher so pointerdown uses strict + // semantics (chorded left-click with middle held is NOT middle input), + // pointermove uses the bitmask held check to survive chords, and + // pointerup identifies the released button via `button`. + if (isMiddleForPointerEvent(event)) { forwardEventToCanvas(event) return } @@ -86,7 +90,6 @@ export function useCanvasInteractions() { event.preventDefault() event.stopPropagation() forwardEventToCanvas(event) - return } } diff --git a/src/renderer/core/layout/transform/useTransformSettling.test.ts b/src/renderer/core/layout/transform/useTransformSettling.test.ts index 783ab84fe0..0ce088a45b 100644 --- a/src/renderer/core/layout/transform/useTransformSettling.test.ts +++ b/src/renderer/core/layout/transform/useTransformSettling.test.ts @@ -100,6 +100,25 @@ describe('useTransformSettling', () => { expect(isTransforming.value).toBe(false) }) + it('should treat middle-click as pan', async () => { + const { isTransforming } = useTransformSettling(element, { + settleDelay: 200 + }) + + element.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 1 }) + ) + element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + await nextTick() + + expect(isTransforming.value).toBe(true) + + element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + vi.advanceTimersByTime(200) + + expect(isTransforming.value).toBe(false) + }) + it('should not track pointermove without pointerdown', async () => { const { isTransforming } = useTransformSettling(element) diff --git a/src/renderer/core/layout/transform/useTransformSettling.ts b/src/renderer/core/layout/transform/useTransformSettling.ts index b07332ecdd..ed1d960084 100644 --- a/src/renderer/core/layout/transform/useTransformSettling.ts +++ b/src/renderer/core/layout/transform/useTransformSettling.ts @@ -1,4 +1,4 @@ -import { useDebounceFn, useEventListener } from '@vueuse/core' +import { useEventListener, useTimeoutFn } from '@vueuse/core' import { isMiddlePointerInput } from '@/base/pointerUtils' import { ref } from 'vue' @@ -52,13 +52,21 @@ export function useTransformSettling( const isTransforming = ref(false) - const markTransformSettled = useDebounceFn(() => { - isTransforming.value = false - }, settleDelay) + // useTimeoutFn auto-stops the pending timer on scope dispose via VueUse's + // tryOnScopeDispose, so the settle timer doesn't outlive an unmounting + // component and resurrect `isTransforming` into a disposed scope. + const { start: restartSettleTimer } = useTimeoutFn( + () => { + isTransforming.value = false + }, + settleDelay, + { immediate: false } + ) function markInteracting() { isTransforming.value = true - void markTransformSettled() + // Each call resets the timer — start() stops any pending one first. + restartSettleTimer() } const eventOptions = { capture: true, passive } diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index 381422a062..34c5422153 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -1,7 +1,7 @@ import { onScopeDispose, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue' -import { isMiddlePointerInput } from '@/base/pointerUtils' +import { isMiddleForPointerEvent } from '@/base/pointerUtils' import { useClickDragGuard } from '@/composables/useClickDragGuard' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' @@ -22,7 +22,7 @@ export function useNodePointerInteractions( const { nodeManager } = useVueNodeLifecycle() const forwardMiddlePointerIfNeeded = (event: PointerEvent) => { - if (!isMiddlePointerInput(event)) return false + if (!isMiddleForPointerEvent(event)) return false forwardEventToCanvas(event) return true } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts index b5d5796f0d..17fb18975b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts @@ -1,116 +1,143 @@ -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type * as LitegraphModule from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import type { DOMWidget } from '@/scripts/domWidget' import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget' -import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' -const { canvasMock } = vi.hoisted(() => ({ - canvasMock: { - processMouseDown: vi.fn(), - processMouseMove: vi.fn(), - processMouseUp: vi.fn() - } -})) +const processMouseDown = vi.fn() +const processMouseMove = vi.fn() +const processMouseUp = vi.fn() vi.mock('@/scripts/app', () => ({ - app: { rootGraph: { id: 'root' }, canvas: canvasMock } -})) -vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } -}) -vi.mock('@/stores/widgetValueStore', () => ({ - useWidgetValueStore: () => ({ getWidget: () => undefined }) + app: { + canvas: { + processMouseDown: (e: Event) => processMouseDown(e), + processMouseMove: (e: Event) => processMouseMove(e), + processMouseUp: (e: Event) => processMouseUp(e) + }, + rootGraph: { id: 'root' } + } })) -function createMarkdownWidget(node: LGraphNode) { - const inputSpec: InputSpec = { - type: 'MARKDOWN', - name: 'note', - default: '' +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ + getWidget: () => undefined + }) +})) + +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveNodeRootGraphId: () => 'root' + } +}) + +function createNodeMock(): { + node: LGraphNode + getInputEl: () => HTMLElement +} { + let capturedEl: HTMLElement | undefined + + const node = { + id: 1, + addDOMWidget: vi.fn((_name: string, _type: string, el: HTMLElement) => { + capturedEl = el + return { + element: el, + options: {}, + value: '', + callback: vi.fn() + } + }) + } as unknown as LGraphNode + + return { + node, + getInputEl: () => { + if (!capturedEl) throw new Error('addDOMWidget was not invoked') + return capturedEl + } } - return useMarkdownWidget()(node, inputSpec) as DOMWidget } -describe('useMarkdownWidget', () => { - function setup() { +const markdownInputSpec: InputSpec = { + type: 'STRING', + name: 'text', + default: '' +} as InputSpec + +describe('useMarkdownWidget pointer handlers', () => { + let inputEl: HTMLElement + + beforeEach(() => { vi.clearAllMocks() - const node = createMockDOMWidgetNode() - const widget = createMarkdownWidget(node) - const callback = vi.fn<(value: string) => void>() - widget.callback = callback - const inputEl = widget.element - const textarea = inputEl.querySelector('textarea')! - const parentKeydown = vi.fn<(ev: KeyboardEvent) => void>() - document.body.append(inputEl) - document.body.addEventListener('keydown', parentKeydown) - onTestFinished(() => { - document.body.removeEventListener('keydown', parentKeydown) - inputEl.remove() + const { node, getInputEl } = createNodeMock() + useMarkdownWidget()(node, markdownInputSpec) + inputEl = getInputEl() + }) + + describe('pointerdown', () => { + it('forwards middle-button pointerdown to canvas', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + expect(processMouseDown).toHaveBeenCalledTimes(1) }) - return { widget, inputEl, textarea, callback, parentKeydown } - } - it('fires the widget callback on textarea input and change', () => { - const { textarea, callback } = setup() - textarea.value = 'hello' - textarea.dispatchEvent(new Event('input', { bubbles: true })) - textarea.dispatchEvent(new Event('change', { bubbles: true })) - expect(callback).toHaveBeenCalledTimes(2) + it('ignores left-button pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('ignores right-button pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('ignores left-click pointerdown when middle is incidentally held', () => { + inputEl.dispatchEvent( + new PointerEvent('pointerdown', { button: 0, buttons: 5 }) + ) + expect(processMouseDown).not.toHaveBeenCalled() + }) }) - it('toggles editing on dblclick/blur and stops keydown propagation', () => { - const { inputEl, textarea, parentKeydown } = setup() - inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) - expect(inputEl.classList.contains('editing')).toBe(true) + describe('pointermove', () => { + it('forwards pointermove while middle is the only held button', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) - textarea.dispatchEvent(new Event('blur')) - expect(inputEl.classList.contains('editing')).toBe(false) + it('forwards pointermove when middle is held chorded with left', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) - inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) - expect(parentKeydown).not.toHaveBeenCalled() + it('forwards pointermove when middle is held chorded with right', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) + + it('ignores pointermove when middle is not held', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 })) + expect(processMouseMove).not.toHaveBeenCalled() + }) }) - it('forwards middle-click pointer events to the canvas while alive', () => { - const { inputEl } = setup() - inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) - inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) - inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + describe('pointerup', () => { + it('forwards middle-button pointerup to canvas', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + expect(processMouseUp).toHaveBeenCalledTimes(1) + }) - expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) - expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) - expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) - }) + it('ignores left-button pointerup', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 })) + expect(processMouseUp).not.toHaveBeenCalled() + }) - it('detaches every listener and lets keydown bubble after removal', () => { - const { widget, inputEl, textarea, callback, parentKeydown } = setup() - widget.onRemove?.() - - textarea.value = 'after' - textarea.dispatchEvent(new Event('input', { bubbles: true })) - textarea.dispatchEvent(new Event('change', { bubbles: true })) - inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) - inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) - inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) - inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) - inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) - - expect(callback).not.toHaveBeenCalled() - expect(canvasMock.processMouseDown).not.toHaveBeenCalled() - expect(canvasMock.processMouseMove).not.toHaveBeenCalled() - expect(canvasMock.processMouseUp).not.toHaveBeenCalled() - expect(inputEl.classList.contains('editing')).toBe(false) - // keydown listener (which called stopPropagation) is gone, so the event - // now bubbles to the parent. - expect(parentKeydown).toHaveBeenCalledTimes(1) - }) - - it('survives onRemove being invoked twice', () => { - const { widget } = setup() - widget.onRemove?.() - expect(() => widget.onRemove?.()).not.toThrow() + it('ignores right-button pointerup', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 2 })) + expect(processMouseUp).not.toHaveBeenCalled() + }) }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts index 96a9a5e09a..b5783f2522 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts @@ -7,10 +7,9 @@ import TiptapTableRow from '@tiptap/extension-table-row' import TiptapStarterKit from '@tiptap/starter-kit' import { Markdown as TiptapMarkdown } from 'tiptap-markdown' -import { isMiddlePointerInput } from '@/base/pointerUtils' -import { useChainCallback } from '@/composables/functional/useChainCallback' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { app } from '@/scripts/app' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' @@ -66,75 +65,35 @@ function addMarkdownWidget( widget.element = inputEl widget.options.minNodeSize = [400, 200] - const controller = new AbortController() - const { signal } = controller - - inputEl.addEventListener( - 'input', - (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) - }, - { signal } - ) - - inputEl.addEventListener( - 'dblclick', - () => { - inputEl.classList.add('editing') - setTimeout(() => textarea.focus(), 0) - }, - { signal } - ) - - textarea.addEventListener('blur', () => inputEl.classList.remove('editing'), { - signal + inputEl.addEventListener('input', (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) }) - textarea.addEventListener( - 'change', - () => { - editor.commands.setContent(textarea.value) - widget.callback?.(widget.value) - }, - { signal } - ) - - inputEl.addEventListener('keydown', (event) => event.stopPropagation(), { - signal + inputEl.addEventListener('dblclick', () => { + inputEl.classList.add('editing') + setTimeout(() => { + textarea.focus() + }, 0) }) - inputEl.addEventListener( - 'pointerdown', - (event) => { - if (isMiddlePointerInput(event)) app.canvas.processMouseDown(event) - }, - { signal } - ) - - inputEl.addEventListener( - 'pointermove', - (event) => { - if (isMiddlePointerInput(event)) app.canvas.processMouseMove(event) - }, - { signal } - ) - - inputEl.addEventListener( - 'pointerup', - (event) => { - if (event.button === 1) app.canvas.processMouseUp(event) - }, - { signal } - ) - - widget.onRemove = useChainCallback(widget.onRemove, () => { - controller.abort() - if (!editor.isDestroyed) editor.destroy() + textarea.addEventListener('blur', () => { + inputEl.classList.remove('editing') }) + textarea.addEventListener('change', () => { + editor.commands.setContent(textarea.value) + widget.callback?.(widget.value) + }) + + inputEl.addEventListener('keydown', (event: KeyboardEvent) => { + event.stopPropagation() + }) + + forwardMiddleButtonToCanvas(inputEl) + return widget } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts index 2c7258f37c..551b4d02c1 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts @@ -1,96 +1,177 @@ -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type * as LitegraphModule from '@/lib/litegraph/src/litegraph' +import type * as FeedbackModule from '@/lib/litegraph/src/utils/feedback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import type { DOMWidget } from '@/scripts/domWidget' import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget' -import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' -const { canvasMock } = vi.hoisted(() => ({ - canvasMock: { - processMouseDown: vi.fn(), - processMouseMove: vi.fn(), - processMouseUp: vi.fn(), - processMouseWheel: vi.fn() - } -})) +// Capture the inputEl the widget attaches listeners to, so tests can dispatch +// synthetic pointer events directly on it without mounting into the real DOM. +const processMouseDown = vi.fn() +const processMouseMove = vi.fn() +const processMouseUp = vi.fn() +const processMouseWheel = vi.fn() vi.mock('@/scripts/app', () => ({ - app: { rootGraph: { id: 'root' }, canvas: canvasMock } -})) -vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } -}) -vi.mock('@/stores/widgetValueStore', () => ({ - useWidgetValueStore: () => ({ getWidget: () => undefined }) -})) -vi.mock('@/platform/settings/settingStore', () => ({ - useSettingStore: () => ({ get: () => false }) + app: { + canvas: { + processMouseDown: (e: Event) => processMouseDown(e), + processMouseMove: (e: Event) => processMouseMove(e), + processMouseUp: (e: Event) => processMouseUp(e), + processMouseWheel: (e: Event) => processMouseWheel(e) + }, + rootGraph: { id: 'root' } + } })) -function createStringWidget(node: LGraphNode) { - const inputSpec: InputSpec = { - type: 'STRING', - name: 'prompt', - default: '', - multiline: true +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: (key: string) => { + if (key === 'Comfy.TextareaWidget.Spellcheck') return false + if (key === 'LiteGraph.Pointer.TrackpadGestures') return false + return undefined + } + }) +})) + +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ + getWidget: () => undefined + }) +})) + +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveNodeRootGraphId: () => 'root' + } +}) + +vi.mock('@/lib/litegraph/src/utils/feedback', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + defineDeprecatedProperty: vi.fn() + } +}) + +function createNodeMock(): { + node: LGraphNode + getInputEl: () => HTMLTextAreaElement +} { + let capturedEl: HTMLTextAreaElement | undefined + + const node = { + id: 1, + addDOMWidget: vi.fn( + (_name: string, _type: string, el: HTMLTextAreaElement) => { + capturedEl = el + return { + element: el, + options: {}, + value: '', + callback: vi.fn() + } + } + ) + } as unknown as LGraphNode + + return { + node, + getInputEl: () => { + if (!capturedEl) throw new Error('addDOMWidget was not invoked') + return capturedEl + } } - return useStringWidget()(node, inputSpec) as DOMWidget< - HTMLTextAreaElement, - string - > } -describe('useStringWidget (multiline)', () => { - function setup() { +const multilineInputSpec: InputSpec = { + type: 'STRING', + name: 'text', + multiline: true, + default: '' +} as InputSpec + +describe('useStringWidget multiline pointer handlers', () => { + let inputEl: HTMLTextAreaElement + + beforeEach(() => { vi.clearAllMocks() - const node = createMockDOMWidgetNode() - const widget = createStringWidget(node) - const callback = vi.fn<(value: string) => void>() - widget.callback = callback - const inputEl = widget.element - document.body.append(inputEl) - onTestFinished(() => inputEl.remove()) - return { widget, inputEl, callback } - } - - it('fires the widget callback on input', () => { - const { inputEl, callback } = setup() - inputEl.value = 'hello' - inputEl.dispatchEvent(new Event('input', { bubbles: true })) - expect(callback).toHaveBeenCalledTimes(1) + const { node, getInputEl } = createNodeMock() + useStringWidget()(node, multilineInputSpec) + inputEl = getInputEl() }) - it('forwards middle-click pointer events and ctrl+wheel to the canvas while alive', () => { - const { inputEl } = setup() - inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) - inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) - inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) - inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + describe('pointerdown', () => { + it('forwards middle-button pointerdown to canvas', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + expect(processMouseDown).toHaveBeenCalledTimes(1) + }) - expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) - expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) - expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) - expect(canvasMock.processMouseWheel).toHaveBeenCalledTimes(1) + it('ignores left-button pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('ignores right-button pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('ignores left-click pointerdown even when middle is incidentally held', () => { + // Chorded pointerdown — user left-clicks while middle is held. The + // strict semantics in isMiddlePointerInput are what prevents this from + // being misclassified as a middle-button event. + inputEl.dispatchEvent( + new PointerEvent('pointerdown', { button: 0, buttons: 5 }) + ) + expect(processMouseDown).not.toHaveBeenCalled() + }) }) - it('detaches every listener when the widget is removed', () => { - const { widget, inputEl, callback } = setup() - widget.onRemove?.() + describe('pointermove', () => { + it('forwards pointermove while middle is the only held button', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) - inputEl.value = 'after' - inputEl.dispatchEvent(new Event('input', { bubbles: true })) - inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) - inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) - inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) - inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + it('forwards pointermove when middle is held chorded with left', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) - expect(callback).not.toHaveBeenCalled() - expect(canvasMock.processMouseDown).not.toHaveBeenCalled() - expect(canvasMock.processMouseMove).not.toHaveBeenCalled() - expect(canvasMock.processMouseUp).not.toHaveBeenCalled() - expect(canvasMock.processMouseWheel).not.toHaveBeenCalled() + it('forwards pointermove when middle is held chorded with right', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) + + it('ignores pointermove when middle is not held', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 })) + expect(processMouseMove).not.toHaveBeenCalled() + }) + + it('ignores pointermove with no buttons held', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 0 })) + expect(processMouseMove).not.toHaveBeenCalled() + }) + }) + + describe('pointerup', () => { + it('forwards middle-button pointerup to canvas', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + expect(processMouseUp).toHaveBeenCalledTimes(1) + }) + + it('ignores left-button pointerup', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 })) + expect(processMouseUp).not.toHaveBeenCalled() + }) + + it('ignores right-button pointerup', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 2 })) + expect(processMouseUp).not.toHaveBeenCalled() + }) }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index e6a676b589..2369d502c3 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -1,11 +1,10 @@ -import { isMiddlePointerInput } from '@/base/pointerUtils' -import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback' import { useSettingStore } from '@/platform/settings/settingStore' import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas' import { app } from '@/scripts/app' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -20,12 +19,13 @@ function addMultilineWidget( opts: { defaultVal: string; placeholder?: string } ) { const widgetStore = useWidgetValueStore() + const settingStore = useSettingStore() const inputEl = document.createElement('textarea') inputEl.className = 'comfy-multiline-input' inputEl.dataset.testid = 'dom-widget-textarea' inputEl.value = opts.defaultVal inputEl.placeholder = opts.placeholder || name - inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck') + inputEl.spellcheck = settingStore.get('Comfy.TextareaWidget.Spellcheck') const widget = node.addDOMWidget(name, 'customtext', inputEl, { getValue(): string { @@ -53,101 +53,64 @@ function addMultilineWidget( ) widget.options.minNodeSize = [400, 200] - const controller = new AbortController() - const { signal } = controller + inputEl.addEventListener('input', (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) + }) - inputEl.addEventListener( - 'input', - (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) - }, - { signal } - ) + forwardMiddleButtonToCanvas(inputEl) - inputEl.addEventListener( - 'pointerdown', - (event: PointerEvent) => { - if (isMiddlePointerInput(event)) app.canvas.processMouseDown(event) - }, - { signal } - ) + inputEl.addEventListener('wheel', (event: WheelEvent) => { + const gesturesEnabled = settingStore.get( + 'LiteGraph.Pointer.TrackpadGestures' + ) + const deltaX = event.deltaX + const deltaY = event.deltaY - inputEl.addEventListener( - 'pointermove', - (event: PointerEvent) => { - if (isMiddlePointerInput(event)) app.canvas.processMouseMove(event) - }, - { signal } - ) + const canScrollY = inputEl.scrollHeight > inputEl.clientHeight + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) - inputEl.addEventListener( - 'pointerup', - (event: PointerEvent) => { - if (event.button === 1) app.canvas.processMouseUp(event) - }, - { signal } - ) - - inputEl.addEventListener( - 'wheel', - (event: WheelEvent) => { - const gesturesEnabled = useSettingStore().get( - 'LiteGraph.Pointer.TrackpadGestures' - ) - const deltaX = event.deltaX - const deltaY = event.deltaY - - const canScrollY = inputEl.scrollHeight > inputEl.clientHeight - const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) - - // Prevent pinch zoom from zooming the page - if (event.ctrlKey) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // Detect if this is likely a trackpad gesture vs mouse wheel - // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) - // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) - const isLikelyTrackpad = - Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD - - // Trackpad gestures: when enabled, trackpad panning goes to canvas - if (gesturesEnabled && isLikelyTrackpad) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) - if (isHorizontal) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // Vertical scrolling when gestures disabled: let textarea scroll if scrollable - if (canScrollY) { - event.stopPropagation() - return - } - - // If textarea can't scroll vertically, pass to canvas + // Prevent pinch zoom from zooming the page + if (event.ctrlKey) { event.preventDefault() + event.stopPropagation() app.canvas.processMouseWheel(event) - }, - { signal } - ) + return + } - widget.onRemove = useChainCallback(widget.onRemove, () => { - controller.abort() + // Detect if this is likely a trackpad gesture vs mouse wheel + // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) + // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) + const isLikelyTrackpad = + Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD + + // Trackpad gestures: when enabled, trackpad panning goes to canvas + if (gesturesEnabled && isLikelyTrackpad) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) + if (isHorizontal) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Vertical scrolling when gestures disabled: let textarea scroll if scrollable + if (canScrollY) { + event.stopPropagation() + return + } + + // If textarea can't scroll vertically, pass to canvas + event.preventDefault() + app.canvas.processMouseWheel(event) }) return widget diff --git a/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.test.ts b/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.test.ts new file mode 100644 index 0000000000..4dc303bba1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas' + +const processMouseDown = vi.fn() +const processMouseMove = vi.fn() +const processMouseUp = vi.fn() + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { + processMouseDown: (e: Event) => processMouseDown(e), + processMouseMove: (e: Event) => processMouseMove(e), + processMouseUp: (e: Event) => processMouseUp(e) + } + } +})) + +describe('forwardMiddleButtonToCanvas', () => { + let inputEl: HTMLElement + + beforeEach(() => { + vi.clearAllMocks() + inputEl = document.createElement('div') + forwardMiddleButtonToCanvas(inputEl) + }) + + describe('pointerdown — strict semantic', () => { + it('forwards a middle-button pointerdown', () => { + inputEl.dispatchEvent( + new PointerEvent('pointerdown', { button: 1, buttons: 4 }) + ) + expect(processMouseDown).toHaveBeenCalledTimes(1) + }) + + it('does NOT forward a chorded pointerdown (left pressed while middle held)', () => { + // button=0 (left), buttons=5 (middle + left). Strict semantics on + // pointerdown must reject — user is left-clicking, not middle-clicking. + inputEl.dispatchEvent( + new PointerEvent('pointerdown', { button: 0, buttons: 5 }) + ) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('does NOT forward a left pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + + it('does NOT forward a right pointerdown', () => { + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + expect(processMouseDown).not.toHaveBeenCalled() + }) + }) + + describe('pointermove — held/bitmask semantic', () => { + it('forwards a middle-only pointermove', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) + + it('forwards a pointermove when middle is chorded with left (buttons=5)', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) + + it('forwards a pointermove when middle is chorded with right (buttons=6)', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 6 })) + expect(processMouseMove).toHaveBeenCalledTimes(1) + }) + + it('does NOT forward a pointermove with no middle bit held', () => { + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 1 })) + expect(processMouseMove).not.toHaveBeenCalled() + }) + }) + + describe('pointerup — button field semantic', () => { + it('forwards a middle-button pointerup even if buttons is already 0', () => { + inputEl.dispatchEvent( + new PointerEvent('pointerup', { button: 1, buttons: 0 }) + ) + expect(processMouseUp).toHaveBeenCalledTimes(1) + }) + + it('does NOT forward a left pointerup', () => { + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 0 })) + expect(processMouseUp).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.ts b/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.ts new file mode 100644 index 0000000000..d0808d6d5c --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.ts @@ -0,0 +1,39 @@ +import { isMiddleForPointerEvent } from '@/base/pointerUtils' +import { app } from '@/scripts/app' + +/** + * Wires pointerdown / pointermove / pointerup on a DOM widget's input element + * so middle-button gestures pass through to the LGraph canvas instead of being + * swallowed by the widget surface. Consolidates the three-listener trio that + * useStringWidget and useMarkdownWidget would otherwise duplicate. + * + * Each listener routes through {@link isMiddleForPointerEvent} so pointerdown + * gets strict semantics, pointermove survives chorded buttons via the held + * bitmask, and pointerup uses the `button` field after release. + * + * No explicit cleanup is returned: the three listeners are attached directly + * to the widget-owned input element and only capture `app.canvas` (a + * singleton). When the widget's DOM element is detached and GC'd, the + * listeners go with it. If a future widget lifecycle ever rebinds the same + * element across instances, this will need to grow a disposer — for now, + * simpler is better. + */ +export function forwardMiddleButtonToCanvas(inputEl: HTMLElement): void { + inputEl.addEventListener('pointerdown', (event: PointerEvent) => { + if (isMiddleForPointerEvent(event)) { + app.canvas.processMouseDown(event) + } + }) + + inputEl.addEventListener('pointermove', (event: PointerEvent) => { + if (isMiddleForPointerEvent(event)) { + app.canvas.processMouseMove(event) + } + }) + + inputEl.addEventListener('pointerup', (event: PointerEvent) => { + if (isMiddleForPointerEvent(event)) { + app.canvas.processMouseUp(event) + } + }) +}