Compare commits

..

6 Commits

Author SHA1 Message Date
bymyself
f22f9409b7 test: assert fitView centers bounds within unobscured region
Replace the directional inset-position assertions (which encoded the
inverted-sign behavior) with precise centering invariants: the bounds
center must map to the center of the visible region for left/right/top
insets. Add a fit/animate parity test that locks in the full-canvas
scale recovery during animation.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183573005
2026-06-29 20:28:46 -07:00
bymyself
aed10ab551 fix: derive animation scale from full canvas width, not inset width
The animation edge points (startX2/targetX2) span the full canvas width
(fullCw/fullCh), so the per-frame scale recovery must divide by
fullCw/fullCh. Using the inset-reduced cw/ch landed the animation on a
scale that did not match fitToBounds with the same insets.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183576748
2026-06-29 20:26:47 -07:00
bymyself
4977c518e8 fix: shift fitView centering toward visible region, not into panels
The inset shift sign was inverted: subtracting insetLeft/insetTop placed
content at the obscured panel edge instead of centering it within the
unobscured viewport region. Per convertOffsetToCanvas
(canvas_px = (graph + offset) * scale), centering the bounds at
insetLeft + cw/2 requires adding insetLeft/targetScale. Applied to both
fitToBounds and animateToBounds.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183573005
2026-06-29 20:25:54 -07:00
bymyself
1ebd371c92 fix: return zero insets when panel element is absent
useElementBounding returns 0s for a missing target, which previously
made the right/bottom insets balloon to the full canvas size. With new
menu disabled (no .graph-canvas-panel), fitView produced a degenerate
viewport and broke screenshot-stable workflow loads.
2026-05-04 16:26:28 -07:00
bymyself
435622709f refactor: make useCanvasViewportInsets a reactive composable
Use VueUse useElementBounding (passive ResizeObserver + scroll listener)
behind a singleton, mirroring useCanvasPositionConversion. Call sites read
a cached computed instead of querying the DOM per fitView invocation.
2026-05-04 16:07:05 -07:00
christian-byrne
c21fbedf15 fix: account for panel width in fitView() viewport bounds
fitView() and fitViewToSelectionAnimated() used the full canvas element
dimensions when calculating viewport bounds, ignoring overlay panels
(sidebar, right side panel). This caused fitted content to be partially
obscured by open panels.

Add ViewportInsets support to DragAndScale.fitToBounds() and
animateToBounds(). The insets reduce the effective viewport area and
shift the centering calculation so content is centered within the
unobscured region.

getCanvasViewportInsets() computes insets by comparing the canvas
element's bounding rect with the graph-canvas-panel (visible area).

Fixes #11154
2026-05-04 16:07:05 -07:00
67 changed files with 2186 additions and 6340 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
name: /Payment was not completed/i,
level: 1
})
).toBeVisible()
@@ -102,7 +102,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
page.getByRole('heading', { name: '支付未完成', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',

View File

@@ -3694,8 +3694,8 @@ const translations = {
'zh-CN': '支付'
},
'payment.failed.title': {
en: 'Unable to complete payment',
'zh-CN': '无法完成支付'
en: 'Payment was not completed',
'zh-CN': '支付未完成'
},
'payment.failed.subtitle': {
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",

View File

@@ -460,15 +460,10 @@ export const testComfySnapToGridGridSize = 50
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
}>({
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
return use(page)
@@ -485,7 +480,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
comfyPage: async ({ page, request }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -529,10 +524,6 @@ export const comfyPageFixture = base.extend<{
await comfyPage.cloudAuth.mockAuth()
}
if (Object.keys(initialFeatureFlags).length > 0) {
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
}
await comfyPage.setup()
if (isVueNodes) {

View File

@@ -1,23 +1,16 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class Topbar {
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
readonly workflowTabs: Locator
readonly integratedTabBarActions: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
this.integratedTabBarActions = this.workflowTabs.getByTestId(
TestIds.topbar.integratedTabBarActions
)
}
async getTabNames(): Promise<string[]> {

View File

@@ -1,136 +0,0 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const MASK_CANVAS_INDEX = 2
const RGB_CANVAS_INDEX = 1
export type BrushSliderLabel = 'thickness'
export class MaskEditorHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
}
async loadImageOnNode() {
await this.comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await this.comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await this.comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = this.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)
}
}
async openDialog(): Promise<Locator> {
const { imagePreview } = await this.loadImageOnNode()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.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
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await this.page.mouse.move(startX, startY)
await this.page.mouse.down()
await this.page.mouse.move(endX, endY, { steps: 10 })
await this.page.mouse.up()
return { startX, startY, endX, endY, box }
}
async drawStrokeAndExpectPixels(dialog: Locator) {
await this.drawStrokeOnPointerZone(dialog)
await expect.poll(() => this.pollMaskPixelCount()).toBeGreaterThan(0)
}
getCanvasPixelData(canvasIndex: number) {
return this.page.evaluate((idx) => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
const canvas = canvases[idx] as HTMLCanvasElement | undefined
if (!canvas) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
}, canvasIndex)
}
pollMaskPixelCount(): Promise<number> {
return this.getCanvasPixelData(MASK_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
pollRgbPixelCount(): Promise<number> {
return this.getCanvasPixelData(RGB_CANVAS_INDEX).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
getCanvasSnapshot(canvasIndex: number): Promise<string> {
return this.page.evaluate((idx) => {
const canvas = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)[idx] as HTMLCanvasElement | undefined
return canvas?.toDataURL() ?? ''
}, canvasIndex)
}
brushInput(dialog: Locator, label: BrushSliderLabel): Locator {
return dialog.getByTestId(`brush-${label}-input`)
}
}
export const maskEditorTest = comfyPageFixture.extend<{
maskEditor: MaskEditorHelper
}>({
maskEditor: async ({ comfyPage }, use) => {
await use(new MaskEditorHelper(comfyPage))
}
})

View File

@@ -91,8 +91,6 @@ export const TestIds = {
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
@@ -213,7 +211,6 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'

View File

@@ -1,127 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
test.describe('Comfy.Sidebar.Size', () => {
test('"small" applies small-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await expect(comfyPage.menu.sideToolbar).toContainClass('small-sidebar')
})
test('"normal" does not apply small-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'small-sidebar'
)
})
})
test.describe('Comfy.Sidebar.Style', () => {
// `isConnected` overrides the Style setting when the toolbar overflows;
// small (48px) items keep content under the default viewport so Style
// actually drives rendering.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
})
test('"connected" applies connected-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'connected')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'floating-sidebar'
)
})
test('"floating" applies floating-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'connected-sidebar'
)
})
test('"floating" + Size "normal" is overridden to connected by overflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).toContainClass(
'overflowing-sidebar'
)
})
test('"floating" + Size "normal" renders floating in a viewport tall enough to fit', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await comfyPage.page.setViewportSize({ width: 1280, height: 1500 })
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'overflowing-sidebar'
)
})
})
test.describe('Comfy.UI.TabBarLayout', () => {
test('"Default" renders integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
})
test('"Legacy" does not render integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
})
})
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
// The setting writes a CSS var consumed by .p-tree-node-content,
// which only renders in the legacy PrimeVue Tree.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.menu.nodeLibraryTab.open()
})
test('low padding (0px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 0)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '0px')
})
test('high padding (8px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 8)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '8px')
})
})
})

View File

@@ -1,32 +0,0 @@
import { expect } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
test.describe('Load3D LOD', () => {
test(
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await expect(load3d.canvas).toBeVisible()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(0)
const initialWidth = await load3d.canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
window.app!.canvas.ds.scale = 2.0
node.onResize?.(node.size)
})
await comfyPage.nextFrame()
await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(initialWidth)
}
)
})

View File

@@ -1,13 +1,117 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
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)
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
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)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { imagePreview } = await maskEditor.loadImageOnNode()
async ({ comfyPage }) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
// Hover over the image panel to reveal action buttons
await imagePreview.getByRole('region').hover()
@@ -35,8 +139,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'opens mask editor from context menu',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
@@ -62,61 +166,63 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await maskEditor.getCanvasPixelData(2)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('redo restores an undone stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
@@ -124,10 +230,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await maskEditor.getCanvasPixelData(2)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
@@ -136,29 +242,26 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await invertButton.click()
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
@@ -176,9 +279,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
)
test('switching tools updates the selected indicator', async ({
maskEditor
comfyPage
}) => {
const dialog = await maskEditor.openDialog()
const dialog = await openMaskEditorDialog(comfyPage)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
@@ -197,9 +300,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
})
test('brush settings panel is visible with thickness controls', async ({
maskEditor
comfyPage
}) => {
const dialog = await maskEditor.openDialog()
const dialog = await openMaskEditorDialog(comfyPage)
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
@@ -212,11 +315,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
let maskUploadCount = 0
let imageUploadCount = 0
@@ -259,8 +359,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
@@ -280,23 +380,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Draw a stroke with the mask pen (default tool)
await maskEditor.drawStrokeAndExpectPixels(dialog)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
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()
// Draw over the same area with the eraser
await maskEditor.drawStrokeOnPointerZone(dialog)
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)

View File

@@ -1,100 +0,0 @@
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
const RGB_PAINT_TOOL_INDEX = 1 // RGB / color paint tool
const ERASER_TOOL_INDEX = 2 // Eraser tool
test.describe(
'Mask Editor brush adjustment and layer management',
{ tag: '@vue-nodes' },
() => {
test.describe('Brush settings interaction', () => {
test('Adjusting brush thickness slider changes stroke output', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
const thicknessInput = maskEditor.brushInput(dialog, 'thickness')
// Thin brush
await thicknessInput.fill('2')
await expect(thicknessInput).toHaveValue('2')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const thinPixels = await maskEditor.pollMaskPixelCount()
await comfyPage.page.keyboard.press('Control+z')
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
// Thick brush
await thicknessInput.fill('200')
await expect(thicknessInput).toHaveValue('200')
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(thinPixels)
})
})
test.describe('Layer management', () => {
test('Drawing on different tools produces independent mask data', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshotAfterPen = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(RGB_PAINT_TOOL_INDEX).click()
await expect(toolEntries.nth(RGB_PAINT_TOOL_INDEX)).toHaveClass(
/Selected/
)
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollRgbPixelCount())
.toBeGreaterThan(0)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshotAfterPen)
})
test("Switching between tools preserves previous tool's mask data", async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeOnPointerZone(dialog)
await expect
.poll(() => maskEditor.pollMaskPixelCount())
.toBeGreaterThan(0)
const maskSnapshot = await maskEditor.getCanvasSnapshot(2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
await toolEntries.nth(ERASER_TOOL_INDEX).click()
await expect(toolEntries.nth(ERASER_TOOL_INDEX)).toHaveClass(/Selected/)
await toolEntries.nth(0).click()
await expect(toolEntries.nth(0)).toHaveClass(/Selected/)
await expect
.poll(() => maskEditor.getCanvasSnapshot(2))
.toBe(maskSnapshot)
})
})
}
)

View File

@@ -31,21 +31,12 @@ const MOCK_JOBS: RawJobListItem[] = [
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
}),
createMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
})
@@ -115,64 +106,4 @@ test.describe('Queue overlay', () => {
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
})
test('Job details popover stays inside the viewport for bottom rows', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
const bottomJob = comfyPage.page.locator(
'[data-job-id="job-failed-bottom"]'
)
await expect(bottomJob).toBeVisible()
await bottomJob.scrollIntoViewIfNeeded()
await expect(bottomJob).toBeVisible()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) throw new Error('Viewport must be available')
const rowBox = await bottomJob.boundingBox()
if (!rowBox) throw new Error('Bottom job row should be measurable')
expect(
rowBox.y + rowBox.height,
'Test row should be low enough to exercise bottom-edge collision handling'
).toBeGreaterThan(viewportSize.height * 0.55)
await expect
.poll(async () =>
bottomJob.evaluate((element) => {
const rect = element.getBoundingClientRect()
const hitTarget = document.elementFromPoint(
rect.x + rect.width / 2,
rect.y + rect.height / 2
)
return hitTarget ? element.contains(hitTarget) : false
})
)
.toBe(true)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(
rowBox.x + rowBox.width / 2,
rowBox.y + rowBox.height / 2,
{ steps: 5 }
)
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
await expect(popover).toBeVisible()
await expect
.poll(async () => {
const popoverBox = await popover.boundingBox()
if (!popoverBox) return false
return (
popoverBox.y >= 0 &&
popoverBox.y + popoverBox.height <= viewportSize.height
)
})
.toBe(true)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.17",
"version": "1.44.16",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -2524,46 +2524,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a Luma Agents generation
* @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}.
*/
post: operations["lumaAgentsCreateGeneration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations/{generation_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get a Luma Agents generation
* @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images.
*/
get: operations["lumaAgentsGetGeneration"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/pixverse/video/text/generate": {
parameters: {
query?: never;
@@ -10421,88 +10381,6 @@ export interface components {
/** @description The request of the generation */
request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"];
};
/**
* @description Output aspect ratio
* @enum {string}
*/
LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3";
/**
* @description Style preset
* @enum {string}
*/
LumaAgentsStyle: "auto" | "manga";
/**
* @description Output image format
* @enum {string}
*/
LumaAgentsOutputFormat: "png" | "jpeg";
/**
* @description The kind of generation to perform
* @enum {string}
*/
LumaAgentsGenerationType: "image" | "image_edit";
/**
* @description Current state of the generation
* @enum {string}
*/
LumaAgentsState: "queued" | "processing" | "completed" | "failed";
/**
* @description Machine-readable failure code for programmatic handling
* @enum {string}
*/
LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found";
/** @description Reference image for style/content guidance or guided generation */
LumaAgentsImageRef: {
/** @description Base64-encoded image data */
data?: string;
/** @description MIME type (e.g. image/jpeg). Required with data. */
media_type?: string;
/** @description Publicly accessible image URL */
url?: string;
};
/** @description The Luma Agents generation request object */
LumaAgentsGenerationRequest: {
/** @description Text prompt */
prompt: string;
aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"];
/** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */
image_ref?: components["schemas"]["LumaAgentsImageRef"][];
/** @description Model to use */
model?: string;
output_format?: components["schemas"]["LumaAgentsOutputFormat"];
source?: components["schemas"]["LumaAgentsImageRef"];
style?: components["schemas"]["LumaAgentsStyle"];
type?: components["schemas"]["LumaAgentsGenerationType"];
/** @description Enable web search grounding */
web_search?: boolean;
};
/** @description A generated output entry */
LumaAgentsGenerationOutput: {
/** @description Media type (e.g. image) */
type?: string;
/** @description Presigned URL (1hr expiry) */
url?: string;
};
/** @description Generation status and output */
LumaAgentsGeneration: {
/** @description Generation identifier */
id?: string;
/** @description Creation timestamp */
created_at?: string;
/** @description Model used */
model?: string;
state?: components["schemas"]["LumaAgentsState"];
type?: components["schemas"]["LumaAgentsGenerationType"];
failure_code?: components["schemas"]["LumaAgentsFailureCode"];
/** @description Human-readable failure description */
failure_reason?: string;
output?: components["schemas"]["LumaAgentsGenerationOutput"][];
};
/** @description The error object */
LumaAgentsError: {
/** @description The error message */
detail?: string;
};
PixverseTextVideoRequest: {
/** @enum {string} */
aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16";
@@ -26305,72 +26183,6 @@ export interface operations {
};
};
};
lumaAgentsCreateGeneration: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description The generation request object */
requestBody: {
content: {
"application/json": components["schemas"]["LumaAgentsGenerationRequest"];
};
};
responses: {
/** @description Generation accepted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
lumaAgentsGetGeneration: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The ID of the generation */
generation_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Generation found */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
PixverseGenerateTextVideo: {
parameters: {
query?: never;

View File

@@ -1,104 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CustomizationDialog from './CustomizationDialog.vue'
const DEFAULT_ICON = 'pi-bookmark-fill'
const DEFAULT_COLOR = '#a1a1aa'
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
defaultBookmarkIcon: DEFAULT_ICON,
defaultBookmarkColor: DEFAULT_COLOR,
bookmarksCustomization: {}
})
}))
vi.mock('primevue/dialog', () => ({
default: {
name: 'Dialog',
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
props: ['visible']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template: '<div />',
props: ['modelValue', 'options']
}
}))
vi.mock('primevue/divider', () => ({
default: { name: 'Divider', template: '<hr />' }
}))
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
default: {
name: 'ColorCustomizationSelector',
template: '<div />',
props: ['modelValue', 'colorOptions']
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'Button',
template: `<button @click="$emit('click')"><slot /></button>`,
emits: ['click']
}
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderDialog(extraProps: Record<string, unknown> = {}) {
const onConfirm = vi.fn()
render(CustomizationDialog, {
global: { plugins: [i18n] },
props: { modelValue: true, onConfirm, ...extraProps }
})
return { onConfirm }
}
describe('CustomizationDialog', () => {
describe('confirmCustomization', () => {
it('emits confirm with default icon and color when no initial values provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog()
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with matching initialIcon when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-star' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR)
})
it('falls back to default icon when initialIcon does not match any option', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with initialColor when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialColor: '#007bff' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff')
})
})
})

View File

@@ -94,15 +94,17 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ??
iconOptions[0]
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}

View File

