Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
d930514bea fix: incorporate Fuse search scores into template sorting
When searching templates, the Fuse.js relevance scores were being
discarded when any sort option other than 'default' was selected.
This caused templates with better search matches to be ranked lower
than templates with higher usage/popularity but worse search relevance.

Changes:
- Store Fuse search scores in a Map for use during sorting
- For 'recommended' sort with active search: weight search relevance
  at 60% and base recommendation score at 40%
- For 'popular' sort with active search: weight both equally at 50%
- For VRAM/size sorts: use search relevance as tiebreaker
- 'default' sort preserves Fuse's original relevance order
2026-01-08 20:01:41 +00:00
251 changed files with 4575 additions and 76741 deletions

View File

@@ -1,9 +1,9 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: 'CI: Tests E2E (Deploy for Forks)'
name: "CI: Tests E2E (Deploy for Forks)"
on:
workflow_run:
workflows: ['CI: Tests E2E']
workflows: ["CI: Tests E2E"]
types: [requested, completed]
env:
@@ -81,7 +81,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: 'CI: Tests E2E'
name: "CI: Tests E2E"
on:
push:
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -85,7 +85,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -222,7 +222,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,11 +6,10 @@ const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
splitToken: 1024,
saveImmediately: true,
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
@@ -19,11 +18,5 @@ module.exports = defineConfig({
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
IMPORTANT Persian Translation Guidelines:
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
`
});

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
addons: ['@storybook/addon-docs'],
framework: {
name: '@storybook/vue3-vite',
options: {}
@@ -69,32 +69,9 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: [
{
find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
alias: {
'@': process.cwd() + '/src'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -63,9 +63,6 @@ The project uses **Nx** for build orchestration and task management
- Imports:
- sorted/grouped by plugin
- run `pnpm format` before committing
- use separate `import type` statements, not inline `type` in mixed imports
-`import type { Foo } from './foo'` + `import { bar } from './foo'`
-`import { bar, type Foo } from './foo'`
- ESLint:
- Vue + TS rules
- no floating promises
@@ -122,10 +119,7 @@ The project uses **Nx** for build orchestration and task management
- Prefer reactive props destructuring to `const props = defineProps<...>`
- Do not use `withDefaults` or runtime props declaration
- Do not import Vue macros unnecessarily
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
- Define slots via template usage, not `defineSlots`
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
- Prefer `useModel` to separately defining a prop and emit
- Be judicious with addition of new refs or other state
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
- If it's possible to use the `ref` or prop directly, don't add a `computed`
@@ -143,7 +137,7 @@ The project uses **Nx** for build orchestration and task management
8. Implement proper error handling
9. Follow Vue 3 style guide and naming conventions
10. Use Vite for fast development and building
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
12. Avoid new usage of PrimeVue components
13. Write tests for all changes, especially bug fixes to catch future regressions
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
@@ -161,8 +155,6 @@ The project uses **Nx** for build orchestration and task management
## Testing Guidelines
See @docs/testing/*.md for detailed patterns.
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
@@ -276,8 +268,6 @@ When referencing Comfy-Org repos:
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
## Agent-only rules

View File

@@ -133,11 +133,8 @@ test.describe('Menu', () => {
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page
.locator('body')
.click({ position: { x: viewport.width - 10, y: 10 } })
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -194,10 +194,7 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,138 +0,0 @@
---
globs:
- '**/*.test.ts'
- '**/*.spec.ts'
---
# Vitest Patterns
## Setup
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
```typescript
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('MyStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.resetAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
})
```
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## Mock Patterns
### Reset all mocks at once
```typescript
beforeEach(() => {
vi.resetAllMocks() // Not individual mock.mockReset() calls
})
```
### Module mocks with vi.mock()
```typescript
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
fetchData: vi.fn()
}
}))
vi.mock('@/services/myService', () => ({
myService: {
doThing: vi.fn()
}
}))
```
### Configure mocks in tests
```typescript
import { api } from '@/scripts/api'
import { myService } from '@/services/myService'
it('handles success', () => {
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
// ... test code
})
```
## Testing Event Listeners
When a store registers event listeners at module load time:
```typescript
function getEventHandler() {
const call = vi.mocked(api.addEventListener).mock.calls.find(
([event]) => event === 'my_event'
)
return call?.[1] as (e: CustomEvent<MyEventType>) => void
}
function dispatch(data: MyEventType) {
const handler = getEventHandler()
handler(new CustomEvent('my_event', { detail: data }))
}
it('handles events', () => {
const store = useMyStore()
dispatch({ field: 'value' })
expect(store.items).toHaveLength(1)
})
```
## Testing with Fake Timers
For stores with intervals, timeouts, or polling:
```typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('polls after delay', async () => {
const store = useMyStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(30000)
expect(mockService.fetch).toHaveBeenCalled()
})
```
## Assertion Style
Prefer `.toHaveLength()` over `.length.toBe()`:
```typescript
// Good
expect(store.items).toHaveLength(1)
// Avoid
expect(store.items.length).toBe(1)
```
Use `.toMatchObject()` for partial matching:
```typescript
expect(store.completedItems[0]).toMatchObject({
id: 'task-123',
status: 'done'
})
```

View File

@@ -8,8 +8,7 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.11",
"version": "1.37.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -66,7 +66,6 @@
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",

134
pnpm-lock.yaml generated
View File

@@ -84,9 +84,6 @@ catalogs:
'@storybook/addon-docs':
specifier: ^10.1.9
version: 10.1.9
'@storybook/addon-mcp':
specifier: 0.1.6
version: 0.1.6
'@storybook/vue3':
specifier: ^10.1.9
version: 10.1.9
@@ -552,9 +549,6 @@ importers:
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
'@storybook/vue3':
specifier: 'catalog:'
version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3))
@@ -3154,11 +3148,6 @@ packages:
peerDependencies:
storybook: ^10.1.9
'@storybook/addon-mcp@0.1.6':
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
peerDependencies:
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
'@storybook/builder-vite@10.1.9':
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
peerDependencies:
@@ -3192,9 +3181,6 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/mcp@0.1.1':
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
'@storybook/react-dom-shim@10.1.9':
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
peerDependencies:
@@ -3467,26 +3453,6 @@ packages:
'@tiptap/starter-kit@2.10.4':
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
'@tmcp/adapter-valibot@0.1.5':
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
peerDependencies:
tmcp: ^1.17.0
valibot: ^1.1.0
'@tmcp/session-manager@0.2.1':
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
peerDependencies:
tmcp: ^1.16.3
'@tmcp/transport-http@0.8.3':
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
peerDependencies:
'@tmcp/auth': ^0.3.3 || ^0.4.0
tmcp: ^1.18.0
peerDependenciesMeta:
'@tmcp/auth':
optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
@@ -3820,11 +3786,6 @@ packages:
cpu: [x64]
os: [win32]
'@valibot/to-json-schema@1.5.0':
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
peerDependencies:
valibot: ^1.2.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5209,9 +5170,6 @@ packages:
jiti:
optional: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esm-resolve@1.0.11:
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
@@ -6020,9 +5978,6 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-rpc-2.0@1.7.1:
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7431,9 +7386,6 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqids@0.3.0:
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -7661,9 +7613,6 @@ packages:
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
hasBin: true
tmcp@1.19.0:
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -7909,9 +7858,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uri-template-matcher@1.1.2:
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -7927,14 +7873,6 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -8081,9 +8019,6 @@ packages:
vue-component-type-helpers@3.2.1:
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -11014,18 +10949,6 @@ snapshots:
- vite
- webpack
'@storybook/addon-mcp@0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)':
dependencies:
'@storybook/mcp': 0.1.1(typescript@5.9.3)
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -11055,16 +10978,6 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@storybook/mcp@0.1.1(typescript@5.9.3)':
dependencies:
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
react: 19.2.3
@@ -11094,7 +11007,7 @@ snapshots:
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.2
vue-component-type-helpers: 3.2.1
'@swc/helpers@0.5.17':
dependencies:
@@ -11362,23 +11275,6 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
tmcp: 1.19.0(typescript@5.9.3)
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3)
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
@@ -11727,10 +11623,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
@@ -13411,8 +13303,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
esm-env@1.2.2: {}
esm-resolve@1.0.11: {}
espree@10.4.0:
@@ -14299,8 +14189,6 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-rpc-2.0@1.7.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16167,8 +16055,6 @@ snapshots:
sprintf-js@1.0.3: {}
sqids@0.3.0: {}
stable-hash-x@0.2.0: {}
stack-utils@2.0.6:
@@ -16461,16 +16347,6 @@ snapshots:
dependencies:
tldts-core: 7.0.19
tmcp@1.19.0(typescript@5.9.3):
dependencies:
'@standard-schema/spec': 1.1.0
json-rpc-2.0: 1.7.1
sqids: 0.3.0
uri-template-matcher: 1.1.2
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
tmp@0.2.5: {}
to-regex-range@5.0.1:
@@ -16768,8 +16644,6 @@ snapshots:
dependencies:
punycode: 2.3.1
uri-template-matcher@1.1.2: {}
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
@@ -16780,10 +16654,6 @@ snapshots:
uuid@11.1.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -17044,8 +16914,6 @@ snapshots:
vue-component-type-helpers@3.2.1: {}
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
vue: 3.5.13(typescript@5.9.3)

View File

@@ -29,7 +29,6 @@ catalog:
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12

View File

@@ -10,158 +10,37 @@ interface TestStats {
finished?: number
}
interface TestResult {
status: string
duration?: number
error?: {
message?: string
stack?: string
}
attachments?: Array<{
name: string
path?: string
contentType: string
}>
}
interface TestCase {
title: string
ok: boolean
outcome: string
results: TestResult[]
}
interface Suite {
title: string
file: string
suites?: Suite[]
tests?: TestCase[]
}
interface FullReportData {
stats?: TestStats
suites?: Suite[]
}
interface ReportData {
stats?: TestStats
}
interface FailedTest {
name: string
file: string
traceUrl?: string
error?: string
}
interface TestCounts {
passed: number
failed: number
flaky: number
skipped: number
total: number
failures?: FailedTest[]
}
/**
* Extract failed test details from Playwright report
*/
function extractFailedTests(
reportData: FullReportData,
baseUrl?: string
): FailedTest[] {
const failures: FailedTest[] = []
function processTest(test: TestCase, file: string, suitePath: string[]) {
// Check if test failed or is flaky
const hasFailed = test.results.some(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
const isFlaky = test.outcome === 'flaky'
if (hasFailed || isFlaky) {
const fullTestName = [...suitePath, test.title]
.filter(Boolean)
.join(' ')
const failedResult = test.results.find(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
// Find trace attachment
let traceUrl: string | undefined
if (failedResult?.attachments) {
const traceAttachment = failedResult.attachments.find(
(a) => a.name === 'trace' && a.contentType === 'application/zip'
)
if (traceAttachment?.path) {
// Convert local path to URL path
const tracePath = traceAttachment.path.replace(/\\/g, '/')
const traceFile = path.basename(tracePath)
if (baseUrl) {
// Construct trace viewer URL
const traceDataUrl = `${baseUrl}/data/${traceFile}`
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
}
}
}
failures.push({
name: fullTestName,
file: file,
traceUrl,
error: failedResult?.error?.message
})
}
}
function processSuite(suite: Suite, parentPath: string[] = []) {
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
// Process tests in this suite
if (suite.tests) {
for (const test of suite.tests) {
processTest(test, suite.file, suitePath)
}
}
// Recursively process nested suites
if (suite.suites) {
for (const childSuite of suite.suites) {
processSuite(childSuite, suitePath)
}
}
}
if (reportData.suites) {
for (const suite of reportData.suites) {
processSuite(suite)
}
}
return failures
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @param baseUrl - Base URL of the deployed report (for trace links)
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
* @returns Test counts { passed, failed, flaky, skipped, total }
*/
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
function extractTestCounts(reportDir: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0,
failures: []
total: 0
}
try {
// First, try to find report.json which Playwright generates with JSON reporter
const jsonReportFile = path.join(reportDir, 'report.json')
if (fs.existsSync(jsonReportFile)) {
const reportJson: FullReportData = JSON.parse(
const reportJson: ReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
@@ -175,12 +54,6 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract detailed failure information
if (counts.failed > 0 || counts.flaky > 0) {
counts.failures = extractFailedTests(reportJson, baseUrl)
}
return counts
}
}
@@ -296,18 +169,15 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
// Main execution
const reportDir = process.argv[2]
const baseUrl = process.argv[3] // Optional: base URL for trace links
if (!reportDir) {
console.error(
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
)
console.error('Usage: extract-playwright-counts.ts <report-directory>')
process.exit(1)
}
const counts = extractTestCounts(reportDir, baseUrl)
const counts = extractTestCounts(reportDir)
// Output as JSON for easy parsing in shell script
process.stdout.write(JSON.stringify(counts) + '\n')
console.log(JSON.stringify(counts))
export { extractTestCounts, extractFailedTests }
export { extractTestCounts }

View File

@@ -134,22 +134,23 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post concise starting comment
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Tests: ⏳ Running...
## 🎭 Playwright Test Results
Tests started at $START_TIME UTC
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
<details>
<summary>📊 Browser Tests</summary>
⏰ Started at: $START_TIME UTC
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
</details>
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
@@ -188,8 +189,7 @@ else
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
# Pass the base URL so we can generate trace links
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
echo "Extracted counts for $browser: $counts" >&2
echo "$counts" > "$temp_dir/$i.counts"
else
@@ -286,74 +286,43 @@ else
# Determine overall status
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Failed"
status_text="Some tests failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Passed with flaky tests"
status_text="Tests passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="Passed"
status_text="All tests passed!"
else
status_icon="🕵🏻"
status_text="No test results"
status_text="No test results found"
fi
# Generate concise completion comment
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Tests: $status_icon **$status_text**"
## 🎭 Playwright Test Results
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
fi
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"
# Process each browser's failures
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
if command -v jq > /dev/null 2>&1; then
# Extract failures array from JSON
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
if [ -n "$failures" ]; then
while IFS='|' read -r test_name test_file trace_url; do
[ -z "$test_name" ] && continue
# Convert file path to GitHub URL (relative to repo root)
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
# Build the failed test line
test_line="- [$test_name]($github_file_url)"
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
test_line="$test_line: [View trace]($trace_url)"
fi
comment="$comment
$test_line"
done <<< "$failures"
fi
fi
done
### 📈 Summary
- **Total Tests:** $total_tests
- **Passed:** $total_passed
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
fi
# Add browser reports in collapsible section
comment="$comment
<details>
<summary>📊 Browser Reports</summary>
"
### 📊 Test Reports by Browser"
# Add browser results
# Add browser results with individual counts
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
@@ -380,7 +349,7 @@ $test_line"
fi
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
counts_str=" ($b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
counts_str=" $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
else
counts_str=""
fi
@@ -389,10 +358,10 @@ $test_line"
fi
comment="$comment
- **${browser}**: [View Report](${url})${counts_str}"
- **${browser}**: [View Report](${url})${counts_str}"
else
comment="$comment
- **${browser}**: Deployment failed"
- **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
@@ -400,7 +369,8 @@ $test_line"
comment="$comment
</details>"
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -20,14 +20,9 @@
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
@@ -54,16 +49,13 @@
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</Button>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -99,19 +91,15 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
@@ -123,15 +111,8 @@ const commandStore = useCommandStore()
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -139,12 +120,6 @@ const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,37 +13,17 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Button
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
icon="pi pi-bars"
text
severity="secondary"
size="small"
@click="handleMenuClick"
/>
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"
@click="handleBackClick"
>
<i class="icon-[lucide--undo-2]" />
</Button>
<Breadcrumb
ref="breadcrumbRef"
class="w-fit rounded-lg p-0"
:class="{ hidden: !isInSubgraph }"
:model="items"
:pt="{ item: { class: 'pointer-events-auto' } }"
:aria-label="$t('g.graphNavigation')"
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:ref="(el) => setItemRef(item, el)"
:item="item"
:is-active="item.key === activeItemKey"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
@@ -55,7 +35,6 @@
<script setup lang="ts">
import Breadcrumb from 'primevue/breadcrumb'
import Button from 'primevue/button'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -64,7 +43,6 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -77,12 +55,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
const setItemRef = (item: MenuItem, el: unknown) => {
if (item.key === 'root') {
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
}
}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -90,28 +62,17 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(canvas.graph.rootGraph)
}
}))
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
@@ -134,26 +95,21 @@ const items = computed(() => {
return [home.value, ...items]
})
const activeItemKey = computed(() => items.value.at(-1)?.key)
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_menu_selected'
})
rootItemRef.value?.toggleMenu(event)
}
const handleBackClick = () => {
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
}
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
canvas.setGraph(canvas.graph.rootGraph)
}
}))
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
@@ -233,18 +189,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden h-8;
@apply flex items-center overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
transition: all 0.2s;
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
border: 1px solid transparent;
background-color: transparent;
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
@@ -254,9 +205,11 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-color: var(--interface-stroke);
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--interface-stroke);
border-bottom: 1px solid var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -265,8 +218,10 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:first-child) {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--interface-stroke);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -274,10 +229,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:last-child) {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--interface-stroke);
}
:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,

View File

@@ -7,7 +7,7 @@
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -25,7 +25,7 @@
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive || isRoot"
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
@@ -59,7 +59,6 @@ import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -136,28 +135,79 @@ const tooltipText = computed(() => {
return props.item.label
})
const startRename = async () => {
// Check if element is hidden (collapsed breadcrumb)
// When collapsed, root item is hidden via CSS display:none, so use rename command
if (isRoot && wrapperRef.value?.offsetParent === null) {
await useCommandStore().execute('Comfy.RenameWorkflow')
return
}
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot && !props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: t('menuLabels.Save'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflow')
},
visible: isRoot
},
{
label: t('menuLabels.Save As'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflowAs')
},
visible: isRoot
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot
}
})
}
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
@@ -178,6 +228,20 @@ const handleClick = (event: MouseEvent) => {
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -185,14 +249,6 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -2,8 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
backgroundClass
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
)
"
>

View File

@@ -1,65 +0,0 @@
<template>
<span class="relative inline-flex items-center justify-center size-[1em]">
<i :class="mainIcon" class="text-[1em]" />
<i
:class="
cn(
subIcon,
'absolute leading-none pointer-events-none',
positionX === 'left' ? 'left-0' : 'right-0',
positionY === 'top' ? 'top-0' : 'bottom-0'
)
"
:style="subIconStyle"
/>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
type Position = 'top' | 'bottom' | 'left' | 'right'
export interface OverlayIconProps {
mainIcon: string
subIcon: string
positionX?: Position
positionY?: Position
offsetX?: number
offsetY?: number
subIconScale?: number
}
const {
mainIcon,
subIcon,
positionX = 'right',
positionY = 'bottom',
offsetX = 0,
offsetY = 0,
subIconScale = 0.6
} = defineProps<OverlayIconProps>()
const textShadow = [
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
`1px 0 0 rgba(0, 0, 0, 0.7)`,
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
`0 1px 0 rgba(0, 0, 0, 0.7)`
].join(', ')
const subIconStyle = computed(() => ({
fontSize: `${subIconScale}em`,
textShadow,
...(offsetX !== 0 && {
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
}),
...(offsetY !== 0 && {
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
})
}))
</script>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -563,8 +563,7 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters,
loadFuseOptions
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
/**
@@ -816,10 +815,10 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run all operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
workflowTemplatesStore.loadWorkflowTemplates()
])
return true
},

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
{{
isCloud

View File

@@ -1,264 +1,180 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<div class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Warnings -->
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoTemplateBasedCredits') }}
</span>
</div>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<!-- Buy Button -->
<Button
:disabled="!isValidAmount || loading"
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
size="lg"
class="h-10 justify-center"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
>
{{ $t('credits.topUp.buyCredits') }}
{{ $t('credits.topUp.buy') }}
</Button>
<div class="flex items-center justify-center gap-1">
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
:href="pricingUrl"
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { formattedRenewalDate } = useSubscription()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const popover = ref()
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 30 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 301 })
}
})
]
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
// Prevent double-clicks
if (loading.value || !isValidAmount.value) return
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -220,12 +220,6 @@ function show(event: MouseEvent) {
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -1,134 +0,0 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</template>
<script setup lang="ts">
import { useHelpCenter } from '@/composables/useHelpCenter'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
} = useHelpCenter()
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,293 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
import HoneyToast from './HoneyToast.vue'
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
const meta: Meta<typeof HoneyToast> = {
title: 'Toast/HoneyToast',
component: HoneyToast,
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Expanded: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Completed: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
bytesDownloaded: 1000000,
progress: 1,
status: 'completed'
}),
createMockJob({
taskId: 'task-2',
assetId: 'asset-2',
assetName: 'lora-style.safetensors',
bytesTotal: 500000,
bytesDownloaded: 500000,
progress: 1,
status: 'completed'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">All downloads completed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const WithError: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'failed',
progress: 0.23
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'completed',
progress: 1
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
<span class="font-bold text-base-foreground">1 download failed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Hidden: Story = {
render: () => ({
components: { HoneyToast },
template: `
<div>
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
<HoneyToast :visible="false">
<template #default>
<div class="px-4 py-4">Content</div>
</template>
<template #footer>
<div class="h-12 px-4">Footer</div>
</template>
</HoneyToast>
</div>
`
})
}

View File

@@ -1,137 +0,0 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
h(
'div',
{ 'data-testid': 'content' },
slotProps.isExpanded ? 'expanded' : 'collapsed'
),
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
h(
'button',
{
'data-testid': 'toggle-btn',
onClick: slotProps.toggle
},
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
})
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
const TestWrapper = defineComponent({
components: { HoneyToast },
setup() {
const expanded = ref(false)
return { expanded }
},
template: `
<HoneyToast :visible="true" v-model:expanded="expanded">
<template #default="slotProps">
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
</template>
<template #footer="slotProps">
<button data-testid="toggle-btn" @click="slotProps.toggle">
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
</button>
</template>
</HoneyToast>
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { visible } = defineProps<{
visible: boolean
}>()
const isExpanded = defineModel<boolean>('expanded', { default: false })
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<slot :is-expanded />
</div>
<slot name="footer" :is-expanded :toggle />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -160,7 +160,7 @@
>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
class="text-bold icon-[lucide--check] text-xs text-white"
/>
</div>
<span>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@@ -14,12 +14,12 @@
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-base-foreground" />
<i class="pi pi-bars text-lg text-white" />
</Button>
<div
v-show="isMenuOpen"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -29,13 +29,13 @@
:class="
cn(
'flex w-full items-center justify-start',
activeCategory === category && 'bg-button-active-surface'
activeCategory === category && 'bg-smoke-600'
)
"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-base-foreground">{{
<span class="whitespace-nowrap text-white">{{
$t(categoryLabels[category])
}}</span>
</Button>
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-base-foreground text-lg`
return `${icons[category]} text-white text-lg`
}
const emit = defineEmits<{

View File

@@ -2,11 +2,11 @@
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
>
<div class="flex flex-col items-center">
<div class="spinner" />
<div class="mt-4 text-lg text-base-foreground">
<div class="mt-4 text-lg text-white">
{{ loadingMessage }}
</div>
</div>

View File

@@ -15,7 +15,7 @@
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-base-foreground'
'text-lg text-white'
]"
/>
</Button>
@@ -46,7 +46,7 @@
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-base-foreground">
<span class="min-w-16 text-xs text-white">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>

View File

@@ -11,7 +11,7 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i :class="['pi', 'pi-camera', 'text-lg text-white']" />
</Button>
<PopupSlider
v-if="showFOVButton"

View File

@@ -12,18 +12,18 @@
:aria-label="$t('load3d.exportModel')"
@click="toggleExportFormats"
>
<i class="pi pi-download text-lg text-base-foreground" />
<i class="pi pi-download text-lg text-white" />
</Button>
<div
v-show="showExportFormats"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
variant="textonly"
class="text-base-foreground"
class="text-white"
@click="exportModel(format.value)"
>
{{ format.label }}

View File

@@ -12,7 +12,7 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-base-foreground" />
<i class="pi pi-sun text-lg text-white" />
</Button>
<div
v-show="showLightIntensity"

View File

@@ -12,11 +12,11 @@
:aria-label="t('load3d.upDirection')"
@click="toggleUpDirection"
>
<i class="pi pi-arrow-up text-lg text-base-foreground" />
<i class="pi pi-arrow-up text-lg text-white" />
</Button>
<div
v-show="showUpDirection"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -24,10 +24,7 @@
:key="direction"
variant="textonly"
:class="
cn(
'text-base-foreground',
upDirection === direction && 'bg-blue-500'
)
cn('text-white', upDirection === direction && 'bg-blue-500')
"
@click="selectUpDirection(direction)"
>
@@ -49,11 +46,11 @@
:aria-label="t('load3d.materialMode')"
@click="toggleMaterialMode"
>
<i class="pi pi-box text-lg text-base-foreground" />
<i class="pi pi-box text-lg text-white" />
</Button>
<div
v-show="showMaterialMode"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -62,7 +59,7 @@
variant="textonly"
:class="
cn(
'whitespace-nowrap text-base-foreground',
'whitespace-nowrap text-white',
materialMode === mode && 'bg-blue-500'
)
"
@@ -86,7 +83,7 @@
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-base-foreground" />
<i class="pi pi-sitemap text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -8,11 +8,11 @@
:aria-label="tooltipText"
@click="toggleSlider"
>
<i :class="['pi', icon, 'text-lg text-base-foreground']" />
<i :class="['pi', icon, 'text-lg text-white']" />
</Button>
<div
v-show="showSlider"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
>
<Slider
v-model="value"

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -25,7 +25,7 @@
:class="[
'pi',
isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-base-foreground'
'text-lg text-white'
]"
/>
</Button>
@@ -42,7 +42,7 @@
:aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording"
>
<i class="pi pi-download text-lg text-base-foreground" />
<i class="pi pi-download text-lg text-white" />
</Button>
<Button
@@ -57,12 +57,12 @@
:aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording"
>
<i class="pi pi-trash text-lg text-base-foreground" />
<i class="pi pi-trash text-lg text-white" />
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-base-foreground"
class="mt-1 text-center text-xs text-white"
>
{{ formatDuration(recordingDuration) }}
</div>

View File

@@ -8,7 +8,7 @@
:aria-label="$t('load3d.showGrid')"
@click="toggleGrid"
>
<i class="pi pi-table text-lg text-base-foreground" />
<i class="pi pi-table text-lg text-white" />
</Button>
<div v-if="!hasBackgroundImage">
@@ -23,7 +23,7 @@
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<i class="pi pi-palette text-lg text-white" />
<input
ref="colorPickerRef"
type="color"
@@ -48,7 +48,7 @@
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<i class="pi pi-image text-lg text-white" />
<input
ref="imagePickerRef"
type="file"
@@ -76,7 +76,7 @@
:aria-label="$t('load3d.panoramaMode')"
@click="toggleBackgroundRenderMode"
>
<i class="pi pi-globe text-lg text-base-foreground" />
<i class="pi pi-globe text-lg text-white" />
</Button>
</div>
@@ -98,7 +98,7 @@
:aria-label="$t('load3d.removeBackgroundImage')"
@click="removeBackgroundImage"
>
<i class="pi pi-times text-lg text-base-foreground" />
<i class="pi pi-times text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -12,7 +12,7 @@
:aria-label="t('load3d.openIn3DViewer')"
@click="openIn3DViewer"
>
<i class="pi pi-expand text-lg text-base-foreground" />
<i class="pi pi-expand text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -4,7 +4,6 @@
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
@keydown.stop
>
<div
id="maskEditorCanvasContainer"

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-current"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -35,74 +35,6 @@
</svg>
</button>
<div class="h-5 border-l border-border" />
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateLeft')"
@click="onRotateLeft"
>
<svg
viewBox="-6 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateRight')"
@click="onRotateRight"
>
<svg
viewBox="-9 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<g transform="scale(-1, 1)">
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</g>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorHorizontal')"
@click="onMirrorHorizontal"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
/>
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorVertical')"
@click="onMirrorVertical"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
/>
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
</svg>
</button>
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
@@ -131,7 +63,6 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
@@ -140,17 +71,16 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()
const canvasTransform = useCanvasTransform()
const saver = useMaskEditorSaver()
const saveButtonText = ref(t('g.save'))
const saveEnabled = ref(true)
const iconButtonClass =
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const textButtonClass =
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const onUndo = () => {
store.canvasHistory.undo()
@@ -160,38 +90,6 @@ const onRedo = () => {
store.canvasHistory.redo()
}
const onRotateLeft = async () => {
try {
await canvasTransform.rotateCounterclockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate left failed:', error)
}
}
const onRotateRight = async () => {
try {
await canvasTransform.rotateClockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate right failed:', error)
}
}
const onMirrorHorizontal = async () => {
try {
await canvasTransform.mirrorHorizontal()
} catch (error) {
console.error('[TopBarHeader] Mirror horizontal failed:', error)
}
}
const onMirrorVertical = async () => {
try {
await canvasTransform.mirrorVertical()
} catch (error) {
console.error('[TopBarHeader] Mirror vertical failed:', error)
}
}
const onInvert = () => {
canvasTools.invertMask()
}

View File

@@ -50,22 +50,20 @@
<div
v-if="
props.state === 'running' &&
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
:class="progressBarContainerClass"
class="absolute inset-0"
>
<div
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
v-if="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
@@ -203,7 +201,6 @@ import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -248,14 +245,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))

View File

@@ -1,10 +1,10 @@
<template>
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
:class="{
'small-sidebar': isSmall,
'connected-sidebar pointer-events-auto': isConnected,
'connected-sidebar': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing,
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
@@ -40,13 +40,12 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
</nav>
</template>
@@ -55,7 +54,6 @@ import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
@@ -91,9 +89,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isConnected = computed(
() =>
selectedTab.value ||
@@ -150,8 +145,8 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-interface')
)
)

View File

@@ -1,28 +1,204 @@
<template>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
<div>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { useHelpCenter } from '@/composables/useHelpCenter'
import { storeToRefs } from 'pinia'
import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import SidebarIcon from './SidebarIcon.vue'
defineProps<{
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const { showNodeConflictDialog } = useDialogService()
// Use conflict acknowledgment state from composable - call only once
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
const props = defineProps<{
isSmall: boolean
}>()
const { isSmall } = toRefs(props)
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
/**
* Toggle Help Center and track UI button click.
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle()
}
const closeHelpCenter = () => {
helpCenterStore.hide()
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
showNodeConflictDialog({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;

View File

@@ -1,154 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
type StoryArgs = {
assets: AssetItem[]
jobs: JobListItem[]
selectedAssetIds?: string[]
actionsByJobId?: Record<string, JobAction[]>
}
function baseDecorator() {
return {
template: `
<div class="bg-base-background p-6">
<story />
</div>
`
}
}
const meta: Meta<StoryArgs> = {
title: 'Components/Sidebar/AssetsSidebarListView',
component: AssetsSidebarListView,
parameters: {
layout: 'centered'
},
decorators: [baseDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const baseTimestamp = '2024-01-15T10:00:00Z'
const sampleJobs: JobListItem[] = [
{
id: 'job-pending-1',
title: 'In queue',
meta: '8:59:30pm',
state: 'pending',
iconName: iconForJobState('pending'),
showClear: true
},
{
id: 'job-init-1',
title: 'Initializing...',
meta: '8:59:35pm',
state: 'initialization',
iconName: iconForJobState('initialization'),
showClear: true
},
{
id: 'job-running-1',
title: 'Total: 30%',
meta: 'KSampler: 70%',
state: 'running',
iconName: iconForJobState('running'),
showClear: true,
progressTotalPercent: 30,
progressCurrentPercent: 70
}
]
const sampleAssets: AssetItem[] = [
{
id: 'asset-image-1',
name: 'image-032.png',
created_at: baseTimestamp,
preview_url: '/assets/images/comfy-logo-single.svg',
size: 1887437,
tags: [],
user_metadata: {
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}
},
{
id: 'asset-video-1',
name: 'clip-01.mp4',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 8394820,
tags: [],
user_metadata: {
duration: 132000
}
},
{
id: 'asset-audio-1',
name: 'soundtrack-01.mp3',
created_at: baseTimestamp,
size: 5242880,
tags: [],
user_metadata: {
duration: 200000
}
},
{
id: 'asset-3d-1',
name: 'scene-01.glb',
created_at: baseTimestamp,
size: 134217728,
tags: []
}
]
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export const RunningAndGenerated: Story = {
args: {
assets: sampleAssets,
jobs: sampleJobs,
actionsByJobId: {
'job-pending-1': [cancelAction],
'job-init-1': [cancelAction],
'job-running-1': [cancelAction]
}
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
</div>
`
}
}

