Compare commits

...

12 Commits

Author SHA1 Message Date
jaeone94
1288e43b38 test: cover MMB widget patch paths 2026-05-02 17:47:28 +09:00
jaeone94
cea5f0b721 fix: restore ghost placement cancellation handling 2026-05-02 17:17:50 +09:00
jaeone94
dfdb51b9de test: restore note nodes fixture position 2026-05-02 17:11:38 +09:00
jaeone94
0661283bd2 test: remove unused DOM widget mock helper 2026-05-02 16:55:08 +09:00
jaeone94
47fc43afd2 fix: restore MMB follow-up refinements 2026-05-02 16:55:08 +09:00
jaeone94
b47a597756 test: restore MMB screenshot expectations 2026-05-02 16:55:08 +09:00
jaeone94
366d5d4175 test: stabilize MMB screenshot tests 2026-05-02 16:55:08 +09:00
Glary-Bot
7f887558da fix: use .widget-markdown selector for Vue Nodes mode
.comfy-markdown is the DOM widget class from useMarkdownWidget.ts.
In Vue Nodes mode, WidgetMarkdown.vue renders with .widget-markdown instead.
2026-05-02 07:37:21 +00:00
Glary-Bot
80dcb96032 fix: add toBeVisible check before boundingBox in textarea MMB test 2026-05-02 06:44:07 +00:00
Glary-Bot
6d6bd8aced test: rewrite 5 MMB browser tests per reviewer feedback
- Add middleClickDrag helper to CanvasHelper for simple MMB drag
- pan.spec.ts: use snapshot assertion + middleClickDrag helper
- multilineStringWidget.spec.ts: use snapshots, remove getCanvasOffset helper
- maskEditor.spec.ts: use dialog screenshot via expectScreenshot
- Move slot auto-node test to dedicated slotAutoNode.spec.ts with
  type guards, beforeEach/afterEach, and getConnectionPos API
2026-05-02 06:44:06 +00:00
Glary-Bot
7b4096e8d9 fix: use e.button === 1 for pointerup handlers instead of isMiddlePointerInput
On pointerup, e.buttons reflects post-release state. Releasing LEFT while
MIDDLE is held gives e.buttons=4, causing isMiddlePointerInput to
false-positive. e.button is authoritative on up events, so use it directly
in all three pointerup sites: LGraphCanvas.processMouseUp, useStringWidget,
useMarkdownWidget.
2026-05-02 06:43:26 +00:00
Glary-Bot
599e864d42 refactor: standardize MMB detection on isMiddlePointerInput utility
- Remove dead code: redundant event.buttons === 4 check in
  useCanvasInteractions.ts (already handled by isMiddlePointerInput)
- Refactor 7 ad-hoc MMB detection sites to use centralized utility:
  useToolManager, useStringWidget, useMarkdownWidget, LGraphCanvas (3
  sites), InputIndicators, useTransformSettling
- Add 5 Playwright tests for previously untested MMB features:
  middle-click slot auto-node creation, mask editor MMB pan,
  string/markdown widget MMB pass-through, Vue node MMB forwarding
2026-05-02 06:42:31 +00:00
31 changed files with 1742 additions and 501 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

@@ -264,6 +264,18 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async middleClickDrag(
from: { x: number; y: number },
to: { x: number; y: number },
options?: { steps?: number }
): Promise<void> {
const { steps = 10 } = options ?? {}
await this.page.mouse.move(from.x, from.y)
await this.page.mouse.down({ button: 'middle' })
await this.page.mouse.move(to.x, to.y, { steps })
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {

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'
)
}
@@ -176,14 +159,33 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test(
'Middle-click drag should pan the mask editor canvas',
{ tag: ['@screenshot', '@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await comfyMouse.mmbDragFromCenter(
dialog.canvasContainer,
{ dx: 140, dy: 90 },
{ steps: 10 }
)
await dialog.hideBrushCursor()
await comfyPage.expectScreenshot(
dialog.canvasContainer,
'mask-editor-paned-with-mmb.png'
)
}
)
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
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)
})
@@ -193,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))
@@ -212,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)
})
@@ -224,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 }) => {
@@ -237,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))
@@ -251,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)
})
@@ -263,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)
}
)
@@ -283,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 ({
@@ -305,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 }) => {
@@ -346,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 }) => {
@@ -370,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(
@@ -389,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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Slot Auto Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
true
)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
false
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Middle-click on output slot should create default node', async ({
comfyPage
}) => {
const [nodeRef] =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
nodeRef,
'Expected CLIPTextEncode node in default workflow'
).toBeTruthy()
if (!nodeRef)
throw new Error('Expected CLIPTextEncode node in default workflow')
const slotPos = await comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph!.getNodeById(targetNodeId)
if (!node) return null
const pos = node.getConnectionPos(false, 0)
return window.app!.canvasPosToClientPos(pos)
}, nodeRef.id)
if (!slotPos) throw new Error('Could not resolve output slot position')
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.page.mouse.click(slotPos[0], slotPos[1], {
button: 'middle'
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(initialNodeCount)
})
})

