Merge branch 'main' into fix/spacebar-panning-vue-nodes

This commit is contained in:
Alexander Brown
2026-01-13 13:27:54 -08:00
committed by GitHub
308 changed files with 85401 additions and 12229 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,6 +81,7 @@ 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.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
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.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -222,6 +222,7 @@ 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.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,10 +6,11 @@ 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'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
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.
@@ -18,5 +19,11 @@ 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'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
framework: {
name: '@storybook/vue3-vite',
options: {}
@@ -69,9 +69,32 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: {
'@': process.cwd() + '/src'
}
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'
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -63,6 +63,9 @@ 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
@@ -119,7 +122,10 @@ 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 `useModel` to separately defining a prop and emit
- 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
- 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`
@@ -137,7 +143,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
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.
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
@@ -155,6 +161,8 @@ 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)
@@ -268,6 +276,8 @@ 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,8 +133,11 @@ test.describe('Menu', () => {
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// 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 } })
// 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: 75 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 54 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: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.3",
"version": "1.38.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -66,6 +66,7 @@
"@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:",

View File

@@ -9,6 +9,8 @@
@config '../../tailwind.config.ts';
@custom-variant touch (@media (hover: none));
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

134
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ 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
@@ -549,6 +552,9 @@ 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))
@@ -3148,6 +3154,11 @@ 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:
@@ -3181,6 +3192,9 @@ 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:
@@ -3453,6 +3467,26 @@ 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'}
@@ -3786,6 +3820,11 @@ 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}
@@ -5170,6 +5209,9 @@ 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==}
@@ -5978,6 +6020,9 @@ 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==}
@@ -7386,6 +7431,9 @@ 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'}
@@ -7613,6 +7661,9 @@ 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'}
@@ -7858,6 +7909,9 @@ 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:
@@ -7873,6 +7927,14 @@ 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==}
@@ -8019,6 +8081,9 @@ 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'}
@@ -10949,6 +11014,18 @@ 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))
@@ -10978,6 +11055,16 @@ 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
@@ -11007,7 +11094,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.1
vue-component-type-helpers: 3.2.2
'@swc/helpers@0.5.17':
dependencies:
@@ -11275,6 +11362,23 @@ 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
@@ -11623,6 +11727,10 @@ 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
@@ -13303,6 +13411,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
esm-env@1.2.2: {}
esm-resolve@1.0.11: {}
espree@10.4.0:
@@ -14189,6 +14299,8 @@ 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: {}
@@ -16055,6 +16167,8 @@ snapshots:
sprintf-js@1.0.3: {}
sqids@0.3.0: {}
stable-hash-x@0.2.0: {}
stack-utils@2.0.6:
@@ -16347,6 +16461,16 @@ 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:
@@ -16644,6 +16768,8 @@ 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
@@ -16654,6 +16780,10 @@ 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
@@ -16914,6 +17044,8 @@ 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,6 +29,7 @@ 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,37 +10,158 @@ 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
* @returns Test counts { passed, failed, flaky, skipped, total }
* @param baseUrl - Base URL of the deployed report (for trace links)
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
*/
function extractTestCounts(reportDir: string): TestCounts {
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0
total: 0,
failures: []
}
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: ReportData = JSON.parse(
const reportJson: FullReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
@@ -54,6 +175,12 @@ function extractTestCounts(reportDir: 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
}
}
@@ -169,15 +296,18 @@ function extractTestCounts(reportDir: 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>')
console.error(
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
)
process.exit(1)
}
const counts = extractTestCounts(reportDir)
const counts = extractTestCounts(reportDir, baseUrl)
// Output as JSON for easy parsing in shell script
console.log(JSON.stringify(counts))
process.stdout.write(JSON.stringify(counts) + '\n')
export { extractTestCounts }
export { extractTestCounts, extractFailedTests }

View File

@@ -134,23 +134,22 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
# Post concise starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
## 🎭 Playwright Tests: ⏳ Running...
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
Tests started at $START_TIME UTC
⏰ Started at: $START_TIME UTC
<details>
<summary>📊 Browser Tests</summary>
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
---
⏱️ Please wait while tests are running...
</details>
EOF
)
post_comment "$comment"
@@ -189,7 +188,8 @@ else
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
# Pass the base URL so we can generate trace links
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
echo "Extracted counts for $browser: $counts" >&2
echo "$counts" > "$temp_dir/$i.counts"
else
@@ -286,43 +286,74 @@ else
# Determine overall status
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Some tests failed"
status_text="Failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Tests passed with flaky tests"
status_text="Passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="All tests passed!"
status_text="Passed"
else
status_icon="🕵🏻"
status_text="No test results found"
status_text="No test results"
fi
# Generate completion comment
# Generate concise completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
## 🎭 Playwright Tests: $status_icon **$status_text**"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
### 📈 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 '')"
**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
fi
# Add browser reports in collapsible section
comment="$comment
### 📊 Test Reports by Browser"
<details>
<summary>📊 Browser Reports</summary>
"
# Add browser results with individual counts
# Add browser results
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
@@ -349,7 +380,7 @@ $status_icon **$status_text**
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
@@ -358,10 +389,10 @@ $status_icon **$status_text**
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
@@ -369,8 +400,7 @@ $status_icon **$status_text**
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
</details>"
post_comment "$comment"
fi

View File

@@ -20,9 +20,14 @@
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>
@@ -49,13 +54,16 @@
<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-white"
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"
>
{{ queuedCount }}
</span>
</Button>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -91,15 +99,19 @@ 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()
@@ -111,8 +123,15 @@ 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'))
)
@@ -120,6 +139,12 @@ 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 w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,17 +13,37 @@
'--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 === items.at(-1)"
:is-active="item.key === activeItemKey"
/>
</template>
<template #separator
@@ -35,6 +55,7 @@
<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'
@@ -43,6 +64,7 @@ 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'
@@ -55,6 +77,12 @@ 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)
@@ -62,17 +90,28 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
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 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'
@@ -95,21 +134,26 @@ const items = computed(() => {
return [home.value, ...items]
})
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 activeItemKey = computed(() => items.value.at(-1)?.key)
canvas.setGraph(canvas.graph.rootGraph)
}
}))
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
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
@@ -189,13 +233,18 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden;
@apply flex items-center overflow-hidden h-8;
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);
}
@@ -205,11 +254,9 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
: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);
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -218,10 +265,8 @@ 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);
@@ -229,13 +274,10 @@ 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-12 cursor-pointer px-2"
class="p-breadcrumb-item-link h-8 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"
v-if="isActive || isRoot"
ref="menu"
:model="menuItems"
:popup="true"
@@ -59,6 +59,7 @@ 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,
@@ -135,79 +136,28 @@ const tooltipText = computed(() => {
return props.item.label
})
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')
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`
}
},
{
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) {
@@ -228,20 +178,6 @@ 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)
@@ -249,6 +185,14 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -2,7 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
'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
)
"
>
@@ -12,4 +13,8 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { backgroundClass } = defineProps<{
backgroundClass?: string
}>()
</script>