View File

@@ -1,203 +0,0 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assets.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@click.stop="emit('select-asset', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import type { JobState } from '@/types/queue'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'approach-end'): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
padding: '0 0.5rem',
gap: '0.5rem'
}
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}
const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
}
if (typeof asset.size === 'number') {
return formatSize(asset.size)
}
return ''
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-pointer',
selected &&
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -79,10 +79,10 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="showEmptyState">
<div v-else-if="!loading && !displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -96,15 +96,7 @@
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
v-else
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
@@ -122,15 +114,11 @@
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
:selected-assets="getSelectedAssets(displayAssets)"
:has-selection="hasSelection"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/>
</template>
</VirtualGrid>
@@ -146,6 +134,7 @@
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
@@ -209,7 +198,6 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
@@ -244,9 +232,6 @@ const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
@@ -258,6 +243,11 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -295,8 +285,6 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
deactivate: deactivateSelection
} = useAssetSelection()
@@ -328,7 +316,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value)
return getTotalOutputCount(selectedAssets)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
})
const currentAssets = computed(() =>
@@ -359,20 +347,6 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
@@ -563,16 +537,6 @@ const handleDeleteSelected = async () => {
clearSelection()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const meta: Meta<typeof ProgressToastItem> = {
title: 'Toast/ProgressToastItem',
component: ProgressToastItem,
parameters: {
layout: 'padded'
},
decorators: [
() => ({
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
export const Pending: Story = {
args: {
job: createMockJob({
status: 'created',
assetName: 'sd-xl-base-1.0.safetensors'
})
}
}
export const Running: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.45,
assetName: 'lora-detail-enhancer.safetensors'
})
}
}
export const RunningAlmostComplete: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.92,
assetName: 'vae-ft-mse-840000.safetensors'
})
}
}
export const Completed: Story = {
args: {
job: createMockJob({
status: 'completed',
progress: 1,
assetName: 'controlnet-canny.safetensors'
})
}
}
export const Failed: Story = {
args: {
job: createMockJob({
status: 'failed',
progress: 0.23,
assetName: 'unreachable-model.safetensors'
})
}
}
export const LongFileName: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.67,
assetName:
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
})
}
}

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { job } = defineProps<{
job: AssetDownload
}>()
const { t } = useI18n()
const progressPercent = computed(() => Math.round(job.progress * 100))
const isCompleted = computed(() => job.status === 'completed')
const isFailed = computed(() => job.status === 'failed')
const isRunning = computed(() => job.status === 'running')
const isPending = computed(() => job.status === 'created')
</script>
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<template v-if="isFailed">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent }}%
</span>
</template>
<template v-else-if="isPending">
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</template>