@@ -2,7 +2,7 @@
<span class="flex flex-row gap-0.5">
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground capitalize"
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground uppercase"
:severity="isModified ? 'info' : 'secondary'"
>
{{ sequence }}

View File

@@ -6,7 +6,6 @@
<template v-if="showUI" #workflow-tabs>
<div
v-if="workflowTabsPosition === 'Topbar'"
data-testid="topbar-workflow-tabs"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<div

View File

@@ -64,7 +64,6 @@
</span>
<input
v-model.number="brushSize"
data-testid="brush-thickness-input"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
:min="1"

View File

@@ -4,7 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type * as RekaUi from 'reka-ui'
import './testUtils/mockTanstackVirtualizer'
@@ -28,85 +27,6 @@ vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: hoisted.jobDetailsPopoverStub
}))
vi.mock('reka-ui', async (importOriginal) => {
const actual = await importOriginal<typeof RekaUi>()
const { computed, defineComponent, h, inject, provide } = await import('vue')
const popoverOpenKey = Symbol('popoverOpen')
return {
...actual,
PopoverContent: defineComponent({
name: 'PopoverContent',
props: {
align: { type: String, default: undefined },
avoidCollisions: { type: Boolean, default: undefined },
collisionPadding: { type: Number, default: undefined },
hideWhenDetached: { type: Boolean, default: undefined },
positionStrategy: { type: String, default: undefined },
reference: { type: null, default: undefined },
side: { type: String, default: undefined },
sideFlip: { type: Boolean, default: undefined },
sideOffset: { type: Number, default: undefined },
sticky: { type: String, default: undefined }
},
emits: ['mouseenter', 'mouseleave'],
setup(props, { attrs, emit, slots }) {
const isOpen = inject(
popoverOpenKey,
computed(() => false)
)
return () =>
isOpen.value
? h(
'div',
{
class: attrs.class,
'data-align': props.align,
'data-avoid-collisions': props.avoidCollisions,
'data-collision-padding': props.collisionPadding,
'data-hide-when-detached': props.hideWhenDetached,
'data-position-strategy': props.positionStrategy,
'data-reference-bound': props.reference ? 'true' : 'false',
'data-side': props.side,
'data-side-flip': props.sideFlip,
'data-side-offset': props.sideOffset,
'data-sticky': props.sticky,
onMouseenter: () => emit('mouseenter'),
onMouseleave: () => emit('mouseleave')
},
slots.default?.()
)
: null
}
}),
PopoverPortal: {
name: 'PopoverPortal',
template: '<div><slot /></div>'
},
PopoverRoot: defineComponent({
name: 'PopoverRoot',
props: {
open: { type: Boolean, default: false }
},
setup(props, { slots }) {
provide(
popoverOpenKey,
computed(() => props.open)
)
return () =>
h(
'div',
{
'data-open': props.open
},
slots.default?.()
)
}
})
}
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
@@ -152,7 +72,6 @@ vi.mock('vue-i18n', () => {
type TestPreviewOutput = {
url: string
previewUrl: string
isImage: boolean
isVideo: boolean
}
@@ -177,7 +96,6 @@ const createPreviewOutput = (
const url = `/api/view/${filename}`
return {
url,
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
}
@@ -235,6 +153,30 @@ function renderJobAssetsList({
return { ...result, user }
}
function createDomRect({
top,
left,
width,
height
}: {
top: number
left: number
width: number
height: number
}): DOMRect {
return {
x: left,
y: top,
top,
left,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ''
} as DOMRect
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
@@ -298,18 +240,6 @@ describe('JobAssetsList', () => {
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('uses thumbnail preview URLs for completed image rows', () => {
const preview = createPreviewOutput('job-1.png')
const job = buildJob({
taskRef: createTaskRef(preview)
})
const { container } = renderJobAssetsList({ jobs: [job] })
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(preview.previewUrl)
expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url)
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
@@ -448,24 +378,54 @@ describe('JobAssetsList', () => {
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('anchors the popover to the active row through Reka', async () => {
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 40,
width: 200,
height: 48
})
)
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('data-avoid-collisions')).toBe('true')
expect(popover.getAttribute('data-hide-when-detached')).toBe('true')
expect(popover.getAttribute('data-reference-bound')).toBe('true')
expect(popover.getAttribute('data-side')).toBe('right')
expect(popover.getAttribute('data-side-flip')).toBe('true')
expect(popover.getAttribute('data-position-strategy')).toBe('fixed')
expect(popover.getAttribute('style')).toContain('left: 248px;')
})
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 980,
width: 200,
height: 48
})
)
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 672px;')
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
@@ -498,7 +458,7 @@ describe('JobAssetsList', () => {
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('updates the visible popover without closing when hovering another row', async () => {
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
@@ -519,21 +479,17 @@ describe('JobAssetsList', () => {
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
await vi.advanceTimersByTimeAsync(50)
await nextTick()
expect(
container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
).toBe('job-2')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(
container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
).toBe('job-2')
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {

View File

@@ -90,15 +90,23 @@
</div>
</div>
<JobDetailsHoverPopover
:open="isDetailsOpen && !!activeDetails && !!activeRowElement"
:job-id="activeDetails?.jobId"
:workflow-id="activeDetails?.workflowId"
:reference-element="activeRowElement"
@content-enter="onPopoverEnter"
@content-leave="onPopoverLeave"
@update:open="onPopoverOpenChange"
/>
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="activeDetails.jobId"
:workflow-id="activeDetails.workflowId"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -106,11 +114,13 @@ import type { VirtualItem } from '@tanstack/vue-virtual'
import type { CSSProperties } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsHoverPopover from '@/components/queue/job/JobDetailsHoverPopover.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -122,8 +132,6 @@ import type { VirtualJobRow } from './buildVirtualJobRows'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
const JOB_ROW_HEIGHT = 48
const DETAILS_SHOW_DELAY_MS = 200
const DETAILS_HIDE_DELAY_MS = 150
defineOptions({
inheritAttrs: false
@@ -142,11 +150,7 @@ const { t } = useI18n()
const scrollContainer = ref<HTMLElement | null>(null)
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
const isDetailsOpen = ref(false)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const virtualizer = useVirtualizer({
get count(): number {
@@ -180,6 +184,18 @@ const virtualWrapperStyle = computed<CSSProperties>(() => ({
height: `${virtualizer.value.getTotalSize()}px`
})
}))
const {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
getActiveId: (details) => details.jobId,
getDisplayedJobGroups: () => displayedJobGroups,
onReset: clearPopoverAnchor
})
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
return {
position: 'absolute',
@@ -213,88 +229,22 @@ function onListScroll() {
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null
}
function clearHideTimer() {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
function updatePopoverPosition() {
const rowElement = activeRowElement.value
if (!rowElement) return
function clearShowTimer() {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
function clearHoverTimers() {
clearHideTimer()
clearShowTimer()
}
function resetActiveDetails() {
clearHoverTimers()
isDetailsOpen.value = false
activeDetails.value = null
clearPopoverAnchor()
}
function hasDisplayedJob(jobId: string) {
return displayedJobGroups.some((group) =>
group.items.some((item) => item.id === jobId)
)
}
function scheduleDetailsShow(nextActive: {
jobId: string
workflowId?: string
}) {
clearShowTimer()
showTimer.value = window.setTimeout(() => {
showTimer.value = null
if (!hasDisplayedJob(nextActive.jobId)) return
activeDetails.value = nextActive
isDetailsOpen.value = true
}, DETAILS_SHOW_DELAY_MS)
}
function showDetailsNow(nextActive: { jobId: string; workflowId?: string }) {
clearHoverTimers()
if (!hasDisplayedJob(nextActive.jobId)) return
activeDetails.value = nextActive
isDetailsOpen.value = true
}
function scheduleDetailsHide(jobId?: string) {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
const currentActive = activeDetails.value
if (currentActive?.jobId === jobId) {
isDetailsOpen.value = false
}
hideTimer.value = null
hideTimerJobId.value = null
}, DETAILS_HIDE_DELAY_MS)
const rect = rowElement.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
function onJobEnter(job: JobListItem, event: MouseEvent) {
@@ -304,22 +254,22 @@ function onJobEnter(job: JobListItem, event: MouseEvent) {
if (!(rowElement instanceof HTMLElement)) return
activeRowElement.value = rowElement
const nextActive = {
jobId: job.id,
workflowId: job.taskRef?.workflowId
}
if (isDetailsOpen.value && activeDetails.value?.jobId === job.id) {
if (activeDetails.value?.jobId === job.id) {
clearHoverTimers()
void nextTick(updatePopoverPosition)
return
}
const isSwitchingVisibleDetails = isDetailsOpen.value
const showDetails = isSwitchingVisibleDetails
? showDetailsNow
: scheduleDetailsShow
showDetails(nextActive)
scheduleDetailsShow(
{
jobId: job.id,
workflowId: job.taskRef?.workflowId
},
() => {
activeRowElement.value = rowElement
void nextTick(updatePopoverPosition)
}
)
}
function isCancelable(job: JobListItem) {
@@ -337,7 +287,7 @@ function getPreviewOutput(job: JobListItem) {
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.previewUrl
return preview.url
}
return job.iconImageUrl
}
@@ -377,13 +327,7 @@ function onPopoverEnter() {
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId)
}
function onPopoverOpenChange(open: boolean) {
if (!open) {
resetActiveDetails()
}
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
function getJobIconClass(job: JobListItem): string | undefined {
@@ -393,18 +337,4 @@ function getJobIconClass(job: JobListItem): string | undefined {
}
return undefined
}
watch(
() => displayedJobGroups,
() => {
const currentActive = activeDetails.value
if (!currentActive) return
if (!hasDisplayedJob(currentActive.jobId)) {
resetActiveDetails()
}
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -1,62 +0,0 @@
<template>
<Popover :open="isOpen" @update:open="onOpenChange">
<PopoverContent
v-if="hasReference"
:reference="referenceElement ?? undefined"
data-testid="queue-job-details-popover"
side="right"
align="start"
:side-offset="8"
:collision-padding="8"
:avoid-collisions="true"
:side-flip="true"
:hide-when-detached="true"
position-strategy="fixed"
sticky="always"
class="job-details-popover z-1700 max-h-(--reka-popover-content-available-height) w-auto overflow-y-auto border-0 bg-transparent p-0 shadow-none will-change-transform"
@mouseenter="$emit('content-enter')"
@mouseleave="$emit('content-leave')"
@open-auto-focus.prevent
@close-auto-focus.prevent
>
<JobDetailsPopover
v-if="jobId"
:job-id="jobId"
:workflow-id="workflowId"
/>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
const {
open,
jobId = null,
workflowId,
referenceElement = null
} = defineProps<{
open: boolean
jobId?: string | null
workflowId?: string
referenceElement?: HTMLElement | null
}>()
const emit = defineEmits<{
(e: 'content-enter'): void
(e: 'content-leave'): void
(e: 'update:open', open: boolean): void
}>()
const isOpen = computed(() => open && !!jobId)
const hasReference = computed(() => !!jobId && !!referenceElement)
function onOpenChange(nextOpen: boolean) {
emit('update:open', nextOpen)
}
</script>

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
describe('getHoverPopoverPosition', () => {
it('places the popover to the right when space is available', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 100, left: 248 })
})
it('places the popover to the left when right space is insufficient', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 980, right: 1180 },
1280
)
expect(position).toEqual({ top: 100, left: 672 })
})
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
const position = getHoverPopoverPosition(
{ top: 2, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 8, left: 248 })
})
it('clamps left to viewport padding when fallback would go off-screen', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 100, right: 300 },
320
)
expect(position).toEqual({ top: 100, left: 8 })
})
it('prefers right when both sides have equal space', () => {
const position = getHoverPopoverPosition(
{ top: 200, left: 340, right: 640 },
1280
)
expect(position).toEqual({ top: 200, left: 648 })
})
it('falls back to left when right space is less than popover width', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 600, right: 1000 },
1280
)
expect(position).toEqual({ top: 100, left: 292 })
})
it('handles narrow viewport where popover barely fits', () => {
const position = getHoverPopoverPosition(
{ top: 50, left: 8, right: 100 },
316
)
expect(position).toEqual({ top: 50, left: 8 })
})
})

View File

@@ -0,0 +1,39 @@
const POPOVER_GAP = 8
const POPOVER_WIDTH = 300
const VIEWPORT_PADDING = 8
type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
type HoverPopoverPosition = {
top: number
left: number
}
export function getHoverPopoverPosition(
rect: AnchorRect,
viewportWidth: number
): HoverPopoverPosition {
const availableLeft = rect.left - POPOVER_GAP
const availableRight = viewportWidth - rect.right - POPOVER_GAP
const preferredLeft = rect.right + POPOVER_GAP
const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP
const maxLeft = Math.max(
VIEWPORT_PADDING,
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
)
if (
availableRight >= POPOVER_WIDTH &&
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
) {
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.min(maxLeft, preferredLeft)
}
}
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
}
}

View File

@@ -81,7 +81,6 @@
</Button>
<div
v-if="isIntegratedTabBar"
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<Button

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot v-bind="forwarded">
<slot />
</PopoverRoot>
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({
inheritAttrs: false
})
const {
align = 'center',
sideOffset = 4,
class: className,
...restProps
} = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = computed(() => ({
align,
sideOffset,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-72 rounded-md border bg-base-background p-4 text-base-foreground shadow-md outline-none',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
describe('useCanvasViewportInsets', () => {
let canvasEl: HTMLElement
let panelEl: HTMLElement
function mockRect(el: HTMLElement, rect: Partial<DOMRect>) {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 0,
top: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
})
}
beforeEach(async () => {
vi.resetModules()
canvasEl = document.createElement('div')
canvasEl.id = 'graph-canvas'
document.body.appendChild(canvasEl)
panelEl = document.createElement('div')
panelEl.classList.add('graph-canvas-panel')
document.body.appendChild(panelEl)
})
afterEach(() => {
canvasEl.remove()
panelEl.remove()
vi.restoreAllMocks()
})
async function load() {
const mod = await import('./useCanvasViewportInsets')
return mod.useCanvasViewportInsets()
}
it('returns a singleton across calls', async () => {
const { useCanvasViewportInsets } =
await import('./useCanvasViewportInsets')
expect(useCanvasViewportInsets()).toBe(useCanvasViewportInsets())
})
it('reports zero insets when canvas and panel are coincident', async () => {
const rect = {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
}
mockRect(canvasEl, rect)
mockRect(panelEl, rect)
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
it('computes left/right insets from a centered panel', async () => {
mockRect(canvasEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
mockRect(panelEl, {
left: 300,
right: 1620,
top: 0,
bottom: 1080,
width: 1320,
height: 1080,
x: 300
})
const insets = await load()
await nextTick()
expect(insets.value).toMatchObject({
left: 300,
right: 300,
top: 0,
bottom: 0
})
})
it('returns zero insets when the panel element is absent', async () => {
panelEl.remove()
mockRect(canvasEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
it('clamps negative differences to zero', async () => {
mockRect(canvasEl, {
left: 100,
right: 1820,
top: 50,
bottom: 1030,
width: 1720,
height: 980,
x: 100,
y: 50
})
mockRect(panelEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
})

View File

@@ -0,0 +1,40 @@
import { useElementBounding } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { computed } from 'vue'
import type { ViewportInsets } from '@/lib/litegraph/src/DragAndScale'
let shared: ComputedRef<ViewportInsets> | null = null
/**
* Reactive insets representing the area of `#graph-canvas` obscured by the
* `.graph-canvas-panel` overlay (sidebar, right panel, etc.) on each side.
*
* Backed by VueUse's `useElementBounding`, which uses passive observers and
* caches reads, so call sites pay no per-call layout cost. Singleton — the
* underlying observers attach once for the app's lifetime.
*/
export function useCanvasViewportInsets(): ComputedRef<ViewportInsets> {
if (shared) return shared
const canvas = useElementBounding(() =>
document.getElementById('graph-canvas')
)
const panel = useElementBounding(() =>
document.querySelector<HTMLElement>('.graph-canvas-panel')
)
shared = computed<ViewportInsets>(() => {
const panelMissing = panel.width.value === 0 && panel.height.value === 0
if (panelMissing) return { left: 0, right: 0, top: 0, bottom: 0 }
return {
left: Math.max(0, panel.left.value - canvas.left.value),
right: Math.max(0, canvas.right.value - panel.right.value),
top: Math.max(0, panel.top.value - canvas.top.value),
bottom: Math.max(0, canvas.bottom.value - panel.bottom.value)
}
})
return shared
}

View File

@@ -1,124 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildStrokePoints, clampDirtyRect } from './gpuUtils'
const uninit = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
}
describe('clampDirtyRect', () => {
it('returns full canvas when dirty rect is uninitialised', () => {
expect(clampDirtyRect(uninit, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns the clamped rect when fully inside canvas bounds', () => {
const rect = { minX: 10, minY: 20, maxX: 60, maxY: 90 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 10,
dy: 20,
dw: 50,
dh: 70
})
})
it('clamps rect that extends beyond canvas edges', () => {
const rect = { minX: -5, minY: -10, maxX: 120, maxY: 250 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns full canvas when the clamped area has zero width', () => {
const rect = { minX: 50, minY: 10, maxX: 50, maxY: 80 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('returns full canvas when the clamped area has zero height', () => {
const rect = { minX: 10, minY: 50, maxX: 80, maxY: 50 }
expect(clampDirtyRect(rect, 100, 200)).toEqual({
dx: 0,
dy: 0,
dw: 100,
dh: 200
})
})
it('floors dx/dy and ceils the far edges', () => {
const rect = { minX: 10.7, minY: 20.3, maxX: 59.2, maxY: 89.9 }
const result = clampDirtyRect(rect, 100, 200)
expect(result.dx).toBe(10)
expect(result.dy).toBe(20)
expect(result.dw).toBe(60 - 10) // ceil(59.2)=60, dx=10
expect(result.dh).toBe(90 - 20) // ceil(89.9)=90, dy=20
})
})
describe('buildStrokePoints', () => {
it('returns input points as-is when skipResampling is true', () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 100 }
]
const result = buildStrokePoints(points, true, 10)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ x: 0, y: 0, pressure: 1.0 })
expect(result[1]).toEqual({ x: 100, y: 100, pressure: 1.0 })
})
it('returns empty array for empty input', () => {
expect(buildStrokePoints([], false, 10)).toHaveLength(0)
expect(buildStrokePoints([], true, 10)).toHaveLength(0)
})
it('returns empty array for a single point (no segments to interpolate)', () => {
expect(buildStrokePoints([{ x: 5, y: 5 }], false, 10)).toHaveLength(0)
})
it('interpolates a horizontal segment into multiple evenly-spaced points', () => {
const points = [
{ x: 0, y: 0 },
{ x: 30, y: 0 }
]
const result = buildStrokePoints(points, false, 10)
// 30px distance / 10 stepSize = 3 steps → 4 points (s=0,1,2,3)
expect(result).toHaveLength(4)
expect(result[0]).toMatchObject({ x: 0, y: 0 })
expect(result[3]).toMatchObject({ x: 30, y: 0 })
result.forEach((p) => expect(p.pressure).toBe(1.0))
})
it('uses at least one step when points are very close together', () => {
const points = [
{ x: 0, y: 0 },
{ x: 0.1, y: 0 }
]
// distance 0.1 < stepSize 10 → steps=1 → 2 points
const result = buildStrokePoints(points, false, 10)
expect(result).toHaveLength(2)
})
it('interpolates all pressure values to 1.0', () => {
const points = [
{ x: 0, y: 0 },
{ x: 50, y: 50 }
]
const result = buildStrokePoints(points, false, 10)
result.forEach((p) => expect(p.pressure).toBe(1.0))
})
})

View File

@@ -1,60 +0,0 @@
import type { Point } from '@/extensions/core/maskeditor/types'
import type { DirtyRect } from './brushDrawingUtils'
/**
* Computes the clamped dirty-rect coordinates for a putImageData call.
*
* Returns the full canvas dimensions when the dirty rect is uninitialised
* (Infinity sentinels) or the resulting area has zero/negative size.
*/
export function clampDirtyRect(
rect: DirtyRect,
canvasWidth: number,
canvasHeight: number
): { dx: number; dy: number; dw: number; dh: number } {
const full = { dx: 0, dy: 0, dw: canvasWidth, dh: canvasHeight }
if (rect.minX === Infinity || rect.maxX === -Infinity) return full
const dx = Math.floor(Math.max(0, rect.minX))
const dy = Math.floor(Math.max(0, rect.minY))
const dw = Math.ceil(Math.min(canvasWidth, rect.maxX)) - dx
const dh = Math.ceil(Math.min(canvasHeight, rect.maxY)) - dy
return dw > 0 && dh > 0 ? { dx, dy, dw, dh } : full
}
/**
* Linearly interpolates a sequence of points at a fixed step size,
* returning GPU-ready stroke points with pressure=1.
*
* When skipResampling is true the input points are returned as-is (used
* during live preview where the caller has already handled spacing).
*/
export function buildStrokePoints(
points: Point[],
skipResampling: boolean,
stepSize: number
): { x: number; y: number; pressure: number }[] {
if (skipResampling) {
return points.map((p) => ({ x: p.x, y: p.y, pressure: 1.0 }))
}
const result: { x: number; y: number; pressure: number }[] = []
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i]
const p2 = points[i + 1]
const steps = Math.max(
1,
Math.ceil(Math.hypot(p2.x - p1.x, p2.y - p1.y) / stepSize)
)
for (let s = 0; s <= steps; s++) {
const t = s / steps
result.push({
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t,
pressure: 1.0
})
}
}
return result
}

View File

@@ -1,295 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, ref } from 'vue'
import type { EffectScope } from 'vue'
// vi.hoisted runs before imports — only vi.fn() is safe here (no Vue)
const saveStateSpy = vi.hoisted(() => vi.fn())
const mockStoreDef = vi.hoisted(() => ({
brushSettings: {
size: 20,
hardness: 0.9,
opacity: 1,
stepSize: 5,
type: 'arc' as string
},
currentTool: 'pen' as string,
activeLayer: 'mask' as string,
maskCanvas: null as HTMLCanvasElement | null,
maskCtx: null as CanvasRenderingContext2D | null,
rgbCanvas: null as HTMLCanvasElement | null,
rgbCtx: null as CanvasRenderingContext2D | null,
maskBlendMode: 'black',
maskOpacity: 0.8,
maskColor: { r: 0, g: 0, b: 0 },
rgbColor: '#FF0000',
canvasHistory: { saveState: saveStateSpy }
}))
// vi.mock factory runs after hoisting — ref/computed from Vue are available
vi.mock('./useGPUResources', () => {
// Singletons shared across all calls to useGPUResources() in this test file
const isSavingHistory = ref(false)
const dirtyRect = ref({
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
})
const hasRenderer = ref(false)
const previewCanvas = ref<HTMLCanvasElement | null>(null)
const prepareStroke = vi.fn()
const clearPreview = vi.fn()
const compositeStroke = vi.fn()
const copyGpuToCanvas = vi
.fn()
.mockResolvedValue({ maskData: undefined, rgbData: undefined })
return {
useGPUResources: () => ({
isSavingHistory,
dirtyRect,
hasRenderer,
previewCanvas,
prepareStroke,
clearPreview,
compositeStroke,
copyGpuToCanvas,
gpuRender: vi.fn(),
gpuDrawPoint: vi.fn(),
clearGPU: vi.fn(),
destroy: vi.fn(),
initGPUResources: vi.fn().mockResolvedValue(undefined),
initPreviewCanvas: vi.fn()
})
}
})
vi.mock('./useCoordinateTransform', () => ({
useCoordinateTransform: () => ({
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
})
}))
vi.mock('./useBrushPersistence', () => ({
useBrushPersistence: () => ({ loadAndApply: vi.fn(), save: vi.fn() })
}))
vi.mock('./useBrushAdjustment', () => ({
useBrushAdjustment: () => ({
startBrushAdjustment: vi.fn(),
handleBrushAdjustment: vi.fn()
})
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStoreDef)
}))
vi.mock('@/scripts/app', () => ({
app: { registerExtension: vi.fn() }
}))
import { useGPUResources } from './useGPUResources'
import { useBrushDrawing } from './useBrushDrawing'
function makePointerEvent(
x: number,
y: number,
opts: { buttons?: number; shiftKey?: boolean } = {}
): PointerEvent {
return {
offsetX: x,
offsetY: y,
buttons: opts.buttons ?? 1,
shiftKey: opts.shiftKey ?? false,
preventDefault: vi.fn()
} as unknown as PointerEvent
}
function makeMockCtx(): CanvasRenderingContext2D {
const gradient = { addColorStop: vi.fn() }
return {
beginPath: vi.fn(),
fill: vi.fn(),
rect: vi.fn(),
arc: vi.fn(),
fillStyle: '',
drawImage: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
globalCompositeOperation: 'source-over'
} as unknown as CanvasRenderingContext2D
}
let scope: EffectScope | null = null
function setup() {
scope = effectScope()
return scope.run(() => useBrushDrawing())!
}
beforeEach(() => {
vi.clearAllMocks()
const mockCtx = makeMockCtx()
const mockCanvas = {
width: 200,
height: 200,
style: { opacity: '' }
} as unknown as HTMLCanvasElement
mockStoreDef.maskCanvas = mockCanvas
mockStoreDef.maskCtx = mockCtx
mockStoreDef.rgbCanvas = mockCanvas
mockStoreDef.rgbCtx = mockCtx
mockStoreDef.currentTool = 'pen'
mockStoreDef.activeLayer = 'mask'
const gpu = useGPUResources()
gpu.isSavingHistory.value = false
gpu.hasRenderer.value = false
gpu.previewCanvas.value = null
gpu.dirtyRect.value = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
}
})
afterEach(() => {
scope?.stop()
scope = null
})
describe('startDrawing', () => {
it('calls prepareStroke on the GPU resources', async () => {
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
expect(useGPUResources().prepareStroke).toHaveBeenCalledOnce()
})
it('sets DestinationOut composition when tool is eraser', async () => {
mockStoreDef.currentTool = 'eraser'
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
'destination-out'
)
})
it('sets SourceOver composition when tool is mask pen', async () => {
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe('source-over')
})
it('sets DestinationOut composition when right mouse button is used', async () => {
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50, { buttons: 2 }))
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
'destination-out'
)
})
})
describe('startDrawing error handling', () => {
it('catches initShape errors and resets drawing state', async () => {
mockStoreDef.maskCtx = null
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
expect(consoleSpy).toHaveBeenCalledWith(
'[useBrushDrawing] Failed to start drawing:',
expect.any(Error)
)
expect(mockStoreDef.maskCtx).toBeNull()
consoleSpy.mockRestore()
})
})
describe('startDrawing shift+click', () => {
it('draws a line from the previous point when shift is held', async () => {
const { startDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
await startDrawing(makePointerEvent(100, 50, { shiftKey: true }))
expect(
(mockStoreDef.maskCtx as unknown as ReturnType<typeof makeMockCtx>)
.beginPath
).toHaveBeenCalled()
})
})
describe('handleDrawing', () => {
it('updates smoothingLastDrawTime after each move event', async () => {
const rafSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb) => {
cb(0)
return 0
})
const { startDrawing, handleDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
await handleDrawing(makePointerEvent(55, 55))
expect(rafSpy).toHaveBeenCalled()
rafSpy.mockRestore()
})
})
describe('drawEnd canvas visibility', () => {
it('restores rgb canvas opacity when activeLayer is rgb', async () => {
mockStoreDef.activeLayer = 'rgb'
const mockRgbCanvas = {
width: 200,
height: 200,
style: { opacity: '' }
} as unknown as HTMLCanvasElement
mockStoreDef.rgbCanvas = mockRgbCanvas
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(mockRgbCanvas.style.opacity).toBe('1')
})
it('restores preview canvas opacity to 1 after drawEnd', async () => {
const gpu = useGPUResources()
const mockPreviewCanvas = {
style: { opacity: '' }
} as unknown as HTMLCanvasElement
gpu.previewCanvas.value = mockPreviewCanvas
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(mockPreviewCanvas.style.opacity).toBe('1')
})
})
describe('drawEnd', () => {
it('calls compositeStroke indicating the active layer and erasing state', async () => {
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(useGPUResources().compositeStroke).toHaveBeenCalledOnce()
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false)
})
it('calls clearPreview to clean up the GPU overlay', async () => {
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(useGPUResources().clearPreview).toHaveBeenCalledOnce()
})
it('saves canvas history on stroke completion', async () => {
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(saveStateSpy).toHaveBeenCalledOnce()
})
it('is a no-op when drawing was never started', async () => {
const { drawEnd } = setup()
await drawEnd(makePointerEvent(60, 60))
expect(useGPUResources().compositeStroke).not.toHaveBeenCalled()
expect(saveStateSpy).not.toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
vi.mock('typegpu', () => ({
tgpu: {
init: vi.fn().mockRejectedValue(new Error('WebGPU not supported'))
}
}))
vi.mock('./gpu/GPUBrushRenderer', () => ({
GPUBrushRenderer: vi.fn()
}))
const mockStore = reactive({
tgpuRoot: null as unknown,
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
maskCtx: null as CanvasRenderingContext2D | null,
rgbCtx: null as CanvasRenderingContext2D | null,
clearTrigger: 0,
canvasHistory: { currentStateIndex: 0 },
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null as null,
pendingGPURgbData: null as null,
brushSettings: {
size: 20,
hardness: 0.9,
opacity: 1,
stepSize: 5,
type: 'arc'
},
activeLayer: 'mask',
currentTool: 'pen',
maskColor: { r: 0, g: 0, b: 0 },
rgbColor: '#FF0000'
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
import { resetDirtyRect } from './brushDrawingUtils'
import { useGPUResources } from './useGPUResources'
let scope: EffectScope | null = null
function setup() {
scope = effectScope()
return scope.run(() => useGPUResources())!
}
beforeEach(() => {
vi.clearAllMocks()
mockStore.tgpuRoot = null
mockStore.maskCanvas = null
mockStore.rgbCanvas = null
mockStore.maskCtx = null
mockStore.rgbCtx = null
mockStore.clearTrigger = 0
mockStore.canvasHistory.currentStateIndex = 0
mockStore.gpuTexturesNeedRecreation = false
})
afterEach(() => {
scope?.stop()
scope = null
})
describe('initial reactive state', () => {
it('hasRenderer is false when no renderer exists', () => {
const { hasRenderer } = setup()
expect(hasRenderer.value).toBe(false)
})
it('isSavingHistory is false initially', () => {
const { isSavingHistory } = setup()
expect(isSavingHistory.value).toBe(false)
})
it('previewCanvas is null initially', () => {
const { previewCanvas } = setup()
expect(previewCanvas.value).toBeNull()
})
it('dirtyRect starts with uninitialised sentinel values', () => {
const { dirtyRect } = setup()
expect(dirtyRect.value).toEqual(resetDirtyRect())
})
})
describe('no-op when GPU is not initialised', () => {
it('prepareStroke does not throw', () => {
const { prepareStroke } = setup()
expect(() => prepareStroke()).not.toThrow()
})
it('clearPreview does not throw', () => {
const { clearPreview } = setup()
expect(() => clearPreview()).not.toThrow()
})
it('clearGPU does not throw', () => {
const { clearGPU } = setup()
expect(() => clearGPU()).not.toThrow()
})
it('destroy does not throw', () => {
const { destroy } = setup()
expect(() => destroy()).not.toThrow()
})
it('gpuRender does not throw with empty or non-empty point arrays', () => {
const { gpuRender } = setup()
expect(() => gpuRender([])).not.toThrow()
expect(() => gpuRender([{ x: 10, y: 20 }])).not.toThrow()
})
it('compositeStroke does not throw for any combination of flags', () => {
const { compositeStroke } = setup()
expect(() => compositeStroke(false, false)).not.toThrow()
expect(() => compositeStroke(true, true)).not.toThrow()
})
})
describe('initGPUResources', () => {
it('leaves hasRenderer false when TypeGPU initialisation fails', async () => {
const { initGPUResources, hasRenderer } = setup()
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
})
describe('copyGpuToCanvas', () => {
it('rejects with a descriptive error when GPU resources are not ready', async () => {
const { copyGpuToCanvas } = setup()
await expect(copyGpuToCanvas()).rejects.toThrow('GPU resources not ready')
})
})
describe('watchers', () => {
it('clearTrigger watcher calls clearGPU without throwing', async () => {
setup()
mockStore.clearTrigger++
await nextTick()
})
it('currentStateIndex watcher short-circuits when isSavingHistory is true', async () => {
const { isSavingHistory } = setup()
isSavingHistory.value = true
mockStore.canvasHistory.currentStateIndex++
await nextTick()
})
it('currentStateIndex watcher calls updateGPUFromCanvas when not saving history', async () => {
setup()
mockStore.canvasHistory.currentStateIndex++
await nextTick()
})
it('gpuTexturesNeedRecreation watcher returns early when device is not initialised', async () => {
setup()
mockStore.gpuTexturesNeedRecreation = true
await nextTick()
})
})
describe('initGPUResources with pre-existing tgpuRoot', () => {
it('returns early with a warning when canvas contexts are not ready', async () => {
const { initGPUResources, hasRenderer } = setup()
mockStore.tgpuRoot = { device: {} } as unknown
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
})
describe('initPreviewCanvas', () => {
it('returns early when device is not initialised', () => {
const { initPreviewCanvas } = setup()
const canvas = document.createElement('canvas')
expect(() => initPreviewCanvas(canvas)).not.toThrow()
})
})
describe('gpuDrawPoint', () => {
it('resolves immediately when renderer is not initialised', async () => {
const { gpuDrawPoint } = setup()
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
})
})

View File

@@ -1,624 +0,0 @@
/// <reference types="@webgpu/types" />
import { onUnmounted, ref, watch } from 'vue'
import { tgpu } from 'typegpu'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { parseToRgb } from '@/utils/colorUtil'
import type { DirtyRect } from './brushDrawingUtils'
import {
premultiplyData,
resetDirtyRect,
updateDirtyRect
} from './brushDrawingUtils'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
import { buildStrokePoints, clampDirtyRect } from './gpuUtils'
export function useGPUResources() {
const store = useMaskEditorStore()
// GPU state — plain variables, not reactive, as Vue doesn't need to track them
let maskTexture: GPUTexture | null = null
let rgbTexture: GPUTexture | null = null
let device: GPUDevice | null = null
let renderer: GPUBrushRenderer | null = null
let previewContext: GPUCanvasContext | null = null
// Readback buffers
let readbackStorageMask: GPUBuffer | null = null
let readbackStorageRgb: GPUBuffer | null = null
let readbackStagingMask: GPUBuffer | null = null
let readbackStagingRgb: GPUBuffer | null = null
let currentBufferSize = 0
// Reactive state shared with useBrushDrawing
const previewCanvas = ref<HTMLCanvasElement | null>(null)
const isSavingHistory = ref(false)
const dirtyRect = ref<DirtyRect>(resetDirtyRect())
const hasRenderer = ref(false)
const isRecreatingTextures = ref(false)
// ── Watchers ────────────────────────────────────────────────────────────────
watch(
() => store.clearTrigger,
() => clearGPU()
)
watch(
() => store.canvasHistory.currentStateIndex,
async () => {
if (isSavingHistory.value) return
await updateGPUFromCanvas()
if (renderer && previewContext) renderer.clearPreview(previewContext)
}
)
watch(
() => store.gpuTexturesNeedRecreation,
async (needsRecreation) => {
if (
!needsRecreation ||
!device ||
!store.maskCanvas ||
isRecreatingTextures.value
)
return
/* c8 ignore start */
isRecreatingTextures.value = true
const width = store.gpuTextureWidth
const height = store.gpuTextureHeight
try {
maskTexture?.destroy()
maskTexture = null
rgbTexture?.destroy()
rgbTexture = null
maskTexture = createTexture(device, width, height)
rgbTexture = createTexture(device, width, height)
if (store.pendingGPUMaskData && store.pendingGPURgbData) {
device.queue.writeTexture(
{ texture: maskTexture },
store.pendingGPUMaskData,
{ bytesPerRow: width * 4 },
{ width, height }
)
device.queue.writeTexture(
{ texture: rgbTexture },
store.pendingGPURgbData,
{ bytesPerRow: width * 4 },
{ width, height }
)
} else {
await updateGPUFromCanvas()
}
if (previewCanvas.value && renderer) {
previewCanvas.value.width = width
previewCanvas.value.height = height
}
resizeReadbackBuffers(device, width, height)
} catch (error) {
console.error(
'[useGPUResources] Failed to recreate GPU textures:',
error
)
} finally {
store.gpuTexturesNeedRecreation = false
store.gpuTextureWidth = 0
store.gpuTextureHeight = 0
store.pendingGPUMaskData = null
store.pendingGPURgbData = null
isRecreatingTextures.value = false
}
/* c8 ignore stop */
}
)
onUnmounted(() => {
// c8 ignore start
renderer?.destroy()
renderer = null
hasRenderer.value = false
maskTexture?.destroy()
maskTexture = null
rgbTexture?.destroy()
rgbTexture = null
readbackStorageMask?.destroy()
readbackStorageMask = null
readbackStorageRgb?.destroy()
readbackStorageRgb = null
readbackStagingMask?.destroy()
readbackStagingMask = null
readbackStagingRgb?.destroy()
readbackStagingRgb = null
previewContext = null
previewCanvas.value = null
dirtyRect.value = resetDirtyRect()
// Device is managed by TGPU root; do not destroy it here
// c8 ignore stop
})
// ── Helpers ─────────────────────────────────────────────────────────────────
/* c8 ignore start — requires a live GPUDevice */
function createTexture(
gpuDevice: GPUDevice,
width: number,
height: number
): GPUTexture {
return gpuDevice.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
})
}
function resizeReadbackBuffers(
gpuDevice: GPUDevice,
width: number,
height: number
): void {
const bufferSize = width * height * 4
if (currentBufferSize === bufferSize) return
readbackStorageMask?.destroy()
readbackStorageRgb?.destroy()
readbackStagingMask?.destroy()
readbackStagingRgb?.destroy()
readbackStorageMask = gpuDevice.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStorageRgb = gpuDevice.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStagingMask = gpuDevice.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
readbackStagingRgb = gpuDevice.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
currentBufferSize = bufferSize
}
/* c8 ignore stop */
// ── Internal functions ───────────────────────────────────────────────────────
async function initTypeGPU(): Promise<void> {
if (store.tgpuRoot) {
/* c8 ignore start */
device = store.tgpuRoot.device
return
/* c8 ignore stop */
}
try {
/* c8 ignore start — requires functional WebGPU hardware */
const root = await tgpu.init()
store.tgpuRoot = root
device = root.device
console.warn('✅ TypeGPU initialized! Root:', root)
console.warn('Device info:', root.device.limits)
/* c8 ignore stop */
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn('Failed to initialize TypeGPU:', message)
}
}
async function updateGPUFromCanvas(): Promise<void> {
if (
!device ||
!maskTexture ||
!rgbTexture ||
!store.maskCanvas ||
!store.maskCtx ||
!store.rgbCtx
)
return
/* c8 ignore start — requires live GPU device and textures */
const w = store.maskCanvas.width
const h = store.maskCanvas.height
const maskData = store.maskCtx.getImageData(0, 0, w, h)
premultiplyData(maskData.data)
device.queue.writeTexture(
{ texture: maskTexture },
maskData.data,
{ bytesPerRow: w * 4 },
{ width: w, height: h }
)
const rgbData = store.rgbCtx.getImageData(0, 0, w, h)
premultiplyData(rgbData.data)
device.queue.writeTexture(
{ texture: rgbTexture },
rgbData.data,
{ bytesPerRow: w * 4 },
{ width: w, height: h }
)
/* c8 ignore stop */
}
// ── Public API ───────────────────────────────────────────────────────────────
async function initGPUResources(): Promise<void> {
await initTypeGPU()
if (!store.tgpuRoot || !device) {
console.warn('TypeGPU not initialized, skipping GPU resource setup')
return
}
if (
!store.maskCanvas ||
!store.rgbCanvas ||
!store.maskCtx ||
!store.rgbCtx
) {
console.warn('Canvas contexts not ready, skipping GPU resource setup')
return
}
const w = store.maskCanvas.width
const h = store.maskCanvas.height
/* c8 ignore start — requires functional WebGPU hardware */
try {
console.warn(`🎨 Initializing GPU resources for ${w}x${h} canvas`)
maskTexture = createTexture(device, w, h)
rgbTexture = createTexture(device, w, h)
await updateGPUFromCanvas()
console.warn('✅ GPU resources initialized successfully')
renderer = new GPUBrushRenderer(
device,
navigator.gpu.getPreferredCanvasFormat()
)
hasRenderer.value = true
console.warn('✅ Brush renderer initialized')
} catch (error) {
console.error('Failed to initialize GPU resources:', error)
maskTexture = null
rgbTexture = null
}
/* c8 ignore stop */
}
function initPreviewCanvas(canvas: HTMLCanvasElement): void {
if (!device) return
/* c8 ignore start — requires live GPUDevice and WebGPU canvas context */
const ctx = canvas.getContext('webgpu')
if (!ctx) return
ctx.configure({
device,
format: navigator.gpu.getPreferredCanvasFormat(),
alphaMode: 'premultiplied'
})
previewContext = ctx
previewCanvas.value = canvas
console.warn('✅ Preview Canvas Initialized')
/* c8 ignore stop */
}
function clearGPU(): void {
if (!device || !maskTexture || !rgbTexture || !store.maskCanvas) return
/* c8 ignore start — requires live GPUDevice and textures */
const w = store.maskCanvas.width
const h = store.maskCanvas.height
const zeros = new Uint8Array(w * h * 4)
device.queue.writeTexture(
{ texture: maskTexture },
zeros,
{ bytesPerRow: w * 4 },
{ width: w, height: h }
)
device.queue.writeTexture(
{ texture: rgbTexture },
zeros,
{ bytesPerRow: w * 4 },
{ width: w, height: h }
)
/* c8 ignore stop */
}
function destroy(): void {
renderer?.destroy()
maskTexture?.destroy()
rgbTexture?.destroy()
readbackStorageMask?.destroy()
readbackStorageRgb?.destroy()
readbackStagingMask?.destroy()
readbackStagingRgb?.destroy()
renderer = null
hasRenderer.value = false
maskTexture = null
rgbTexture = null
readbackStorageMask = null
readbackStorageRgb = null
readbackStagingMask = null
readbackStagingRgb = null
currentBufferSize = 0
previewContext = null
previewCanvas.value = null
dirtyRect.value = resetDirtyRect()
/* c8 ignore next — tgpuRoot only exists after successful GPU init */
if (store.tgpuRoot) {
store.tgpuRoot.destroy()
store.tgpuRoot = null
}
device = null
}
// ── Wrappers called by useBrushDrawing ──────────────────────────────────────
function prepareStroke(): void {
if (!renderer || !store.maskCanvas) return
/* c8 ignore next */
renderer.prepareStroke(store.maskCanvas.width, store.maskCanvas.height)
}
function clearPreview(): void {
if (!renderer || !previewContext) return
/* c8 ignore next */
renderer.clearPreview(previewContext)
}
function compositeStroke(isRgb: boolean, isErasing: boolean): void {
if (!renderer || !maskTexture || !rgbTexture || !store.maskCanvas) return
/* c8 ignore start — requires live renderer */
const targetTex = isRgb ? rgbTexture : maskTexture
const { size, hardness, opacity, type } = store.brushSettings
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(
size,
hardness,
effectiveSize
)
const brushShape = type === BrushShape.Rect ? 1 : 0
renderer.compositeStroke(targetTex.createView(), {
opacity,
color: [0, 0, 0],
hardness: effectiveHardness,
screenSize: [store.maskCanvas.width, store.maskCanvas.height],
brushShape,
isErasing
})
/* c8 ignore stop */
}
async function copyGpuToCanvas(): Promise<{
maskData: ImageData
rgbData: ImageData
}> {
if (
!device ||
!maskTexture ||
!rgbTexture ||
!store.maskCanvas ||
!store.rgbCanvas ||
!store.maskCtx ||
!store.rgbCtx ||
!renderer
)
throw new Error('GPU resources not ready')
/* c8 ignore start — requires live GPU device, textures and renderer */
const width = store.maskCanvas.width
const height = store.maskCanvas.height
resizeReadbackBuffers(device, width, height)
renderer.prepareReadback(maskTexture, readbackStorageMask!)
renderer.prepareReadback(rgbTexture, readbackStorageRgb!)
const encoder = device.createCommandEncoder()
encoder.copyBufferToBuffer(
readbackStorageMask!,
0,
readbackStagingMask!,
0,
currentBufferSize
)
encoder.copyBufferToBuffer(
readbackStorageRgb!,
0,
readbackStagingRgb!,
0,
currentBufferSize
)
device.queue.submit([encoder.finish()])
await Promise.all([
readbackStagingMask!.mapAsync(GPUMapMode.READ),
readbackStagingRgb!.mapAsync(GPUMapMode.READ)
])
const maskDataArr = new Uint8ClampedArray(
readbackStagingMask!.getMappedRange().slice(0)
)
const rgbDataArr = new Uint8ClampedArray(
readbackStagingRgb!.getMappedRange().slice(0)
)
readbackStagingMask!.unmap()
readbackStagingRgb!.unmap()
const maskImageData = new ImageData(maskDataArr, width, height)
const rgbImageData = new ImageData(rgbDataArr, width, height)
const { dx, dy, dw, dh } = clampDirtyRect(dirtyRect.value, width, height)
store.maskCtx.putImageData(maskImageData, 0, 0, dx, dy, dw, dh)
store.rgbCtx.putImageData(rgbImageData, 0, 0, dx, dy, dw, dh)
return { maskData: maskImageData, rgbData: rgbImageData }
/* c8 ignore stop */
}
function gpuRender(points: Point[], skipResampling = false): void {
if (!renderer || !maskTexture || !rgbTexture) return
/* c8 ignore start — requires live renderer */
const isRgb = store.activeLayer === 'rgb'
const color = resolveColor(isRgb)
const stepPercentage =
Math.pow(100, store.brushSettings.stepSize / 100) / 100
const gpuStepSize = Math.max(1.0, store.brushSettings.size * stepPercentage)
const strokePoints = buildStrokePoints(points, skipResampling, gpuStepSize)
const { size, hardness } = store.brushSettings
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(
size,
hardness,
effectiveSize
)
const brushShape = store.brushSettings.type === BrushShape.Rect ? 1 : 0
renderer.renderStrokeToAccumulator(strokePoints, {
size: effectiveSize,
opacity: 0.5,
hardness: effectiveHardness,
color,
width: store.maskCanvas!.width,
height: store.maskCanvas!.height,
brushShape
})
for (const p of strokePoints) {
dirtyRect.value = updateDirtyRect(
dirtyRect.value,
p.x,
p.y,
effectiveSize
)
}
if (previewContext) {
const isErasing =
store.currentTool === 'eraser' ||
store.maskCtx?.globalCompositeOperation === 'destination-out'
const targetTex = isRgb ? rgbTexture : maskTexture
renderer.blitToCanvas(
previewContext,
{
opacity: store.brushSettings.opacity,
color,
hardness: effectiveHardness,
screenSize: [store.maskCanvas!.width, store.maskCanvas!.height],
brushShape,
isErasing
},
targetTex ?? undefined
)
}
/* c8 ignore stop */
}
async function gpuDrawPoint(point: Point, opacity = 1): Promise<void> {
if (!renderer) return
/* c8 ignore start — requires live renderer */
const width = store.maskCanvas!.width
const height = store.maskCanvas!.height
const { size, hardness } = store.brushSettings
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(
size,
hardness,
effectiveSize
)
const brushShape = store.brushSettings.type === BrushShape.Rect ? 1 : 0
dirtyRect.value = updateDirtyRect(
dirtyRect.value,
point.x,
point.y,
effectiveSize
)
renderer.renderStrokeToAccumulator(
[{ x: point.x, y: point.y, pressure: opacity }],
{
size: effectiveSize,
opacity: 0.5,
hardness: effectiveHardness,
color: [1, 1, 1],
width,
height,
brushShape
}
)
if (maskTexture && previewContext) {
const isRgb = store.activeLayer === 'rgb'
const isErasing =
store.currentTool === 'eraser' ||
store.maskCtx?.globalCompositeOperation === 'destination-out'
renderer.blitToCanvas(
previewContext,
{
opacity: store.brushSettings.opacity,
color: resolveColor(isRgb),
hardness: effectiveHardness,
screenSize: [width, height],
brushShape,
isErasing
},
undefined
)
}
/* c8 ignore stop */
}
// ── Private helpers ─────────────────────────────────────────────────────────
/* c8 ignore start — only reachable after successful GPU init */
function resolveColor(isRgb: boolean): [number, number, number] {
if (isRgb) {
const c = parseToRgb(store.rgbColor)
return [c.r / 255, c.g / 255, c.b / 255]
}
const c = store.maskColor as { r: number; g: number; b: number }
return [c.r / 255, c.g / 255, c.b / 255]
}
/* c8 ignore stop */
return {
// Lifecycle — spread into useBrushDrawing's public return
initGPUResources,
initPreviewCanvas,
clearGPU,
destroy,
// Rendering — called internally by useBrushDrawing
gpuRender,
gpuDrawPoint,
copyGpuToCanvas,
// Renderer wrappers — called internally by useBrushDrawing
prepareStroke,
clearPreview,
compositeStroke,
// Shared reactive state
hasRenderer,
previewCanvas,
isSavingHistory,
dirtyRect
}
}

View File

@@ -0,0 +1,107 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import type { JobGroup } from '@/composables/queue/useJobList'
const DETAILS_SHOW_DELAY_MS = 200
const DETAILS_HIDE_DELAY_MS = 150
interface UseJobDetailsHoverOptions<TActive> {
getActiveId: (active: TActive) => string
getDisplayedJobGroups: () => JobGroup[]
onReset?: () => void
}
export function useJobDetailsHover<TActive>({
getActiveId,
getDisplayedJobGroups,
onReset
}: UseJobDetailsHoverOptions<TActive>) {
const activeDetails = ref<TActive | null>(null)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
function clearHideTimer() {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
function clearShowTimer() {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
function clearHoverTimers() {
clearHideTimer()
clearShowTimer()
}
function resetActiveDetails() {
clearHoverTimers()
activeDetails.value = null
onReset?.()
}
function hasDisplayedJob(jobId: string) {
return getDisplayedJobGroups().some((group) =>
group.items.some((item) => item.id === jobId)
)
}
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
const nextActiveId = getActiveId(nextActive)
clearShowTimer()
showTimer.value = window.setTimeout(() => {
showTimer.value = null
if (!hasDisplayedJob(nextActiveId)) return
activeDetails.value = nextActive
onShow?.()
}, DETAILS_SHOW_DELAY_MS)
}
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
const currentActive = activeDetails.value
if (currentActive && getActiveId(currentActive) === jobId) {
activeDetails.value = null
onHide?.()
}
hideTimer.value = null
hideTimerJobId.value = null
}, DETAILS_HIDE_DELAY_MS)
}
watch(getDisplayedJobGroups, () => {
const currentActive = activeDetails.value
if (!currentActive) return
if (!hasDisplayedJob(getActiveId(currentActive))) {
resetActiveDetails()
}
})
onBeforeUnmount(resetActiveDetails)
return {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
}
}

View File

@@ -1,5 +1,6 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useCanvasViewportInsets } from '@/composables/canvas/useCanvasViewportInsets'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
@@ -401,7 +402,9 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
app.canvas.fitViewToSelectionAnimated()
app.canvas.fitViewToSelectionAnimated({
insets: useCanvasViewportInsets().value
})
}
},
{

View File

@@ -481,30 +481,6 @@ describe('useImageCrop', () => {
expect(vm.modelValue.x).toBe(50)
})
it('resizes from the top edge, moving y and shrinking height', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 500, 500)
vm.modelValue = { x: 50, y: 100, width: 120, height: 200 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 100), 'top')
resizeMove(makePointerEvent('pointermove', captureEl, 100, 150))
resizeEnd(makePointerEvent('pointerup', captureEl, 100, 150))
expect(vm.modelValue.y).toBeGreaterThan(100)
expect(vm.modelValue.height).toBeLessThan(200)
})
it('applies a preset aspect ratio and clamps height to the image', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 800, 500)

View File

@@ -1,7 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import { nextTick, ref, shallowRef } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -11,7 +9,6 @@ import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import {
@@ -62,18 +59,6 @@ vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
getActivePinia: vi.fn(() => null)
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
describe('useLoad3d', () => {
let mockLoad3d: Partial<Load3d>
let mockNode: LGraphNode
@@ -82,7 +67,6 @@ describe('useLoad3d', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
mockNode = createMockLGraphNode({
properties: {
@@ -350,73 +334,6 @@ describe('useLoad3d', () => {
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})
it('passes getZoomScale callback to createLoad3d', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({ getZoomScale: expect.any(Function) })
)
})
})
describe('zoom watcher', () => {
it('calls load3d.handleResize after debounce when canvas appScalePercentage changes', async () => {
vi.useFakeTimers()
const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
vi.mocked(mockLoad3d.handleResize!).mockClear()
canvasStore.appScalePercentage = 200
await nextTick()
expect(mockLoad3d.handleResize).not.toHaveBeenCalled()
vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it('debounces rapid zoom changes into a single handleResize call', async () => {
vi.useFakeTimers()
const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
vi.mocked(mockLoad3d.handleResize!).mockClear()
canvasStore.appScalePercentage = 150
await nextTick()
canvasStore.appScalePercentage = 200
await nextTick()
canvasStore.appScalePercentage = 250
await nextTick()
vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {

View File

@@ -1,6 +1,6 @@
import type { MaybeRef } from 'vue'
import { toRef, useDebounceFn } from '@vueuse/core'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
@@ -31,7 +31,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLoad3dService } from '@/services/load3dService'
@@ -45,15 +44,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true
const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)
watch(
() => (getActivePinia() ? useCanvasStore().appScalePercentage : 0),
debouncedHandleResize
)
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
@@ -142,7 +132,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
height: heightWidget.value as number
})
: undefined,
getZoomScale: () => app.canvas?.ds?.scale ?? 1,
onContextMenu: (event) => {
const menuOptions = app.canvas.getNodeMenuOptions(node)
new LiteGraph.ContextMenu(menuOptions, {

View File

@@ -404,23 +404,6 @@ describe('useLoad3dViewer', () => {
.intensity
).toBe(1)
})
it('should preserve unknown fields on Model Config when restoring', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
;(
mockNode.properties!['Model Config'] as Record<string, unknown>
).futureField = 'preserve-me'
viewer.restoreInitialState()
expect(
(mockNode.properties!['Model Config'] as Record<string, unknown>)
.futureField
).toBe('preserve-me')
})
})
describe('applyChanges', () => {
@@ -474,23 +457,6 @@ describe('useLoad3dViewer', () => {
expect(result).toBe(false)
})
it('should preserve unknown fields on Model Config when applying', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
;(
mockNode.properties!['Model Config'] as Record<string, unknown>
).futureField = 'preserve-me'
await viewer.applyChanges()
expect(
(mockNode.properties!['Model Config'] as Record<string, unknown>)
.futureField
).toBe('preserve-me')
})
})
describe('refreshViewport', () => {

View File

@@ -619,11 +619,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
intensity: initialState.value.lightIntensity
}
const existingModelConfig = nodeValue.properties['Model Config'] as
| ModelConfig
| undefined
nodeValue.properties['Model Config'] = {
...existingModelConfig,
upDirection: initialState.value.upDirection,
materialMode: initialState.value.materialMode,
gizmo: {
@@ -675,13 +671,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
const gizmoTransform = load3d.getGizmoTransform()
const existingModelConfig = nodeValue.properties['Model Config'] as
| ModelConfig
| undefined
nodeValue.properties['Model Config'] = {
...existingModelConfig,
upDirection: upDirection.value,
materialMode: materialMode.value,
showSkeleton: false,
gizmo: {
enabled: gizmoEnabled.value,
mode: gizmoMode.value,

View File

@@ -1,124 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: vi.fn(),
ui: { settings: { addSetting: vi.fn() } }
}
}))
import {
addWeightToParentheses,
findNearestEnclosure,
incrementWeight
} from './editAttention'
describe('incrementWeight', () => {
it('increments a weight by the given delta', () => {
expect(incrementWeight('1.0', 0.05)).toBe('1.05')
})
it('decrements a weight by the given delta', () => {
expect(incrementWeight('1.05', -0.05)).toBe('1')
})
it('returns the original string when weight is not a number', () => {
expect(incrementWeight('abc', 0.05)).toBe('abc')
})
it('rounds correctly and avoids floating point accumulation', () => {
expect(incrementWeight('1.1', 0.1)).toBe('1.2')
})
it('can produce a weight of zero', () => {
expect(incrementWeight('0.05', -0.05)).toBe('0')
})
it('produces negative weights', () => {
expect(incrementWeight('0.0', -0.05)).toBe('-0.05')
})
})
describe('findNearestEnclosure', () => {
it.each([
[
'returns start and end of a simple parenthesized expression',
'(cat)',
2,
{ start: 1, end: 4 }
],
[
'finds enclosure when cursor is on opening paren',
'(cat)',
0,
{ start: 1, end: 4 }
],
['returns null when there are no parentheses', 'cat dog', 3, null],
['returns null when cursor is outside any enclosure', '(cat) dog', 7, null],
[
'finds the inner enclosure when cursor is on nested content',
'(outer (inner) end)',
9,
{ start: 8, end: 13 }
],
[
'finds the outer enclosure when cursor is on outer content',
'(outer (inner) end)',
2,
{ start: 1, end: 18 }
],
['returns null for empty string', '', 0, null],
[
'returns null when opening paren has no matching closing paren',
'(cat',
2,
null
]
])('%s', (_, text, cursor, expected) => {
expect(findNearestEnclosure(text, cursor)).toEqual(expected)
})
})
describe('addWeightToParentheses', () => {
it.each([
['adds weight 1.0 to a bare parenthesized token', '(cat)', '(cat:1.0)'],
[
'leaves a token that already has a weight unchanged',
'(cat:1.5)',
'(cat:1.5)'
],
['leaves a token without parentheses unchanged', 'cat', 'cat'],
[
'leaves a token with scientific notation weight unchanged',
'(cat:1e-3)',
'(cat:1e-3)'
],
[
'leaves a token with a negative weight unchanged',
'(cat:-0.5)',
'(cat:-0.5)'
],
[
'adds weight to a multi-word parenthesized token',
'(cat dog)',
'(cat dog:1.0)'
],
[
'adds weight when colon-number appears in content but no trailing weight exists',
'(time 12:30)',
'(time 12:30:1.0)'
],
[
'preserves existing weight on a digit-ending token name',
'(v2:1.5)',
'(v2:1.5)'
],
[
'preserves existing weight on a LoRA name ending in a digit',
'(sdxl1:0.8)',
'(sdxl1:0.8)'
]
])('%s', (_, input, expected) => {
expect(addWeightToParentheses(input)).toBe(expected)
})
})

View File

@@ -1,62 +1,6 @@
import { app } from '../../scripts/app'
type Enclosure = {
start: number
end: number
}
export function incrementWeight(weight: string, delta: number): string {
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
return String(Number(newWeight.toFixed(10)))
}
export function findNearestEnclosure(
text: string,
cursorPos: number
): Enclosure | null {
let start = cursorPos
let end = cursorPos
let openCount = 0
let closeCount = 0
if (text[cursorPos] === '(') {
end = cursorPos + 1
} else {
while (start >= 0) {
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return null
openCount = 0
closeCount = 0
}
while (end < text.length) {
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return null
return { start: start + 1, end: end }
}
export function addWeightToParentheses(text: string): string {
const parenMatch = text.match(/^\((.*)\)$/)
if (!parenMatch) return text
const innerText = parenMatch[1]
// A time-like pattern (e.g. "12:30") is preceded by whitespace or string-start;
// everything else ending in ":number" is a weight, including digit-ending names like "v2:1.5".
const looksLikeTime = /(?:^|\s)\d{1,2}:\d{2}$/.test(innerText)
const hasTrailingWeight =
!looksLikeTime && /:[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?$/.test(innerText)
return hasTrailingWeight ? text : `(${innerText}:1.0)`
}
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
app.registerExtension({
name: 'Comfy.EditAttention',
@@ -74,6 +18,65 @@ app.registerExtension({
defaultValue: 0.05
})
function incrementWeight(weight: string, delta: number): string {
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
return String(Number(newWeight.toFixed(10)))
}
type Enclosure = {
start: number
end: number
}
function findNearestEnclosure(
text: string,
cursorPos: number
): Enclosure | null {
let start = cursorPos,
end = cursorPos
let openCount = 0,
closeCount = 0
// Find opening parenthesis before cursor
while (start >= 0) {
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return null
openCount = 0
closeCount = 0
// Find closing parenthesis after cursor
while (end < text.length) {
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return null
return { start: start + 1, end: end }
}
function addWeightToParentheses(text: string): string {
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
const floatMatch = text.match(floatRegex)
if (parenMatch && !floatMatch) {
return `(${parenMatch[1]}:1.0)`
} else {
return text
}
}
function editAttention(event: KeyboardEvent) {
// @ts-expect-error Runtime narrowing not impl.
const inputField: HTMLTextAreaElement = event.composedPath()[0]
@@ -89,6 +92,7 @@ app.registerExtension({
let end = inputField.selectionEnd
let selectedText = inputField.value.substring(start, end)
// If there is no selection, attempt to find the nearest enclosure, or select the current word
if (!selectedText) {
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
if (nearestEnclosure) {
@@ -96,6 +100,7 @@ app.registerExtension({
end = nearestEnclosure.end
selectedText = inputField.value.substring(start, end)
} else {
// Select the current word, find the start and end of the word
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
while (
@@ -117,30 +122,37 @@ app.registerExtension({
}
}
const selectionEndsWithSpace =
selectedText[selectedText.length - 1] === ' '
if (selectionEndsWithSpace) {
// If the selection ends with a space, remove it
if (selectedText[selectedText.length - 1] === ' ') {
selectedText = selectedText.substring(0, selectedText.length - 1)
end -= 1
}
const selectionIsSurroundedByParens =
inputField.value[start - 1] === '(' && inputField.value[end] === ')'
if (selectionIsSurroundedByParens) {
// If there are parentheses left and right of the selection, select them
if (
inputField.value[start - 1] === '(' &&
inputField.value[end] === ')'
) {
start -= 1
end += 1
selectedText = inputField.value.substring(start, end)
}
const selectionIsNotEnclosedInParens =
selectedText[0] !== '(' || selectedText[selectedText.length - 1] !== ')'
if (selectionIsNotEnclosedInParens) selectedText = `(${selectedText})`
// If the selection is not enclosed in parentheses, add them
if (
selectedText[0] !== '(' ||
selectedText[selectedText.length - 1] !== ')'
) {
selectedText = `(${selectedText})`
}
// If the selection does not have a weight, add a weight of 1.0
selectedText = addWeightToParentheses(selectedText)
// Increment the weight
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
const updatedText = selectedText.replace(
/\((.*):([+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?)\)/,
/\((.*):([+-]?\d+(?:\.\d+)?)\)/,
(_, text, weight) => {
weight = incrementWeight(weight, weightDelta)
if (weight == 1) {

View File

@@ -280,7 +280,7 @@ describe('Load3d', () => {
const sceneResize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
renderer: { domElement: canvas, setSize },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
@@ -383,70 +383,6 @@ describe('Load3d', () => {
expect(args[2]).toBe(800)
expect(args[3]).toBe(400)
})
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setPixelRatio = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: () => 2.5,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})
ctx.load3d.handleResize()
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
})
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setPixelRatio = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: undefined,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})
ctx.load3d.handleResize()
expect(setPixelRatio).toHaveBeenCalledWith(1)
})
})
describe('render loop wiring', () => {

View File

@@ -102,7 +102,6 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
constructor(
container: Element | HTMLElement,
@@ -113,7 +112,6 @@ class Load3d {
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
if (options.width && options.height) {
this.targetWidth = options.width
@@ -647,11 +645,6 @@ class Load3d {
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {

View File

@@ -25,37 +25,6 @@ vi.mock('three', async (importOriginal) => {
return { ...actual, TextureLoader: StubTextureLoader }
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
class OrbitControls {}
return { OrbitControls }
})
function makeMockRenderer(pixelRatio = 1): THREE.WebGLRenderer {
const domElement = {
toDataURL: vi.fn().mockReturnValue('data:image/png;base64,abc'),
clientWidth: 400,
clientHeight: 300
}
return {
domElement,
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.ACESFilmicToneMapping,
toneMappingExposure: 1,
getSize: vi.fn((v: THREE.Vector2) => {
v.set(400, 300)
return v
}),
getPixelRatio: vi.fn().mockReturnValue(pixelRatio),
getClearColor: vi.fn((c: THREE.Color) => c),
getClearAlpha: vi.fn().mockReturnValue(0),
setPixelRatio: vi.fn(),
setSize: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
@@ -81,12 +50,6 @@ function makeRenderer() {
domElement: canvas,
setClearColor: vi.fn(),
setSize: vi.fn(),
getSize: vi.fn((v: THREE.Vector2) => {
v.set(800, 600)
return v
}),
getPixelRatio: vi.fn().mockReturnValue(1),
setPixelRatio: vi.fn(),
render: vi.fn(),
clear: vi.fn(),
getClearColor: vi.fn().mockReturnValue(new THREE.Color(0xffffff)),
@@ -581,90 +544,3 @@ describe('SceneManager', () => {
})
})
})
function makeSceneManager(
pixelRatio = 1,
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera
) {
const renderer = makeMockRenderer(pixelRatio)
const camera = cameraOverride ?? new THREE.PerspectiveCamera()
const eventManager = makeMockEventManager()
const manager = new SceneManager(
renderer,
() => camera,
vi.fn() as unknown as () => InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>,
eventManager
)
return { manager, renderer, camera, eventManager }
}
describe('SceneManager.captureScene', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves with scene, mask, and normal data URLs', async () => {
const { manager } = makeSceneManager()
const result = await manager.captureScene(800, 600)
expect(result.scene).toContain('data:image/png')
expect(result.mask).toContain('data:image/png')
expect(result.normal).toContain('data:image/png')
})
it('forces pixel ratio to 1 before rendering regardless of original value', async () => {
const { manager, renderer } = makeSceneManager(2)
await manager.captureScene(800, 600)
expect(vi.mocked(renderer.setPixelRatio).mock.calls[0]).toEqual([1])
})
it('restores original pixel ratio after capture completes', async () => {
const originalPixelRatio = 3
const { manager, renderer } = makeSceneManager(originalPixelRatio)
await manager.captureScene(800, 600)
const calls = vi.mocked(renderer.setPixelRatio).mock.calls
expect(calls.at(-1)).toEqual([originalPixelRatio])
})
it('renders at requested capture dimensions', async () => {
const { manager, renderer } = makeSceneManager()
await manager.captureScene(1920, 1080)
expect(vi.mocked(renderer.setSize).mock.calls[0]).toEqual([1920, 1080])
})
it('restores original renderer size after capture', async () => {
const { manager, renderer } = makeSceneManager()
await manager.captureScene(1920, 1080)
const calls = vi.mocked(renderer.setSize).mock.calls
expect(calls.at(-1)).toEqual([400, 300])
})
it('restores perspective camera aspect after capture', async () => {
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
const { manager } = makeSceneManager(1, camera)
const originalAspect = camera.aspect
await manager.captureScene(800, 600)
expect(camera.aspect).toBe(originalAspect)
})
it('restores orthographic camera bounds after capture', async () => {
const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 1000)
const { manager } = makeSceneManager(1, camera)
await manager.captureScene(800, 600)
expect(camera.left).toBe(-5)
expect(camera.right).toBe(5)
expect(camera.top).toBe(5)
expect(camera.bottom).toBe(-5)
})
it('disposes each temporary MeshNormalMaterial after the normal pass', async () => {
const { manager } = makeSceneManager()
manager.scene.add(
new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
)
const disposeSpy = vi.spyOn(THREE.MeshNormalMaterial.prototype, 'dispose')
await manager.captureScene(800, 600)
expect(disposeSpy).toHaveBeenCalledOnce()
})
})

View File

@@ -332,138 +332,120 @@ export class SceneManager implements SceneManagerInterface {
}
}
async captureScene(
captureScene(
width: number,
height: number
): Promise<{ scene: string; mask: string; normal: string }> {
const originalSize = new THREE.Vector2()
this.renderer.getSize(originalSize)
const originalPixelRatio = this.renderer.getPixelRatio()
const originalClearColor = this.renderer.getClearColor(new THREE.Color())
const originalClearAlpha = this.renderer.getClearAlpha()
const originalOutputColorSpace = this.renderer.outputColorSpace
const activeCamera = this.getActiveCamera()
const savedCameraParams =
activeCamera instanceof THREE.PerspectiveCamera
? { type: 'perspective' as const, aspect: activeCamera.aspect }
: {
type: 'orthographic' as const,
left: (activeCamera as THREE.OrthographicCamera).left,
right: (activeCamera as THREE.OrthographicCamera).right,
top: (activeCamera as THREE.OrthographicCamera).top,
bottom: (activeCamera as THREE.OrthographicCamera).bottom
}
const originalMaterials = new Map<
THREE.Mesh,
THREE.Material | THREE.Material[]
>()
const tempMaterials: THREE.MeshNormalMaterial[] = []
const gridVisible = this.gridHelper.visible
try {
// Capture at exactly the requested pixel dimensions, independent of
// the current zoom-driven pixel ratio.
this.renderer.setPixelRatio(1)
this.renderer.setSize(width, height)
if (activeCamera instanceof THREE.PerspectiveCamera) {
activeCamera.aspect = width / height
activeCamera.updateProjectionMatrix()
} else {
const orthographicCamera = activeCamera as THREE.OrthographicCamera
const frustumSize = 10
const aspect = width / height
orthographicCamera.left = (-frustumSize * aspect) / 2
orthographicCamera.right = (frustumSize * aspect) / 2
orthographicCamera.top = frustumSize / 2
orthographicCamera.bottom = -frustumSize / 2
orthographicCamera.updateProjectionMatrix()
}
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
width,
height
return new Promise(async (resolve, reject) => {
try {
const originalWidth = this.renderer.domElement.width
const originalHeight = this.renderer.domElement.height
const originalClearColor = this.renderer.getClearColor(
new THREE.Color()
)
}
const originalClearAlpha = this.renderer.getClearAlpha()
const originalOutputColorSpace = this.renderer.outputColorSpace
this.renderer.clear()
this.renderBackground()
this.renderer.render(this.scene, activeCamera)
const sceneData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setSize(width, height)
this.renderer.setClearColor(0x000000, 0)
this.renderer.clear()
this.renderer.render(this.scene, activeCamera)
const maskData = this.renderer.domElement.toDataURL('image/png')
if (this.getActiveCamera() instanceof THREE.PerspectiveCamera) {
const perspectiveCamera =
this.getActiveCamera() as THREE.PerspectiveCamera
this.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
originalMaterials.set(child, child.material)
perspectiveCamera.aspect = width / height
perspectiveCamera.updateProjectionMatrix()
} else {
const orthographicCamera =
this.getActiveCamera() as THREE.OrthographicCamera
const tempMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1)
})
tempMaterials.push(tempMaterial)
child.material = tempMaterial
const frustumSize = 10
const aspect = width / height
orthographicCamera.left = (-frustumSize * aspect) / 2
orthographicCamera.right = (frustumSize * aspect) / 2
orthographicCamera.top = frustumSize / 2
orthographicCamera.bottom = -frustumSize / 2
orthographicCamera.updateProjectionMatrix()
}
})
this.gridHelper.visible = false
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
width,
height
)
}
this.renderer.setClearColor(0x000000, 1)
this.renderer.clear()
this.renderer.render(this.scene, activeCamera)
const normalData = this.renderer.domElement.toDataURL('image/png')
const originalMaterials = new Map<
THREE.Mesh,
THREE.Material | THREE.Material[]
>()
this.renderer.setClearColor(0xffffff, 1)
this.renderer.clear()
this.renderer.clear()
this.renderBackground()
this.renderer.render(this.scene, this.getActiveCamera())
const sceneData = this.renderer.domElement.toDataURL('image/png')
return { scene: sceneData, mask: maskData, normal: normalData }
} finally {
this.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
const originalMaterial = originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
this.renderer.setClearColor(0x000000, 0)
this.renderer.clear()
this.renderer.render(this.scene, this.getActiveCamera())
const maskData = this.renderer.domElement.toDataURL('image/png')
this.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
originalMaterials.set(child, child.material)
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1)
})
}
}
})
for (const mat of tempMaterials) {
mat.dispose()
})
const gridVisible = this.gridHelper.visible
this.gridHelper.visible = false
this.renderer.setClearColor(0x000000, 1)
this.renderer.clear()
this.renderer.render(this.scene, this.getActiveCamera())
const normalData = this.renderer.domElement.toDataURL('image/png')
this.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
const originalMaterial = originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
}
}
})
this.renderer.setClearColor(0xffffff, 1)
this.renderer.clear()
this.gridHelper.visible = gridVisible
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
this.renderer.setSize(originalWidth, originalHeight)
this.renderer.outputColorSpace = originalOutputColorSpace
this.handleResize(originalWidth, originalHeight)
resolve({
scene: sceneData,
mask: maskData,
normal: normalData
})
} catch (error) {
reject(error)
}
this.gridHelper.visible = gridVisible
if (savedCameraParams.type === 'perspective') {
const persp = activeCamera as THREE.PerspectiveCamera
persp.aspect = savedCameraParams.aspect
persp.updateProjectionMatrix()
} else {
const ortho = activeCamera as THREE.OrthographicCamera
ortho.left = savedCameraParams.left
ortho.right = savedCameraParams.right
ortho.top = savedCameraParams.top
ortho.bottom = savedCameraParams.bottom
ortho.updateProjectionMatrix()
}
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
this.renderer.setPixelRatio(originalPixelRatio)
this.renderer.setSize(originalSize.x, originalSize.y)
this.renderer.outputColorSpace = originalOutputColorSpace
this.handleResize(originalSize.x, originalSize.y)
}
})
}
reset(): void {}

View File

@@ -211,40 +211,20 @@ describe('SceneModelManager', () => {
expect(setupCamera).toHaveBeenCalled()
})
it('reapplies non-original materialMode after snapshotting', async () => {
it('does not skip materialMode when it differs from original', async () => {
const { manager } = createManager()
const model = createMeshModel()
// setupModel calls setupModelMaterials first (which internally calls
// setMaterialMode('original') to reset), then reapplies the stored mode.
// setupModel checks materialMode !== 'original' and calls
// setMaterialMode, but the guard `mode === this.materialMode`
// causes it to no-op. Then setupModelMaterials resets to 'original'.
manager.materialMode = 'wireframe'
const spy = vi.spyOn(manager, 'setMaterialMode')
await manager.setupModel(model)
expect(spy).toHaveBeenCalledWith('original')
// setMaterialMode is called with the stored mode and then 'original'
expect(spy).toHaveBeenCalledWith('wireframe')
// The final material mode visible on the mesh should be wireframe.
const mesh = model.children[0] as THREE.Mesh
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
})
it('snapshots original materials before applying materialMode so restore is correct', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
// Set a non-original mode before loading — this was the bug:
// originalMaterials would capture the wireframe material instead of the real one.
manager.materialMode = 'wireframe'
await manager.setupModel(model)
// The snapshot must hold the *pre-mutation* material.
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
// Restoring to 'original' must give back the true original, not wireframe.
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
expect(spy).toHaveBeenCalledWith('original')
})
it('applies current up direction if not original', async () => {
@@ -699,80 +679,6 @@ describe('SceneModelManager', () => {
})
})
describe('fitToViewer', () => {
it('does nothing when no current model', () => {
const { manager, setupCamera, setupGizmo } = createManager()
manager.fitToViewer()
expect(setupCamera).not.toHaveBeenCalled()
expect(setupGizmo).not.toHaveBeenCalled()
})
it('reapplies currentUpDirection after fitting', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+z')
vi.mocked(eventManager.emitEvent).mockClear()
manager.fitToViewer()
// rotation.x should reflect +z direction (-PI/2) applied to the post-fit base (0,0,0)
expect(model.rotation.x).toBeCloseTo(-Math.PI / 2)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
it('does not compound rotations when fitToViewer is called multiple times', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('-x')
manager.fitToViewer()
const rotationAfterFirst = model.rotation.z
manager.fitToViewer()
expect(model.rotation.z).toBeCloseTo(rotationAfterFirst)
})
it('leaves rotation at zero when currentUpDirection is original', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.fitToViewer()
expect(model.rotation.x).toBeCloseTo(0)
expect(model.rotation.y).toBeCloseTo(0)
expect(model.rotation.z).toBeCloseTo(0)
})
it('does not compound rotation when fitToViewer is called after manual rotation override', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
// Set an up direction, then manually override originalRotation to simulate
// a prior state where the base rotation was non-zero before fit
manager.setUpDirection('+x')
// Simulate that originalRotation was captured at a non-zero rotation
manager.originalRotation = new THREE.Euler(0.5, 0.3, 0.1)
manager.fitToViewer()
// After fit, the rotation should be correct for +x direction applied to (0,0,0) base
// Not compounded with the stale originalRotation
expect(model.rotation.x).toBeCloseTo(0)
expect(model.rotation.z).toBeCloseTo(-Math.PI / 2)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager({

View File

@@ -447,16 +447,15 @@ export class SceneModelManager implements ModelManagerInterface {
}
this.scene.add(model)
const pendingMaterialMode = this.materialMode
this.setupModelMaterials(model)
if (pendingMaterialMode !== 'original') {
this.setMaterialMode(pendingMaterialMode)
if (this.materialMode !== 'original') {
this.setMaterialMode(this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}
this.setupModelMaterials(model)
const box = this.computeWorldBounds(model)
const size = box.getSize(new THREE.Vector3())
@@ -492,13 +491,6 @@ export class SceneModelManager implements ModelManagerInterface {
model.position.set(-center.x, -scaledBox.min.y, -center.z)
// fitToViewer resets rotation to (0,0,0), so originalRotation must be cleared
// before reapplying currentUpDirection to avoid compounding rotations.
this.originalRotation = null
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}
const newBox = this.computeWorldBounds(model)
const newSize = newBox.getSize(new THREE.Vector3())
const newCenter = newBox.getCenter(new THREE.Vector3())

View File

@@ -77,11 +77,6 @@ export interface Load3DOptions {
// Use this for reactive dimensions that change over time
getDimensions?: () => { width: number; height: number } | null
// Returns the current canvas zoom scale (e.g. ds.scale from LiteGraph).
// Used to scale the renderer pixel ratio so the 3D scene renders at the
// correct resolution when the graph is zoomed in or out.
getZoomScale?: () => number
// Viewer mode flag (affects aspect ratio behavior)
isViewerMode?: boolean

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { DragAndScale } from './DragAndScale'
function createDragAndScale(width: number, height: number): DragAndScale {
const dpr = window.devicePixelRatio
const element = {
width: width * dpr,
height: height * dpr
} as HTMLCanvasElement
return new DragAndScale(element)
}
describe('DragAndScale.fitToBounds', () => {
it('centers bounds in viewport without insets', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
ds.fitToBounds(bounds)
// Content should be centered in full viewport
const scaledW = 1920 / ds.scale
const scaledH = 1080 / ds.scale
expect(ds.offset[0]).toBeCloseTo(-bounds[2] * 0.5 + scaledW * 0.5)
expect(ds.offset[1]).toBeCloseTo(-bounds[3] * 0.5 + scaledH * 0.5)
})
it('centers bounds within the unobscured region for a left inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const left = 300
ds.fitToBounds(bounds, { insets: { left } })
// The bounds center should map to the center of the visible region,
// i.e. left + (fullCw - left) / 2 in canvas pixels.
const boundsCenterX = bounds[0] + bounds[2] * 0.5
const visibleCenterX = left + (1920 - left) / 2
expect((boundsCenterX + ds.offset[0]) * ds.scale).toBeCloseTo(
visibleCenterX
)
})
it('centers bounds within the unobscured region for a right inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const right = 300
ds.fitToBounds(bounds, { insets: { right } })
const boundsCenterX = bounds[0] + bounds[2] * 0.5
const visibleCenterX = (1920 - right) / 2
expect((boundsCenterX + ds.offset[0]) * ds.scale).toBeCloseTo(
visibleCenterX
)
})
it('centers bounds within the unobscured region for a top inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const top = 200
ds.fitToBounds(bounds, { insets: { top } })
const boundsCenterY = bounds[1] + bounds[3] * 0.5
const visibleCenterY = top + (1080 - top) / 2
expect((boundsCenterY + ds.offset[1]) * ds.scale).toBeCloseTo(
visibleCenterY
)
})
it('uses reduced viewport for scale calculation with insets', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 800, 600]
const dsNoInset = createDragAndScale(1920, 1080)
dsNoInset.fitToBounds(bounds)
ds.fitToBounds(bounds, { insets: { left: 300, right: 300 } })
// Insets reduce available width, so scale should be smaller
expect(ds.scale).toBeLessThan(dsNoInset.scale)
})
it('does nothing different when insets are all zero', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const dsNoInset = createDragAndScale(1920, 1080)
dsNoInset.fitToBounds(bounds)
ds.fitToBounds(bounds, { insets: { left: 0, right: 0, top: 0, bottom: 0 } })
expect(ds.scale).toBeCloseTo(dsNoInset.scale)
expect(ds.offset[0]).toBeCloseTo(dsNoInset.offset[0])
expect(ds.offset[1]).toBeCloseTo(dsNoInset.offset[1])
})
})
describe('DragAndScale.animateToBounds', () => {
it('accepts insets in animation options', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
// Should not throw when insets are provided
expect(() =>
ds.animateToBounds(bounds, () => {}, {
duration: 1,
insets: { left: 300, right: 200 }
})
).not.toThrow()
})
it('ends at the same state as fitToBounds with the same insets', () => {
vi.useFakeTimers()
try {
const bounds: [number, number, number, number] = [50, 50, 500, 400]
const insets = { left: 150, right: 250, top: 100 }
const dsFit = createDragAndScale(1200, 900)
dsFit.fitToBounds(bounds, { insets })
const dsAnimate = createDragAndScale(1200, 900)
dsAnimate.animateToBounds(bounds, () => {}, { duration: 350, insets })
vi.advanceTimersByTime(400)
expect(dsAnimate.scale).toBeCloseTo(dsFit.scale, 4)
expect(dsAnimate.offset[0]).toBeCloseTo(dsFit.offset[0], 4)
expect(dsAnimate.offset[1]).toBeCloseTo(dsFit.offset[1], 4)
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -11,6 +11,18 @@ export interface DragAndScaleState {
scale: number
}
/**
* Insets that reduce the effective viewport area used by fit-to-bounds
* calculations. Each value is in CSS pixels and represents the width/height
* of UI panels overlaying the canvas on each side.
*/
export interface ViewportInsets {
left?: number
right?: number
top?: number
bottom?: number
}
export type AnimationOptions = {
/** Duration of the animation in milliseconds. */
duration?: number
@@ -18,6 +30,8 @@ export type AnimationOptions = {
zoom?: number
/** The animation easing function (curve) */
easing?: EaseFunction
/** Insets that reduce the effective viewport for panel-aware fitting. */
insets?: ViewportInsets
}
export class DragAndScale {
@@ -190,7 +204,7 @@ export class DragAndScale {
*/
fitToBounds(
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
{ zoom = 0.75, insets }: { zoom?: number; insets?: ViewportInsets } = {}
): void {
//If element hasn't initialized (browser tab is in background)
//it has a size of 300x150 and a more reasonable default is used instead.
@@ -198,8 +212,16 @@ export class DragAndScale {
this.element.width === 300 && this.element.height === 150
? [1920, 1080]
: [this.element.width, this.element.height]
const cw = width / window.devicePixelRatio
const ch = height / window.devicePixelRatio
const fullCw = width / window.devicePixelRatio
const fullCh = height / window.devicePixelRatio
const insetLeft = insets?.left ?? 0
const insetRight = insets?.right ?? 0
const insetTop = insets?.top ?? 0
const insetBottom = insets?.bottom ?? 0
const cw = fullCw - insetLeft - insetRight
const ch = fullCh - insetTop - insetBottom
let targetScale = this.scale
if (zoom > 0) {
@@ -214,9 +236,12 @@ export class DragAndScale {
const scaledWidth = cw / targetScale
const scaledHeight = ch / targetScale
// Calculate the target position to center the bounds in the viewport
const targetX = -bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5
const targetY = -bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5
// Calculate the target position to center the bounds in the visible area
// Shift by insetLeft/insetTop so content is centered within the unobscured region
const targetX =
-bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5 + insetLeft / targetScale
const targetY =
-bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5 + insetTop / targetScale
// Apply the changes immediately
this.offset[0] = targetX
@@ -234,7 +259,8 @@ export class DragAndScale {
{
duration = 350,
zoom = 0.75,
easing = EaseFunction.EASE_IN_OUT_QUAD
easing = EaseFunction.EASE_IN_OUT_QUAD,
insets
}: AnimationOptions = {}
) {
if (!(duration > 0)) throw new RangeError('Duration must be greater than 0')
@@ -247,13 +273,20 @@ export class DragAndScale {
}
const easeFunction = easeFunctions[easing] ?? easeFunctions.linear
const insetLeft = insets?.left ?? 0
const insetRight = insets?.right ?? 0
const insetTop = insets?.top ?? 0
const insetBottom = insets?.bottom ?? 0
const startTimestamp = performance.now()
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
const fullCw = this.element.width / window.devicePixelRatio
const fullCh = this.element.height / window.devicePixelRatio
const cw = fullCw - insetLeft - insetRight
const ch = fullCh - insetTop - insetBottom
const startX = this.offset[0]
const startY = this.offset[1]
const startX2 = startX - cw / this.scale
const startY2 = startY - ch / this.scale
const startX2 = startX - fullCw / this.scale
const startY2 = startY - fullCh / this.scale
const startScale = this.scale
let targetScale = startScale
@@ -268,10 +301,12 @@ export class DragAndScale {
const scaledWidth = cw / targetScale
const scaledHeight = ch / targetScale
const targetX = -bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5
const targetY = -bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5
const targetX2 = targetX - scaledWidth
const targetY2 = targetY - scaledHeight
const targetX =
-bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5 + insetLeft / targetScale
const targetY =
-bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5 + insetTop / targetScale
const targetX2 = targetX - fullCw / targetScale
const targetY2 = targetY - fullCh / targetScale
const animate = (timestamp: number) => {
const elapsed = timestamp - startTimestamp
@@ -289,7 +324,7 @@ export class DragAndScale {
const currentWidth = Math.abs(currentX2 - currentX)
const currentHeight = Math.abs(currentY2 - currentY)
this.scale = Math.min(cw / currentWidth, ch / currentHeight)
this.scale = Math.min(fullCw / currentWidth, fullCh / currentHeight)
}
setDirty()

View File

@@ -294,7 +294,7 @@
"label": "說明中心"
},
"Comfy_ToggleLinear": {
"label": "切換應用模式"
"label": "切換線性模式"
},
"Comfy_ToggleQPOV2": {
"label": "切換佇列面板 V2"
@@ -312,7 +312,7 @@
"label": "登出"
},
"Experimental_ToggleVueNodes": {
"label": "啟用 Nodes 2.0"
"label": "實驗性:啟用 Vue 節點"
},
"Workspace_CloseWorkflow": {
"label": "關閉當前工作流程"

View File

@@ -903,8 +903,8 @@
"resume": "繼續下載"
},
"errorDialog": {
"accessRestrictedMessage": "您的帳戶無權使用此功能。",
"accessRestrictedTitle": "存取受限",
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "發生錯誤",
"extensionFileHint": "這可能是由於以下指令碼所致",
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",

View File

@@ -371,7 +371,7 @@
},
"BriaImageEditNode": {
"description": "使用 Bria 最新模型編輯圖像",
"display_name": "Bria FIBO 圖像編輯",
"display_name": "Bria 圖像編輯",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
@@ -864,7 +864,7 @@
},
"ByteDanceSeedreamNode": {
"description": "統一文字生成圖片和精確單句編輯,最高支援 4K 解析度。",
"display_name": "ByteDance Seedream 4.5 & 5.0",
"display_name": "字節跳動 Seedream 4",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
@@ -1047,7 +1047,7 @@
}
},
"CLIPLoader": {
"description": "[配方]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncosmos: 舊 t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\nhidream: llama-3.1(推薦)或 t5\nomnigen2: qwen vl 2.5 3B",
"description": "[配方]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncosmos: 舊 t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\nhidream: llama-3.1(推薦)或 t5",
"display_name": "載入 CLIP",
"inputs": {
"clip_name": {

View File

@@ -428,11 +428,11 @@
"name": "驗證工作流程"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "自動縮放佈局 (Nodes 2.0)",
"name": "自動縮放佈局 (Vue 節點)",
"tooltip": "切換至 Vue 渲染時自動縮放節點位置以防止重疊"
},
"Comfy_VueNodes_Enabled": {
"name": "現代節點設計 (Nodes 2.0)",
"name": "現代節點設計 (Vue 節點)",
"tooltip": "現代:基於 DOM 的渲染,具備增強的互動性、原生瀏覽器功能和更新的視覺設計。經典:傳統畫布渲染。"
},
"Comfy_WidgetControlMode": {

View File

@@ -294,7 +294,7 @@
"label": "说明中心"
},
"Comfy_ToggleLinear": {
"label": "切换应用模式"
"label": "切换线性模式"
},
"Comfy_ToggleQPOV2": {
"label": "切换队列面板V2"
@@ -312,7 +312,7 @@
"label": "退出登录"
},
"Experimental_ToggleVueNodes": {
"label": "启用 Nodes 2.0"
"label": "实验性:启用 Vue 节点"
},
"Workspace_CloseWorkflow": {
"label": "关闭当前工作流"

View File

@@ -903,8 +903,8 @@
"resume": "恢复下载"
},
"errorDialog": {
"accessRestrictedMessage": "您的账户无权使用此功能。",
"accessRestrictedTitle": "访问受限",
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "发生错误",
"extensionFileHint": "这可能是由于以下脚本",
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",

View File

@@ -3,7 +3,7 @@
"display_name": "自适应投影引导",
"inputs": {
"eta": {
"name": "eta",
"name": "预计到达时间",
"tooltip": "控制平行引导向量的缩放比例。默认 CFG 行为设置为 1。"
},
"model": {
@@ -371,7 +371,7 @@
},
"BriaImageEditNode": {
"description": "使用 Bria 最新模型编辑图像",
"display_name": "Bria FIBO 图像编辑",
"display_name": "Bria 图像编辑",
"inputs": {
"control_after_generate": {
"name": "control after generate"
@@ -864,7 +864,7 @@
},
"ByteDanceSeedreamNode": {
"description": "统一文本到图像生成支持高达4K分辨率的精确单句编辑。",
"display_name": "ByteDance Seedream 4.5 & 5.0",
"display_name": "字节跳动Seedream 4",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
@@ -1047,7 +1047,7 @@
}
},
"CLIPLoader": {
"description": "[配方]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncosmos: 旧 t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\nhidream: llama-3.1(推荐)或 t5\nomnigen2: qwen vl 2.5 3B",
"description": "[配方]\n\nStable Diffusionclip-l\nStable Cascadeclip-g\nSD3t5 / clip-g / clip-l\nStable Audiot5\nMochit5\ncosmosold t5 xxl",
"display_name": "加载CLIP",
"inputs": {
"clip_name": {

View File

@@ -428,11 +428,11 @@
"name": "校验工作流"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "自动缩放布局(Nodes 2.0",
"name": "自动缩放布局(Vue节点",
"tooltip": "切换到Vue渲染时自动缩放节点位置以防止重叠"
},
"Comfy_VueNodes_Enabled": {
"name": "现代节点设计(Nodes 2.0",
"name": "现代节点设计(Vue节点",
"tooltip": "现代基于DOM的渲染具有增强的交互性、原生浏览器功能和更新的视觉设计。经典传统的画布渲染。"
},
"Comfy_WidgetControlMode": {

View File

@@ -1,349 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
import type {
PendingInvite,
WorkspaceMember
} from '../../../stores/teamWorkspaceStore'
const mockHandleCopyInviteLink = vi.fn()
const mockHandleRevokeInvite = vi.fn()
const mockHandleCreateWorkspace = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockSelectMember = vi.fn()
const mockToggleSort = vi.fn()
const {
mockMembers,
mockPendingInvites,
mockFilteredMembers,
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsSingleSeatPlan,
mockIsActiveSubscription,
mockActiveView,
mockSearchQuery,
mockPermissions,
mockUiConfig
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockFilteredMembers: ref<WorkspaceMember[]>([]),
mockFilteredPendingInvites: ref<PendingInvite[]>([]),
mockIsPersonalWorkspace: ref(false),
mockIsSingleSeatPlan: ref(false),
mockIsActiveSubscription: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
})
}
})
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
useMembersPanel: () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
isSingleSeatPlan: mockIsSingleSeatPlan,
personalWorkspaceMember: computed(() => ({
id: 'self',
name: 'Owner User',
email: 'owner@example.com',
role: 'owner' as const,
joinDate: new Date(0)
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: computed(() => []),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
isActiveSubscription: mockIsActiveSubscription,
userPhotoUrl: ref(null),
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
selectMember: mockSelectMember,
toggleSort: mockToggleSort,
showSubscriptionDialog: mockShowSubscriptionDialog,
handleCopyInviteLink: mockHandleCopyInviteLink,
handleRevokeInvite: mockHandleRevokeInvite,
handleCreateWorkspace: mockHandleCreateWorkspace,
handleRemoveMember: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
const ButtonStub = {
name: 'Button',
template:
'<button :disabled="disabled" :aria-label="ariaLabel" @click="$emit(\'click\', $event)"><slot /></button>',
props: ['disabled', 'loading', 'variant', 'size', 'ariaLabel']
}
const SearchInputStub = {
name: 'SearchInput',
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'placeholder', 'size'],
emits: ['update:modelValue']
}
function renderComponent() {
return render(MembersPanelContent, {
global: {
plugins: [i18n],
stubs: {
Button: ButtonStub,
SearchInput: SearchInputStub,
UserAvatar: true,
Menu: { template: '<div />', props: ['model', 'popup'] }
},
directives: { tooltip: () => {} }
}
})
}
function createMember(
overrides: Partial<WorkspaceMember> = {}
): WorkspaceMember {
return {
id: 'member-1',
name: 'Member One',
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
...overrides
}
}
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
token: 'token-abc',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
}
}
describe('MembersPanelContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockPendingInvites.value = []
mockFilteredMembers.value = []
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsSingleSeatPlan.value = false
mockIsActiveSubscription.value = true
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
}
mockUiConfig.value = {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip: null
}
})
describe('personal workspace', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockUiConfig.value.showMembersList = false
mockUiConfig.value.showSearch = false
mockUiConfig.value.showPendingTab = false
})
it('shows personal workspace message and create workspace button', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.personalWorkspaceMessage')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.createNewWorkspace')
).toBeTruthy()
})
it('calls handleCreateWorkspace when create button is clicked', async () => {
renderComponent()
await userEvent.click(
screen.getByText('workspacePanel.members.createNewWorkspace')
)
expect(mockHandleCreateWorkspace).toHaveBeenCalled()
})
it('does not show search input', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
})
})
describe('team workspace - member list', () => {
it('renders filtered members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Alice', email: 'alice@test.com' }),
createMember({
id: '2',
name: 'Bob',
email: 'bob@test.com'
})
]
renderComponent()
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
})
it('shows more options button for non-current members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Other', email: 'other@test.com' })
]
renderComponent()
expect(
screen.queryAllByRole('button', { name: 'g.moreOptions' })
).toHaveLength(1)
})
it('does not show more options for current user', () => {
mockFilteredMembers.value = [
createMember({ name: 'Owner User', email: 'owner@example.com' })
]
renderComponent()
expect(
screen.queryAllByRole('button', { name: 'g.moreOptions' })
).toHaveLength(0)
})
})
describe('pending invites tab', () => {
it('shows pending tab button when configured', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeTruthy()
})
it('triggers handleRevokeInvite on revoke click', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
const revokeBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.revokeInvite'
})
await userEvent.click(revokeBtn)
expect(mockHandleRevokeInvite).toHaveBeenCalled()
})
it('triggers handleCopyInviteLink on copy click', async () => {
mockActiveView.value = 'pending'
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
renderComponent()
const copyBtn = screen.getByRole('button', {
name: 'workspacePanel.members.actions.copyLink'
})
await userEvent.click(copyBtn)
expect(mockHandleCopyInviteLink).toHaveBeenCalled()
})
})
describe('single seat plan', () => {
beforeEach(() => {
mockIsSingleSeatPlan.value = true
})
it('shows upsell banner', () => {
renderComponent()
expect(
screen.getByText('workspacePanel.members.upsellBannerUpgrade')
).toBeTruthy()
})
it('opens subscription dialog on view plans click', async () => {
renderComponent()
const viewPlansBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.viewPlans/
})
await userEvent.click(viewPlansBtn)
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
})
it('hides search input', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
})
})
describe('member count display', () => {
it('shows member count header for team workspace', () => {
mockFilteredMembers.value = [
createMember({ id: '1' }),
createMember({ id: '2' })
]
mockMembers.value = mockFilteredMembers.value
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.membersCount/)
).toBeTruthy()
})
})
})

