Compare commits

..

4 Commits

Author SHA1 Message Date
Kelly Yang
7b0d59552d test: add E2E tests for ImageCropV2 drag and free resize (PR 1/4)
- Update workflow asset to include LoadImage source node connected to
  ImageCropV2, enabling image injection in tests
- Add injectSourceImage helper that sets preview images via the Pinia
  nodeOutput store, triggering the composable's reactive image URL update
- Add setCropState / getCropState helpers for controlled test setup and
  assertion
- Add dragFrom / getResizeHandle helpers for pointer interaction
- Level 2 tests (drag): moves box, clamps right, clamps top-left,
  no-op when no image
- Level 3 tests (free resize): all 8 handle directions, MIN_CROP_SIZE
  enforcement, boundary clamping, handle count when ratio unlocked
2026-03-29 16:51:56 -07:00
Christian Byrne
367f810702 feat: expose renderMarkdownToHtml on ExtensionManager (#10700)
## Summary

Expose `renderMarkdownToHtml()` on the `ExtensionManager` interface so
custom node extensions can render markdown to sanitized HTML without
bundling their own copies of `marked`/`DOMPurify`.

## Motivation

Multiple custom node packs (KJNodes, comfy_mtb, rgthree-comfy) bundle
their own markdown rendering libraries to implement help popups on
nodes. This causes:

- **Cloud breakage**: KJNodes uses a `kjweb_async` pattern (custom
aiohttp static route) to lazily load `marked.min.js` and
`purify.min.js`. This 404s on Cloud because the custom route is not
registered.
- **Redundant bundling**: Both `marked` (^15.0.11) and `dompurify`
(^3.2.5) are already direct dependencies of the frontend, used
internally by `markdownRendererUtil.ts`, `NodePreview.vue`,
`WhatsNewPopup.vue`, etc.
- **XSS risk**: Custom nodes using raw `marked` without `DOMPurify`
could introduce XSS vulnerabilities.

By exposing the existing `renderMarkdownToHtml()` through the official
`ExtensionManager` API, custom nodes can:
```js
const html = app.extensionManager.renderMarkdownToHtml(nodeData.description)
```
...instead of bundling and loading their own copies.

## Changes

- **`src/types/extensionTypes.ts`**: Add `renderMarkdownToHtml(markdown:
string, baseUrl?: string): string` to the `ExtensionManager` interface
with JSDoc.
- **`src/stores/workspaceStore.ts`**: Import and re-export
`renderMarkdownToHtml` from `@/utils/markdownRendererUtil`.

## Impact

- **Zero bundle size increase** — the function and its dependencies are
already bundled in the `vendor-markdown` chunk.
- **No breaking changes** — purely additive to the `ExtensionManager`
interface.
- **Follows existing pattern** — same approach as `toast`, `dialog`,
`command`, `setting` on `ExtensionManager`.

Related: #TBD (long-term plan for custom node extension library
dependencies)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10700-feat-expose-renderMarkdownToHtml-on-ExtensionManager-3326d73d36508149bc1dc6bb45e7c077)
by [Unito](https://www.unito.io)
2026-03-29 14:51:45 -07:00
Kelly Yang
798f6de4a9 fix: image compare node displays wrong height with mismatched resolut… (#10714)
## Summary

Revert `object-cover` to `object-contain` so images are never cropped
when the container is short, and add imagecompare to `EXPANDING_TYPES`
so the widget row grows to fill the full node body instead of collapsing
to `min-content`.


## Screenshots
before
<img width="2674" height="2390" alt="image"
src="https://github.com/user-attachments/assets/8fa5cf41-f393-4a7d-a767-75ce944d00d4"
/>

after




https://github.com/user-attachments/assets/46e1fffc-5f65-4b69-9303-fe6255d9de79

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10714-fix-image-compare-node-displays-wrong-height-with-mismatched-resolut-3326d73d3650818293d3c716cb8fafb5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-29 14:45:56 -07:00
Terry Jia
752641cc67 chore: add @jtydhr88 as code owner for image crop, image compare, painter, mask editor, and 3D (#10713)
## Summary
add myself as owner to the components I worked on

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10713-chore-add-jtydhr88-as-code-owner-for-image-crop-image-compare-painter-mask-editor--3326d73d365081a5aaedf67168a32c7e)
by [Unito](https://www.unito.io)
2026-03-29 14:45:09 -07:00
17 changed files with 580 additions and 106 deletions

View File

@@ -41,12 +41,46 @@
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata

View File

@@ -0,0 +1,69 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"pos": [400, 50],
"size": [200, 300],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
},
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -101,10 +101,6 @@ export const TestIds = {
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
maskEditor: {
dialog: 'mask-editor-dialog',
uiContainer: 'mask-editor-ui-container'
}
} as const
@@ -131,4 +127,3 @@ export type TestIdValue =
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.maskEditor)[keyof typeof TestIds.maskEditor]

View File

@@ -1,35 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
export async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
export async function openMaskEditorViaCommand(comfyPage: ComfyPage) {
const { nodeId } = await loadImageOnNode(comfyPage)
await comfyPage.vueNodes.selectNode(nodeId)
await comfyPage.command.executeCommand('Comfy.MaskEditor.OpenMaskEditor')
return comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,429 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// ---- Helpers ---------------------------------------------------------------
function createTestImageDataUrl(
width: number,
height: number,
color: string
): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
`<rect width="${width}" height="${height}" fill="${color}"/>` +
`</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
type Bounds = { x: number; y: number; width: number; height: number }
/**
* Injects an image into the node output store for the given source node,
* simulating what happens after a node executes and produces image output.
*/
async function injectSourceImage(
page: Page,
sourceNodeId: number,
dataUrl: string
): Promise<void> {
await page.evaluate(
({ nodeId, url }) => {
type NodeOutputStore = {
setNodePreviewsByNodeId: (id: number, urls: string[]) => void
}
type VueAppElement = HTMLElement & {
__vue_app__: {
config: {
globalProperties: {
$pinia: { _s: Map<string, NodeOutputStore> }
}
}
}
}
const el = document.getElementById('vue-app') as unknown as VueAppElement
const store =
el.__vue_app__.config.globalProperties.$pinia._s.get('nodeOutput')!
store.setNodePreviewsByNodeId(nodeId, [url])
},
{ nodeId: sourceNodeId, url: dataUrl }
)
}
async function setCropState(page: Page, bounds: Bounds): Promise<void> {
await page.evaluate((bounds) => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget?.value) {
const value = widget.value as unknown as BoundsValue
value.x = bounds.x
value.y = bounds.y
value.width = bounds.width
value.height = bounds.height
widget.callback?.(widget.value)
}
}, bounds)
}
async function getCropState(page: Page): Promise<Bounds> {
return page.evaluate(() => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
const v = widget?.value as unknown as BoundsValue
return { x: v.x, y: v.y, width: v.width, height: v.height }
})
}
/**
* Injects a test image into the source node and waits for the crop widget to
* be fully ready: image loaded, scale factor computed, crop box visible.
*/
async function setupWithImage(
comfyPage: ComfyPage,
imageWidth: number,
imageHeight: number,
initialBounds: Bounds
): Promise<void> {
await injectSourceImage(
comfyPage.page,
2,
createTestImageDataUrl(imageWidth, imageHeight, 'steelblue')
)
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toBeVisible()
await comfyPage.page.waitForFunction(() => {
const img = document.querySelector(
'[data-node-id="1"] img'
) as HTMLImageElement | null
return (img?.complete ?? false) && (img?.naturalWidth ?? 0) > 0
})
await setCropState(comfyPage.page, initialBounds)
await comfyPage.nextFrame()
await expect(node.locator('.cursor-move')).toBeVisible()
}
/**
* Returns a locator for one of the 8 resize handles on the crop widget.
* Handles are ordered in DOM as: top, bottom, left, right, nw, ne, sw, se.
*/
function getResizeHandle(
nodeLocator: Locator,
direction: 'top' | 'bottom' | 'left' | 'right' | 'nw' | 'ne' | 'sw' | 'se'
): Locator {
switch (direction) {
case 'top':
return nodeLocator.locator('.cursor-ns-resize').first()
case 'bottom':
return nodeLocator.locator('.cursor-ns-resize').last()
case 'left':
return nodeLocator.locator('.cursor-ew-resize').first()
case 'right':
return nodeLocator.locator('.cursor-ew-resize').last()
case 'nw':
return nodeLocator.locator('.cursor-nwse-resize').first()
case 'se':
return nodeLocator.locator('.cursor-nwse-resize').last()
case 'ne':
return nodeLocator.locator('.cursor-nesw-resize').first()
case 'sw':
return nodeLocator.locator('.cursor-nesw-resize').last()
}
}
async function dragFrom(
page: Page,
locator: Locator,
deltaX: number,
deltaY: number
): Promise<void> {
const box = await locator.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 })
await page.mouse.up()
}
// ---- Tests -----------------------------------------------------------------
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'shows empty state when no input image is available',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node).toContainText('No input image connected')
await expect(node.locator('.cursor-move')).not.toBeVisible()
}
)
test(
'enforces minimum node size of 300×450',
{ tag: '@node' },
async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.size as [number, number]
})
expect(size[0]).toBeGreaterThanOrEqual(300)
expect(size[1]).toBeGreaterThanOrEqual(450)
}
)
test.describe('drag', { tag: '@widget' }, () => {
test('moves the crop box', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 100, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThan(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
expect(state.height).toBe(200)
})
test('clamps to right boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 550,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('clamps to top-left boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 50,
y: 50,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, -500, -500)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThanOrEqual(0)
expect(state.y).toBeGreaterThanOrEqual(0)
})
test('does nothing when no image is loaded', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('.cursor-move')).not.toBeVisible()
const stateBefore = await getCropState(comfyPage.page)
const nodeBox = await node.boundingBox()
if (nodeBox) {
await comfyPage.page.mouse.click(
nodeBox.x + nodeBox.width / 2,
nodeBox.y + nodeBox.height / 2
)
}
await comfyPage.nextFrame()
const stateAfter = await getCropState(comfyPage.page)
expect(stateAfter).toEqual(stateBefore)
})
})
test.describe('free resize', { tag: '@widget' }, () => {
test('right edge increases width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('left edge adjusts x and width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 100,
width: 300,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'left'), -80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.width).toBeGreaterThan(300)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('bottom edge increases height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'bottom'), 0, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
})
test('top edge adjusts y and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'top'), 0, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.y).toBeLessThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.width).toBe(200)
})
test('SE corner increases width and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'se'), 80, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
})
test('NW corner adjusts x, y, width, and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'nw'), -80, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.y).toBeLessThan(200)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
})
test('enforces minimum crop size of 16px', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 50,
height: 50
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far left to try to collapse width below the minimum
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), -500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThanOrEqual(16)
})
test('clamps resize to image boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 600,
y: 100,
width: 100,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far past the image right boundary
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('shows 8 handles when ratio is unlocked', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handles = node.locator(
'.cursor-ns-resize, .cursor-ew-resize, .cursor-nwse-resize, .cursor-nesw-resize'
)
await expect(handles).toHaveCount(8)
})
})
})

View File

@@ -1,17 +1,37 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
loadImageOnNode,
openMaskEditorViaCommand
} from '../helpers/maskEditorTestUtils'
test.describe('Mask Editor', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
@@ -22,7 +42,7 @@ test.describe('Mask Editor', () => {
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
@@ -33,9 +53,7 @@ test.describe('Mask Editor', () => {
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
await expect(
dialog.getByTestId(TestIds.maskEditor.uiContainer)
).toBeVisible()
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
@@ -60,7 +78,7 @@ test.describe('Mask Editor', () => {
await contextMenu.getByText('Open in Mask Editor').click()
const dialog = comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
@@ -71,47 +89,4 @@ test.describe('Mask Editor', () => {
)
}
)
test(
'opens mask editor via command execution',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorViaCommand(comfyPage)
await expect(
dialog.getByTestId(TestIds.maskEditor.uiContainer)
).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await expect(dialog).toHaveScreenshot('mask-editor-open-via-command.png')
}
)
test(
'cancel closes mask editor dialog without uploading',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorViaCommand(comfyPage)
await expect(dialog).toBeVisible()
const uploadRequests: string[] = []
await comfyPage.page.route('**/upload/mask', (route) => {
uploadRequests.push('mask')
return route.continue()
})
await comfyPage.page.route('**/upload/image', (route) => {
uploadRequests.push('image')
return route.continue()
})
await expect(dialog).toHaveScreenshot('mask-editor-before-cancel.png')
await dialog.getByRole('button', { name: /cancel/i }).click()
await expect(dialog).not.toBeVisible()
expect(uploadRequests).toHaveLength(0)
await expect(comfyPage.canvas).toHaveScreenshot(
'mask-editor-cancelled-canvas-state.png'
)
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

View File

@@ -40,10 +40,7 @@
<LoadingOverlay :loading="!initialized" size="sm" />
<div
class="maskEditor-ui-container flex min-h-0 flex-1 flex-col"
data-testid="mask-editor-ui-container"
>
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 overflow-hidden">
<ToolPanel
v-if="initialized"

View File

@@ -29,8 +29,7 @@ export function useMaskEditor() {
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col',
'data-testid': 'mask-editor-dialog'
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'

View File

@@ -58,7 +58,7 @@ describe('WidgetImageCompare Display', () => {
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('object-contain')
})
})
})

View File

@@ -33,7 +33,7 @@
:src="afterImage"
:alt="afterAlt"
draggable="false"
class="absolute inset-0 size-full object-cover"
class="absolute inset-0 size-full object-contain"
/>
<img
@@ -41,7 +41,7 @@
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-cover"
class="absolute inset-0 size-full object-contain"
:style="
hasCompareImages
? { clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }

View File

@@ -233,7 +233,8 @@ const EXPANDING_TYPES = [
'markdown',
'load3D',
'curve',
'painter'
'painter',
'imagecompare'
] as const
export function shouldExpand(type: string): boolean {

View File

@@ -9,6 +9,7 @@ import type { Settings } from '@/schemas/apiSchema'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useDialogService } from '@/services/dialogService'
import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { useApiKeyAuthStore } from './apiKeyAuthStore'
import { useCommandStore } from './commandStore'
@@ -113,7 +114,8 @@ function workspaceStoreSetup() {
registerSidebarTab,
unregisterSidebarTab,
getSidebarTabs
getSidebarTabs,
renderMarkdownToHtml
}
}

View File

@@ -117,6 +117,14 @@ export interface ExtensionManager {
// Execution error state (read-only)
lastNodeErrors: Record<NodeId, NodeError> | null
lastExecutionError: ExecutionErrorWsMessage | null
/**
* Renders a markdown string to sanitized HTML.
* Uses marked (GFM) + DOMPurify. Safe for direct use with innerHTML.
* @param markdown - The markdown string to render.
* @param baseUrl - Optional base URL for resolving relative image/media paths.
*/
renderMarkdownToHtml(markdown: string, baseUrl?: string): string
}
export interface CommandManager {