View File

@@ -9,16 +9,11 @@
@click="popover?.toggle($event)"
>
<div
:class="
cn(
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
compact && 'size-full '
)
"
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<UserAvatar :photo-url="photoURL" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
<i class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
</Button>
@@ -43,15 +38,9 @@ import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)

View File

@@ -1,19 +1,15 @@
<template>
<Button
v-if="!isLoggedIn"
variant="textonly"
variant="secondary"
size="icon"
:class="cn('group rounded-full text-base-foreground p-0', className)"
class="rounded-full bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
:aria-label="t('g.login')"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"
>
<span
class="flex size-full items-center justify-center rounded-full bg-secondary-background transition-colors group-hover:bg-transparent"
>
<i class="icon-[lucide--user] size-4" />
</span>
<i class="icon-[lucide--user] size-4" />
</Button>
<Popover
ref="popoverRef"
@@ -35,18 +31,12 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { HTMLAttributes } from 'vue'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useExternalLink } from '@/composables/useExternalLink'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { isLoggedIn, handleSignIn } = useCurrentUser()
const { buildDocsUrl } = useExternalLink()

View File

@@ -1,21 +0,0 @@
<template>
<Button
class="comfy-help-center-btn relative text-base-foreground"
variant="textonly"
@click="toggleHelpCenter"
>
{{ $t('menu.helpAndFeedback') }}
<i class="icon-[lucide--circle-help] ml-0.5" />
<span
v-if="shouldShowRedDot"
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
/>
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
</script>