View File

@@ -0,0 +1,65 @@
<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

@@ -0,0 +1,30 @@
<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

@@ -28,7 +28,7 @@
/>
</div>
<div
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

View File

@@ -563,7 +563,8 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters
resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
/**
@@ -815,10 +816,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()
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
])
return true
},

View File

@@ -22,7 +22,7 @@
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" size="sm" @click="openManager">{{
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton

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-gold-600"></i>
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{
isCloud

View File

@@ -1,133 +1,264 @@
<template>
<div class="flex w-112 flex-col gap-8 p-8">
<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)]"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</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 v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</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>
<!-- 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>
<!-- 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="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.buy') }}
</Button>
{{ $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>
</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>
</div>
<!-- Warnings -->
<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>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading"
:loading="loading"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('credits.topUp.buyCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
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()
const selectedCredits = ref<number | null>(null)
// 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 loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
}
]
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const handleBuy = async () => {
if (!selectedCredits.value) return
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
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
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
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'
)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -220,6 +220,12 @@ 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

@@ -0,0 +1,134 @@
<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

@@ -0,0 +1,293 @@
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

@@ -0,0 +1,137 @@
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

@@ -0,0 +1,46 @@
<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-white"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>

View File

@@ -24,6 +24,7 @@
v-model:light-config="lightConfig"
:is-splat-model="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
@@ -116,6 +117,7 @@ const {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,

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-smoke-700/30"
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@@ -14,12 +14,12 @@
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-white" />
<i class="pi pi-bars text-lg text-base-foreground" />
</Button>
<div
v-show="isMenuOpen"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -29,13 +29,13 @@
:class="
cn(
'flex w-full items-center justify-start',
activeCategory === category && 'bg-smoke-600'
activeCategory === category && 'bg-button-active-surface'
)
"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-white">{{
<span class="whitespace-nowrap text-base-foreground">{{
$t(categoryLabels[category])
}}</span>
</Button>
@@ -58,8 +58,10 @@
v-if="showModelControls"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
v-model:show-skeleton="modelConfig!.showSkeleton"
:hide-material-mode="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
/>
<CameraControls
@@ -99,9 +101,14 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { isSplatModel = false, isPlyModel = false } = defineProps<{
const {
isSplatModel = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
@@ -162,7 +169,7 @@ const getCategoryIcon = (category: string) => {
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
return `${icons[category]} text-base-foreground text-lg`
}
const emit = defineEmits<{

View File

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

View File

@@ -15,7 +15,7 @@
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-white'
'text-lg text-base-foreground'
]"
/>
</Button>
@@ -46,7 +46,7 @@
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-white">
<span class="min-w-16 text-xs text-base-foreground">
{{ 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-white']" />
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
</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-white" />
<i class="pi pi-download text-lg text-base-foreground" />
</Button>
<div
v-show="showExportFormats"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
variant="textonly"
class="text-white"
class="text-base-foreground"
@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-white" />
<i class="pi pi-sun text-lg text-base-foreground" />
</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-white" />
<i class="pi pi-arrow-up text-lg text-base-foreground" />
</Button>
<div
v-show="showUpDirection"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -24,7 +24,10 @@
:key="direction"
variant="textonly"
:class="
cn('text-white', upDirection === direction && 'bg-blue-500')
cn(
'text-base-foreground',
upDirection === direction && 'bg-blue-500'
)
"
@click="selectUpDirection(direction)"
>
@@ -46,11 +49,11 @@
:aria-label="t('load3d.materialMode')"
@click="toggleMaterialMode"
>
<i class="pi pi-box text-lg text-white" />
<i class="pi pi-box text-lg text-base-foreground" />
</Button>
<div
v-show="showMaterialMode"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -59,7 +62,7 @@
variant="textonly"
:class="
cn(
'whitespace-nowrap text-white',
'whitespace-nowrap text-base-foreground',
materialMode === mode && 'bg-blue-500'
)
"
@@ -70,6 +73,22 @@
</div>
</div>
</div>
<div v-if="hasSkeleton">
<Button
v-tooltip.right="{
value: t('load3d.showSkeleton'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-base-foreground" />
</Button>
</div>
</div>
</template>
@@ -84,13 +103,19 @@ import type {
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
const {
hideMaterialMode = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const showSkeleton = defineModel<boolean>('showSkeleton')
const showUpDirection = ref(false)
const showMaterialMode = ref(false)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="relative rounded-lg bg-backdrop/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-white" />
<i class="pi pi-expand text-lg text-base-foreground" />
</Button>
</div>
</div>

View File

@@ -4,6 +4,7 @@
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-[var(--input-text)]"
class="h-6.25 w-6.25 pointer-events-none fill-current"
>
<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,6 +35,74 @@
</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>
@@ -63,6 +131,7 @@ 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'
@@ -71,16 +140,17 @@ 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-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
'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'
const textButtonClass =
'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'
'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'
const onUndo = () => {
store.canvasHistory.undo()
@@ -90,6 +160,38 @@ 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,20 +50,22 @@
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
"
class="absolute inset-0"
:class="progressBarContainerClass"
>
<div
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}%` }"
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
/>
<div
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}%` }"
v-if="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
/>
</div>
@@ -201,6 +203,7 @@ 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'
@@ -245,6 +248,14 @@ 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 pointer-events-auto"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
'connected-sidebar pointer-events-auto': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing,
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
@@ -40,12 +40,13 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
</nav>
</template>
@@ -54,6 +55,7 @@ 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'
@@ -89,6 +91,9 @@ 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 ||
@@ -145,8 +150,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')
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
)
)

View File

@@ -1,204 +1,28 @@
<template>
<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>
<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"
/>
</template>
<script setup lang="ts">
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 { useHelpCenter } from '@/composables/useHelpCenter'
import SidebarIcon from './SidebarIcon.vue'
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<{
defineProps<{
isSmall: boolean
}>()
const { isSmall } = toRefs(props)
// 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()
})
const { shouldShowRedDot, toggleHelpCenter } = 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;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;

View File

@@ -0,0 +1,154 @@
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

@@ -0,0 +1,203 @@
<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

@@ -47,17 +47,42 @@
<MediaAssetFilterBar
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<div
v-if="isQueuePanelV2Enabled"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="!loading && !displayAssets.length">
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -71,7 +96,15 @@
/>
</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',
@@ -89,11 +122,15 @@
: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>
@@ -109,7 +146,6 @@
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
@@ -164,7 +200,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { Divider } from 'primevue'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -173,6 +209,7 @@ 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'
@@ -187,17 +224,29 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
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)
@@ -209,11 +258,6 @@ 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
@@ -226,6 +270,19 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobs',
{ count: n(count) },
count
)
})
const toast = useToast()
const inputAssets = useMediaAssets('input')
@@ -238,6 +295,8 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
deactivate: deactivateSelection
} = useAssetSelection()
@@ -269,7 +328,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
return getTotalOutputCount(selectedAssets)
})
const currentAssets = computed(() =>
@@ -300,6 +359,20 @@ 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(
@@ -490,6 +563,20 @@ 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')
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&

View File

@@ -0,0 +1,95 @@
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

@@ -0,0 +1,65 @@
<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,11 +9,16 @@
@click="popover?.toggle($event)"
>
<div
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
:class="
cn(
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
compact && 'size-full '
)
"
>
<UserAvatar :photo-url="photoURL" />
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<i class="icon-[lucide--chevron-down] size-3 px-1" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
</Button>
@@ -38,9 +43,15 @@ 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,15 +1,19 @@
<template>
<Button
v-if="!isLoggedIn"
variant="secondary"
variant="textonly"
size="icon"
class="rounded-full bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
:class="cn('group rounded-full text-base-foreground p-0', className)"
:aria-label="t('g.login')"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"
>
<i class="icon-[lucide--user] size-4" />
<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>
</Button>
<Popover
ref="popoverRef"
@@ -31,12 +35,18 @@
<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

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

View File

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

View File

@@ -7,6 +7,15 @@
@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>
@@ -34,9 +43,26 @@
: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'
@@ -45,11 +71,14 @@ 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'
@@ -114,6 +143,12 @@ 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)
@@ -127,6 +162,14 @@ 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,7 +67,25 @@
>
<i class="pi pi-plus" />
</Button>
<ContextMenu ref="menu" :model="contextMenuItems" />
<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>
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
@@ -81,15 +99,20 @@ 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 {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -107,10 +130,16 @@ 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()
@@ -169,54 +198,73 @@ 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 as WorkflowOption
const tab = rightClickedTab.value
if (!tab) return []
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
return [
{
label: t('tabMenu.duplicateTab'),
command: async () => {
await workflowService.duplicateWorkflow(tab.workflow)
}
},
{
separator: true
},
...baseMenuItems.value,
{
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: '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: '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',
variants: {
variant: {
secondary:
@@ -18,7 +18,8 @@ export const buttonVariants = cva({
'muted-textonly':
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10'
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -44,7 +45,8 @@ const variants = [
'destructive',
'textonly',
'muted-textonly',
'destructive-textonly'
'destructive-textonly',
'overlay-white'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<i :class="icon" class="text-neutral text-sm" />
<i :class="icon" class="text-neutral text-sm shrink-0" />
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -9,9 +9,11 @@
role="button"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<i v-else class="text-neutral icon-[lucide--folder] text-xs" />
<span class="flex items-center">
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span class="flex items-center break-all">
<slot></slot>
</span>
</div>

View File

@@ -75,6 +75,7 @@ export interface VueNodeData {
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
@@ -244,17 +245,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
// Update only widgets with new slot metadata, keeping other widget data intact
const updatedWidgets = currentData.widgets?.map((widget) => {
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.name)
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
})
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets,
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
})
if (slotInfo) widget.slotMetadata = slotInfo
}
}
// Extract safe data from LiteGraph node for Vue consumption
@@ -325,6 +319,7 @@ 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,3 +1,4 @@
/// <reference types="@webgpu/types" />
import { ref, watch, nextTick, onUnmounted } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
@@ -233,6 +234,128 @@ 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,23 +2,70 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
let mockMaskCanvas: any
let mockRgbCanvas: any
let mockMaskCtx: any
let mockRgbCtx: any
// 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
}
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as 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
}
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap for test environment
// Mock ImageBitmap using safe global augmentation pattern
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
@@ -28,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as any
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasHistory', () => {
@@ -43,9 +90,8 @@ describe('useCanvasHistory', () => {
return rafCallCount
}
)
vi.stubGlobal('alert', () => {})
const createMockImageData = () => {
const createMockImageData = (): ImageData => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
@@ -53,34 +99,43 @@ describe('useCanvasHistory', () => {
} as ImageData
}
mockMaskCtx = {
// Mock contexts using explicit partial-cast pattern
mockRefs.maskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockRgbCtx = {
mockRefs.rgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockMaskCanvas = {
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 = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockRgbCanvas = {
mockRefs.rgbCanvas = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
mockRefs.imgCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
})
describe('initialization', () => {
@@ -96,8 +151,14 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
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(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
@@ -105,27 +166,47 @@ describe('useCanvasHistory', () => {
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
mockRefs.maskCanvas = {
...mockRefs.maskCanvas,
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
mockRefs.maskCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCtx = null
mockRefs.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
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
})
})
@@ -134,13 +215,20 @@ describe('useCanvasHistory', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
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(history.canUndo.value).toBe(true)
})
@@ -184,8 +272,9 @@ describe('useCanvasHistory', () => {
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
@@ -193,15 +282,17 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
mockRefs.maskCtx = savedMaskCtx
})
})
@@ -214,20 +305,27 @@ describe('useCanvasHistory', () => {
history.undo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(true)
})
it('should show alert when no undo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
it('should not undo when no undo states available', () => {
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(alertSpy).toHaveBeenCalledWith('No more undo states available')
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
it('should undo multiple times', () => {
@@ -249,16 +347,22 @@ 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(alertSpy).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
})
@@ -270,25 +374,33 @@ describe('useCanvasHistory', () => {
history.saveState()
history.undo()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(history.canRedo.value).toBe(false)
expect(history.canUndo.value).toBe(true)
})
it('should show alert when no redo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
it('should not redo when no redo states available', () => {
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(alertSpy).toHaveBeenCalledWith('No more redo states available')
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
it('should redo multiple times', () => {
@@ -314,7 +426,6 @@ describe('useCanvasHistory', () => {
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
@@ -322,9 +433,16 @@ 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(alertSpy).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
})
@@ -348,13 +466,15 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.clearStates()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
})
})
@@ -446,15 +566,17 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.saveState()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
mockRefs.maskCtx = savedMaskCtx
})
})
@@ -499,8 +621,12 @@ describe('useCanvasHistory', () => {
})
it('should handle zero-sized canvas', () => {
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
if (mockRefs.maskCanvas) {
mockRefs.maskCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
const history = useCanvasHistory()

View File

@@ -1,12 +1,17 @@
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<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const states = ref<CanvasState[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -22,22 +27,29 @@ export function useCanvasHistory(maxStates = 20) {
})
const saveInitialState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
// Ensure all 3 contexts and canvases are ready
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
) {
requestAnimationFrame(saveInitialState)
return
}
if (!maskCanvas.width || !rgbCanvas.width) {
if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) {
requestAnimationFrame(saveInitialState)
return
}
states.value = []
// Capture all three layers
const maskState = maskCtx.getImageData(
0,
0,
@@ -50,35 +62,51 @@ export function useCanvasHistory(maxStates = 20) {
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
const imgState = imgCtx.getImageData(
0,
0,
imgCanvas.width,
imgCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
currentStateIndex.value = 0
initialized.value = true
}
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap
providedRgbData?: ImageData | ImageBitmap,
providedImgData?: ImageData | ImageBitmap
) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
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) {
if (providedMaskData && providedRgbData && providedImgData) {
maskState = providedMaskData
rgbState = providedRgbData
imgState = providedImgData
} else {
maskState = maskCtx.getImageData(
0,
@@ -87,71 +115,84 @@ 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 })
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
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) {
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
cleanupState(removed)
}
currentStateIndex.value--
}
}
const undo = () => {
if (!canUndo.value) {
alert('No more undo states available')
return
}
if (!canUndo.value) return
currentStateIndex.value--
restoreState(states.value[currentStateIndex.value])
}
const redo = () => {
if (!canRedo.value) {
alert('No more redo states available')
return
}
if (!canRedo.value) return
currentStateIndex.value++
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
const restoreState = (state: CanvasState) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
return
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)
// 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.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 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()
}
const clearStates = () => {
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value.forEach(cleanupState)
states.value = []
currentStateIndex.value = -1
initialized.value = false

View File

@@ -0,0 +1,683 @@
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

@@ -0,0 +1,359 @@
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,31 +1664,41 @@ describe('useNodePricing', () => {
{
model: 'gemini-2.5-pro-preview-05-06',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-pro',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-3-pro-preview',
expected: creditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-flash',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
@@ -1702,16 +1712,6 @@ 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,73 +1737,97 @@ describe('useNodePricing', () => {
{
model: 'o4-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-pro',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3',
expected: creditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4o',
expected: creditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-nano',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-mini',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5-nano',
expected: creditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5-mini',
expected: creditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
]
@@ -1824,37 +1848,49 @@ describe('useNodePricing', () => {
{
model: 'gpt-4.1-nano-test',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-mini-test',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-test',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-pro-test',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-test',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3-mini-test',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{ model: 'unknown-model', expected: 'Token-based' }

View File

@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const model = String(modelWidget.value)
// 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')) {
if (model.includes('gemini-2.5-flash-preview-04-17')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-flash')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-pro')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-3-pro-preview')) {
return formatCreditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
// For other Gemini models, show token-based pricing info
@@ -1899,51 +1906,75 @@ 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'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o1-pro')) {
return formatCreditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o1')) {
return formatCreditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o3-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o3')) {
return formatCreditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4o')) {
return formatCreditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1-nano')) {
return formatCreditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1-mini')) {
return formatCreditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1')) {
return formatCreditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5-nano')) {
return formatCreditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5-mini')) {
return formatCreditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
return 'Token-based'
@@ -2101,6 +2132,267 @@ 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)
}
}
}
@@ -2254,8 +2546,13 @@ export const useNodePricing = () => {
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'],
WanReferenceVideoApi: ['duration', 'size'],
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

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

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