View File

@@ -198,47 +198,190 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
searchQuery,
activeView,
maxSeats,
isSingleSeatPlan,
personalWorkspaceMember,
filteredMembers,
filteredPendingInvites,
memberMenuItems,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
userPhotoUrl,
isCurrentUser,
selectMember,
toggleSort,
subscription,
showSubscriptionDialog,
handleCopyInviteLink,
handleRevokeInvite,
handleCreateWorkspace
} = useMembersPanel()
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const personalWorkspaceMember = computed<WorkspaceMember>(() => ({
id: 'self',
name: userDisplayName.value ?? '',
email: userEmail.value ?? '',
role: 'owner' as const,
joinDate: new Date(0)
}))
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectMember(member)
selectedMember.value = member
memberMenu.value?.toggle(event)
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
const filteredMembers = computed(() => {
let result = [...members.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
const aIsCurrentUser = isCurrentUser(a)
const bIsCurrentUser = isCurrentUser(b)
if (aIsCurrentUser && !bIsCurrentUser) return -1
if (!aIsCurrentUser && bIsCurrentUser) return 1
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
result.sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error')
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -1,501 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import {
filterBySearch,
sortMembers,
sortPendingInvites
} from './useMembersPanel'
function createMember(
overrides: Partial<WorkspaceMember> = {}
): WorkspaceMember {
return {
id: 'member-1',
name: 'Member One',
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
...overrides
}
}
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
token: 'token-abc',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
}
}
describe('sortMembers', () => {
it('places owners before members', () => {
const owner = createMember({ id: 'o', role: 'owner', name: 'Owner' })
const member = createMember({ id: 'm', role: 'member', name: 'Member' })
const result = sortMembers([member, owner], null, 'desc')
expect(result[0].id).toBe('o')
expect(result[1].id).toBe('m')
})
it('places current user after owners but before others', () => {
const owner = createMember({
id: 'o',
role: 'owner',
email: 'boss@test.com',
joinDate: new Date('2025-03-01')
})
const current = createMember({
id: 'me',
role: 'member',
email: 'me@test.com',
joinDate: new Date('2025-02-01')
})
const other = createMember({
id: 'other',
role: 'member',
email: 'other@test.com',
joinDate: new Date('2025-01-01')
})
const result = sortMembers([other, current, owner], 'me@test.com', 'desc')
expect(result.map((m) => m.id)).toEqual(['o', 'me', 'other'])
})
it('sorts remaining members by joinDate descending', () => {
const early = createMember({
id: 'early',
joinDate: new Date('2025-01-01')
})
const late = createMember({
id: 'late',
joinDate: new Date('2025-06-01')
})
const result = sortMembers([early, late], null, 'desc')
expect(result[0].id).toBe('late')
expect(result[1].id).toBe('early')
})
it('sorts remaining members by joinDate ascending', () => {
const early = createMember({
id: 'early',
joinDate: new Date('2025-01-01')
})
const late = createMember({
id: 'late',
joinDate: new Date('2025-06-01')
})
const result = sortMembers([early, late], null, 'asc')
expect(result[0].id).toBe('early')
expect(result[1].id).toBe('late')
})
it('does not mutate the input array', () => {
const members = [
createMember({ id: 'b', joinDate: new Date('2025-06-01') }),
createMember({ id: 'a', joinDate: new Date('2025-01-01') })
]
const original = [...members]
sortMembers(members, null, 'desc')
expect(members).toEqual(original)
})
})
describe('filterBySearch', () => {
const alice = createMember({
id: '1',
name: 'Alice',
email: 'alice@example.com'
})
const bob = createMember({
id: '2',
name: 'Bob',
email: 'bob@example.com'
})
it('returns all items when query is empty', () => {
expect(filterBySearch([alice, bob], '')).toEqual([alice, bob])
})
it('filters by name (case-insensitive)', () => {
const result = filterBySearch([alice, bob], 'alice')
expect(result).toEqual([alice])
})
it('filters by email', () => {
const result = filterBySearch([alice, bob], 'bob@')
expect(result).toEqual([bob])
})
it('filters pending invites by email only', () => {
const inv1 = createInvite({ id: 'i1', email: 'alice@test.com' })
const inv2 = createInvite({ id: 'i2', email: 'bob@test.com' })
const result = filterBySearch([inv1, inv2], 'alice')
expect(result).toEqual([inv1])
})
})
describe('sortPendingInvites', () => {
it('sorts by inviteDate descending by default', () => {
const early = createInvite({
id: 'e',
inviteDate: new Date('2025-01-01')
})
const late = createInvite({
id: 'l',
inviteDate: new Date('2025-06-01')
})
const result = sortPendingInvites([early, late], 'inviteDate', 'desc')
expect(result[0].id).toBe('l')
expect(result[1].id).toBe('e')
})
it('sorts by expiryDate ascending', () => {
const early = createInvite({
id: 'e',
expiryDate: new Date('2025-01-01')
})
const late = createInvite({
id: 'l',
expiryDate: new Date('2025-06-01')
})
const result = sortPendingInvites([early, late], 'expiryDate', 'asc')
expect(result[0].id).toBe('e')
expect(result[1].id).toBe('l')
})
it('falls back to inviteDate when sortField is joinDate', () => {
const early = createInvite({
id: 'e',
inviteDate: new Date('2025-01-01')
})
const late = createInvite({
id: 'l',
inviteDate: new Date('2025-06-01')
})
const result = sortPendingInvites([early, late], 'joinDate', 'desc')
expect(result[0].id).toBe('l')
})
it('does not mutate the input array', () => {
const invites = [
createInvite({
id: 'b',
inviteDate: new Date('2025-06-01')
}),
createInvite({
id: 'a',
inviteDate: new Date('2025-01-01')
})
]
const original = [...invites]
sortPendingInvites(invites, 'inviteDate', 'desc')
expect(invites).toEqual(original)
})
})
const mockToastAdd = vi.fn()
const mockCopyInviteLink = vi.fn()
const mockShowRemoveMemberDialog = vi.fn()
const mockShowRevokeInviteDialog = vi.fn()
const mockShowCreateWorkspaceDialog = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const {
mockMembers,
mockPendingInvites,
mockIsInPersonalWorkspace,
mockPermissions,
mockUiConfig,
mockIsActiveSubscription,
mockSubscription
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockIsInPersonalWorkspace: ref(false),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
}),
mockIsActiveSubscription: ref(true),
mockSubscription: ref<{ tier: string } | null>({ tier: 'PRO' })
}
})
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
members: mockMembers,
pendingInvites: mockPendingInvites,
isInPersonalWorkspace: mockIsInPersonalWorkspace,
copyInviteLink: mockCopyInviteLink
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: mockPermissions,
uiConfig: mockUiConfig
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userPhotoUrl: ref(null),
userEmail: ref('owner@example.com'),
userDisplayName: ref('Owner User')
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
showSubscriptionDialog: mockShowSubscriptionDialog,
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
free: 1,
standard: 1,
creator: 5,
pro: 20
}
return seats[tierKey] ?? 1
}
})
}))
vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
TIER_TO_KEY: {
FREE: 'free',
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
}
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showRemoveMemberDialog: mockShowRemoveMemberDialog,
showRevokeInviteDialog: mockShowRevokeInviteDialog,
showCreateWorkspaceDialog: mockShowCreateWorkspaceDialog
})
}))
describe('useMembersPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockPendingInvites.value = []
mockIsInPersonalWorkspace.value = false
mockIsActiveSubscription.value = true
mockSubscription.value = { tier: 'PRO' }
})
// Lazy import so mocks are in place
async function setup() {
const { useMembersPanel } = await import('./useMembersPanel')
return useMembersPanel()
}
describe('isSingleSeatPlan', () => {
it('is false for personal workspace', async () => {
mockIsInPersonalWorkspace.value = true
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(false)
})
it('is true when no active subscription', async () => {
mockIsActiveSubscription.value = false
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(true)
})
it('is true for standard tier (1 seat)', async () => {
mockSubscription.value = { tier: 'STANDARD' }
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(true)
})
it('is false for pro tier (20 seats)', async () => {
mockSubscription.value = { tier: 'PRO' }
const panel = await setup()
expect(panel.isSingleSeatPlan.value).toBe(false)
})
})
describe('maxSeats', () => {
it('returns 1 for personal workspace', async () => {
mockIsInPersonalWorkspace.value = true
const panel = await setup()
expect(panel.maxSeats.value).toBe(1)
})
it('returns tier-appropriate seats', async () => {
mockSubscription.value = { tier: 'CREATOR' }
const panel = await setup()
expect(panel.maxSeats.value).toBe(5)
})
it('returns 1 when no subscription', async () => {
mockSubscription.value = null
const panel = await setup()
expect(panel.maxSeats.value).toBe(1)
})
})
describe('personalWorkspaceMember', () => {
it('uses current user info', async () => {
const panel = await setup()
expect(panel.personalWorkspaceMember.value).toMatchObject({
id: 'self',
name: 'Owner User',
email: 'owner@example.com',
role: 'owner'
})
})
})
describe('isCurrentUser', () => {
it('matches by email (case-insensitive)', async () => {
const panel = await setup()
const member = createMember({ email: 'OWNER@EXAMPLE.COM' })
expect(panel.isCurrentUser(member)).toBe(true)
})
it('returns false for other users', async () => {
const panel = await setup()
const member = createMember({ email: 'other@example.com' })
expect(panel.isCurrentUser(member)).toBe(false)
})
})
describe('filteredMembers', () => {
it('filters and sorts members based on searchQuery', async () => {
const alice = createMember({
id: '1',
name: 'Alice',
email: 'alice@example.com'
})
const bob = createMember({
id: '2',
name: 'Bob',
email: 'bob@example.com'
})
mockMembers.value = [alice, bob]
const panel = await setup()
panel.searchQuery.value = 'alice'
expect(panel.filteredMembers.value).toHaveLength(1)
expect(panel.filteredMembers.value[0].name).toBe('Alice')
})
})
describe('toggleSort', () => {
it('changes sortField and resets to desc', async () => {
const panel = await setup()
panel.toggleSort('expiryDate')
expect(panel.sortField.value).toBe('expiryDate')
expect(panel.sortDirection.value).toBe('desc')
})
it('toggles direction when same field', async () => {
const panel = await setup()
panel.toggleSort('inviteDate')
expect(panel.sortDirection.value).toBe('asc')
panel.toggleSort('inviteDate')
expect(panel.sortDirection.value).toBe('desc')
})
})
describe('handleCopyInviteLink', () => {
it('shows success toast on success', async () => {
mockCopyInviteLink.mockResolvedValue(undefined)
const panel = await setup()
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
expect(mockCopyInviteLink).toHaveBeenCalledWith('inv-1')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows error toast on failure', async () => {
mockCopyInviteLink.mockRejectedValue(new Error('fail'))
const panel = await setup()
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
describe('handleRevokeInvite', () => {
it('calls showRevokeInviteDialog', async () => {
const panel = await setup()
panel.handleRevokeInvite(createInvite({ id: 'inv-42' }))
expect(mockShowRevokeInviteDialog).toHaveBeenCalledWith('inv-42')
})
})
describe('handleCreateWorkspace', () => {
it('calls showCreateWorkspaceDialog', async () => {
const panel = await setup()
panel.handleCreateWorkspace()
expect(mockShowCreateWorkspaceDialog).toHaveBeenCalled()
})
})
describe('handleRemoveMember', () => {
it('calls showRemoveMemberDialog', async () => {
const panel = await setup()
panel.handleRemoveMember(createMember({ id: 'mem-7' }))
expect(mockShowRemoveMemberDialog).toHaveBeenCalledWith('mem-7')
})
})
})

View File

@@ -1,219 +0,0 @@
import { storeToRefs } from 'pinia'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'joinDate'
type SortDirection = 'asc' | 'desc'
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
sortDirection: SortDirection
): WorkspaceMember[] {
return [...members].sort((a, b) => {
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
const aIsCurrent = a.email.toLowerCase() === currentUserEmail?.toLowerCase()
const bIsCurrent = b.email.toLowerCase() === currentUserEmail?.toLowerCase()
if (aIsCurrent && !bIsCurrent) return -1
if (!aIsCurrent && bIsCurrent) return 1
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection === 'asc' ? aValue - bValue : bValue - aValue
})
}
export function filterBySearch<T extends { email: string; name?: string }>(
items: T[],
query: string
): T[] {
if (!query) return items
const q = query.toLowerCase()
return items.filter(
(item) =>
item.email.toLowerCase().includes(q) ||
('name' in item && item.name?.toLowerCase().includes(q))
)
}
export function sortPendingInvites(
invites: PendingInvite[],
sortField: SortField,
sortDirection: SortDirection
): PendingInvite[] {
const field = sortField === 'joinDate' ? 'inviteDate' : sortField
return [...invites].sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection === 'asc' ? aValue - bValue : bValue - aValue
})
}
export function useMembersPanel() {
const { t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const personalWorkspaceMember = computed<WorkspaceMember>(() => ({
id: 'self',
name: userDisplayName.value ?? '',
email: userEmail.value ?? '',
role: 'owner' as const,
joinDate: new Date(0)
}))
const searchQuery = ref('')
const activeView = ref<ActiveView>('active')
const sortField = ref<SortField>('inviteDate')
const sortDirection = ref<SortDirection>('desc')
const selectedMember = ref<WorkspaceMember | null>(null)
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function selectMember(member: WorkspaceMember) {
selectedMember.value = member
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
const filteredMembers = computed(() => {
const searched = filterBySearch(members.value, searchQuery.value)
return sortMembers(searched, userEmail.value ?? null, sortDirection.value)
})
const filteredPendingInvites = computed(() => {
const searched = filterBySearch(pendingInvites.value, searchQuery.value)
return sortPendingInvites(searched, sortField.value, sortDirection.value)
})
function toggleSort(field: SortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error')
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
void showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
void showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
void showRemoveMemberDialog(member.id)
}
return {
searchQuery,
activeView,
sortField,
sortDirection,
selectedMember,
maxSeats,
isSingleSeatPlan,
personalWorkspaceMember,
filteredMembers,
filteredPendingInvites,
memberMenuItems,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
isActiveSubscription,
userPhotoUrl,
isCurrentUser,
selectMember,
toggleSort,
showSubscriptionDialog,
handleCopyInviteLink,
handleRevokeInvite,
handleCreateWorkspace,
handleRemoveMember
}
}

View File

@@ -6,25 +6,10 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
LayoutChange,
LayoutOperation,
NodeLayout,
SlotLayout
} from '@/renderer/core/layout/types'
function getOperationsAddedBy(action: () => void): LayoutOperation[] {
const operationCount = layoutStore.getOperationsSince(0).length
action()
return layoutStore.getOperationsSince(0).slice(operationCount)
}
function expectSingleOperation(
operations: LayoutOperation[],
expectedOperation: Record<string, unknown>
): void {
expect(operations).toHaveLength(1)
expect(operations[0]).toEqual(expect.objectContaining(expectedOperation))
}
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
@@ -591,15 +576,9 @@ describe('layoutStore CRDT operations', () => {
actor: 'test'
})
const originalTitleHeightDescriptor = Object.getOwnPropertyDescriptor(
LiteGraph,
'NODE_TITLE_HEIGHT'
)
Object.defineProperty(LiteGraph, 'NODE_TITLE_HEIGHT', {
configurable: true,
value: undefined,
writable: true
})
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
// @ts-expect-error intentionally simulate undefined runtime value
LiteGraph.NODE_TITLE_HEIGHT = undefined
try {
layoutStore.setSource(LayoutSource.DOM)
@@ -618,13 +597,7 @@ describe('layoutStore CRDT operations', () => {
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBe(layout.size.height)
} finally {
if (originalTitleHeightDescriptor) {
Object.defineProperty(
LiteGraph,
'NODE_TITLE_HEIGHT',
originalTitleHeightDescriptor
)
}
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
}
})
@@ -673,228 +646,3 @@ describe('layoutStore CRDT operations', () => {
}
)
})
describe('layoutStore getNodeLayoutRef setter', () => {
beforeEach(() => {
layoutStore.initializeFromLiteGraph([])
})
function baseLayout(): NodeLayout {
return {
id: 'ref-node',
position: { x: 10, y: 20 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 10, y: 20, width: 100, height: 50 }
}
}
it('creates a node when setter receives a layout for an unknown id', () => {
const ref = layoutStore.getNodeLayoutRef('ref-node')
const layout = baseLayout()
expect(ref.value).toBeNull()
const operations = getOperationsAddedBy(() => {
ref.value = layout
})
expectSingleOperation(operations, {
type: 'createNode',
nodeId: 'ref-node',
layout
})
expect(ref.value).toEqual(layout)
})
it.for<{
name: string
nextLayout: NodeLayout
expectedOperation: Record<string, unknown>
}>([
{
name: 'moveNode',
nextLayout: {
...baseLayout(),
position: { x: 99, y: 88 },
bounds: { x: 99, y: 88, width: 100, height: 50 }
},
expectedOperation: {
type: 'moveNode',
nodeId: 'ref-node',
position: { x: 99, y: 88 },
previousPosition: baseLayout().position
}
},
{
name: 'resizeNode',
nextLayout: {
...baseLayout(),
size: { width: 200, height: 80 },
bounds: { x: 10, y: 20, width: 200, height: 80 }
},
expectedOperation: {
type: 'resizeNode',
nodeId: 'ref-node',
size: { width: 200, height: 80 },
previousSize: baseLayout().size
}
},
{
name: 'setNodeZIndex',
nextLayout: { ...baseLayout(), zIndex: 5 },
expectedOperation: {
type: 'setNodeZIndex',
nodeId: 'ref-node',
zIndex: 5,
previousZIndex: 0
}
}
])(
'emits a $name operation for layout-only updates',
({ nextLayout, expectedOperation }) => {
const ref = layoutStore.getNodeLayoutRef('ref-node')
ref.value = baseLayout()
const operations = getOperationsAddedBy(() => {
ref.value = nextLayout
})
expectSingleOperation(operations, expectedOperation)
expect(ref.value).toEqual(nextLayout)
}
)
it('emits a deleteNode operation when setter receives null', () => {
const ref = layoutStore.getNodeLayoutRef('ref-node')
const layout = baseLayout()
ref.value = layout
const operations = getOperationsAddedBy(() => {
ref.value = null
})
expectSingleOperation(operations, {
type: 'deleteNode',
nodeId: 'ref-node',
previousLayout: layout
})
expect(ref.value).toBeNull()
})
})
describe('layoutStore queries', () => {
beforeEach(() => {
layoutStore.initializeFromLiteGraph([])
})
const seedNode = (id: string, x: number, y: number, z = 0) => {
const layout: NodeLayout = {
id,
position: { x, y },
size: { width: 100, height: 50 },
zIndex: z,
visible: true,
bounds: { x, y, width: 100, height: 50 }
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: id,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
}
it('getNodesInBounds returns reactive node IDs that intersect bounds', () => {
seedNode('inside', 0, 0)
seedNode('outside', 1000, 1000)
const inBounds = layoutStore.getNodesInBounds({
x: 0,
y: 0,
width: 200,
height: 200
})
expect(inBounds.value).toContain('inside')
expect(inBounds.value).not.toContain('outside')
})
it('queryNodeAtPoint returns the top-zIndex node containing the point', () => {
seedNode('low', 0, 0, 0)
seedNode('high', 0, 0, 10)
const hit = layoutStore.queryNodeAtPoint({ x: 25, y: 25 })
expect(hit).toBe('high')
})
it('queryNodeAtPoint returns null when no node contains the point', () => {
seedNode('only', 0, 0)
const hit = layoutStore.queryNodeAtPoint({ x: 999, y: 999 })
expect(hit).toBeNull()
})
})
describe('layoutStore link layout updates', () => {
beforeEach(() => {
layoutStore.initializeFromLiteGraph([])
})
const stubPath = () => ({}) as unknown as Path2D
const baseLink = (path = stubPath()) => ({
id: 1 as const,
path,
bounds: { x: 0, y: 0, width: 50, height: 50 },
centerPos: { x: 25, y: 25 },
sourceNodeId: 'a',
targetNodeId: 'b',
sourceSlot: 0,
targetSlot: 0
})
it('updateLinkLayout short-circuits when bounds and centerPos are unchanged', () => {
layoutStore.updateLinkLayout(1, baseLink())
const newPath = stubPath()
layoutStore.updateLinkLayout(1, baseLink(newPath))
expect(layoutStore.getLinkLayout(1)?.path).toBe(newPath)
})
it('updateLinkLayout replaces stored layout when bounds change', () => {
layoutStore.updateLinkLayout(1, baseLink())
const moved = {
...baseLink(),
bounds: { x: 10, y: 10, width: 50, height: 50 }
}
layoutStore.updateLinkLayout(1, moved)
expect(layoutStore.getLinkLayout(1)?.bounds.x).toBe(10)
})
it('deleteLinkLayout removes the link and its segment layouts', () => {
layoutStore.updateLinkLayout(1, baseLink())
layoutStore.updateLinkSegmentLayout(1, null, {
path: stubPath(),
bounds: { x: 0, y: 0, width: 5, height: 5 },
centerPos: { x: 1, y: 1 }
})
expect(layoutStore.queryLinkSegmentAtPoint({ x: 1, y: 1 })).toEqual({
linkId: 1,
rerouteId: null
})
layoutStore.deleteLinkLayout(1)
expect(layoutStore.getLinkLayout(1)).toBeNull()
expect(layoutStore.queryLinkSegmentAtPoint({ x: 1, y: 1 })).toBeNull()
})
})

View File

@@ -1,6 +1,7 @@
import _ from 'es-toolkit/compat'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import { useCanvasViewportInsets } from '@/composables/canvas/useCanvasViewportInsets'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
@@ -990,7 +991,7 @@ export const useLitegraphService = () => {
const bounds = createBounds(nodes)
if (!bounds) return
canvas.ds.fitToBounds(bounds)
canvas.ds.fitToBounds(bounds, { insets: useCanvasViewportInsets().value })
canvas.setDirty(true, true)
}