View File

@@ -161,7 +161,7 @@ describe('TopbarBadge', () => {
)
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-warning-background').exists()).toBe(true)
expect(wrapper.find('.text-gold-600').exists()).toBe(true)
})
it('uses default error icon for error variant', () => {
@@ -185,9 +185,7 @@ describe('TopbarBadge', () => {
'full'
)
expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
true
)
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true)
})
})

View File

@@ -174,7 +174,7 @@ const textClasses = computed(() => {
case 'error':
return 'text-danger-100'
case 'warning':
return 'text-warning-background'
return 'text-gold-600'
case 'info':
default:
return 'text-text-primary'
@@ -191,7 +191,7 @@ const iconClass = computed(() => {
case 'error':
return 'pi pi-exclamation-circle'
case 'warning':
return 'icon-[lucide--triangle-alert]'
return 'pi pi-exclamation-triangle'
case 'info':
default:
return undefined

View File

@@ -7,15 +7,6 @@
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<Button
v-if="isActiveTab"
class="context-menu-button -mx-1 w-auto px-1 py-0"
variant="muted-textonly"
size="icon-sm"
@click.stop="handleMenuClick"
>
<i class="pi pi-bars" />
</Button>
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }}
</span>
@@ -43,26 +34,9 @@
:thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab"
/>
<Menu
v-if="isActiveTab"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
</template>
<script setup lang="ts">
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -71,14 +45,11 @@ import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -143,12 +114,6 @@ const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const { menuItems } = useWorkflowActionsMenu(() =>
useCommandStore().execute('Comfy.RenameWorkflow')
)
// Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event)
@@ -162,14 +127,6 @@ const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event)
}
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'workflow_tab_menu_selected'
})
// Show breadcrumb menu instead of emitting context click
menu.value?.toggle(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (

View File

@@ -67,25 +67,7 @@
>
<i class="pi pi-plus" />
</Button>
<div
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<TopMenuHelpButton />
<CurrentUserButton
v-if="isLoggedIn"
:show-arrow="false"
compact
class="shrink-0 p-1"
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<ContextMenu ref="menu" :model="contextMenuItems">
<template #itemicon="{ item }">
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
<i v-else-if="item.icon" :class="item.icon" />
</template>
</ContextMenu>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
@@ -99,20 +81,15 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import OverlayIcon from '@/components/common/OverlayIcon.vue'
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -130,16 +107,10 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
@@ -198,73 +169,54 @@ const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
rightClickedTab.value = option
menu.value.show(event)
}
const rightClickedWorkflow = computed(
() => rightClickedTab.value?.workflow ?? null
)
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
() => commandStore.execute('Comfy.RenameWorkflow'),
{
includeDelete: false,
workflow: rightClickedWorkflow
}
)
const contextMenuItems = computed(() => {
const tab = rightClickedTab.value
const tab = rightClickedTab.value as WorkflowOption
if (!tab) return []
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
return [
...baseMenuItems.value,
{
label: t('tabMenu.duplicateTab'),
command: async () => {
await workflowService.duplicateWorkflow(tab.workflow)
}
},
{
separator: true
},
{
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(tab)
},
{
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-left',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(0, index)),
disabled: index <= 0
},
{
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-right',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(index + 1)),
disabled: index === options.value.length - 1
},
{
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrows-h',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () =>
closeWorkflows([
...options.value.slice(index + 1),
...options.value.slice(0, index)
]),
disabled: options.value.length <= 1
},
{
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
disabled: tab.workflow.isTemporary
}
]
})
const commandStore = useCommandStore()
// Horizontal scroll on wheel
const handleWheel = (event: WheelEvent) => {

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -1,181 +0,0 @@
<template>
<label
:for="inputId"
:class="
cn(
'flex h-10 cursor-text items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover focus-within:ring-1 focus-within:ring-secondary-foreground',
disabled && 'opacity-50 pointer-events-none'
)
"
>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue <= min"
:aria-label="$t('g.decrement')"
@click="handleStep(-1)"
>
<i class="icon-[lucide--minus] size-4" />
</button>
<div
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
>
<slot name="prefix" />
<input
:id="inputId"
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
:style="{ width: `${inputWidth}ch` }"
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none"
:disabled="disabled"
@input="handleInputChange"
@blur="handleInputBlur"
@focus="handleInputFocus"
/>
<slot name="suffix" />
</div>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue >= max"
:aria-label="$t('g.increment')"
@click="handleStep(1)"
>
<i class="icon-[lucide--plus] size-4" />
</button>
</label>
</template>
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = 0,
max = Infinity,
step = 1,
formatOptions = { useGrouping: true },
disabled = false
} = defineProps<{
min?: number
max?: number
step?: number | ((value: number) => number)
formatOptions?: Intl.NumberFormatOptions
disabled?: boolean
}>()
const emit = defineEmits<{
'max-reached': []
}>()
const modelValue = defineModel<number>({ required: true })
const inputId = useId()
const inputRef = ref<HTMLInputElement | null>(null)
const inputValue = ref(formatNumber(modelValue.value))
const inputWidth = computed(() =>
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
)
watch(modelValue, (newValue) => {
if (document.activeElement !== inputRef.value) {
inputValue.value = formatNumber(newValue)
}
})
function formatNumber(num: number): string {
return num.toLocaleString('en-US', formatOptions)
}
function parseFormattedNumber(str: string): number {
const cleaned = str.replace(/[^0-9]/g, '')
return cleaned === '' ? 0 : parseInt(cleaned, 10)
}
function clamp(value: number, minVal: number, maxVal: number): number {
return Math.min(Math.max(value, minVal), maxVal)
}
function formatWithCursor(
value: string,
cursorPos: number
): { formatted: string; newCursor: number } {
const num = parseFormattedNumber(value)
const formatted = formatNumber(num)
const digitsBeforeCursor = value
.slice(0, cursorPos)
.replace(/[^0-9]/g, '').length
let digitCount = 0
let newCursor = 0
for (let i = 0; i < formatted.length; i++) {
if (/[0-9]/.test(formatted[i])) {
digitCount++
}
if (digitCount >= digitsBeforeCursor) {
newCursor = i + 1
break
}
}
if (digitCount < digitsBeforeCursor) {
newCursor = formatted.length
}
return { formatted, newCursor }
}
function getStepAmount(): number {
return typeof step === 'function' ? step(modelValue.value) : step
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const raw = input.value
const cursorPos = input.selectionStart ?? raw.length
const num = parseFormattedNumber(raw)
const clamped = Math.min(num, max)
const wasClamped = num > max
if (wasClamped) {
emit('max-reached')
}
modelValue.value = clamped
const { formatted, newCursor } = formatWithCursor(
wasClamped ? formatNumber(clamped) : raw,
wasClamped ? formatNumber(clamped).length : cursorPos
)
inputValue.value = formatted
requestAnimationFrame(() => {
inputRef.value?.setSelectionRange(newCursor, newCursor)
})
}
function handleInputBlur() {
const clamped = clamp(modelValue.value, min, max)
modelValue.value = clamped
inputValue.value = formatNumber(clamped)
}
function handleInputFocus(e: FocusEvent) {
const input = e.target as HTMLInputElement
const len = input.value.length
input.setSelectionRange(len, len)
}
function handleStep(direction: 1 | -1) {
const stepAmount = getStepAmount()
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
modelValue.value = newValue
inputValue.value = formatNumber(newValue)
}
</script>

View File

@@ -13,11 +13,10 @@
<i class="icon-[lucide--panel-right] text-sm" />
</Button>
<Button
size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<i class="pi pi-times" />
<i class="pi pi-times text-sm"></i>
</Button>
<div class="flex h-full w-full">
<Transition name="slide-panel">
@@ -81,9 +80,7 @@
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
<slot name="content"></slot>
</div>
</main>

View File

@@ -9,7 +9,7 @@
role="button"
@click="onClick"
>
<div v-if="icon" class="pt-0.5">
<div v-if="icon" class="py-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />

View File

@@ -75,7 +75,6 @@ export interface VueNodeData {
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
@@ -245,10 +244,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
const updatedWidgets = currentData.widgets?.map((widget) => {
const slotInfo = slotMetadata.get(widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
}
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
})
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets,
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption
@@ -319,7 +325,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape
}
}

View File

