fix: restore MMB follow-up refinements

This commit is contained in:
jaeone94
2026-05-02 16:52:17 +09:00
parent b47a597756
commit 47fc43afd2
24 changed files with 1273 additions and 541 deletions

View File

@@ -66,6 +66,45 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
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<DragOptions, 'button'> = {}
) {
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<DragOptions, 'button'> = {}
) {
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)

View File

@@ -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<void> {
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()
}
}

View File

@@ -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<MaskEditorDialog> {
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<typeof page.locator>
) {
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<typeof comfyPage.page.locator>
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)

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -8,6 +8,11 @@
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<!-- Native drag area for Electron -->
<div
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
/>
<div
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
>
@@ -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

View File

@@ -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 === ' ')

View File

@@ -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<void> => {
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
}
}

View File

@@ -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<LGraphCanvasEventMap>
private _visibleReroutes: Set<Reroute> = 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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
/** 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<LGraphCanvasEventMap>
* @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<LGraphCanvasEventMap>
'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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
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

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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<PointerEvent> = {
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()

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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<typeof Litegraph>()
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<typeof LitegraphModule>()
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<HTMLElement, string>
}
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()
})
})
})

View File

@@ -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
}

View File

@@ -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<typeof Litegraph>()
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<typeof LitegraphModule>()
return {
...actual,
resolveNodeRootGraphId: () => 'root'
}
})
vi.mock('@/lib/litegraph/src/utils/feedback', async (importOriginal) => {
const actual = await importOriginal<typeof FeedbackModule>()
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()
})
})
})

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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)
}
})
}