mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
fix: restore MMB follow-up refinements
This commit is contained in:
@@ -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)
|
||||
|
||||
89
browser_tests/fixtures/components/MaskEditorDialog.ts
Normal file
89
browser_tests/fixtures/components/MaskEditorDialog.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
264
src/base/pointerUtils.test.ts
Normal file
264
src/base/pointerUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 === ' ')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
121
src/lib/litegraph/src/canvas/InputIndicators.test.ts
Normal file
121
src/lib/litegraph/src/canvas/InputIndicators.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user