@@ -1,4 +1,3 @@
/// <reference types="@webgpu/types" />
import { ref, watch, nextTick, onUnmounted } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
@@ -234,128 +233,6 @@ export function useBrushDrawing(initialSettings?: {
}
)
const isRecreatingTextures = ref(false)
watch(
() => store.gpuTexturesNeedRecreation,
async (needsRecreation) => {
if (
!needsRecreation ||
!device ||
!store.maskCanvas ||
isRecreatingTextures.value
)
return
isRecreatingTextures.value = true
const width = store.gpuTextureWidth
const height = store.gpuTextureHeight
try {
// Destroy old textures
if (maskTexture) {
maskTexture.destroy()
maskTexture = null
}
if (rgbTexture) {
rgbTexture.destroy()
rgbTexture = null
}
// Create new textures with updated dimensions
maskTexture = device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
})
rgbTexture = device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
})
// Upload pending data if available
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 {
// Fallback: read from canvas
await updateGPUFromCanvas()
}
// Update preview canvas if it exists
if (previewCanvas && renderer) {
previewCanvas.width = width
previewCanvas.height = height
}
// Recreate readback buffers with new size
const bufferSize = width * height * 4
if (currentBufferSize !== bufferSize) {
readbackStorageMask?.destroy()
readbackStorageRgb?.destroy()
readbackStagingMask?.destroy()
readbackStagingRgb?.destroy()
readbackStorageMask = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStorageRgb = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStagingMask = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
readbackStagingRgb = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
currentBufferSize = bufferSize
}
} catch (error) {
console.error(
'[useBrushDrawing] Failed to recreate GPU textures:',
error
)
} finally {
// Clear the recreation flag and pending data
store.gpuTexturesNeedRecreation = false
store.gpuTextureWidth = 0
store.gpuTextureHeight = 0
store.pendingGPUMaskData = null
store.pendingGPURgbData = null
isRecreatingTextures.value = false
}
}
)
// Cleanup GPU resources on unmount
onUnmounted(() => {
if (renderer) {

View File

@@ -2,70 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
// Define the store shape to avoid 'any' and cast to the expected type
interface MaskEditorStoreState {
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
imgCanvas: HTMLCanvasElement | null
maskCtx: CanvasRenderingContext2D | null
rgbCtx: CanvasRenderingContext2D | null
imgCtx: CanvasRenderingContext2D | null
}
let mockMaskCanvas: any
let mockRgbCanvas: any
let mockMaskCtx: any
let mockRgbCtx: any
// Use vi.hoisted to create isolated mock state container
const mockRefs = vi.hoisted(() => ({
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
maskCtx: null as CanvasRenderingContext2D | null,
rgbCtx: null as CanvasRenderingContext2D | null,
imgCtx: null as CanvasRenderingContext2D | null
}))
const mockStore: MaskEditorStoreState = {
get maskCanvas() {
return mockRefs.maskCanvas
},
set maskCanvas(val) {
mockRefs.maskCanvas = val
},
get rgbCanvas() {
return mockRefs.rgbCanvas
},
set rgbCanvas(val) {
mockRefs.rgbCanvas = val
},
get imgCanvas() {
return mockRefs.imgCanvas
},
set imgCanvas(val) {
mockRefs.imgCanvas = val
},
get maskCtx() {
return mockRefs.maskCtx
},
set maskCtx(val) {
mockRefs.maskCtx = val
},
get rgbCtx() {
return mockRefs.rgbCtx
},
set rgbCtx(val) {
mockRefs.rgbCtx = val
},
get imgCtx() {
return mockRefs.imgCtx
},
set imgCtx(val) {
mockRefs.imgCtx = val
}
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap using safe global augmentation pattern
// Mock ImageBitmap for test environment
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
@@ -75,7 +28,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as unknown as typeof globalThis.ImageBitmap
} as any
}
describe('useCanvasHistory', () => {
@@ -90,8 +43,9 @@ describe('useCanvasHistory', () => {
return rafCallCount
}
)
vi.stubGlobal('alert', () => {})
const createMockImageData = (): ImageData => {
const createMockImageData = () => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
@@ -99,43 +53,34 @@ describe('useCanvasHistory', () => {
} as ImageData
}
// Mock contexts using explicit partial-cast pattern
mockRefs.maskCtx = {
mockMaskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
}
mockRefs.rgbCtx = {
mockRgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
}
mockRefs.imgCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
// Mock canvases using explicit partial-cast pattern
mockRefs.maskCanvas = {
mockMaskCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockRefs.rgbCanvas = {
mockRgbCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockRefs.imgCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
})
describe('initialization', () => {
@@ -151,14 +96,8 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
@@ -166,47 +105,27 @@ describe('useCanvasHistory', () => {
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockRefs.maskCanvas = {
...mockRefs.maskCanvas,
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockRefs.maskCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = mockMaskCanvas
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockRefs.maskCtx = null
mockStore.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
const createMockImageData = (): ImageData => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
}
mockRefs.maskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockStore.maskCtx = mockMaskCtx
})
})
@@ -215,20 +134,13 @@ describe('useCanvasHistory', () => {
const history = useCanvasHistory()
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(true)
})
@@ -272,9 +184,8 @@ describe('useCanvasHistory', () => {
history.saveState()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
@@ -282,17 +193,15 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
mockRefs.maskCtx = savedMaskCtx
mockStore.maskCtx = mockMaskCtx
})
})
@@ -305,27 +214,20 @@ describe('useCanvasHistory', () => {
history.undo()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(true)
})
it('should not undo when no undo states available', () => {
it('should show alert when no undo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
})
it('should undo multiple times', () => {
@@ -347,22 +249,16 @@ describe('useCanvasHistory', () => {
})
it('should not undo beyond first state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
expect(alertSpy).toHaveBeenCalled()
})
})
@@ -374,33 +270,25 @@ describe('useCanvasHistory', () => {
history.saveState()
history.undo()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.redo()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canRedo.value).toBe(false)
expect(history.canUndo.value).toBe(true)
})
it('should not redo when no redo states available', () => {
it('should show alert when no redo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
})
it('should redo multiple times', () => {
@@ -426,6 +314,7 @@ describe('useCanvasHistory', () => {
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
@@ -433,16 +322,9 @@ describe('useCanvasHistory', () => {
history.undo()
history.redo()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
expect(alertSpy).toHaveBeenCalled()
})
})
@@ -466,15 +348,13 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.clearStates()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveInitialState()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
})
@@ -566,17 +446,15 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.saveState()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.undo()
expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
mockRefs.maskCtx = savedMaskCtx
mockStore.maskCtx = mockMaskCtx
})
})
@@ -621,12 +499,8 @@ describe('useCanvasHistory', () => {
})
it('should handle zero-sized canvas', () => {
if (mockRefs.maskCanvas) {
mockRefs.maskCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
const history = useCanvasHistory()

View File

@@ -1,17 +1,12 @@
import { ref, computed } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
// Define the state interface for better readability
interface CanvasState {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
img: ImageData | ImageBitmap
}
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<CanvasState[]>([])
const states = ref<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -27,29 +22,22 @@ export function useCanvasHistory(maxStates = 20) {
})
const saveInitialState = () => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
// Ensure all 3 contexts and canvases are ready
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
) {
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
requestAnimationFrame(saveInitialState)
return
}
if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) {
if (!maskCanvas.width || !rgbCanvas.width) {
requestAnimationFrame(saveInitialState)
return
}
states.value = []
// Capture all three layers
const maskState = maskCtx.getImageData(
0,
0,
@@ -62,51 +50,35 @@ export function useCanvasHistory(maxStates = 20) {
rgbCanvas.width,
rgbCanvas.height
)
const imgState = imgCtx.getImageData(
0,
0,
imgCanvas.width,
imgCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value = 0
initialized.value = true
}
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap,
providedImgData?: ImageData | ImageBitmap
providedRgbData?: ImageData | ImageBitmap
) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
return
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
if (!initialized.value || currentStateIndex.value === -1) {
saveInitialState()
return
}
// Clear redo history
states.value = states.value.slice(0, currentStateIndex.value + 1)
let maskState: ImageData | ImageBitmap
let rgbState: ImageData | ImageBitmap
let imgState: ImageData | ImageBitmap
if (providedMaskData && providedRgbData && providedImgData) {
if (providedMaskData && providedRgbData) {
maskState = providedMaskData
rgbState = providedRgbData
imgState = providedImgData
} else {
maskState = maskCtx.getImageData(
0,
@@ -115,84 +87,71 @@ export function useCanvasHistory(maxStates = 20) {
maskCanvas.height
)
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
imgState = imgCtx.getImageData(0, 0, imgCanvas.width, imgCanvas.height)
}
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
// Maintain max history size and clean up memory
if (states.value.length > maxStates) {
const removed = states.value.shift()
// Cleanup ImageBitmaps to avoid memory leaks
if (removed) {
cleanupState(removed)
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
}
currentStateIndex.value--
}
}
const undo = () => {
if (!canUndo.value) return
if (!canUndo.value) {
alert('No more undo states available')
return
}
currentStateIndex.value--
restoreState(states.value[currentStateIndex.value])
}
const redo = () => {
if (!canRedo.value) return
if (!canRedo.value) {
alert('No more redo states available')
return
}
currentStateIndex.value++
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: CanvasState) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
return
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
// Update canvas dimensions to match state (handles rotation undo/redo)
const refData = state.mask
const newWidth = refData.width
const newHeight = refData.height
if (maskCanvas.width !== newWidth || maskCanvas.height !== newHeight) {
maskCanvas.width = newWidth
maskCanvas.height = newHeight
rgbCanvas.width = newWidth
rgbCanvas.height = newHeight
imgCanvas.width = newWidth
imgCanvas.height = newHeight
if (state.mask instanceof ImageBitmap) {
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
maskCtx.drawImage(state.mask, 0, 0)
} else {
maskCtx.putImageData(state.mask, 0, 0)
}
const layers = [
{ ctx: maskCtx, data: state.mask },
{ ctx: rgbCtx, data: state.rgb },
{ ctx: imgCtx, data: state.img }
]
layers.forEach(({ ctx, data }) => {
if (data instanceof ImageBitmap) {
ctx.clearRect(0, 0, data.width, data.height)
ctx.drawImage(data, 0, 0)
} else {
ctx.putImageData(data, 0, 0)
}
})
}
const cleanupState = (state: CanvasState) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
if (state.img instanceof ImageBitmap) state.img.close()
if (state.rgb instanceof ImageBitmap) {
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
rgbCtx.drawImage(state.rgb, 0, 0)
} else {
rgbCtx.putImageData(state.rgb, 0, 0)
}
}
const clearStates = () => {
states.value.forEach(cleanupState)
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value = []
currentStateIndex.value = -1
initialized.value = false

View File

@@ -1,683 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
interface IMockCanvas {
width: number
height: number
}
interface IMockContext {
getImageData: ReturnType<typeof vi.fn>
putImageData: ReturnType<typeof vi.fn>
clearRect: ReturnType<typeof vi.fn>
drawImage: ReturnType<typeof vi.fn>
}
interface IMockCanvasHistory {
saveState: ReturnType<typeof vi.fn>
}
interface IMockStore {
maskCanvas: IMockCanvas | null
rgbCanvas: IMockCanvas | null
imgCanvas: IMockCanvas | null
maskCtx: IMockContext | null
rgbCtx: IMockContext | null
imgCtx: IMockContext | null
tgpuRoot: unknown
canvasHistory: IMockCanvasHistory
gpuTexturesNeedRecreation: boolean
gpuTextureWidth: number
gpuTextureHeight: number
pendingGPUMaskData: Uint8ClampedArray | null
pendingGPURgbData: Uint8ClampedArray | null
}
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
const mockCanvasHistory: IMockCanvasHistory = {
saveState: vi.fn()
}
const mockStore: IMockStore = {
maskCanvas: null,
rgbCanvas: null,
imgCanvas: null,
maskCtx: null,
rgbCtx: null,
imgCtx: null,
tgpuRoot: null,
canvasHistory: mockCanvasHistory,
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null,
pendingGPURgbData: null
}
return { mockStore, mockCanvasHistory }
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageData with improved type safety
if (typeof globalThis.ImageData === 'undefined') {
globalThis.ImageData = class ImageData {
data: Uint8ClampedArray
width: number
height: number
constructor(
dataOrWidth: Uint8ClampedArray | number,
widthOrHeight?: number,
height?: number
) {
if (dataOrWidth instanceof Uint8ClampedArray) {
// Constructor overload: new ImageData(data, width, height)
if (widthOrHeight === undefined || height === undefined) {
throw new Error(
'ImageData constructor requires width and height when data is provided'
)
}
this.data = dataOrWidth
this.width = widthOrHeight
this.height = height
} else {
// Constructor overload: new ImageData(width, height)
if (widthOrHeight === undefined) {
throw new Error(
'ImageData constructor requires height when width is provided'
)
}
this.width = dataOrWidth
this.height = widthOrHeight
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
}
}
} as unknown as typeof globalThis.ImageData
}
// Mock ImageBitmap for test environment using safe type casting
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
height: number
constructor(width = 100, height = 100) {
this.width = width
this.height = height
}
close() {}
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasTransform', () => {
let mockMaskCanvas: IMockCanvas
let mockRgbCanvas: IMockCanvas
let mockImgCanvas: IMockCanvas
let mockMaskCtx: IMockContext
let mockRgbCtx: IMockContext
let mockImgCtx: IMockContext
beforeEach(() => {
vi.clearAllMocks()
const createMockImageData = (width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 // R
data[i + 1] = 0 // G
data[i + 2] = 0 // B
data[i + 3] = 255 // A
}
return {
data,
width,
height
} as ImageData
}
mockMaskCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockRgbCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockImgCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockMaskCanvas = {
width: 100,
height: 50
}
mockRgbCanvas = {
width: 100,
height: 50
}
mockImgCanvas = {
width: 100,
height: 50
}
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.imgCanvas = mockImgCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
mockStore.imgCtx = mockImgCtx
mockStore.tgpuRoot = null
mockStore.gpuTexturesNeedRecreation = false
mockStore.gpuTextureWidth = 0
mockStore.gpuTextureHeight = 0
mockStore.pendingGPUMaskData = null
mockStore.pendingGPURgbData = null
})
describe('rotateClockwise', () => {
it('should rotate canvas 90 degrees clockwise', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCanvas.width).toBe(50)
expect(mockMaskCanvas.height).toBe(100)
expect(mockRgbCanvas.width).toBe(50)
expect(mockRgbCanvas.height).toBe(100)
expect(mockImgCanvas.width).toBe(50)
expect(mockImgCanvas.height).toBe(100)
})
it('should call getImageData with original dimensions', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
expect(mockImgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
})
it('should call putImageData with rotated data', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockImgCtx.putImageData).toHaveBeenCalled()
const maskCall = mockMaskCtx.putImageData.mock.calls[0][0]
expect(maskCall.width).toBe(50)
expect(maskCall.height).toBe(100)
})
it('should save transformed state to history', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
const savedArgs = mockCanvasHistory.saveState.mock.calls[0]
expect(savedArgs).toHaveLength(3)
expect(savedArgs[0].width).toBe(50)
expect(savedArgs[0].height).toBe(100)
expect(savedArgs[1].width).toBe(50)
expect(savedArgs[1].height).toBe(100)
expect(savedArgs[2].width).toBe(50)
expect(savedArgs[2].height).toBe(100)
})
it('should log error when canvas contexts not ready', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockStore.maskCanvas = null
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useCanvasTransform] Canvas contexts not ready'
)
consoleErrorSpy.mockRestore()
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(50)
expect(mockStore.gpuTextureHeight).toBe(100)
})
it('should not recreate GPU textures when GPU is inactive', async () => {
mockStore.tgpuRoot = null
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(false)
})
it('should correctly rotate pixels clockwise at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.rotateClockwise()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After clockwise rotation:
// New TL should be old BL (Blue)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(0) // G
expect(result.data[2]).toBe(255) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old TL (Red)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(0) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old BR (Yellow)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(255) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old TR (Green)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(255) // G
expect(result.data[14]).toBe(0) // B
expect(result.data[15]).toBe(255) // A
})
})
describe('rotateCounterclockwise', () => {
it('should rotate canvas 90 degrees counterclockwise', async () => {
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
expect(mockMaskCanvas.width).toBe(50)
expect(mockMaskCanvas.height).toBe(100)
})
it('should call getImageData with original dimensions', async () => {
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
})
it('should correctly rotate pixels counterclockwise at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After counterclockwise rotation:
// New TL should be old TR (Green)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(255) // G
expect(result.data[2]).toBe(0) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old BR (Yellow)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(255) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old TL (Red)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(0) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old BL (Blue)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(0) // G
expect(result.data[14]).toBe(255) // B
expect(result.data[15]).toBe(255) // A
})
it('should produce different result than clockwise rotation', async () => {
const transform = useCanvasTransform()
const createAsymmetricImageData = (width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4
if (x < width / 2 && y < height / 2) {
data[i] = 255
data[i + 3] = 255
} else {
data[i + 3] = 255
}
}
}
return { data, width, height } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
await transform.rotateCounterclockwise()
const ccwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
mockMaskCanvas.width = 100
mockMaskCanvas.height = 50
mockMaskCtx.putImageData.mockClear()
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
await transform.rotateClockwise()
const cwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
let pixelDifferences = 0
for (let i = 0; i < ccwResult.data.length; i++) {
if (ccwResult.data[i] !== cwResult.data[i]) {
pixelDifferences++
}
}
expect(pixelDifferences).toBeGreaterThan(0)
})
})
describe('mirrorHorizontal', () => {
it('should mirror canvas horizontally', async () => {
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
expect(mockMaskCanvas.width).toBe(100)
expect(mockMaskCanvas.height).toBe(50)
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(100)
expect(mockStore.gpuTextureHeight).toBe(50)
})
it('should correctly flip pixels horizontally at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After horizontal flip:
// New TL should be old TR (Green)
expect(result.data[0]).toBe(0)
expect(result.data[1]).toBe(255)
// New TR should be old TL (Red)
expect(result.data[4]).toBe(255)
expect(result.data[5]).toBe(0)
})
})
describe('mirrorVertical', () => {
it('should mirror canvas vertically', async () => {
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(mockMaskCanvas.width).toBe(100)
expect(mockMaskCanvas.height).toBe(50)
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(100)
expect(mockStore.gpuTextureHeight).toBe(50)
})
it('should correctly flip pixels vertically at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.mirrorVertical()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After vertical flip:
// New TL should be old BL (Blue)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(0) // G
expect(result.data[2]).toBe(255) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old BR (Yellow)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(255) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old TL (Red)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(0) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old TR (Green)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(255) // G
expect(result.data[14]).toBe(0) // B
expect(result.data[15]).toBe(255) // A
})
it('should log error when canvas contexts not ready', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockStore.maskCanvas = null
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useCanvasTransform] Canvas contexts not ready'
)
consoleErrorSpy.mockRestore()
})
})
describe('GPU integration', () => {
it('should set GPU recreation flags for rotation', async () => {
mockStore.tgpuRoot = {}
mockMaskCanvas.width = 100
mockMaskCanvas.height = 50
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(50)
expect(mockStore.gpuTextureHeight).toBe(100)
expect(mockStore.pendingGPUMaskData!.length).toBe(50 * 100 * 4)
expect(mockStore.pendingGPURgbData!.length).toBe(50 * 100 * 4)
})
it('should premultiply alpha when preparing GPU data', async () => {
mockStore.tgpuRoot = {}
mockMaskCanvas.width = 1
mockMaskCanvas.height = 1
// Create 1x1 ImageData with semi-transparent pixel
const createSemiTransparentImageData = () => {
const data = new Uint8ClampedArray(1 * 1 * 4)
data[0] = 200 // R
data[1] = 100 // G
data[2] = 50 // B
data[3] = 128 // A (50% opacity)
return { data, width: 1, height: 1 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
mockRgbCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
mockImgCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
const transform = useCanvasTransform()
await transform.rotateClockwise()
// Verify pendingGPUMaskData contains premultiplied values
expect(mockStore.pendingGPUMaskData).not.toBeNull()
const maskData = mockStore.pendingGPUMaskData!
// Expected premultiplied values: RGB * alpha / 255
// R: 200 * 128 / 255 ≈ 100
// G: 100 * 128 / 255 ≈ 50
// B: 50 * 128 / 255 ≈ 25
// A: 128 (preserved)
expect(maskData[0]).toBeCloseTo(100, 0) // R premultiplied
expect(maskData[1]).toBeCloseTo(50, 0) // G premultiplied
expect(maskData[2]).toBeCloseTo(25, 0) // B premultiplied
expect(maskData[3]).toBe(128) // A preserved
// Also verify RGB canvas data
expect(mockStore.pendingGPURgbData).not.toBeNull()
const rgbData = mockStore.pendingGPURgbData!
expect(rgbData[0]).toBeCloseTo(100, 0)
expect(rgbData[1]).toBeCloseTo(50, 0)
expect(rgbData[2]).toBeCloseTo(25, 0)
expect(rgbData[3]).toBe(128)
})
})
})

