mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
## Summary Refactors middle mouse button pan handling around the intent of #11409, dropping the outdated implementation details from that PR and aligning the core behavior with the current main branch. ## Changes - **What**: Centralized phase-specific middle mouse button handling in `src/base/pointerUtils.ts`, added a shared Vue widget forwarding helper, and updated canvas, LiteGraph, Vue node, and mask editor call sites to use the same semantics. - **Breaking**: None expected. This keeps existing middle-click pan behavior while making pointerdown, pointermove, pointerup, and auxclick checks explicit for their event phases. - **Dependencies**: None. ## Review Focus This PR is intentionally narrower than #11409. That PR had the right goal, but its implementation became outdated against main: mask editor tests now have helper coverage on main, Vue node/widget code has shifted, and a blanket replacement with `isMiddlePointerInput` would lose the bitmask behavior needed during pointermove drags. The core difference is that this PR preserves the useful part of #11409, namely removing scattered ad-hoc MMB checks, while avoiding stale changes that no longer fit the current codebase. Key behavior changes: - `isMiddlePointerInput` is the conservative pointerdown-style check: changed middle button or strict middle-only `buttons === 4`. - `isMiddleButtonHeld` handles pointermove-style held-button bitmasks so chorded drags with the middle button still pan. - `isMiddleButtonEvent` handles pointerup/auxclick-style changed-button events. - Call sites now choose the phase-specific helper directly instead of routing through an event-type dispatcher. - String and markdown widgets now share `forwardMiddleButtonToCanvas(...)` instead of duplicating three pointer listeners each. - The widget helper intentionally keeps the existing `app.canvas.processMouseDown/Move/Up` forwarding route and only centralizes the duplicated listener logic. - Mask editor pan handling, Vue node pointer forwarding, graph canvas pan forwarding, LiteGraph middle-click checks, input indicators, and transform settling now use the centralized helpers. Coverage added or updated: - Unit coverage for middle-button helper semantics, including chorded pointermove drags and pointercancel held-bit behavior. - Unit coverage for widget forwarding helper down/move/up routing. - Regression coverage for canvas, mask editor, Vue node media preview, and transform-settling pointer handling. - Browser coverage for middle-click drag panning on a Vue node, a multiline string widget, and the mask editor canvas. Validation run: - `pnpm format` - `pnpm lint` - `pnpm typecheck` - `pnpm test:unit src/base/pointerUtils.test.ts src/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas.test.ts src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts src/renderer/core/canvas/useCanvasInteractions.test.ts src/composables/maskeditor/useToolManager.test.ts src/renderer/core/layout/transform/useTransformSettling.test.ts src/composables/node/useNodeImage.test.ts src/composables/node/useNodeAnimatedImage.test.ts src/components/graph/SelectionToolbox.test.ts src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts` - `pnpm typecheck:browser` - `pnpm test:browser:local browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts browser_tests/tests/maskEditor.spec.ts --project chromium --grep "Middle-click drag"` - Commit hook: staged file format/lint, `pnpm typecheck` ## Screenshots (if applicable) Not applicable; this is interaction behavior covered by unit and browser tests.
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import { expect, mergeTests } from '@playwright/test'
|
|
|
|
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
|
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
|
import { webSocketFixture } from '@e2e/fixtures/ws'
|
|
|
|
const wstest = mergeTests(test, webSocketFixture)
|
|
|
|
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
|
test(
|
|
'opens mask editor from image preview button',
|
|
{ tag: ['@smoke', '@screenshot'] },
|
|
async ({ comfyPage, maskEditor }) => {
|
|
const { imagePreview } = await maskEditor.loadImageOnNode()
|
|
|
|
// Hover over the image panel to reveal action buttons
|
|
await imagePreview.getByRole('region').hover()
|
|
await comfyPage.page.getByLabel('Edit or mask image').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)
|
|
|
|
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')
|
|
}
|
|
)
|
|
|
|
test(
|
|
'opens mask editor from context menu',
|
|
{ tag: ['@smoke', '@screenshot'] },
|
|
async ({ comfyPage, maskEditor }) => {
|
|
const { nodeId } = await maskEditor.loadImageOnNode()
|
|
|
|
const nodeHeader = comfyPage.vueNodes
|
|
.getNodeLocator(nodeId)
|
|
.locator('.lg-node-header')
|
|
await nodeHeader.click()
|
|
await nodeHeader.click({ button: 'right' })
|
|
|
|
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
|
await expect(contextMenu).toBeVisible()
|
|
|
|
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()
|
|
|
|
await comfyPage.expectScreenshot(
|
|
dialog,
|
|
'mask-editor-dialog-from-context-menu.png'
|
|
)
|
|
}
|
|
)
|
|
|
|
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
|
expect(dataBefore).not.toBeNull()
|
|
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
})
|
|
|
|
test(
|
|
'Middle-click drag should pan the mask editor canvas',
|
|
{ tag: ['@canvas'] },
|
|
async ({ comfyPage, comfyMouse, maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
const pointerZone = dialog.getByTestId('pointer-zone')
|
|
const getCanvasPosition = () =>
|
|
comfyPage.page.evaluate(() => {
|
|
const container = document.querySelector('#maskEditorCanvasContainer')
|
|
if (!(container instanceof HTMLElement)) return null
|
|
|
|
return {
|
|
left: container.style.left,
|
|
top: container.style.top
|
|
}
|
|
})
|
|
const canvasPositionBefore = await getCanvasPosition()
|
|
|
|
await comfyMouse.middleDragFromCenter(
|
|
pointerZone,
|
|
{ x: 140, y: 90 },
|
|
{ steps: 10 }
|
|
)
|
|
|
|
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
|
|
}
|
|
)
|
|
|
|
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const undoButton = dialog.locator('button[title="Undo"]')
|
|
await expect(undoButton).toBeVisible()
|
|
await undoButton.click()
|
|
|
|
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
|
})
|
|
|
|
test('redo restores an undone stroke', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const undoButton = dialog.locator('button[title="Undo"]')
|
|
await undoButton.click()
|
|
|
|
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
|
|
|
const redoButton = dialog.locator('button[title="Redo"]')
|
|
await expect(redoButton).toBeVisible()
|
|
await redoButton.click()
|
|
|
|
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('clear button removes all mask content', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
|
await expect(clearButton).toBeVisible()
|
|
await clearButton.click()
|
|
|
|
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
|
})
|
|
|
|
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
|
await cancelButton.click()
|
|
|
|
await expect(dialog).toBeHidden()
|
|
})
|
|
|
|
test('invert button inverts the mask', async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
|
expect(dataBefore).not.toBeNull()
|
|
const pixelsBefore = dataBefore!.nonTransparentPixels
|
|
|
|
const invertButton = dialog.getByRole('button', { name: 'Invert' })
|
|
await expect(invertButton).toBeVisible()
|
|
await invertButton.click()
|
|
|
|
await expect
|
|
.poll(() => maskEditor.pollMaskPixelCount())
|
|
.toBeGreaterThan(pixelsBefore)
|
|
})
|
|
|
|
test('keyboard shortcut Ctrl+Z triggers undo', async ({
|
|
comfyPage,
|
|
maskEditor
|
|
}) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
|
await comfyPage.page.keyboard.press(modifier)
|
|
|
|
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
|
})
|
|
|
|
test(
|
|
'tool panel shows all five tools',
|
|
{ tag: ['@smoke'] },
|
|
async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
|
await expect(toolPanel).toBeVisible()
|
|
|
|
// The tool panel should contain exactly 5 tool entries
|
|
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
|
await expect(toolEntries).toHaveCount(5)
|
|
|
|
// First tool (MaskPen) should be selected by default
|
|
const selectedTool = dialog.locator(
|
|
'.maskEditor_toolPanelContainerSelected'
|
|
)
|
|
await expect(selectedTool).toHaveCount(1)
|
|
}
|
|
)
|
|
|
|
test('switching tools updates the selected indicator', async ({
|
|
maskEditor
|
|
}) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
|
await expect(toolEntries).toHaveCount(5)
|
|
|
|
// Click the third tool (Eraser, index 2)
|
|
await toolEntries.nth(2).click()
|
|
|
|
// The third tool should now be selected
|
|
const selectedTool = dialog.locator(
|
|
'.maskEditor_toolPanelContainerSelected'
|
|
)
|
|
await expect(selectedTool).toHaveCount(1)
|
|
|
|
// Verify it's the eraser (3rd entry)
|
|
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
|
|
})
|
|
|
|
test('brush settings panel is visible with thickness controls', async ({
|
|
maskEditor
|
|
}) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
// 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()
|
|
})
|
|
|
|
test('save uploads all layers and closes dialog', async ({
|
|
comfyPage,
|
|
maskEditor
|
|
}) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
let maskUploadCount = 0
|
|
let imageUploadCount = 0
|
|
|
|
await comfyPage.page.route('**/upload/mask', (route) => {
|
|
maskUploadCount++
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
name: `test-mask-${maskUploadCount}.png`,
|
|
subfolder: 'clipspace',
|
|
type: 'input'
|
|
})
|
|
})
|
|
})
|
|
await comfyPage.page.route('**/upload/image', (route) => {
|
|
imageUploadCount++
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
name: `test-image-${imageUploadCount}.png`,
|
|
subfolder: 'clipspace',
|
|
type: 'input'
|
|
})
|
|
})
|
|
})
|
|
|
|
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
|
await expect(saveButton).toBeVisible()
|
|
await saveButton.click()
|
|
|
|
await expect(dialog).toBeHidden()
|
|
|
|
// The save pipeline uploads multiple layers (mask + image variants)
|
|
expect(
|
|
maskUploadCount + imageUploadCount,
|
|
'save should trigger upload calls'
|
|
).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
// Fail all upload routes
|
|
await comfyPage.page.route('**/upload/mask', (route) =>
|
|
route.fulfill({ status: 500 })
|
|
)
|
|
await comfyPage.page.route('**/upload/image', (route) =>
|
|
route.fulfill({ status: 500 })
|
|
)
|
|
|
|
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
|
await saveButton.click()
|
|
|
|
// Dialog should remain open when save fails
|
|
await expect(dialog).toBeVisible()
|
|
})
|
|
|
|
test(
|
|
'eraser tool removes mask content',
|
|
{ tag: ['@screenshot'] },
|
|
async ({ maskEditor }) => {
|
|
const dialog = await maskEditor.openDialog()
|
|
|
|
// Draw a stroke with the mask pen (default tool)
|
|
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
|
|
|
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
|
|
|
|
// Switch to eraser tool (3rd tool, index 2)
|
|
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
|
await toolEntries.nth(2).click()
|
|
|
|
// Draw over the same area with the eraser
|
|
await maskEditor.drawStrokeOnPointerZone(dialog)
|
|
|
|
await expect
|
|
.poll(() => maskEditor.pollMaskPixelCount())
|
|
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
|
}
|
|
)
|
|
})
|
|
|
|
wstest(
|
|
'Will not use stale litegraph previews',
|
|
async ({ comfyPage, getWebSocket }) => {
|
|
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
|
await comfyPage.menu.topbar.newWorkflowButton.click()
|
|
await comfyPage.searchBoxV2.addNode('Preview Image')
|
|
|
|
async function getNodeOutput() {
|
|
return await comfyPage.page.evaluate(
|
|
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
|
)
|
|
}
|
|
|
|
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
|
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
|
await expect.poll(getNodeOutput).toBe('test1.png')
|
|
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
|
|
const resolvableFile = { filename: 'example.png', type: 'input' }
|
|
executionHelper.executed('', '1', { images: [resolvableFile] })
|
|
await expect.poll(getNodeOutput).toBe('example.png')
|
|
|
|
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
|
await node.imagePreview.hover()
|
|
await node.imagePreview
|
|
.getByRole('button', { name: 'Edit or mask image' })
|
|
.click()
|
|
|
|
// On previous versions, attempting to open the mask editor here would
|
|
// incorrectly reference the non-existant test1.png
|
|
// This causes the mask editor to throw in setup and not display
|
|
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
|
}
|
|
)
|