View File

@@ -17,4 +17,23 @@ test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
)
}
)
test(
'Middle-click drag on node should pan canvas',
{ tag: ['@screenshot', '@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const node = comfyPage.vueNodes
.getNodeByTitle('CLIP Text Encode (Prompt)')
.first()
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

@@ -54,4 +54,40 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
test(
'Middle-click drag on textarea should pan canvas',
{ tag: ['@screenshot', '@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
await comfyMouse.mmbDragFromCenter(
textarea,
{ dx: 120, dy: 80 },
{ steps: 10 }
)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-paned-with-mmb-over-textarea.png'
)
}
)
test(
'Middle-click drag on markdown widget should pan canvas',
{ tag: ['@screenshot', '@canvas'] },
async ({ comfyPage, comfyMouse }) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
const markdownWidget = comfyPage.page.locator('.widget-markdown').first()
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,274 @@
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)
})
it('returns false when the event has no button state', () => {
const event = {} as MouseEvent
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)
})
it('returns false when the event has no buttons bitmask', () => {
const event = {} as MouseEvent
expect(isMiddleButtonHeld(event)).toBe(false)
})
})
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

@@ -11,6 +11,7 @@ import { useCanvasTools } from './useCanvasTools'
import { useCoordinateTransform } from './useCoordinateTransform'
import type { useKeyboard } from './useKeyboard'
import type { usePanAndZoom } from './usePanAndZoom'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { app } from '@/scripts/app'
export function useToolManager(
@@ -110,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
@@ -118,18 +128,14 @@ export function useToolManager(
panZoom.addPenPointerId(event.pointerId)
}
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (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
}
@@ -166,7 +172,6 @@ export function useToolManager(
if ([0, 2].includes(event.button) && isDrawingTool) {
await brushDrawing.startDrawing(event)
return
}
}
@@ -177,9 +182,7 @@ export function useToolManager(
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
if (shouldPan(event)) {
await panZoom.handlePanMove(event)
return
}
@@ -204,7 +207,6 @@ export function useToolManager(
if (event.buttons === 1 || event.buttons === 2) {
await brushDrawing.handleDrawing(event)
return
}
}

View File

@@ -1,6 +1,7 @@
import { toString } from 'es-toolkit/compat'
import { toValue } from 'vue'
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'
@@ -1860,7 +1861,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()
@@ -1973,7 +1973,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** Prevents default for middle-click auxclick only. */
_preventMiddleAuxClick(e: MouseEvent): void {
if (e.button === 1) 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. */
@@ -2299,7 +2303,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// left button mouse / single finger
if (e.button === 0 && !pointer.isDouble) {
this._processPrimaryButton(e, node)
} else if (e.button === 1) {
} else if (isMiddlePointerInput(e)) {
this._processMiddleButton(e, node)
} else if (
(e.button === 2 || pointer.isDouble) &&
@@ -3666,7 +3670,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()
@@ -3709,13 +3712,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
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()
@@ -3848,8 +3849,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this
)
}
} else if (e.button === 1) {
// middle button
} else if (isMiddleButtonEvent(e)) {
this.dirty_canvas = true
this.dragging_canvas = false
} else if (e.button === 2) {
@@ -3943,6 +3943,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,3 +1,4 @@
import { isMiddleButtonHeld } from '@/base/pointerUtils'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
/**
@@ -5,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()
* ```
@@ -71,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 = (e.buttons & 4) === 4
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
}
@@ -81,15 +85,11 @@ export function useCanvasInteractions() {
const canvas = getCanvas()
if (!canvas) return
// Check conditions for forwarding events to canvas
const isSpacePanningDrag = canvas.read_only && event.buttons === 1 // Space key pressed + left mouse drag
const isMiddleMousePanning = event.buttons === 4 // Middle mouse button for panning
if (isSpacePanningDrag || isMiddleMousePanning) {
// Space+left-drag panning (read_only is set while space is held)
if (canvas.read_only && event.buttons === 1) {
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,6 @@
import { useDebounceFn, useEventListener } from '@vueuse/core'
import { useEventListener, useTimeoutFn } from '@vueuse/core'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { ref } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
@@ -50,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 }
@@ -84,8 +94,7 @@ function usePointerDrag(
target,
'pointerdown',
(e: PointerEvent) => {
// Only primary (0) and middle (1) buttons trigger canvas pan.
if (e.button === 0 || e.button === 1) pointerCount.value++
if (e.button === 0 || isMiddlePointerInput(e)) pointerCount.value++
},
eventOptions
)

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

@@ -3,18 +3,6 @@ import { vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
interface FakeDOMWidget {
name: string
type: string
element: HTMLElement
options: Record<string, unknown>
value: string
callback?: (value: string) => void
onRemove?: () => void
serialize?: boolean
serializeValue?: () => unknown
}
interface FakeMediaWidget {
name: string
element: HTMLElement
@@ -26,26 +14,6 @@ interface FakeMediaWidget {
type NodeOverrides = Record<string, unknown> & { widgets?: never }
export function createMockDOMWidgetNode(overrides: NodeOverrides = {}) {
const widgets: FakeDOMWidget[] = []
return fromAny<LGraphNode & { widgets: FakeDOMWidget[] }, unknown>({
id: 1,
widgets,
addDOMWidget: vi.fn((name: string, type: string, element: HTMLElement) => {
const widget: FakeDOMWidget = {
name,
type,
element,
options: {},
value: ''
}
widgets.push(widget)
return widget
}),
...overrides
})
}
export function createMockMediaNode(overrides: NodeOverrides = {}) {
const widgets: FakeMediaWidget[] = []
return fromAny<LGraphNode & { widgets: FakeMediaWidget[] }, unknown>({

View File

@@ -1,116 +1,276 @@
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()
type TestWidget = {
element: HTMLElement
options: {
getValue?: () => string
setValue?: (value: string) => void
minNodeSize?: [number, number]
}
}))
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 })
}))
function createMarkdownWidget(node: LGraphNode) {
const inputSpec: InputSpec = {
type: 'MARKDOWN',
name: 'note',
default: ''
}
return useMarkdownWidget()(node, inputSpec) as DOMWidget<HTMLElement, string>
value: string
callback: ReturnType<typeof vi.fn>
}
describe('useMarkdownWidget', () => {
function setup() {
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()
})
return { widget, inputEl, textarea, callback, parentKeydown }
const processMouseDown = vi.fn()
const processMouseMove = vi.fn()
const processMouseUp = vi.fn()
let widgetState: { value: unknown } | undefined
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
processMouseDown: (e: Event) => processMouseDown(e),
processMouseMove: (e: Event) => processMouseMove(e),
processMouseUp: (e: Event) => processMouseUp(e)
},
rootGraph: { id: 'root' }
}
}))
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)
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: () => widgetState
})
}))
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LitegraphModule>()
return {
...actual,
resolveNodeRootGraphId: () => 'root'
}
})
function resetMocks() {
vi.clearAllMocks()
widgetState = undefined
}
function createNodeMock(): {
node: LGraphNode
getInputEl: () => HTMLElement
getTextarea: () => HTMLTextAreaElement
getWidget: () => TestWidget
} {
let capturedEl: HTMLElement | undefined
let capturedWidget: TestWidget | undefined
const node = {
id: 1,
addDOMWidget: vi.fn(
(
_name: string,
_type: string,
el: HTMLElement,
options: TestWidget['options']
) => {
capturedEl = el
capturedWidget = {
element: el,
options,
value: '',
callback: vi.fn()
}
return capturedWidget
}
)
} as unknown as LGraphNode
return {
node,
getInputEl: () => {
if (!capturedEl) throw new Error('addDOMWidget was not invoked')
return capturedEl
},
getTextarea: () => {
const textarea = capturedEl?.querySelector('textarea')
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('Markdown textarea was not created')
}
return textarea
},
getWidget: () => {
if (!capturedWidget) throw new Error('addDOMWidget was not invoked')
return capturedWidget
}
}
}
const markdownInputSpec: InputSpec = {
type: 'STRING',
name: 'text',
default: ''
} as InputSpec
describe('useMarkdownWidget', () => {
beforeEach(resetMocks)
it('syncs DOM widget value with widget state when available', () => {
widgetState = { value: 'stored' }
const { node, getTextarea, getWidget } = createNodeMock()
useMarkdownWidget()(node, markdownInputSpec)
const textarea = getTextarea()
const widget = getWidget()
expect(widget.options.getValue?.()).toBe('stored')
widget.options.setValue?.('updated')
expect(textarea.value).toBe('updated')
expect(widgetState.value).toBe('updated')
})
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)
it('falls back to textarea value when no widget state exists', () => {
const { node, getTextarea, getWidget } = createNodeMock()
textarea.dispatchEvent(new Event('blur'))
expect(inputEl.classList.contains('editing')).toBe(false)
useMarkdownWidget()(node, markdownInputSpec)
const textarea = getTextarea()
const widget = getWidget()
textarea.value = 'typed'
inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }))
expect(parentKeydown).not.toHaveBeenCalled()
expect(widget.options.getValue?.()).toBe('typed')
})
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 }))
it('updates widget value and invokes callback on markdown input', () => {
const { node, getTextarea, getWidget } = createNodeMock()
expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1)
expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1)
expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1)
useMarkdownWidget()(node, markdownInputSpec)
const textarea = getTextarea()
const widget = getWidget()
textarea.value = 'typed'
textarea.dispatchEvent(
new InputEvent('input', { bubbles: true, inputType: 'insertText' })
)
expect(widget.value).toBe('typed')
expect(widget.callback).toHaveBeenCalledWith('typed')
})
it('detaches every listener and lets keydown bubble after removal', () => {
const { widget, inputEl, textarea, callback, parentKeydown } = setup()
widget.onRemove?.()
it('toggles editing state around double-click and blur', () => {
vi.useFakeTimers()
const { node, getInputEl, getTextarea } = createNodeMock()
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 }))
try {
useMarkdownWidget()(node, markdownInputSpec)
const inputEl = getInputEl()
const textarea = getTextarea()
const focusSpy = vi.spyOn(textarea, 'focus')
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)
inputEl.dispatchEvent(new MouseEvent('dblclick'))
vi.runAllTimers()
expect(inputEl.classList.contains('editing')).toBe(true)
expect(focusSpy).toHaveBeenCalled()
textarea.dispatchEvent(new FocusEvent('blur'))
expect(inputEl.classList.contains('editing')).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('survives onRemove being invoked twice', () => {
const { widget } = setup()
widget.onRemove?.()
expect(() => widget.onRemove?.()).not.toThrow()
it('updates rendered content and invokes callback on textarea change', () => {
const { node, getTextarea, getWidget } = createNodeMock()
useMarkdownWidget()(node, markdownInputSpec)
const textarea = getTextarea()
const widget = getWidget()
textarea.value = '# heading'
textarea.dispatchEvent(new Event('change'))
expect(widget.callback).toHaveBeenCalledWith(widget.value)
})
it('stops keydown events inside the markdown widget', () => {
const { node, getInputEl } = createNodeMock()
useMarkdownWidget()(node, markdownInputSpec)
const inputEl = getInputEl()
const event = new KeyboardEvent('keydown', { bubbles: true })
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
inputEl.dispatchEvent(event)
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('useMarkdownWidget pointer handlers', () => {
let inputEl: HTMLElement
beforeEach(() => {
resetMocks()
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)
})
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()
})
})
describe('pointermove', () => {
it('forwards pointermove while middle is the only held button', () => {
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
expect(processMouseMove).toHaveBeenCalledTimes(1)
})
it('forwards pointermove when middle is held chorded with left', () => {
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
expect(processMouseMove).toHaveBeenCalledTimes(1)
})
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()
})
})
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

@@ -7,9 +7,9 @@ import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
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'
@@ -65,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 (event.button === 1) app.canvas.processMouseDown(event)
},
{ signal }
)
inputEl.addEventListener(
'pointermove',
(event) => {
if ((event.buttons & 4) === 4) 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,380 @@
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()
type TestWidget = {
element: HTMLTextAreaElement
options: {
getValue?: () => string
setValue?: (value: string) => void
minNodeSize?: [number, number]
}
}))
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 })
}))
function createStringWidget(node: LGraphNode) {
const inputSpec: InputSpec = {
type: 'STRING',
name: 'prompt',
default: '',
multiline: true
}
return useStringWidget()(node, inputSpec) as DOMWidget<
HTMLTextAreaElement,
string
>
value: string
callback: ReturnType<typeof vi.fn>
dynamicPrompts?: boolean
}
describe('useStringWidget (multiline)', () => {
function setup() {
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 }
const processMouseDown = vi.fn()
const processMouseMove = vi.fn()
const processMouseUp = vi.fn()
const processMouseWheel = vi.fn()
const settings = new Map<string, boolean>()
let widgetState: { value: unknown } | undefined
vi.mock('@/scripts/app', () => ({
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' }
}
}))
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)
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings.get(key)
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: () => widgetState
})
}))
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 resetMocks() {
vi.clearAllMocks()
settings.clear()
settings.set('Comfy.TextareaWidget.Spellcheck', false)
settings.set('LiteGraph.Pointer.TrackpadGestures', false)
widgetState = undefined
}
function createNodeMock(): {
node: LGraphNode
getInputEl: () => HTMLTextAreaElement
getWidget: () => TestWidget
addWidget: ReturnType<typeof vi.fn>
} {
let capturedEl: HTMLTextAreaElement | undefined
let capturedWidget: TestWidget | undefined
const addWidget = vi.fn(
(
_type: string,
_name: string,
value: string,
_callback: () => void,
_options: object
) => ({
value,
options: {}
})
)
const node = {
id: 1,
addWidget,
addDOMWidget: vi.fn(
(
_name: string,
_type: string,
el: HTMLTextAreaElement,
options: TestWidget['options']
) => {
capturedEl = el
capturedWidget = {
element: el,
options,
value: '',
callback: vi.fn()
}
return capturedWidget
}
)
} as unknown as LGraphNode
return {
node,
getInputEl: () => {
if (!capturedEl) throw new Error('addDOMWidget was not invoked')
return capturedEl
},
getWidget: () => {
if (!capturedWidget) throw new Error('addDOMWidget was not invoked')
return capturedWidget
},
addWidget
}
}
function setScrollMetrics(
inputEl: HTMLTextAreaElement,
metrics: { scrollHeight: number; clientHeight: number }
) {
Object.defineProperties(inputEl, {
scrollHeight: { configurable: true, value: metrics.scrollHeight },
clientHeight: { configurable: true, value: metrics.clientHeight }
})
}
function dispatchWheel(
inputEl: HTMLTextAreaElement,
init: WheelEventInit
): WheelEvent {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
...init
})
inputEl.dispatchEvent(event)
return event
}
function expectWheelForwarded(event: WheelEvent) {
expect(event.defaultPrevented).toBe(true)
expect(processMouseWheel).toHaveBeenCalledTimes(1)
}
const multilineInputSpec: InputSpec = {
type: 'STRING',
name: 'text',
multiline: true,
default: ''
} as InputSpec
describe('useStringWidget', () => {
beforeEach(resetMocks)
it('creates a single-line text widget for non-multiline inputs', () => {
const { node, addWidget } = createNodeMock()
const widget = useStringWidget()(node, {
type: 'STRING',
name: 'text',
default: 'hello'
} as InputSpec)
expect(addWidget).toHaveBeenCalledWith(
'text',
'text',
'hello',
expect.any(Function),
{}
)
expect(widget.value).toBe('hello')
})
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 }))
it('copies dynamic prompt metadata when present', () => {
const { node } = createNodeMock()
expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1)
expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1)
expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1)
expect(canvasMock.processMouseWheel).toHaveBeenCalledTimes(1)
const widget = useStringWidget()(node, {
type: 'STRING',
name: 'text',
default: 'hello',
dynamicPrompts: true
} as InputSpec)
expect(widget.dynamicPrompts).toBe(true)
})
it('detaches every listener when the widget is removed', () => {
const { widget, inputEl, callback } = setup()
widget.onRemove?.()
it('throws for non-string input specs', () => {
const { node } = createNodeMock()
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 }))
expect(() =>
useStringWidget()(node, {
type: 'INT',
name: 'text'
} as InputSpec)
).toThrow('Invalid input data')
})
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('syncs multiline DOM widget value with widget state when available', () => {
widgetState = { value: 'stored' }
const { node, getInputEl, getWidget } = createNodeMock()
useStringWidget()(node, multilineInputSpec)
const inputEl = getInputEl()
const widget = getWidget()
expect(widget.options.getValue?.()).toBe('stored')
widget.options.setValue?.('updated')
expect(inputEl.value).toBe('updated')
expect(widgetState.value).toBe('updated')
})
it('falls back to textarea value when no widget state exists', () => {
const { node, getInputEl, getWidget } = createNodeMock()
useStringWidget()(node, multilineInputSpec)
const inputEl = getInputEl()
const widget = getWidget()
inputEl.value = 'typed'
expect(widget.options.getValue?.()).toBe('typed')
})
it('updates widget value and invokes callback on textarea input', () => {
const { node, getInputEl, getWidget } = createNodeMock()
useStringWidget()(node, multilineInputSpec)
const inputEl = getInputEl()
const widget = getWidget()
inputEl.value = 'typed'
inputEl.dispatchEvent(new InputEvent('input', { bubbles: true }))
expect(widget.value).toBe('typed')
expect(widget.callback).toHaveBeenCalledWith('typed')
})
})
describe('useStringWidget wheel handling', () => {
let inputEl: HTMLTextAreaElement
beforeEach(() => {
resetMocks()
const { node, getInputEl } = createNodeMock()
useStringWidget()(node, multilineInputSpec)
inputEl = getInputEl()
setScrollMetrics(inputEl, { scrollHeight: 100, clientHeight: 100 })
})
it('forwards ctrl-wheel pinch gestures to the canvas', () => {
const event = dispatchWheel(inputEl, { ctrlKey: true, deltaY: 10 })
expectWheelForwarded(event)
})
it('forwards likely trackpad gestures when trackpad gestures are enabled', () => {
settings.set('LiteGraph.Pointer.TrackpadGestures', true)
const event = dispatchWheel(inputEl, { deltaY: 10 })
expectWheelForwarded(event)
})
it('forwards horizontal wheel gestures to the canvas', () => {
const event = dispatchWheel(inputEl, { deltaX: 120, deltaY: 10 })
expectWheelForwarded(event)
})
it('keeps vertical wheel events inside a scrollable textarea', () => {
setScrollMetrics(inputEl, { scrollHeight: 200, clientHeight: 100 })
const event = dispatchWheel(inputEl, { deltaY: 120 })
expect(event.defaultPrevented).toBe(false)
expect(processMouseWheel).not.toHaveBeenCalled()
})
it('forwards vertical wheel events when the textarea cannot scroll', () => {
const event = dispatchWheel(inputEl, { deltaY: 120 })
expectWheelForwarded(event)
})
})
describe('useStringWidget multiline pointer handlers', () => {
let inputEl: HTMLTextAreaElement
beforeEach(() => {
resetMocks()
const { node, getInputEl } = createNodeMock()
useStringWidget()(node, multilineInputSpec)
inputEl = getInputEl()
})
describe('pointerdown', () => {
it('forwards middle-button pointerdown to canvas', () => {
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
expect(processMouseDown).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', () => {
inputEl.dispatchEvent(
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
)
expect(processMouseDown).not.toHaveBeenCalled()
})
})
describe('pointermove', () => {
it('forwards pointermove while middle is the only held button', () => {
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
expect(processMouseMove).toHaveBeenCalledTimes(1)
})
it('forwards pointermove when middle is held chorded with left', () => {
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
expect(processMouseMove).toHaveBeenCalledTimes(1)
})
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,10 +1,10 @@
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'
@@ -19,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 {
@@ -52,102 +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)
// Allow middle mouse button panning
inputEl.addEventListener(
'pointerdown',
(event: PointerEvent) => {
if (event.button === 1) 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 ((event.buttons & 4) === 4) 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)
}
})
}