View File

@@ -1,359 +0,0 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
/**
* Composable for canvas transformation operations (rotate, mirror)
*/
export function useCanvasTransform() {
const store = useMaskEditorStore()
/**
* Rotates a canvas 90 degrees clockwise or counter-clockwise
*/
const rotateCanvas = (
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
clockwise: boolean
): ImageData => {
const width = canvas.width
const height = canvas.height
// Get current canvas data
const sourceData = ctx.getImageData(0, 0, width, height)
// Create new ImageData with swapped dimensions
const rotatedData = new ImageData(height, width)
const src = sourceData.data
const dst = rotatedData.data
// Rotate pixel by pixel
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4
// Calculate destination coordinates
let dstX: number, dstY: number
if (clockwise) {
// Rotate 90° clockwise: (x,y) -> (height-1-y, x)
dstX = height - 1 - y
dstY = x
} else {
// Rotate 90° counter-clockwise: (x,y) -> (y, width-1-x)
dstX = y
dstY = width - 1 - x
}
const dstIdx = (dstY * height + dstX) * 4
// Copy RGBA values
dst[dstIdx] = src[srcIdx]
dst[dstIdx + 1] = src[srcIdx + 1]
dst[dstIdx + 2] = src[srcIdx + 2]
dst[dstIdx + 3] = src[srcIdx + 3]
}
}
return rotatedData
}
/**
* Mirrors a canvas horizontally or vertically
*/
const mirrorCanvas = (
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
horizontal: boolean
): ImageData => {
const width = canvas.width
const height = canvas.height
// Get current canvas data
const sourceData = ctx.getImageData(0, 0, width, height)
const mirroredData = new ImageData(width, height)
const src = sourceData.data
const dst = mirroredData.data
// Mirror pixel by pixel
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4
// Calculate destination coordinates
let dstX: number, dstY: number
if (horizontal) {
// Mirror horizontally: flip X axis
dstX = width - 1 - x
dstY = y
} else {
// Mirror vertically: flip Y axis
dstX = x
dstY = height - 1 - y
}
const dstIdx = (dstY * width + dstX) * 4
// Copy RGBA values
dst[dstIdx] = src[srcIdx]
dst[dstIdx + 1] = src[srcIdx + 1]
dst[dstIdx + 2] = src[srcIdx + 2]
dst[dstIdx + 3] = src[srcIdx + 3]
}
}
return mirroredData
}
/**
* Premultiplies alpha for GPU upload
*/
const premultiplyData = (data: Uint8ClampedArray): void => {
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3] / 255
data[i] = Math.round(data[i] * a)
data[i + 1] = Math.round(data[i + 1] * a)
data[i + 2] = Math.round(data[i + 2] * a)
}
}
/**
* Recreates and updates GPU textures after transformation
* This is required because GPU textures have immutable dimensions
*/
const recreateGPUTextures = async (
width: number,
height: number
): Promise<void> => {
if (
!store.tgpuRoot ||
!store.maskCanvas ||
!store.rgbCanvas ||
!store.maskCtx ||
!store.rgbCtx
) {
return
}
// Get references to GPU resources from useBrushDrawing
// These are stored as module-level variables in useBrushDrawing
// We need to trigger a reinitialization through the store
// Signal to useBrushDrawing that textures need recreation
store.gpuTexturesNeedRecreation = true
store.gpuTextureWidth = width
store.gpuTextureHeight = height
// Get current canvas data
const maskImageData = store.maskCtx.getImageData(0, 0, width, height)
const rgbImageData = store.rgbCtx.getImageData(0, 0, width, height)
// Create new Uint8ClampedArray with ArrayBuffer (not SharedArrayBuffer)
// This ensures compatibility with WebGPU writeTexture
const maskData = new Uint8ClampedArray(
new ArrayBuffer(maskImageData.data.length)
)
const rgbData = new Uint8ClampedArray(
new ArrayBuffer(rgbImageData.data.length)
)
// Copy data
maskData.set(maskImageData.data)
rgbData.set(rgbImageData.data)
// Runtime check to ensure we have ArrayBuffer backing
if (
maskData.buffer instanceof SharedArrayBuffer ||
rgbData.buffer instanceof SharedArrayBuffer
) {
console.error(
'[useCanvasTransform] SharedArrayBuffer detected, WebGPU writeTexture will fail'
)
return
}
// Premultiply alpha for GPU
premultiplyData(maskData)
premultiplyData(rgbData)
// Store the premultiplied data for useBrushDrawing to pick up
store.pendingGPUMaskData = maskData
store.pendingGPURgbData = rgbData
}
/**
* Rotates all canvas layers 90 degrees clockwise and updates GPU
*/
const rotateClockwise = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Store original dimensions
const origWidth = maskCanvas.width
const origHeight = maskCanvas.height
// Rotate all three layers clockwise
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, true)
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, true)
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, true)
// Update canvas dimensions (swap width/height)
maskCanvas.width = origHeight
maskCanvas.height = origWidth
rgbCanvas.width = origHeight
rgbCanvas.height = origWidth
imgCanvas.width = origHeight
imgCanvas.height = origWidth
// Apply rotated data
maskCtx.putImageData(rotatedMask, 0, 0)
rgbCtx.putImageData(rotatedRgb, 0, 0)
imgCtx.putImageData(rotatedImg, 0, 0)
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
}
// Save to history
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
}
/**
* Rotates all canvas layers 90 degrees counter-clockwise and updates GPU
*/
const rotateCounterclockwise = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Store original dimensions
const origWidth = maskCanvas.width
const origHeight = maskCanvas.height
// Rotate all three layers counter-clockwise
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, false)
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, false)
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, false)
// Update canvas dimensions (swap width/height)
maskCanvas.width = origHeight
maskCanvas.height = origWidth
rgbCanvas.width = origHeight
rgbCanvas.height = origWidth
imgCanvas.width = origHeight
imgCanvas.height = origWidth
// Apply rotated data
maskCtx.putImageData(rotatedMask, 0, 0)
rgbCtx.putImageData(rotatedRgb, 0, 0)
imgCtx.putImageData(rotatedImg, 0, 0)
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
}
// Save to history
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
}
/**
* Mirrors all canvas layers horizontally and updates GPU
*/
const mirrorHorizontal = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Mirror all three layers horizontally
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, true)
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, true)
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, true)
// Apply mirrored data (dimensions stay the same)
maskCtx.putImageData(mirroredMask, 0, 0)
rgbCtx.putImageData(mirroredRgb, 0, 0)
imgCtx.putImageData(mirroredImg, 0, 0)
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
}
/**
* Mirrors all canvas layers vertically and updates GPU
*/
const mirrorVertical = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Mirror all three layers vertically
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, false)
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, false)
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, false)
// Apply mirrored data (dimensions stay the same)
maskCtx.putImageData(mirroredMask, 0, 0)
rgbCtx.putImageData(mirroredRgb, 0, 0)
imgCtx.putImageData(mirroredImg, 0, 0)
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
}
return {
rotateClockwise,
rotateCounterclockwise,
mirrorHorizontal,
mirrorVertical
}
}

