mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
6 Commits
glary/mast
...
cross-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22f9409b7 | ||
|
|
aed10ab551 | ||
|
|
4977c518e8 | ||
|
|
1ebd371c92 | ||
|
|
435622709f | ||
|
|
c21fbedf15 |
1535
.github/PLANS/remote-combo-master-plan.md
vendored
1535
.github/PLANS/remote-combo-master-plan.md
vendored
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
188
packages/registry-types/src/comfyRegistryTypes.ts
generated
188
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal file
61
src/components/queue/job/getHoverPopoverPosition.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal file
39
src/components/queue/job/getHoverPopoverPosition.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
138
src/composables/canvas/useCanvasViewportInsets.test.ts
Normal file
138
src/composables/canvas/useCanvasViewportInsets.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
40
src/composables/canvas/useCanvasViewportInsets.ts
Normal file
40
src/composables/canvas/useCanvasViewportInsets.ts
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
107
src/composables/queue/useJobDetailsHover.ts
Normal file
107
src/composables/queue/useJobDetailsHover.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
134
src/lib/litegraph/src/DragAndScale.test.ts
Normal file
134
src/lib/litegraph/src/DragAndScale.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "關閉當前工作流程"
|
||||
|
||||
@@ -903,8 +903,8 @@
|
||||
"resume": "繼續下載"
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的帳戶無權使用此功能。",
|
||||
"accessRestrictedTitle": "存取受限",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"defaultTitle": "發生錯誤",
|
||||
"extensionFileHint": "這可能是由於以下指令碼所致",
|
||||
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "关闭当前工作流"
|
||||
|
||||
@@ -903,8 +903,8 @@
|
||||
"resume": "恢复下载"
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的账户无权使用此功能。",
|
||||
"accessRestrictedTitle": "访问受限",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"defaultTitle": "发生错误",
|
||||
"extensionFileHint": "这可能是由于以下脚本",
|
||||
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
|
||||
|
||||
@@ -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 Diffusion:clip-l\nStable Cascade:clip-g\nSD3:t5 / clip-g / clip-l\nStable Audio:t5\nMochi:t5\ncosmos:old t5 xxl",
|
||||
"display_name": "加载CLIP",
|
||||
"inputs": {
|
||||
"clip_name": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user