View File

@@ -1664,41 +1664,31 @@ describe('useNodePricing', () => {
{
model: 'gemini-2.5-pro-preview-05-06',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gemini-2.5-pro',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gemini-3-pro-preview',
expected: creditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gemini-2.5-flash',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
@@ -1712,6 +1702,16 @@ describe('useNodePricing', () => {
})
})
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
})
it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [])
@@ -1737,97 +1737,73 @@ describe('useNodePricing', () => {
{
model: 'o4-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o1-pro',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o1',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o3-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o3',
expected: creditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4o',
expected: creditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4.1-nano',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4.1-mini',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4.1',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-5-nano',
expected: creditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-5-mini',
expected: creditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-5',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
}
]
@@ -1848,49 +1824,37 @@ describe('useNodePricing', () => {
{
model: 'gpt-4.1-nano-test',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4.1-mini-test',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'gpt-4.1-test',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o1-pro-test',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o1-test',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{
model: 'o3-mini-test',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
},
{ model: 'unknown-model', expected: 'Token-based' }

View File

@@ -2,17 +2,6 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
/**
* Meshy credit pricing constant.
* 1 Meshy credit = $0.04 USD
* Change this value to update all Meshy node prices.
*/
const MESHY_CREDIT_PRICE_USD = 0.04
/** Convert Meshy credits to USD */
const meshyCreditsToUsd = (credits: number): number =>
credits * MESHY_CREDIT_PRICE_USD
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
minimumFractionDigits: 0,
maximumFractionDigits: 0
@@ -220,24 +209,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget | undefined
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
const model = String(modelWidget.value).toLowerCase()
const resolution = String(resolutionWidget.value).toLowerCase()
const seconds = parseFloat(String(durationWidget.value))
const generateAudio =
generateAudioWidget &&
String(generateAudioWidget.value).toLowerCase() === 'true'
const priceByModel: Record<string, Record<string, [number, number]>> = {
'seedance-1-5-pro': {
'480p': [0.12, 0.12],
'720p': [0.26, 0.26],
'1080p': [0.58, 0.59]
},
'seedance-1-0-pro': {
'480p': [0.23, 0.24],
'720p': [0.51, 0.56],
@@ -255,15 +233,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
}
}
const modelKey = model.includes('seedance-1-5-pro')
? 'seedance-1-5-pro'
: model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const modelKey = model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const resKey = resolution.includes('1080')
? '1080p'
@@ -279,10 +255,8 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const [min10s, max10s] = baseRange
const scale = seconds / 10
const audioMultiplier =
modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1
const minCost = min10s * scale * audioMultiplier
const maxCost = max10s * scale * audioMultiplier
const minCost = min10s * scale
const maxCost = max10s * scale
if (minCost === maxCost) return formatCreditsLabel(minCost)
return formatCreditsRangeLabel(minCost, maxCost)
@@ -551,54 +525,6 @@ const calculateTripo3DGenerationPrice = (
return formatCreditsLabel(dollars)
}
/**
* Meshy Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 20 credits
* - With texture: 30 credits
*/
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(20),
meshyCreditsToUsd(30),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 30 : 20
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/**
* Meshy Multi-Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 5 credits
* - With texture: 15 credits
*/
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(5),
meshyCreditsToUsd(15),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 15 : 5
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -1886,27 +1812,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
TripoRefineNode: {
displayPrice: formatCreditsLabel(0.3)
},
MeshyTextToModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
},
MeshyRefineNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
MeshyImageToModelNode: {
displayPrice: calculateMeshyImageToModelPrice
},
MeshyMultiImageToModelNode: {
displayPrice: calculateMeshyMultiImageToModelPrice
},
MeshyRigModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
},
MeshyAnimateModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
},
MeshyTextureNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1918,35 +1823,28 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const model = String(modelWidget.value)
if (model.includes('gemini-2.5-flash-preview-04-17')) {
// Google Veo video generation
if (model.includes('veo-2.0')) {
return formatCreditsLabel(0.5, { suffix: '/second' })
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gemini-2.5-flash')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gemini-2.5-pro')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gemini-3-pro-preview')) {
return formatCreditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
}
// For other Gemini models, show token-based pricing info
@@ -2001,75 +1899,51 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('o1-pro')) {
return formatCreditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('o1')) {
return formatCreditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('o3-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('o3')) {
return formatCreditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-4o')) {
return formatCreditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-4.1-nano')) {
return formatCreditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-4.1-mini')) {
return formatCreditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-4.1')) {
return formatCreditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-5-nano')) {
return formatCreditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-5-mini')) {
return formatCreditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
} else if (model.includes('gpt-5')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
suffix: ' per 1K tokens'
})
}
return 'Token-based'
@@ -2227,267 +2101,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
LtxvApiImageToVideo: {
displayPrice: ltxvPricingCalculator
},
WanReferenceVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const sizeWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
if (!durationWidget || !sizeWidget) {
return formatCreditsRangeLabel(0.7, 1.5, {
note: '(varies with size & duration)'
})
}
const seconds = parseFloat(String(durationWidget.value))
const sizeStr = String(sizeWidget.value).toLowerCase()
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
const inputMin = 2 * rate
const inputMax = 5 * rate
const outputPrice = seconds * rate
const minTotal = inputMin + outputPrice
const maxTotal = inputMax + outputPrice
return formatCreditsRangeLabel(minTotal, maxTotal)
}
},
Vidu2TextToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.075, 0.6, {
note: '(varies with duration & resolution)'
})
}
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
// Text-to-Video uses Q2 model only
// 720P: Starts at $0.075, +$0.025/sec
// 1080P: Starts at $0.10, +$0.05/sec
let basePrice: number
let pricePerSecond: number
if (resolution.includes('1080')) {
basePrice = 0.1
pricePerSecond = 0.05
} else {
// 720P default
basePrice = 0.075
pricePerSecond = 0.025
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsRangeLabel(0.075, 0.6, {
note: '(varies with duration & resolution)'
})
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
},
Vidu2ImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!modelWidget || !durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const model = String(modelWidget.value).toLowerCase()
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
const is1080p = resolution.includes('1080')
let basePrice: number
let pricePerSecond: number
if (model.includes('q2-pro-fast')) {
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
basePrice = is1080p ? 0.08 : 0.04
pricePerSecond = is1080p ? 0.02 : 0.01
} else if (model.includes('q2-pro')) {
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
basePrice = is1080p ? 0.275 : 0.075
pricePerSecond = is1080p ? 0.075 : 0.05
} else if (model.includes('q2-turbo')) {
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
if (is1080p) {
basePrice = 0.175
pricePerSecond = 0.05
} else {
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
if (duration <= 1) {
return formatCreditsLabel(0.04)
}
if (duration <= 2) {
return formatCreditsLabel(0.05)
}
const cost = 0.05 + 0.05 * (duration - 2)
return formatCreditsLabel(cost)
}
} else {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
},
Vidu2ReferenceVideoNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const audioWidget = node.widgets?.find(
(w) => w.name === 'audio'
) as IComboWidget
if (!durationWidget) {
return formatCreditsRangeLabel(0.125, 1.5, {
note: '(varies with duration, resolution & audio)'
})
}
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
const is1080p = resolution.includes('1080')
// Check if audio is enabled (adds $0.75)
const audioValue = audioWidget?.value
const hasAudio =
audioValue !== undefined &&
audioValue !== null &&
String(audioValue).toLowerCase() !== 'false' &&
String(audioValue).toLowerCase() !== 'none' &&
audioValue !== ''
// Reference-to-Video uses Q2 model
// 720P: Starts at $0.125, +$0.025/sec
// 1080P: Starts at $0.375, +$0.05/sec
let basePrice: number
let pricePerSecond: number
if (is1080p) {
basePrice = 0.375
pricePerSecond = 0.05
} else {
// 720P default
basePrice = 0.125
pricePerSecond = 0.025
}
let cost = basePrice
if (Number.isFinite(duration) && duration > 0) {
cost = basePrice + pricePerSecond * (duration - 1)
}
// Audio adds $0.75 on top
if (hasAudio) {
cost += 0.075
}
return formatCreditsLabel(cost)
}
},
Vidu2StartEndToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!modelWidget || !durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const model = String(modelWidget.value).toLowerCase()
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
const is1080p = resolution.includes('1080')
let basePrice: number
let pricePerSecond: number
if (model.includes('q2-pro-fast')) {
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
basePrice = is1080p ? 0.08 : 0.04
pricePerSecond = is1080p ? 0.02 : 0.01
} else if (model.includes('q2-pro')) {
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
basePrice = is1080p ? 0.275 : 0.075
pricePerSecond = is1080p ? 0.075 : 0.05
} else if (model.includes('q2-turbo')) {
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
if (is1080p) {
basePrice = 0.175
pricePerSecond = 0.05
} else {
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
if (!Number.isFinite(duration) || duration <= 1) {
return formatCreditsLabel(0.04)
}
if (duration <= 2) {
return formatCreditsLabel(0.05)
}
const cost = 0.05 + 0.05 * (duration - 2)
return formatCreditsLabel(cost)
}
} else {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsLabel(basePrice)
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
}
}
@@ -2622,9 +2235,6 @@ export const useNodePricing = () => {
'animate_in_place'
],
TripoTextureNode: ['texture_quality'],
// Meshy nodes
MeshyImageToModelNode: ['should_texture'],
MeshyMultiImageToModelNode: ['should_texture'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
@@ -2638,34 +2248,14 @@ export const useNodePricing = () => {
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceImageToVideoNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceFirstLastFrameNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'],
WanReferenceVideoApi: ['duration', 'size'],
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

@@ -1,59 +0,0 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { JobState } from '@/types/queue'
export type JobAction = {
icon: string
label: string
variant: 'destructive' | 'secondary' | 'textonly'
}
export function useJobActions(
job: MaybeRefOrGetter<JobListItem | null | undefined>
) {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { cancelJob } = useJobMenu()
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
variant: 'destructive'
}
const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
const jobRef = computed(() => toValue(job) ?? null)
const canCancelJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}
return (
currentJob.showClear !== false &&
cancellableStates.includes(currentJob.state)
)
})
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
if (!currentJob) {
return
}
await cancelJob(currentJob)
})
return {
cancelAction,
canCancelJob,
runCancelJob
}
}

View File

@@ -41,7 +41,7 @@ export type MenuEntry =
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
*/
export function useJobMenu(
currentMenuItem: () => JobListItem | null = () => null,
currentMenuItem: () => JobListItem | null,
onInspectAsset?: (item: JobListItem) => void
) {
const workflowStore = useWorkflowStore()
@@ -52,40 +52,37 @@ export function useJobMenu(
const nodeDefStore = useNodeDefStore()
const mediaAssetActions = useMediaAssetActions()
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
item ?? currentMenuItem()
const openJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
const openJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
if (!data) return
const filename = `Job ${target.id}.json`
const filename = `Job ${item.id}.json`
const temp = workflowStore.createTemporary(filename, data)
await workflowService.openWorkflow(temp)
}
const copyJobId = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
await copyToClipboard(target.id)
const copyJobId = async () => {
const item = currentMenuItem()
if (!item) return
await copyToClipboard(item.id)
}
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
await api.interrupt(target.id)
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
const cancelJob = async () => {
const item = currentMenuItem()
if (!item) return
if (item.state === 'running' || item.state === 'initialization') {
await api.interrupt(item.id)
} else if (item.state === 'pending') {
await api.deleteItem('queue', item.id)
}
await queueStore.update()
}
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const copyErrorMessage = async () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
@@ -93,10 +90,10 @@ export function useJobMenu(
if (message) await copyToClipboard(String(message))
}
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const reportError = () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
@@ -105,10 +102,10 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -156,10 +153,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -167,14 +164,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${target.id}.json`
let filename = `Job ${item.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -191,10 +188,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -205,8 +202,8 @@ export function useJobMenu(
}
}
const removeFailedJob = async (item?: JobListItem | null) => {
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
}
@@ -237,8 +234,8 @@ export function useJobMenu(
icon: 'icon-[lucide--zoom-in]',
onClick: onInspectAsset
? () => {
const current = resolveItem()
if (current) onInspectAsset(current)
const item = currentMenuItem()
if (item) onInspectAsset(item)
}
: undefined
},
@@ -249,33 +246,33 @@ export function useJobMenu(
'Add to current workflow'
),
icon: 'icon-[comfy--node]',
onClick: () => addOutputLoaderNode(resolveItem())
onClick: addOutputLoaderNode
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
onClick: () => downloadPreviewAsset(resolveItem())
onClick: downloadPreviewAsset
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: () => exportJobWorkflow(resolveItem())
onClick: exportJobWorkflow
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{ kind: 'divider', key: 'd3' },
...(hasDeletableAsset
@@ -284,7 +281,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: () => deleteJobAsset(resolveItem())
onClick: deleteJobAsset
}
]
: [])
@@ -296,33 +293,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: () => copyErrorMessage(resolveItem())
onClick: copyErrorMessage
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: () => reportError(resolveItem())
onClick: reportError
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: () => removeFailedJob(resolveItem())
onClick: removeFailedJob
}
]
}
@@ -331,21 +328,21 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: () => cancelJob(resolveItem())
onClick: cancelJob
}
]
})

View File

@@ -64,9 +64,6 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -85,9 +82,6 @@ export function useCoreCommands(): ComfyCommand[] {
const bottomPanelStore = useBottomPanelStore()
const dialogStore = useDialogStore()
const maskEditorStore = useMaskEditorStore()
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
@@ -188,26 +182,6 @@ export function useCoreCommands(): ComfyCommand[] {
await workflowService.saveWorkflowAs(workflow)
}
},
{
id: 'Comfy.RenameWorkflow',
icon: 'pi pi-pencil',
label: 'Rename Workflow',
menubarLabel: 'Rename',
function: async () => {
const workflow = workflowStore.activeWorkflow
if (!workflow || !workflow.isPersisted) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('workflowService.enterFilename') + ':',
defaultValue: workflow.filename
})
if (!newName || newName === workflow.filename) return
const newPath = workflow.directory + '/' + newName + '.json'
await workflowService.renameWorkflow(workflow, newPath)
}
},
{
id: 'Comfy.ExportWorkflow',
icon: 'pi pi-download',
@@ -233,12 +207,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Undo',
category: 'essentials' as const,
function: async () => {
// If Mask Editor is open, use its history instead of the graph
if (dialogStore.isDialogOpen('global-mask-editor')) {
maskEditorStore.canvasHistory.undo()
} else {
await getTracker()?.undo?.()
}
await getTracker()?.undo?.()
}
},
{
@@ -247,11 +216,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Redo',
category: 'essentials' as const,
function: async () => {
if (dialogStore.isDialogOpen('global-mask-editor')) {
maskEditorStore.canvasHistory.redo()
} else {
await getTracker()?.redo?.()
}
await getTracker()?.redo?.()
}
},
{
@@ -940,6 +905,15 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
}
},
{
id: 'Comfy.User.OpenSignInDialog',
icon: 'pi pi-user',

View File

@@ -1,6 +1,5 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -17,8 +16,7 @@ export enum ServerFeatureFlag {
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
}
/**
@@ -87,14 +85,6 @@ export function useFeatureFlags() {
false
)
)
},
get teamWorkspacesEnabled() {
if (!isCloud) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
}
})

View File

@@ -1,100 +0,0 @@
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
export function useHelpCenter(
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
) {
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible, triggerLocation } =
storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const { showNodeConflictDialog } = useDialogService()
// Use conflict acknowledgment state from composable - call only once
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
/**
* Toggle Help Center and track UI button click.
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: `${triggerFrom}_help_center_toggled`
})
helpCenterStore.toggle(triggerFrom)
}
const closeHelpCenter = () => {
helpCenterStore.hide()
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
showNodeConflictDialog({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
return {
isHelpCenterVisible,
triggerLocation,
shouldShowRedDot,
sidebarLocation,
toggleHelpCenter,
closeHelpCenter,
handleWhatsNewDismissed
}
}

View File

@@ -1,314 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraphCanvas,
LGraph,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
function createMockNode() {
return {
pos: [0, 0],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
}
}
function createImageFile(
name: string = 'test.png',
type: string = 'image/png'
): File {
return new File([''], name, { type })
}
function createAudioFile(
name: string = 'test.mp3',
type: string = 'audio/mpeg'
): File {
return new File([''], name, { type })
}
function createDataTransfer(files: File[] = []): DataTransfer {
const dataTransfer = new DataTransfer()
files.forEach((file) => dataTransfer.items.add(file))
return dataTransfer
}
const mockCanvas = {
current_node: null as LGraphNode | null,
graph: {
add: vi.fn(),
change: vi.fn()
} as Partial<LGraph> as LGraph,
graph_mouse: [100, 200],
pasteFromClipboard: vi.fn(),
_deserializeItems: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas,
getCanvas: vi.fn(() => mockCanvas)
}
const mockWorkspaceStore = {
shiftDown: false
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn((target, event, handler) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => mockWorkspaceStore
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn()
}))
describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).not.toHaveBeenCalled()
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle audio paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle workflow JSON paste', async () => {
const workflow = { version: '1.0', nodes: [], extra: {} }
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', JSON.stringify(workflow))
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(app.loadGraphData).toHaveBeenCalledWith(workflow)
})
})
it('should ignore paste when shift is down', () => {
mockWorkspaceStore.shiftDown = true
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(LiteGraph.createNode).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = {
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
} as unknown as Partial<LGraphNode> as LGraphNode
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should call canvas pasteFromClipboard for non-workflow text', () => {
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'just some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
it('should handle clipboard items with metadata', async () => {
const data = { test: 'data' }
const encoded = btoa(JSON.stringify(data))
const html = `<div data-metadata="${encoded}"></div>`
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
data,
expect.any(Object)
)
})
})
})

Some files were not shown because too many files have changed in this diff Show More