Compare commits
1 Commits
core/1.37
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d930514bea |
5
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -1,9 +1,9 @@
|
||||
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
|
||||
name: 'CI: Tests E2E (Deploy for Forks)'
|
||||
name: "CI: Tests E2E (Deploy for Forks)"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests E2E']
|
||||
workflows: ["CI: Tests E2E"]
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
@@ -81,7 +81,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
|
||||
7
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
|
||||
name: 'CI: Tests E2E'
|
||||
name: "CI: Tests E2E"
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -222,7 +222,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,11 +6,10 @@ const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
saveImmediately: true,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -19,11 +18,5 @@ module.exports = defineConfig({
|
||||
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
|
||||
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
|
||||
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
|
||||
|
||||
IMPORTANT Persian Translation Guidelines:
|
||||
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {}
|
||||
@@ -69,32 +69,9 @@ const config: StorybookConfig = {
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@/composables/queue/useJobList',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/formatUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/networkUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: process.cwd() + '/src'
|
||||
}
|
||||
]
|
||||
alias: {
|
||||
'@': process.cwd() + '/src'
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
|
||||
14
AGENTS.md
@@ -63,9 +63,6 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Imports:
|
||||
- sorted/grouped by plugin
|
||||
- run `pnpm format` before committing
|
||||
- use separate `import type` statements, not inline `type` in mixed imports
|
||||
- ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'`
|
||||
- ❌ `import { bar, type Foo } from './foo'`
|
||||
- ESLint:
|
||||
- Vue + TS rules
|
||||
- no floating promises
|
||||
@@ -122,10 +119,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
||||
- Do not use `withDefaults` or runtime props declaration
|
||||
- Do not import Vue macros unnecessarily
|
||||
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
|
||||
- Define slots via template usage, not `defineSlots`
|
||||
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
|
||||
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
|
||||
- Prefer `useModel` to separately defining a prop and emit
|
||||
- Be judicious with addition of new refs or other state
|
||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||
@@ -143,7 +137,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
8. Implement proper error handling
|
||||
9. Follow Vue 3 style guide and naming conventions
|
||||
10. Use Vite for fast development and building
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
12. Avoid new usage of PrimeVue components
|
||||
13. Write tests for all changes, especially bug fixes to catch future regressions
|
||||
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
@@ -161,8 +155,6 @@ The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/*.md for detailed patterns.
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
@@ -276,8 +268,6 @@ When referencing Comfy-Org repos:
|
||||
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||
- NEVER use `!important` or the `!` important prefix for tailwind classes
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -133,11 +133,8 @@ test.describe('Menu', () => {
|
||||
// Checkmark should be invisible again (panel is hidden)
|
||||
await expect(checkmark).toHaveClass(/invisible/)
|
||||
|
||||
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page
|
||||
.locator('body')
|
||||
.click({ position: { x: viewport.width - 10, y: 10 } })
|
||||
// Click outside to close menu
|
||||
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
|
||||
|
||||
// Verify menu is now closed
|
||||
await expect(menu).not.toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -194,10 +194,7 @@ test.describe('Image widget', () => {
|
||||
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'image32x32.webp'
|
||||
})
|
||||
await comboEntry.click()
|
||||
|
||||
// Stabilization for the image swap
|
||||
await comfyPage.nextFrame()
|
||||
await comboEntry.click({ noWaitAfter: true })
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,138 +0,0 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.test.ts'
|
||||
- '**/*.spec.ts'
|
||||
---
|
||||
|
||||
# Vitest Patterns
|
||||
|
||||
## Setup
|
||||
|
||||
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
|
||||
|
||||
```typescript
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('MyStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Reset all mocks at once
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks() // Not individual mock.mockReset() calls
|
||||
})
|
||||
```
|
||||
|
||||
### Module mocks with vi.mock()
|
||||
|
||||
```typescript
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
fetchData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/myService', () => ({
|
||||
myService: {
|
||||
doThing: vi.fn()
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Configure mocks in tests
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
import { myService } from '@/services/myService'
|
||||
|
||||
it('handles success', () => {
|
||||
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
|
||||
// ... test code
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Event Listeners
|
||||
|
||||
When a store registers event listeners at module load time:
|
||||
|
||||
```typescript
|
||||
function getEventHandler() {
|
||||
const call = vi.mocked(api.addEventListener).mock.calls.find(
|
||||
([event]) => event === 'my_event'
|
||||
)
|
||||
return call?.[1] as (e: CustomEvent<MyEventType>) => void
|
||||
}
|
||||
|
||||
function dispatch(data: MyEventType) {
|
||||
const handler = getEventHandler()
|
||||
handler(new CustomEvent('my_event', { detail: data }))
|
||||
}
|
||||
|
||||
it('handles events', () => {
|
||||
const store = useMyStore()
|
||||
dispatch({ field: 'value' })
|
||||
expect(store.items).toHaveLength(1)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Fake Timers
|
||||
|
||||
For stores with intervals, timeouts, or polling:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('polls after delay', async () => {
|
||||
const store = useMyStore()
|
||||
store.startPolling()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
|
||||
expect(mockService.fetch).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
## Assertion Style
|
||||
|
||||
Prefer `.toHaveLength()` over `.length.toBe()`:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
expect(store.items).toHaveLength(1)
|
||||
|
||||
// Avoid
|
||||
expect(store.items.length).toBe(1)
|
||||
```
|
||||
|
||||
Use `.toMatchObject()` for partial matching:
|
||||
|
||||
```typescript
|
||||
expect(store.completedItems[0]).toMatchObject({
|
||||
id: 'task-123',
|
||||
status: 'done'
|
||||
})
|
||||
```
|
||||
@@ -8,8 +8,7 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
'src/types/index.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.11",
|
||||
"version": "1.37.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -66,7 +66,6 @@
|
||||
"@prettier/plugin-oxc": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/addon-mcp": "catalog:",
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
|
||||
134
pnpm-lock.yaml
generated
@@ -84,9 +84,6 @@ catalogs:
|
||||
'@storybook/addon-docs':
|
||||
specifier: ^10.1.9
|
||||
version: 10.1.9
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 0.1.6
|
||||
version: 0.1.6
|
||||
'@storybook/vue3':
|
||||
specifier: ^10.1.9
|
||||
version: 10.1.9
|
||||
@@ -552,9 +549,6 @@ importers:
|
||||
'@storybook/addon-docs':
|
||||
specifier: 'catalog:'
|
||||
version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
|
||||
'@storybook/vue3':
|
||||
specifier: 'catalog:'
|
||||
version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -3154,11 +3148,6 @@ packages:
|
||||
peerDependencies:
|
||||
storybook: ^10.1.9
|
||||
|
||||
'@storybook/addon-mcp@0.1.6':
|
||||
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
|
||||
peerDependencies:
|
||||
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
|
||||
|
||||
'@storybook/builder-vite@10.1.9':
|
||||
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
|
||||
peerDependencies:
|
||||
@@ -3192,9 +3181,6 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@storybook/mcp@0.1.1':
|
||||
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
|
||||
|
||||
'@storybook/react-dom-shim@10.1.9':
|
||||
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
|
||||
peerDependencies:
|
||||
@@ -3467,26 +3453,6 @@ packages:
|
||||
'@tiptap/starter-kit@2.10.4':
|
||||
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
|
||||
|
||||
'@tmcp/adapter-valibot@0.1.5':
|
||||
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
|
||||
peerDependencies:
|
||||
tmcp: ^1.17.0
|
||||
valibot: ^1.1.0
|
||||
|
||||
'@tmcp/session-manager@0.2.1':
|
||||
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
|
||||
peerDependencies:
|
||||
tmcp: ^1.16.3
|
||||
|
||||
'@tmcp/transport-http@0.8.3':
|
||||
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
|
||||
peerDependencies:
|
||||
'@tmcp/auth': ^0.3.3 || ^0.4.0
|
||||
tmcp: ^1.18.0
|
||||
peerDependenciesMeta:
|
||||
'@tmcp/auth':
|
||||
optional: true
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2':
|
||||
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
|
||||
engines: {node: '>18.12'}
|
||||
@@ -3820,11 +3786,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@valibot/to-json-schema@1.5.0':
|
||||
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3':
|
||||
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -5209,9 +5170,6 @@ packages:
|
||||
jiti:
|
||||
optional: true
|
||||
|
||||
esm-env@1.2.2:
|
||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||
|
||||
esm-resolve@1.0.11:
|
||||
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
|
||||
|
||||
@@ -6020,9 +5978,6 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-rpc-2.0@1.7.1:
|
||||
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -7431,9 +7386,6 @@ packages:
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
sqids@0.3.0:
|
||||
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
|
||||
|
||||
stable-hash-x@0.2.0:
|
||||
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -7661,9 +7613,6 @@ packages:
|
||||
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
|
||||
hasBin: true
|
||||
|
||||
tmcp@1.19.0:
|
||||
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
|
||||
|
||||
tmp@0.2.5:
|
||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
engines: {node: '>=14.14'}
|
||||
@@ -7909,9 +7858,6 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
uri-template-matcher@1.1.2:
|
||||
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
@@ -7927,14 +7873,6 @@ packages:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.2.0:
|
||||
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -8081,9 +8019,6 @@ packages:
|
||||
vue-component-type-helpers@3.2.1:
|
||||
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.2:
|
||||
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -11014,18 +10949,6 @@ snapshots:
|
||||
- vite
|
||||
- webpack
|
||||
|
||||
'@storybook/addon-mcp@0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@storybook/mcp': 0.1.1(typescript@5.9.3)
|
||||
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
|
||||
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
|
||||
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
@@ -11055,16 +10978,6 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@storybook/mcp@0.1.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
|
||||
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -11094,7 +11007,7 @@ snapshots:
|
||||
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.2
|
||||
vue-component-type-helpers: 3.2.1
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -11362,23 +11275,6 @@ snapshots:
|
||||
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
|
||||
'@tiptap/pm': 2.10.4
|
||||
|
||||
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
|
||||
esm-env: 1.2.2
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.28.5
|
||||
@@ -11727,10 +11623,6 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
@@ -13411,8 +13303,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
esm-env@1.2.2: {}
|
||||
|
||||
esm-resolve@1.0.11: {}
|
||||
|
||||
espree@10.4.0:
|
||||
@@ -14299,8 +14189,6 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-rpc-2.0@1.7.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -16167,8 +16055,6 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sqids@0.3.0: {}
|
||||
|
||||
stable-hash-x@0.2.0: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@@ -16461,16 +16347,6 @@ snapshots:
|
||||
dependencies:
|
||||
tldts-core: 7.0.19
|
||||
|
||||
tmcp@1.19.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
json-rpc-2.0: 1.7.1
|
||||
sqids: 0.3.0
|
||||
uri-template-matcher: 1.1.2
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
tmp@0.2.5: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -16768,8 +16644,6 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
uri-template-matcher@1.1.2: {}
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -16780,10 +16654,6 @@ snapshots:
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -17044,8 +16914,6 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.1: {}
|
||||
|
||||
vue-component-type-helpers@3.2.2: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
@@ -29,7 +29,6 @@ catalog:
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
|
||||
@@ -10,158 +10,37 @@ interface TestStats {
|
||||
finished?: number
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
status: string
|
||||
duration?: number
|
||||
error?: {
|
||||
message?: string
|
||||
stack?: string
|
||||
}
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
path?: string
|
||||
contentType: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
title: string
|
||||
ok: boolean
|
||||
outcome: string
|
||||
results: TestResult[]
|
||||
}
|
||||
|
||||
interface Suite {
|
||||
title: string
|
||||
file: string
|
||||
suites?: Suite[]
|
||||
tests?: TestCase[]
|
||||
}
|
||||
|
||||
interface FullReportData {
|
||||
stats?: TestStats
|
||||
suites?: Suite[]
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
stats?: TestStats
|
||||
}
|
||||
|
||||
interface FailedTest {
|
||||
name: string
|
||||
file: string
|
||||
traceUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TestCounts {
|
||||
passed: number
|
||||
failed: number
|
||||
flaky: number
|
||||
skipped: number
|
||||
total: number
|
||||
failures?: FailedTest[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract failed test details from Playwright report
|
||||
*/
|
||||
function extractFailedTests(
|
||||
reportData: FullReportData,
|
||||
baseUrl?: string
|
||||
): FailedTest[] {
|
||||
const failures: FailedTest[] = []
|
||||
|
||||
function processTest(test: TestCase, file: string, suitePath: string[]) {
|
||||
// Check if test failed or is flaky
|
||||
const hasFailed = test.results.some(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
const isFlaky = test.outcome === 'flaky'
|
||||
|
||||
if (hasFailed || isFlaky) {
|
||||
const fullTestName = [...suitePath, test.title]
|
||||
.filter(Boolean)
|
||||
.join(' › ')
|
||||
const failedResult = test.results.find(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
|
||||
// Find trace attachment
|
||||
let traceUrl: string | undefined
|
||||
if (failedResult?.attachments) {
|
||||
const traceAttachment = failedResult.attachments.find(
|
||||
(a) => a.name === 'trace' && a.contentType === 'application/zip'
|
||||
)
|
||||
if (traceAttachment?.path) {
|
||||
// Convert local path to URL path
|
||||
const tracePath = traceAttachment.path.replace(/\\/g, '/')
|
||||
const traceFile = path.basename(tracePath)
|
||||
if (baseUrl) {
|
||||
// Construct trace viewer URL
|
||||
const traceDataUrl = `${baseUrl}/data/${traceFile}`
|
||||
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failures.push({
|
||||
name: fullTestName,
|
||||
file: file,
|
||||
traceUrl,
|
||||
error: failedResult?.error?.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function processSuite(suite: Suite, parentPath: string[] = []) {
|
||||
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
|
||||
|
||||
// Process tests in this suite
|
||||
if (suite.tests) {
|
||||
for (const test of suite.tests) {
|
||||
processTest(test, suite.file, suitePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested suites
|
||||
if (suite.suites) {
|
||||
for (const childSuite of suite.suites) {
|
||||
processSuite(childSuite, suitePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportData.suites) {
|
||||
for (const suite of reportData.suites) {
|
||||
processSuite(suite)
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test counts from Playwright HTML report
|
||||
* @param reportDir - Path to the playwright-report directory
|
||||
* @param baseUrl - Base URL of the deployed report (for trace links)
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total }
|
||||
*/
|
||||
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
|
||||
function extractTestCounts(reportDir: string): TestCounts {
|
||||
const counts: TestCounts = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
flaky: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
failures: []
|
||||
total: 0
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try to find report.json which Playwright generates with JSON reporter
|
||||
const jsonReportFile = path.join(reportDir, 'report.json')
|
||||
if (fs.existsSync(jsonReportFile)) {
|
||||
const reportJson: FullReportData = JSON.parse(
|
||||
const reportJson: ReportData = JSON.parse(
|
||||
fs.readFileSync(jsonReportFile, 'utf-8')
|
||||
)
|
||||
if (reportJson.stats) {
|
||||
@@ -175,12 +54,6 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
|
||||
// Extract detailed failure information
|
||||
if (counts.failed > 0 || counts.flaky > 0) {
|
||||
counts.failures = extractFailedTests(reportJson, baseUrl)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -296,18 +169,15 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
|
||||
|
||||
// Main execution
|
||||
const reportDir = process.argv[2]
|
||||
const baseUrl = process.argv[3] // Optional: base URL for trace links
|
||||
|
||||
if (!reportDir) {
|
||||
console.error(
|
||||
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
|
||||
)
|
||||
console.error('Usage: extract-playwright-counts.ts <report-directory>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const counts = extractTestCounts(reportDir, baseUrl)
|
||||
const counts = extractTestCounts(reportDir)
|
||||
|
||||
// Output as JSON for easy parsing in shell script
|
||||
process.stdout.write(JSON.stringify(counts) + '\n')
|
||||
console.log(JSON.stringify(counts))
|
||||
|
||||
export { extractTestCounts, extractFailedTests }
|
||||
export { extractTestCounts }
|
||||
|
||||
@@ -134,22 +134,23 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post concise starting comment
|
||||
# Post starting comment
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Tests: ⏳ Running...
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
Tests started at $START_TIME UTC
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
|
||||
|
||||
<details>
|
||||
<summary>📊 Browser Tests</summary>
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
- **chromium**: Running...
|
||||
- **chromium-0.5x**: Running...
|
||||
- **chromium-2x**: Running...
|
||||
- **mobile-chrome**: Running...
|
||||
### 🚀 Running Tests
|
||||
- 🧪 **chromium**: Running tests...
|
||||
- 🧪 **chromium-0.5x**: Running tests...
|
||||
- 🧪 **chromium-2x**: Running tests...
|
||||
- 🧪 **mobile-chrome**: Running tests...
|
||||
|
||||
</details>
|
||||
---
|
||||
⏱️ Please wait while tests are running...
|
||||
EOF
|
||||
)
|
||||
post_comment "$comment"
|
||||
@@ -188,8 +189,7 @@ else
|
||||
|
||||
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
|
||||
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
|
||||
# Pass the base URL so we can generate trace links
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
|
||||
echo "Extracted counts for $browser: $counts" >&2
|
||||
echo "$counts" > "$temp_dir/$i.counts"
|
||||
else
|
||||
@@ -286,74 +286,43 @@ else
|
||||
# Determine overall status
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Failed"
|
||||
status_text="Some tests failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Passed with flaky tests"
|
||||
status_text="Tests passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="Passed"
|
||||
status_text="All tests passed!"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results"
|
||||
status_text="No test results found"
|
||||
fi
|
||||
|
||||
# Generate concise completion comment
|
||||
# Generate completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Tests: $status_icon **$status_text**"
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
|
||||
fi
|
||||
|
||||
# Extract and display failed tests from all browsers
|
||||
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### ❌ Failed Tests"
|
||||
|
||||
# Process each browser's failures
|
||||
for counts_json in "${counts_array[@]}"; do
|
||||
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
|
||||
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
# Extract failures array from JSON
|
||||
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
|
||||
|
||||
if [ -n "$failures" ]; then
|
||||
while IFS='|' read -r test_name test_file trace_url; do
|
||||
[ -z "$test_name" ] && continue
|
||||
|
||||
# Convert file path to GitHub URL (relative to repo root)
|
||||
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
|
||||
|
||||
# Build the failed test line
|
||||
test_line="- [$test_name]($github_file_url)"
|
||||
|
||||
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
|
||||
test_line="$test_line: [View trace]($trace_url)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
$test_line"
|
||||
done <<< "$failures"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
### 📈 Summary
|
||||
- **Total Tests:** $total_tests
|
||||
- **Passed:** $total_passed ✅
|
||||
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
|
||||
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
|
||||
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
|
||||
fi
|
||||
|
||||
# Add browser reports in collapsible section
|
||||
comment="$comment
|
||||
|
||||
<details>
|
||||
<summary>📊 Browser Reports</summary>
|
||||
|
||||
"
|
||||
### 📊 Test Reports by Browser"
|
||||
|
||||
# Add browser results
|
||||
# Add browser results with individual counts
|
||||
i=0
|
||||
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
|
||||
IFS=' ' read -r -a url_array <<< "$urls"
|
||||
@@ -380,7 +349,7 @@ $test_line"
|
||||
fi
|
||||
|
||||
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
|
||||
counts_str=" (✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
|
||||
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
@@ -389,10 +358,10 @@ $test_line"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
- **${browser}**: [View Report](${url})${counts_str}"
|
||||
- ✅ **${browser}**: [View Report](${url})${counts_str}"
|
||||
else
|
||||
comment="$comment
|
||||
- **${browser}**: ❌ Deployment failed"
|
||||
- ❌ **${browser}**: Deployment failed"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
@@ -400,7 +369,8 @@ $test_line"
|
||||
|
||||
comment="$comment
|
||||
|
||||
</details>"
|
||||
---
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
|
||||
@@ -20,14 +20,9 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -54,16 +49,13 @@
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</Button>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
@@ -99,19 +91,15 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
@@ -123,15 +111,8 @@ const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
@@ -139,12 +120,6 @@ const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,37 +13,17 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleBackClick"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
</Button>
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="w-fit rounded-lg p-0"
|
||||
:class="{ hidden: !isInSubgraph }"
|
||||
:model="items"
|
||||
:pt="{ item: { class: 'pointer-events-auto' } }"
|
||||
:aria-label="$t('g.graphNavigation')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item.key === activeItemKey"
|
||||
:is-active="item === items.at(-1)"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
@@ -55,7 +35,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import Button from 'primevue/button'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
@@ -64,7 +43,6 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -77,12 +55,6 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -90,28 +62,17 @@ const isBlueprint = computed(() =>
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
@@ -134,26 +95,21 @@ const items = computed(() => {
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
@@ -233,18 +189,13 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
@apply flex items-center overflow-hidden;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s;
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator) {
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
padding: 0 var(--p-breadcrumb-item-margin);
|
||||
}
|
||||
@@ -254,9 +205,11 @@ onUpdated(() => {
|
||||
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
:deep(.p-breadcrumb-separator),
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply h-12;
|
||||
border-top: 1px solid var(--interface-stroke);
|
||||
border-bottom: 1px solid var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
@@ -265,8 +218,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
@apply rounded-l-lg;
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
border-left: 1px solid var(--interface-stroke);
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
padding-left: var(--p-breadcrumb-item-padding);
|
||||
@@ -274,10 +229,13 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
@apply rounded-r-lg;
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
border-right: 1px solid var(--interface-stroke);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item-link:hover),
|
||||
:deep(.p-breadcrumb-item-link-menu-visible) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
@@ -25,7 +25,7 @@
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive || isRoot"
|
||||
v-if="isActive"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
@@ -59,7 +59,6 @@ import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -136,28 +135,79 @@ const tooltipText = computed(() => {
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
// Check if element is hidden (collapsed breadcrumb)
|
||||
// When collapsed, root item is hidden via CSS display:none, so use rename command
|
||||
if (isRoot && wrapperRef.value?.offsetParent === null) {
|
||||
await useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
return
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: startRename
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
|
||||
]
|
||||
})
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
@@ -178,6 +228,20 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
@@ -185,14 +249,6 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
|
||||
backgroundClass
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
|
||||
backgroundClass || 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<span class="relative inline-flex items-center justify-center size-[1em]">
|
||||
<i :class="mainIcon" class="text-[1em]" />
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
subIcon,
|
||||
'absolute leading-none pointer-events-none',
|
||||
positionX === 'left' ? 'left-0' : 'right-0',
|
||||
positionY === 'top' ? 'top-0' : 'bottom-0'
|
||||
)
|
||||
"
|
||||
:style="subIconStyle"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type Position = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface OverlayIconProps {
|
||||
mainIcon: string
|
||||
subIcon: string
|
||||
positionX?: Position
|
||||
positionY?: Position
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
subIconScale?: number
|
||||
}
|
||||
const {
|
||||
mainIcon,
|
||||
subIcon,
|
||||
positionX = 'right',
|
||||
positionY = 'bottom',
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
subIconScale = 0.6
|
||||
} = defineProps<OverlayIconProps>()
|
||||
|
||||
const textShadow = [
|
||||
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 1px 0 rgba(0, 0, 0, 0.7)`
|
||||
].join(', ')
|
||||
|
||||
const subIconStyle = computed(() => ({
|
||||
fontSize: `${subIconScale}em`,
|
||||
textShadow,
|
||||
...(offsetX !== 0 && {
|
||||
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
|
||||
}),
|
||||
...(offsetY !== 0 && {
|
||||
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
</template>
|
||||
@@ -563,8 +563,7 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
@@ -816,10 +815,10 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run all operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
isCloud
|
||||
|
||||
@@ -1,264 +1,180 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div class="flex w-112 flex-col gap-8 p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</h1>
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<!-- Current Balance Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.creditsAvailable')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
<!-- Credit Options Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.howManyCredits') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<CreditTopUpOption
|
||||
v-for="option in creditOptions"
|
||||
:key="option.credits"
|
||||
:credits="option.credits"
|
||||
:description="option.description"
|
||||
:selected="selectedCredits === option.credits"
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading"
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buyCredits') }}
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
</div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { creditsToUsd } from '@/base/credits/comfyCredits'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
interface CreditOption {
|
||||
credits: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
const popover = ref()
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
// Prevent double-clicks
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
const handleBuy = async () => {
|
||||
if (!selectedCredits.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
await authActions.purchaseCredits(payAmount.value)
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
const usdAmount = creditsToUsd(selectedCredits.value)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
|
||||
await authActions.purchaseCredits(usdAmount)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -220,12 +220,6 @@ function show(event: MouseEvent) {
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
// Initialize last* values to current transform to prevent updateMenuPosition
|
||||
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const { isSmall = false } = defineProps<{
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
} = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,293 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<typeof HoneyToast> = {
|
||||
title: 'Toast/HoneyToast',
|
||||
component: HoneyToast,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesDownloaded: 1000000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetId: 'asset-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
bytesTotal: 500000,
|
||||
bytesDownloaded: 500000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
||||
<span class="font-bold text-base-foreground">All downloads completed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'failed',
|
||||
progress: 0.23
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
|
||||
<span class="font-bold text-base-foreground">1 download failed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Hidden: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast },
|
||||
template: `
|
||||
<div>
|
||||
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
|
||||
|
||||
<HoneyToast :visible="false">
|
||||
<template #default>
|
||||
<div class="px-4 py-4">Content</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="h-12 px-4">Footer</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
describe('HoneyToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountComponent(
|
||||
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
||||
): VueWrapper {
|
||||
return mount(HoneyToast, {
|
||||
props,
|
||||
slots: {
|
||||
default: (slotProps: { isExpanded: boolean }) =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-testid': 'content' },
|
||||
slotProps.isExpanded ? 'expanded' : 'collapsed'
|
||||
),
|
||||
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'toggle-btn',
|
||||
onClick: slotProps.toggle
|
||||
},
|
||||
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
||||
)
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
}
|
||||
|
||||
it('renders when visible is true', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render when visible is false', async () => {
|
||||
const wrapper = mountComponent({ visible: false })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeFalsy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes is-expanded=false to slots by default', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('supports v-model:expanded with reactive parent state', async () => {
|
||||
const TestWrapper = defineComponent({
|
||||
components: { HoneyToast },
|
||||
setup() {
|
||||
const expanded = ref(false)
|
||||
return { expanded }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast :visible="true" v-model:expanded="expanded">
|
||||
<template #default="slotProps">
|
||||
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
|
||||
</template>
|
||||
<template #footer="slotProps">
|
||||
<button data-testid="toggle-btn" @click="slotProps.toggle">
|
||||
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
const toggleBtn = document.body.querySelector(
|
||||
'[data-testid="toggle-btn"]'
|
||||
) as HTMLButtonElement
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
||||
|
||||
toggleBtn?.click()
|
||||
await nextTick()
|
||||
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { visible } = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const isExpanded = defineModel<boolean>('expanded', { default: false })
|
||||
|
||||
function toggle() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot :is-expanded />
|
||||
</div>
|
||||
|
||||
<slot name="footer" :is-expanded :toggle />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -160,7 +160,7 @@
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.selected"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
class="text-bold icon-[lucide--check] text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@@ -14,12 +14,12 @@
|
||||
class="rounded-full"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<i class="pi pi-bars text-lg text-base-foreground" />
|
||||
<i class="pi pi-bars text-lg text-white" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="isMenuOpen"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -29,13 +29,13 @@
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center justify-start',
|
||||
activeCategory === category && 'bg-button-active-surface'
|
||||
activeCategory === category && 'bg-smoke-600'
|
||||
)
|
||||
"
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<i :class="getCategoryIcon(category)" />
|
||||
<span class="whitespace-nowrap text-base-foreground">{{
|
||||
<span class="whitespace-nowrap text-white">{{
|
||||
$t(categoryLabels[category])
|
||||
}}</span>
|
||||
</Button>
|
||||
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
|
||||
export: 'pi pi-download'
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return `${icons[category]} text-base-foreground text-lg`
|
||||
return `${icons[category]} text-white text-lg`
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
|
||||
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="spinner" />
|
||||
<div class="mt-4 text-lg text-base-foreground">
|
||||
<div class="mt-4 text-lg text-white">
|
||||
{{ loadingMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:class="[
|
||||
'pi',
|
||||
playing ? 'pi-pause' : 'pi-play',
|
||||
'text-lg text-base-foreground'
|
||||
'text-lg text-white'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
@@ -46,7 +46,7 @@
|
||||
class="flex-1"
|
||||
@update:model-value="handleSliderChange"
|
||||
/>
|
||||
<span class="min-w-16 text-xs text-base-foreground">
|
||||
<span class="min-w-16 text-xs text-white">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-white']" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
:aria-label="$t('load3d.exportModel')"
|
||||
@click="toggleExportFormats"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
<i class="pi pi-download text-lg text-white" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showExportFormats"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
variant="textonly"
|
||||
class="text-base-foreground"
|
||||
class="text-white"
|
||||
@click="exportModel(format.value)"
|
||||
>
|
||||
{{ format.label }}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:aria-label="$t('load3d.lightIntensity')"
|
||||
@click="toggleLightIntensity"
|
||||
>
|
||||
<i class="pi pi-sun text-lg text-base-foreground" />
|
||||
<i class="pi pi-sun text-lg text-white" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showLightIntensity"
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
:aria-label="t('load3d.upDirection')"
|
||||
@click="toggleUpDirection"
|
||||
>
|
||||
<i class="pi pi-arrow-up text-lg text-base-foreground" />
|
||||
<i class="pi pi-arrow-up text-lg text-white" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showUpDirection"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -24,10 +24,7 @@
|
||||
:key="direction"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'text-base-foreground',
|
||||
upDirection === direction && 'bg-blue-500'
|
||||
)
|
||||
cn('text-white', upDirection === direction && 'bg-blue-500')
|
||||
"
|
||||
@click="selectUpDirection(direction)"
|
||||
>
|
||||
@@ -49,11 +46,11 @@
|
||||
:aria-label="t('load3d.materialMode')"
|
||||
@click="toggleMaterialMode"
|
||||
>
|
||||
<i class="pi pi-box text-lg text-base-foreground" />
|
||||
<i class="pi pi-box text-lg text-white" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showMaterialMode"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -62,7 +59,7 @@
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'whitespace-nowrap text-base-foreground',
|
||||
'whitespace-nowrap text-white',
|
||||
materialMode === mode && 'bg-blue-500'
|
||||
)
|
||||
"
|
||||
@@ -86,7 +83,7 @@
|
||||
:aria-label="t('load3d.showSkeleton')"
|
||||
@click="showSkeleton = !showSkeleton"
|
||||
>
|
||||
<i class="pi pi-sitemap text-lg text-base-foreground" />
|
||||
<i class="pi pi-sitemap text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
:aria-label="tooltipText"
|
||||
@click="toggleSlider"
|
||||
>
|
||||
<i :class="['pi', icon, 'text-lg text-base-foreground']" />
|
||||
<i :class="['pi', icon, 'text-lg text-white']" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showSlider"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
@@ -25,7 +25,7 @@
|
||||
:class="[
|
||||
'pi',
|
||||
isRecording ? 'pi-circle-fill' : 'pi-video',
|
||||
'text-lg text-base-foreground'
|
||||
'text-lg text-white'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
@@ -42,7 +42,7 @@
|
||||
:aria-label="$t('load3d.exportRecording')"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
<i class="pi pi-download text-lg text-white" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -57,12 +57,12 @@
|
||||
:aria-label="$t('load3d.clearRecording')"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i class="pi pi-trash text-lg text-base-foreground" />
|
||||
<i class="pi pi-trash text-lg text-white" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-base-foreground"
|
||||
class="mt-1 text-center text-xs text-white"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:aria-label="$t('load3d.showGrid')"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i class="pi pi-table text-lg text-base-foreground" />
|
||||
<i class="pi pi-table text-lg text-white" />
|
||||
</Button>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
@@ -23,7 +23,7 @@
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<i class="pi pi-palette text-lg text-white" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
@@ -48,7 +48,7 @@
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<i class="pi pi-image text-lg text-white" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
@@ -76,7 +76,7 @@
|
||||
:aria-label="$t('load3d.panoramaMode')"
|
||||
@click="toggleBackgroundRenderMode"
|
||||
>
|
||||
<i class="pi pi-globe text-lg text-base-foreground" />
|
||||
<i class="pi pi-globe text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
:aria-label="$t('load3d.removeBackgroundImage')"
|
||||
@click="removeBackgroundImage"
|
||||
>
|
||||
<i class="pi pi-times text-lg text-base-foreground" />
|
||||
<i class="pi pi-times text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
@@ -12,7 +12,7 @@
|
||||
:aria-label="t('load3d.openIn3DViewer')"
|
||||
@click="openIn3DViewer"
|
||||
>
|
||||
<i class="pi pi-expand text-lg text-base-foreground" />
|
||||
<i class="pi pi-expand text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
@keydown.stop
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-current"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -35,74 +35,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 border-l border-border" />
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateLeft')"
|
||||
@click="onRotateLeft"
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateRight')"
|
||||
@click="onRotateRight"
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorHorizontal')"
|
||||
@click="onMirrorHorizontal"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
/>
|
||||
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorVertical')"
|
||||
@click="onMirrorVertical"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
/>
|
||||
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
@@ -131,7 +63,6 @@ import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -140,17 +71,16 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const canvasTransform = useCanvasTransform()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
@@ -160,38 +90,6 @@ const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onRotateLeft = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateCounterclockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate left failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onRotateRight = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateClockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate right failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorHorizontal = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorHorizontal()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror horizontal failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorVertical = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorVertical()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror vertical failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
@@ -50,22 +50,20 @@
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
hasAnyProgressPercent(
|
||||
props.progressTotalPercent,
|
||||
props.progressCurrentPercent
|
||||
)
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
"
|
||||
:class="progressBarContainerClass"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(props.progressTotalPercent)"
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(props.progressCurrentPercent)"
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +201,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
@@ -248,14 +245,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="sideToolbarRef"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
|
||||
:class="{
|
||||
'small-sidebar': isSmall,
|
||||
'connected-sidebar pointer-events-auto': isConnected,
|
||||
'connected-sidebar': isConnected,
|
||||
'floating-sidebar': !isConnected,
|
||||
'overflowing-sidebar': isOverflowing,
|
||||
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
|
||||
@@ -40,13 +40,12 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -55,7 +54,6 @@ import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
@@ -91,9 +89,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
@@ -150,8 +145,8 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
const isOverflowing = ref(false)
|
||||
const groupClasses = computed(() =>
|
||||
cn(
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
|
||||
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-interface')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,28 +1,204 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
<div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, toRefs } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Use conflict acknowledgment state from composable - call only once
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
const props = defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
const { isSmall } = toRefs(props)
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle What's New popup dismissal
|
||||
* Check if conflict modal should be shown after ComfyUI update
|
||||
*/
|
||||
const handleWhatsNewDismissed = async () => {
|
||||
try {
|
||||
// Check if conflict modal should be shown after update
|
||||
const shouldShow =
|
||||
await conflictDetection.shouldShowConflictModalAfterUpdate()
|
||||
if (shouldShow) {
|
||||
showConflictModal()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Error checking conflict modal:', error)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show the node conflict dialog with current conflict data
|
||||
*/
|
||||
const showConflictModal = () => {
|
||||
showNodeConflictDialog({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { JobAction } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
|
||||
import { setMockJobItems } from '@/storybook/mocks/useJobList'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
type StoryArgs = {
|
||||
assets: AssetItem[]
|
||||
jobs: JobListItem[]
|
||||
selectedAssetIds?: string[]
|
||||
actionsByJobId?: Record<string, JobAction[]>
|
||||
}
|
||||
|
||||
function baseDecorator() {
|
||||
return {
|
||||
template: `
|
||||
<div class="bg-base-background p-6">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Sidebar/AssetsSidebarListView',
|
||||
component: AssetsSidebarListView,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [baseDecorator]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const baseTimestamp = '2024-01-15T10:00:00Z'
|
||||
|
||||
const sampleJobs: JobListItem[] = [
|
||||
{
|
||||
id: 'job-pending-1',
|
||||
title: 'In queue',
|
||||
meta: '8:59:30pm',
|
||||
state: 'pending',
|
||||
iconName: iconForJobState('pending'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-init-1',
|
||||
title: 'Initializing...',
|
||||
meta: '8:59:35pm',
|
||||
state: 'initialization',
|
||||
iconName: iconForJobState('initialization'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-running-1',
|
||||
title: 'Total: 30%',
|
||||
meta: 'KSampler: 70%',
|
||||
state: 'running',
|
||||
iconName: iconForJobState('running'),
|
||||
showClear: true,
|
||||
progressTotalPercent: 30,
|
||||
progressCurrentPercent: 70
|
||||
}
|
||||
]
|
||||
|
||||
const sampleAssets: AssetItem[] = [
|
||||
{
|
||||
id: 'asset-image-1',
|
||||
name: 'image-032.png',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/comfy-logo-single.svg',
|
||||
size: 1887437,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
promptId: 'job-running-1',
|
||||
nodeId: 12,
|
||||
executionTimeInSeconds: 1.84
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-video-1',
|
||||
name: 'clip-01.mp4',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 8394820,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 132000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-audio-1',
|
||||
name: 'soundtrack-01.mp3',
|
||||
created_at: baseTimestamp,
|
||||
size: 5242880,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-3d-1',
|
||||
name: 'scene-01.glb',
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'Cancel',
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
export const RunningAndGenerated: Story = {
|
||||
args: {
|
||||
assets: sampleAssets,
|
||||
jobs: sampleJobs,
|
||||
actionsByJobId: {
|
||||
'job-pending-1': [cancelAction],
|
||||
'job-init-1': [cancelAction],
|
||||
'job-running-1': [cancelAction]
|
||||
}
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
setup() {
|
||||
setMockJobItems(args.jobs)
|
||||
setMockJobActions(args.actionsByJobId ?? {})
|
||||
const selectedIds = new Set(args.selectedAssetIds ?? [])
|
||||
function isSelected(assetId: string) {
|
||||
return selectedIds.has(assetId)
|
||||
}
|
||||
|
||||
return { args, isSelected }
|
||||
},
|
||||
template: `
|
||||
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
|
||||
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-default'
|
||||
)
|
||||
"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="onJobEnter(job.id)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="canCancelJob"
|
||||
:variant="cancelAction.variant"
|
||||
size="icon"
|
||||
:aria-label="cancelAction.label"
|
||||
@click.stop="runCancelJob()"
|
||||
>
|
||||
<i :class="cancelAction.icon" class="size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{ t('sideToolbar.generatedAssetsHeader') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="listGridStyle"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
})
|
||||
"
|
||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
||||
"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getMediaTypeFromFilename,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { assets, isSelected } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
const hoveredJob = computed(() =>
|
||||
hoveredJobId.value
|
||||
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
||||
null)
|
||||
: null
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const assetItems = computed<AssetListItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
const duration = asset.user_metadata?.duration
|
||||
if (typeof duration === 'number') {
|
||||
return formatDuration(duration)
|
||||
}
|
||||
|
||||
if (typeof asset.size === 'number') {
|
||||
return formatSize(asset.size)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetCardClass(selected: boolean): string {
|
||||
return cn(
|
||||
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-pointer',
|
||||
selected &&
|
||||
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
|
||||
)
|
||||
}
|
||||
|
||||
function onJobEnter(jobId: string) {
|
||||
hoveredJobId.value = jobId
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const classes = []
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
classes.push('animate-spin')
|
||||
}
|
||||
return classes.length ? classes.join(' ') : undefined
|
||||
}
|
||||
</script>
|
||||
@@ -79,10 +79,10 @@
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="showLoadingState">
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else-if="showEmptyState">
|
||||
<div v-else-if="!loading && !displayAssets.length">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
@@ -96,15 +96,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
@select-asset="handleAssetSelect"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
@@ -122,15 +114,11 @@
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
:selected-assets="getSelectedAssets(displayAssets)"
|
||||
:has-selection="hasSelection"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
@context-menu-opened="openContextMenuId = item.id"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -146,6 +134,7 @@
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
@@ -209,7 +198,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
@@ -244,9 +232,6 @@ const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
@@ -258,6 +243,11 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 1
|
||||
}
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -295,8 +285,6 @@ const {
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
activate: activateSelection,
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
@@ -328,7 +316,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
return getTotalOutputCount(selectedAssets)
|
||||
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
@@ -359,20 +347,6 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
@@ -563,16 +537,6 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
await deleteMultipleAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const meta: Meta<typeof ProgressToastItem> = {
|
||||
title: 'Toast/ProgressToastItem',
|
||||
component: ProgressToastItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'created',
|
||||
assetName: 'sd-xl-base-1.0.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.45,
|
||||
assetName: 'lora-detail-enhancer.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningAlmostComplete: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.92,
|
||||
assetName: 'vae-ft-mse-840000.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
assetName: 'controlnet-canny.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'failed',
|
||||
progress: 0.23,
|
||||
assetName: 'unreachable-model.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.67,
|
||||
assetName:
|
||||
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { job } = defineProps<{
|
||||
job: AssetDownload
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const progressPercent = computed(() => Math.round(job.progress * 100))
|
||||
const isCompleted = computed(() => job.status === 'completed')
|
||||
const isFailed = computed(() => job.status === 'failed')
|
||||
const isRunning = computed(() => job.status === 'running')
|
||||
const isPending = computed(() => job.status === 'created')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<template v-if="isFailed">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
||||
/>
|
||||
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isCompleted">
|
||||
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isRunning">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isPending">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('progressToast.pending') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,16 +9,11 @@
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
|
||||
compact && 'size-full '
|
||||
)
|
||||
"
|
||||
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
<UserAvatar :photo-url="photoURL" />
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
<i class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -43,15 +38,9 @@ import { computed, ref } from 'vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="!isLoggedIn"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:class="cn('group rounded-full text-base-foreground p-0', className)"
|
||||
class="rounded-full bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
|
||||
:aria-label="t('g.login')"
|
||||
@click="handleSignIn()"
|
||||
@mouseenter="showPopover"
|
||||
@mouseleave="hidePopover"
|
||||
>
|
||||
<span
|
||||
class="flex size-full items-center justify-center rounded-full bg-secondary-background transition-colors group-hover:bg-transparent"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4" />
|
||||
</span>
|
||||
<i class="icon-[lucide--user] size-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
@@ -35,18 +31,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { isLoggedIn, handleSignIn } = useCurrentUser()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
{{ $t('menu.helpAndFeedback') }}
|
||||
<i class="icon-[lucide--circle-help] ml-0.5" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -161,7 +161,7 @@ describe('TopbarBadge', () => {
|
||||
)
|
||||
|
||||
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
|
||||
expect(wrapper.find('.text-warning-background').exists()).toBe(true)
|
||||
expect(wrapper.find('.text-gold-600').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses default error icon for error variant', () => {
|
||||
@@ -185,9 +185,7 @@ describe('TopbarBadge', () => {
|
||||
'full'
|
||||
)
|
||||
|
||||
expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ const textClasses = computed(() => {
|
||||
case 'error':
|
||||
return 'text-danger-100'
|
||||
case 'warning':
|
||||
return 'text-warning-background'
|
||||
return 'text-gold-600'
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-text-primary'
|
||||
@@ -191,7 +191,7 @@ const iconClass = computed(() => {
|
||||
case 'error':
|
||||
return 'pi pi-exclamation-circle'
|
||||
case 'warning':
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
return 'pi pi-exclamation-triangle'
|
||||
case 'info':
|
||||
default:
|
||||
return undefined
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
v-if="isActiveTab"
|
||||
class="context-menu-button -mx-1 w-auto px-1 py-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
@click.stop="handleMenuClick"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
@@ -43,26 +34,9 @@
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
v-if="isActiveTab"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -71,14 +45,11 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
@@ -143,12 +114,6 @@ const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(() =>
|
||||
useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
)
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
@@ -162,14 +127,6 @@ const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'workflow_tab_menu_selected'
|
||||
})
|
||||
// Show breadcrumb menu instead of emitting context click
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
|
||||
@@ -67,25 +67,7 @@
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
<div
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="shrink-0 p-1"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems">
|
||||
<template #itemicon="{ item }">
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,20 +81,15 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -130,16 +107,10 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
@@ -198,73 +169,54 @@ const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
|
||||
rightClickedTab.value = option
|
||||
menu.value.show(event)
|
||||
}
|
||||
|
||||
const rightClickedWorkflow = computed(
|
||||
() => rightClickedTab.value?.workflow ?? null
|
||||
)
|
||||
|
||||
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
|
||||
() => commandStore.execute('Comfy.RenameWorkflow'),
|
||||
{
|
||||
includeDelete: false,
|
||||
workflow: rightClickedWorkflow
|
||||
}
|
||||
)
|
||||
|
||||
const contextMenuItems = computed(() => {
|
||||
const tab = rightClickedTab.value
|
||||
const tab = rightClickedTab.value as WorkflowOption
|
||||
if (!tab) return []
|
||||
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
|
||||
|
||||
return [
|
||||
...baseMenuItems.value,
|
||||
{
|
||||
label: t('tabMenu.duplicateTab'),
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(tab.workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(tab)
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-left',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(0, index)),
|
||||
disabled: index <= 0
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-right',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(index + 1)),
|
||||
disabled: index === options.value.length - 1
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrows-h',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () =>
|
||||
closeWorkflows([
|
||||
...options.value.slice(index + 1),
|
||||
...options.value.slice(0, index)
|
||||
]),
|
||||
disabled: options.value.length <= 1
|
||||
},
|
||||
{
|
||||
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
|
||||
? t('tabMenu.removeFromBookmarks')
|
||||
: t('tabMenu.addToBookmarks'),
|
||||
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
|
||||
disabled: tab.workflow.isTemporary
|
||||
}
|
||||
]
|
||||
})
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// Horizontal scroll on wheel
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<label
|
||||
:for="inputId"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 cursor-text items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover focus-within:ring-1 focus-within:ring-secondary-foreground',
|
||||
disabled && 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue <= min"
|
||||
:aria-label="$t('g.decrement')"
|
||||
@click="handleStep(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--minus] size-4" />
|
||||
</button>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
|
||||
>
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:style="{ width: `${inputWidth}ch` }"
|
||||
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none"
|
||||
:disabled="disabled"
|
||||
@input="handleInputChange"
|
||||
@blur="handleInputBlur"
|
||||
@focus="handleInputFocus"
|
||||
/>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue >= max"
|
||||
:aria-label="$t('g.increment')"
|
||||
@click="handleStep(1)"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min = 0,
|
||||
max = Infinity,
|
||||
step = 1,
|
||||
formatOptions = { useGrouping: true },
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number | ((value: number) => number)
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'max-reached': []
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const inputId = useId()
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const inputValue = ref(formatNumber(modelValue.value))
|
||||
|
||||
const inputWidth = computed(() =>
|
||||
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
|
||||
)
|
||||
|
||||
watch(modelValue, (newValue) => {
|
||||
if (document.activeElement !== inputRef.value) {
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US', formatOptions)
|
||||
}
|
||||
|
||||
function parseFormattedNumber(str: string): number {
|
||||
const cleaned = str.replace(/[^0-9]/g, '')
|
||||
return cleaned === '' ? 0 : parseInt(cleaned, 10)
|
||||
}
|
||||
|
||||
function clamp(value: number, minVal: number, maxVal: number): number {
|
||||
return Math.min(Math.max(value, minVal), maxVal)
|
||||
}
|
||||
|
||||
function formatWithCursor(
|
||||
value: string,
|
||||
cursorPos: number
|
||||
): { formatted: string; newCursor: number } {
|
||||
const num = parseFormattedNumber(value)
|
||||
const formatted = formatNumber(num)
|
||||
|
||||
const digitsBeforeCursor = value
|
||||
.slice(0, cursorPos)
|
||||
.replace(/[^0-9]/g, '').length
|
||||
|
||||
let digitCount = 0
|
||||
let newCursor = 0
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (/[0-9]/.test(formatted[i])) {
|
||||
digitCount++
|
||||
}
|
||||
if (digitCount >= digitsBeforeCursor) {
|
||||
newCursor = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (digitCount < digitsBeforeCursor) {
|
||||
newCursor = formatted.length
|
||||
}
|
||||
|
||||
return { formatted, newCursor }
|
||||
}
|
||||
|
||||
function getStepAmount(): number {
|
||||
return typeof step === 'function' ? step(modelValue.value) : step
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const raw = input.value
|
||||
const cursorPos = input.selectionStart ?? raw.length
|
||||
const num = parseFormattedNumber(raw)
|
||||
|
||||
const clamped = Math.min(num, max)
|
||||
const wasClamped = num > max
|
||||
|
||||
if (wasClamped) {
|
||||
emit('max-reached')
|
||||
}
|
||||
|
||||
modelValue.value = clamped
|
||||
|
||||
const { formatted, newCursor } = formatWithCursor(
|
||||
wasClamped ? formatNumber(clamped) : raw,
|
||||
wasClamped ? formatNumber(clamped).length : cursorPos
|
||||
)
|
||||
inputValue.value = formatted
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.value?.setSelectionRange(newCursor, newCursor)
|
||||
})
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
const clamped = clamp(modelValue.value, min, max)
|
||||
modelValue.value = clamped
|
||||
inputValue.value = formatNumber(clamped)
|
||||
}
|
||||
|
||||
function handleInputFocus(e: FocusEvent) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const len = input.value.length
|
||||
input.setSelectionRange(len, len)
|
||||
}
|
||||
|
||||
function handleStep(direction: 1 | -1) {
|
||||
const stepAmount = getStepAmount()
|
||||
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
|
||||
modelValue.value = newValue
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
</script>
|
||||
@@ -13,11 +13,10 @@
|
||||
<i class="icon-[lucide--panel-right] text-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
</Button>
|
||||
<div class="flex h-full w-full">
|
||||
<Transition name="slide-panel">
|
||||
@@ -81,9 +80,7 @@
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="icon" class="pt-0.5">
|
||||
<div v-if="icon" class="py-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
|
||||
@@ -75,7 +75,6 @@ export interface VueNodeData {
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
@@ -245,10 +244,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
}
|
||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
||||
})
|
||||
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets,
|
||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
@@ -319,7 +325,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
@@ -234,128 +233,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
)
|
||||
|
||||
const isRecreatingTextures = ref(false)
|
||||
|
||||
watch(
|
||||
() => store.gpuTexturesNeedRecreation,
|
||||
async (needsRecreation) => {
|
||||
if (
|
||||
!needsRecreation ||
|
||||
!device ||
|
||||
!store.maskCanvas ||
|
||||
isRecreatingTextures.value
|
||||
)
|
||||
return
|
||||
|
||||
isRecreatingTextures.value = true
|
||||
|
||||
const width = store.gpuTextureWidth
|
||||
const height = store.gpuTextureHeight
|
||||
|
||||
try {
|
||||
// Destroy old textures
|
||||
if (maskTexture) {
|
||||
maskTexture.destroy()
|
||||
maskTexture = null
|
||||
}
|
||||
if (rgbTexture) {
|
||||
rgbTexture.destroy()
|
||||
rgbTexture = null
|
||||
}
|
||||
|
||||
// Create new textures with updated dimensions
|
||||
maskTexture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: 'rgba8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.COPY_SRC
|
||||
})
|
||||
|
||||
rgbTexture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: 'rgba8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.COPY_SRC
|
||||
})
|
||||
|
||||
// Upload pending data if available
|
||||
if (store.pendingGPUMaskData && store.pendingGPURgbData) {
|
||||
device.queue.writeTexture(
|
||||
{ texture: maskTexture },
|
||||
store.pendingGPUMaskData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
|
||||
device.queue.writeTexture(
|
||||
{ texture: rgbTexture },
|
||||
store.pendingGPURgbData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
} else {
|
||||
// Fallback: read from canvas
|
||||
await updateGPUFromCanvas()
|
||||
}
|
||||
|
||||
// Update preview canvas if it exists
|
||||
if (previewCanvas && renderer) {
|
||||
previewCanvas.width = width
|
||||
previewCanvas.height = height
|
||||
}
|
||||
|
||||
// Recreate readback buffers with new size
|
||||
const bufferSize = width * height * 4
|
||||
if (currentBufferSize !== bufferSize) {
|
||||
readbackStorageMask?.destroy()
|
||||
readbackStorageRgb?.destroy()
|
||||
readbackStagingMask?.destroy()
|
||||
readbackStagingRgb?.destroy()
|
||||
|
||||
readbackStorageMask = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStorageRgb = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStagingMask = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
readbackStagingRgb = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
|
||||
currentBufferSize = bufferSize
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useBrushDrawing] Failed to recreate GPU textures:',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
// Clear the recreation flag and pending data
|
||||
store.gpuTexturesNeedRecreation = false
|
||||
store.gpuTextureWidth = 0
|
||||
store.gpuTextureHeight = 0
|
||||
store.pendingGPUMaskData = null
|
||||
store.pendingGPURgbData = null
|
||||
isRecreatingTextures.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Cleanup GPU resources on unmount
|
||||
onUnmounted(() => {
|
||||
if (renderer) {
|
||||
|
||||
@@ -2,70 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
|
||||
|
||||
// Define the store shape to avoid 'any' and cast to the expected type
|
||||
interface MaskEditorStoreState {
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
rgbCanvas: HTMLCanvasElement | null
|
||||
imgCanvas: HTMLCanvasElement | null
|
||||
maskCtx: CanvasRenderingContext2D | null
|
||||
rgbCtx: CanvasRenderingContext2D | null
|
||||
imgCtx: CanvasRenderingContext2D | null
|
||||
}
|
||||
let mockMaskCanvas: any
|
||||
let mockRgbCanvas: any
|
||||
let mockMaskCtx: any
|
||||
let mockRgbCtx: any
|
||||
|
||||
// Use vi.hoisted to create isolated mock state container
|
||||
const mockRefs = vi.hoisted(() => ({
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
maskCtx: null as CanvasRenderingContext2D | null,
|
||||
rgbCtx: null as CanvasRenderingContext2D | null,
|
||||
imgCtx: null as CanvasRenderingContext2D | null
|
||||
}))
|
||||
|
||||
const mockStore: MaskEditorStoreState = {
|
||||
get maskCanvas() {
|
||||
return mockRefs.maskCanvas
|
||||
},
|
||||
set maskCanvas(val) {
|
||||
mockRefs.maskCanvas = val
|
||||
},
|
||||
get rgbCanvas() {
|
||||
return mockRefs.rgbCanvas
|
||||
},
|
||||
set rgbCanvas(val) {
|
||||
mockRefs.rgbCanvas = val
|
||||
},
|
||||
get imgCanvas() {
|
||||
return mockRefs.imgCanvas
|
||||
},
|
||||
set imgCanvas(val) {
|
||||
mockRefs.imgCanvas = val
|
||||
},
|
||||
get maskCtx() {
|
||||
return mockRefs.maskCtx
|
||||
},
|
||||
set maskCtx(val) {
|
||||
mockRefs.maskCtx = val
|
||||
},
|
||||
get rgbCtx() {
|
||||
return mockRefs.rgbCtx
|
||||
},
|
||||
set rgbCtx(val) {
|
||||
mockRefs.rgbCtx = val
|
||||
},
|
||||
get imgCtx() {
|
||||
return mockRefs.imgCtx
|
||||
},
|
||||
set imgCtx(val) {
|
||||
mockRefs.imgCtx = val
|
||||
}
|
||||
const mockStore = {
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
maskCtx: null as any,
|
||||
rgbCtx: null as any
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
// Mock ImageBitmap using safe global augmentation pattern
|
||||
// Mock ImageBitmap for test environment
|
||||
if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
globalThis.ImageBitmap = class ImageBitmap {
|
||||
width: number
|
||||
@@ -75,7 +28,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
@@ -90,8 +43,9 @@ describe('useCanvasHistory', () => {
|
||||
return rafCallCount
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('alert', () => {})
|
||||
|
||||
const createMockImageData = (): ImageData => {
|
||||
const createMockImageData = () => {
|
||||
return {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
@@ -99,43 +53,34 @@ describe('useCanvasHistory', () => {
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
// Mock contexts using explicit partial-cast pattern
|
||||
mockRefs.maskCtx = {
|
||||
mockMaskCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
mockRefs.rgbCtx = {
|
||||
mockRgbCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
mockRefs.imgCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
// Mock canvases using explicit partial-cast pattern
|
||||
mockRefs.maskCanvas = {
|
||||
mockMaskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
}
|
||||
|
||||
mockRefs.rgbCanvas = {
|
||||
mockRgbCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
}
|
||||
|
||||
mockRefs.imgCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockStore.rgbCanvas = mockRgbCanvas
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockStore.rgbCtx = mockRgbCtx
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
@@ -151,14 +96,8 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
@@ -166,47 +105,27 @@ describe('useCanvasHistory', () => {
|
||||
it('should wait for canvas to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockRefs.maskCanvas = {
|
||||
...mockRefs.maskCanvas,
|
||||
width: 0,
|
||||
height: 0
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
mockRefs.maskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
})
|
||||
|
||||
it('should wait for context to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockRefs.maskCtx = null
|
||||
mockStore.maskCtx = null
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
const createMockImageData = (): ImageData => {
|
||||
return {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockRefs.maskCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,20 +134,13 @@ describe('useCanvasHistory', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
@@ -272,9 +184,8 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save state if context is missing', () => {
|
||||
@@ -282,17 +193,15 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
const savedMaskCtx = mockRefs.maskCtx
|
||||
mockRefs.maskCtx = null
|
||||
vi.mocked(savedMaskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled()
|
||||
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockRefs.maskCtx = savedMaskCtx
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
@@ -305,27 +214,20 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should not undo when no undo states available', () => {
|
||||
it('should show alert when no undo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
|
||||
})
|
||||
|
||||
it('should undo multiple times', () => {
|
||||
@@ -347,22 +249,16 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should not undo beyond first state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,33 +270,25 @@ describe('useCanvasHistory', () => {
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should not redo when no redo states available', () => {
|
||||
it('should show alert when no redo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
|
||||
})
|
||||
|
||||
it('should redo multiple times', () => {
|
||||
@@ -426,6 +314,7 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should not redo beyond last state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
@@ -433,16 +322,9 @@ describe('useCanvasHistory', () => {
|
||||
history.undo()
|
||||
|
||||
history.redo()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -466,15 +348,13 @@ describe('useCanvasHistory', () => {
|
||||
history.saveInitialState()
|
||||
history.clearStates()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -566,17 +446,15 @@ describe('useCanvasHistory', () => {
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
const savedMaskCtx = mockRefs.maskCtx
|
||||
mockRefs.maskCtx = null
|
||||
vi.mocked(savedMaskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockRefs.maskCtx = savedMaskCtx
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
@@ -621,12 +499,8 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should handle zero-sized canvas', () => {
|
||||
if (mockRefs.maskCanvas) {
|
||||
mockRefs.maskCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
}
|
||||
mockMaskCanvas.width = 0
|
||||
mockMaskCanvas.height = 0
|
||||
|
||||
const history = useCanvasHistory()
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
// Define the state interface for better readability
|
||||
interface CanvasState {
|
||||
mask: ImageData | ImageBitmap
|
||||
rgb: ImageData | ImageBitmap
|
||||
img: ImageData | ImageBitmap
|
||||
}
|
||||
|
||||
export function useCanvasHistory(maxStates = 20) {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const states = ref<CanvasState[]>([])
|
||||
const states = ref<
|
||||
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
|
||||
>([])
|
||||
const currentStateIndex = ref(-1)
|
||||
const initialized = ref(false)
|
||||
|
||||
@@ -27,29 +22,22 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
})
|
||||
|
||||
const saveInitialState = () => {
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
|
||||
// Ensure all 3 contexts and canvases are ready
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
) {
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) {
|
||||
if (!maskCanvas.width || !rgbCanvas.width) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
states.value = []
|
||||
|
||||
// Capture all three layers
|
||||
const maskState = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
@@ -62,51 +50,35 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
rgbCanvas.width,
|
||||
rgbCanvas.height
|
||||
)
|
||||
const imgState = imgCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
imgCanvas.width,
|
||||
imgCanvas.height
|
||||
)
|
||||
|
||||
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
currentStateIndex.value = 0
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
const saveState = (
|
||||
providedMaskData?: ImageData | ImageBitmap,
|
||||
providedRgbData?: ImageData | ImageBitmap,
|
||||
providedImgData?: ImageData | ImageBitmap
|
||||
providedRgbData?: ImageData | ImageBitmap
|
||||
) => {
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
)
|
||||
return
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
|
||||
|
||||
if (!initialized.value || currentStateIndex.value === -1) {
|
||||
saveInitialState()
|
||||
return
|
||||
}
|
||||
|
||||
// Clear redo history
|
||||
states.value = states.value.slice(0, currentStateIndex.value + 1)
|
||||
|
||||
let maskState: ImageData | ImageBitmap
|
||||
let rgbState: ImageData | ImageBitmap
|
||||
let imgState: ImageData | ImageBitmap
|
||||
|
||||
if (providedMaskData && providedRgbData && providedImgData) {
|
||||
if (providedMaskData && providedRgbData) {
|
||||
maskState = providedMaskData
|
||||
rgbState = providedRgbData
|
||||
imgState = providedImgData
|
||||
} else {
|
||||
maskState = maskCtx.getImageData(
|
||||
0,
|
||||
@@ -115,84 +87,71 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
maskCanvas.height
|
||||
)
|
||||
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
|
||||
imgState = imgCtx.getImageData(0, 0, imgCanvas.width, imgCanvas.height)
|
||||
}
|
||||
|
||||
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
currentStateIndex.value++
|
||||
|
||||
// Maintain max history size and clean up memory
|
||||
if (states.value.length > maxStates) {
|
||||
const removed = states.value.shift()
|
||||
// Cleanup ImageBitmaps to avoid memory leaks
|
||||
if (removed) {
|
||||
cleanupState(removed)
|
||||
if (removed.mask instanceof ImageBitmap) removed.mask.close()
|
||||
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
|
||||
}
|
||||
currentStateIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo.value) return
|
||||
if (!canUndo.value) {
|
||||
alert('No more undo states available')
|
||||
return
|
||||
}
|
||||
|
||||
currentStateIndex.value--
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo.value) return
|
||||
if (!canRedo.value) {
|
||||
alert('No more redo states available')
|
||||
return
|
||||
}
|
||||
|
||||
currentStateIndex.value++
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const restoreState = (state: CanvasState) => {
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
)
|
||||
return
|
||||
const restoreState = (state: {
|
||||
mask: ImageData | ImageBitmap
|
||||
rgb: ImageData | ImageBitmap
|
||||
}) => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
if (!maskCtx || !rgbCtx) return
|
||||
|
||||
// Update canvas dimensions to match state (handles rotation undo/redo)
|
||||
const refData = state.mask
|
||||
const newWidth = refData.width
|
||||
const newHeight = refData.height
|
||||
|
||||
if (maskCanvas.width !== newWidth || maskCanvas.height !== newHeight) {
|
||||
maskCanvas.width = newWidth
|
||||
maskCanvas.height = newHeight
|
||||
rgbCanvas.width = newWidth
|
||||
rgbCanvas.height = newHeight
|
||||
imgCanvas.width = newWidth
|
||||
imgCanvas.height = newHeight
|
||||
if (state.mask instanceof ImageBitmap) {
|
||||
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
|
||||
maskCtx.drawImage(state.mask, 0, 0)
|
||||
} else {
|
||||
maskCtx.putImageData(state.mask, 0, 0)
|
||||
}
|
||||
|
||||
const layers = [
|
||||
{ ctx: maskCtx, data: state.mask },
|
||||
{ ctx: rgbCtx, data: state.rgb },
|
||||
{ ctx: imgCtx, data: state.img }
|
||||
]
|
||||
|
||||
layers.forEach(({ ctx, data }) => {
|
||||
if (data instanceof ImageBitmap) {
|
||||
ctx.clearRect(0, 0, data.width, data.height)
|
||||
ctx.drawImage(data, 0, 0)
|
||||
} else {
|
||||
ctx.putImageData(data, 0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cleanupState = (state: CanvasState) => {
|
||||
if (state.mask instanceof ImageBitmap) state.mask.close()
|
||||
if (state.rgb instanceof ImageBitmap) state.rgb.close()
|
||||
if (state.img instanceof ImageBitmap) state.img.close()
|
||||
if (state.rgb instanceof ImageBitmap) {
|
||||
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
|
||||
rgbCtx.drawImage(state.rgb, 0, 0)
|
||||
} else {
|
||||
rgbCtx.putImageData(state.rgb, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearStates = () => {
|
||||
states.value.forEach(cleanupState)
|
||||
// Cleanup bitmaps
|
||||
states.value.forEach((state) => {
|
||||
if (state.mask instanceof ImageBitmap) state.mask.close()
|
||||
if (state.rgb instanceof ImageBitmap) state.rgb.close()
|
||||
})
|
||||
states.value = []
|
||||
currentStateIndex.value = -1
|
||||
initialized.value = false
|
||||
|
||||
@@ -1,683 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
|
||||
interface IMockCanvas {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface IMockContext {
|
||||
getImageData: ReturnType<typeof vi.fn>
|
||||
putImageData: ReturnType<typeof vi.fn>
|
||||
clearRect: ReturnType<typeof vi.fn>
|
||||
drawImage: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface IMockCanvasHistory {
|
||||
saveState: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface IMockStore {
|
||||
maskCanvas: IMockCanvas | null
|
||||
rgbCanvas: IMockCanvas | null
|
||||
imgCanvas: IMockCanvas | null
|
||||
maskCtx: IMockContext | null
|
||||
rgbCtx: IMockContext | null
|
||||
imgCtx: IMockContext | null
|
||||
tgpuRoot: unknown
|
||||
canvasHistory: IMockCanvasHistory
|
||||
gpuTexturesNeedRecreation: boolean
|
||||
gpuTextureWidth: number
|
||||
gpuTextureHeight: number
|
||||
pendingGPUMaskData: Uint8ClampedArray | null
|
||||
pendingGPURgbData: Uint8ClampedArray | null
|
||||
}
|
||||
|
||||
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
|
||||
const mockCanvasHistory: IMockCanvasHistory = {
|
||||
saveState: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore: IMockStore = {
|
||||
maskCanvas: null,
|
||||
rgbCanvas: null,
|
||||
imgCanvas: null,
|
||||
maskCtx: null,
|
||||
rgbCtx: null,
|
||||
imgCtx: null,
|
||||
tgpuRoot: null,
|
||||
canvasHistory: mockCanvasHistory,
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null,
|
||||
pendingGPURgbData: null
|
||||
}
|
||||
|
||||
return { mockStore, mockCanvasHistory }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
// Mock ImageData with improved type safety
|
||||
if (typeof globalThis.ImageData === 'undefined') {
|
||||
globalThis.ImageData = class ImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(
|
||||
dataOrWidth: Uint8ClampedArray | number,
|
||||
widthOrHeight?: number,
|
||||
height?: number
|
||||
) {
|
||||
if (dataOrWidth instanceof Uint8ClampedArray) {
|
||||
// Constructor overload: new ImageData(data, width, height)
|
||||
if (widthOrHeight === undefined || height === undefined) {
|
||||
throw new Error(
|
||||
'ImageData constructor requires width and height when data is provided'
|
||||
)
|
||||
}
|
||||
this.data = dataOrWidth
|
||||
this.width = widthOrHeight
|
||||
this.height = height
|
||||
} else {
|
||||
// Constructor overload: new ImageData(width, height)
|
||||
if (widthOrHeight === undefined) {
|
||||
throw new Error(
|
||||
'ImageData constructor requires height when width is provided'
|
||||
)
|
||||
}
|
||||
this.width = dataOrWidth
|
||||
this.height = widthOrHeight
|
||||
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
|
||||
}
|
||||
}
|
||||
} as unknown as typeof globalThis.ImageData
|
||||
}
|
||||
|
||||
// Mock ImageBitmap for test environment using safe type casting
|
||||
if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
globalThis.ImageBitmap = class ImageBitmap {
|
||||
width: number
|
||||
height: number
|
||||
constructor(width = 100, height = 100) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
}
|
||||
|
||||
describe('useCanvasTransform', () => {
|
||||
let mockMaskCanvas: IMockCanvas
|
||||
let mockRgbCanvas: IMockCanvas
|
||||
let mockImgCanvas: IMockCanvas
|
||||
let mockMaskCtx: IMockContext
|
||||
let mockRgbCtx: IMockContext
|
||||
let mockImgCtx: IMockContext
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
const createMockImageData = (width: number, height: number) => {
|
||||
const data = new Uint8ClampedArray(width * height * 4)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 255 // R
|
||||
data[i + 1] = 0 // G
|
||||
data[i + 2] = 0 // B
|
||||
data[i + 3] = 255 // A
|
||||
}
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockRgbCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockImgCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockMaskCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockRgbCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockImgCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockStore.rgbCanvas = mockRgbCanvas
|
||||
mockStore.imgCanvas = mockImgCanvas
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockStore.rgbCtx = mockRgbCtx
|
||||
mockStore.imgCtx = mockImgCtx
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
mockStore.gpuTextureWidth = 0
|
||||
mockStore.gpuTextureHeight = 0
|
||||
mockStore.pendingGPUMaskData = null
|
||||
mockStore.pendingGPURgbData = null
|
||||
})
|
||||
|
||||
describe('rotateClockwise', () => {
|
||||
it('should rotate canvas 90 degrees clockwise', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(50)
|
||||
expect(mockMaskCanvas.height).toBe(100)
|
||||
expect(mockRgbCanvas.width).toBe(50)
|
||||
expect(mockRgbCanvas.height).toBe(100)
|
||||
expect(mockImgCanvas.width).toBe(50)
|
||||
expect(mockImgCanvas.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should call getImageData with original dimensions', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
expect(mockImgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
})
|
||||
|
||||
it('should call putImageData with rotated data', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockImgCtx.putImageData).toHaveBeenCalled()
|
||||
|
||||
const maskCall = mockMaskCtx.putImageData.mock.calls[0][0]
|
||||
expect(maskCall.width).toBe(50)
|
||||
expect(maskCall.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should save transformed state to history', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
|
||||
const savedArgs = mockCanvasHistory.saveState.mock.calls[0]
|
||||
expect(savedArgs).toHaveLength(3)
|
||||
|
||||
expect(savedArgs[0].width).toBe(50)
|
||||
expect(savedArgs[0].height).toBe(100)
|
||||
expect(savedArgs[1].width).toBe(50)
|
||||
expect(savedArgs[1].height).toBe(100)
|
||||
expect(savedArgs[2].width).toBe(50)
|
||||
expect(savedArgs[2].height).toBe(100)
|
||||
})
|
||||
|
||||
it('should log error when canvas contexts not ready', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[useCanvasTransform] Canvas contexts not ready'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(50)
|
||||
expect(mockStore.gpuTextureHeight).toBe(100)
|
||||
})
|
||||
|
||||
it('should not recreate GPU textures when GPU is inactive', async () => {
|
||||
mockStore.tgpuRoot = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly rotate pixels clockwise at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After clockwise rotation:
|
||||
// New TL should be old BL (Blue)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(0) // G
|
||||
expect(result.data[2]).toBe(255) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old TL (Red)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(0) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old BR (Yellow)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(255) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old TR (Green)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(255) // G
|
||||
expect(result.data[14]).toBe(0) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateCounterclockwise', () => {
|
||||
it('should rotate canvas 90 degrees counterclockwise', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(50)
|
||||
expect(mockMaskCanvas.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should call getImageData with original dimensions', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
})
|
||||
|
||||
it('should correctly rotate pixels counterclockwise at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After counterclockwise rotation:
|
||||
// New TL should be old TR (Green)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(255) // G
|
||||
expect(result.data[2]).toBe(0) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old BR (Yellow)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(255) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old TL (Red)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(0) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old BL (Blue)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(0) // G
|
||||
expect(result.data[14]).toBe(255) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
|
||||
it('should produce different result than clockwise rotation', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
|
||||
const createAsymmetricImageData = (width: number, height: number) => {
|
||||
const data = new Uint8ClampedArray(width * height * 4)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4
|
||||
if (x < width / 2 && y < height / 2) {
|
||||
data[i] = 255
|
||||
data[i + 3] = 255
|
||||
} else {
|
||||
data[i + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width, height } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
|
||||
await transform.rotateCounterclockwise()
|
||||
const ccwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
mockMaskCanvas.width = 100
|
||||
mockMaskCanvas.height = 50
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
|
||||
await transform.rotateClockwise()
|
||||
const cwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
let pixelDifferences = 0
|
||||
for (let i = 0; i < ccwResult.data.length; i++) {
|
||||
if (ccwResult.data[i] !== cwResult.data[i]) {
|
||||
pixelDifferences++
|
||||
}
|
||||
}
|
||||
|
||||
expect(pixelDifferences).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirrorHorizontal', () => {
|
||||
it('should mirror canvas horizontally', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(100)
|
||||
expect(mockMaskCanvas.height).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(100)
|
||||
expect(mockStore.gpuTextureHeight).toBe(50)
|
||||
})
|
||||
|
||||
it('should correctly flip pixels horizontally at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After horizontal flip:
|
||||
// New TL should be old TR (Green)
|
||||
expect(result.data[0]).toBe(0)
|
||||
expect(result.data[1]).toBe(255)
|
||||
// New TR should be old TL (Red)
|
||||
expect(result.data[4]).toBe(255)
|
||||
expect(result.data[5]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirrorVertical', () => {
|
||||
it('should mirror canvas vertically', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(100)
|
||||
expect(mockMaskCanvas.height).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(100)
|
||||
expect(mockStore.gpuTextureHeight).toBe(50)
|
||||
})
|
||||
|
||||
it('should correctly flip pixels vertically at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After vertical flip:
|
||||
// New TL should be old BL (Blue)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(0) // G
|
||||
expect(result.data[2]).toBe(255) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old BR (Yellow)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(255) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old TL (Red)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(0) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old TR (Green)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(255) // G
|
||||
expect(result.data[14]).toBe(0) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
|
||||
it('should log error when canvas contexts not ready', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[useCanvasTransform] Canvas contexts not ready'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPU integration', () => {
|
||||
it('should set GPU recreation flags for rotation', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
mockMaskCanvas.width = 100
|
||||
mockMaskCanvas.height = 50
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(50)
|
||||
expect(mockStore.gpuTextureHeight).toBe(100)
|
||||
expect(mockStore.pendingGPUMaskData!.length).toBe(50 * 100 * 4)
|
||||
expect(mockStore.pendingGPURgbData!.length).toBe(50 * 100 * 4)
|
||||
})
|
||||
|
||||
it('should premultiply alpha when preparing GPU data', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
mockMaskCanvas.width = 1
|
||||
mockMaskCanvas.height = 1
|
||||
|
||||
// Create 1x1 ImageData with semi-transparent pixel
|
||||
const createSemiTransparentImageData = () => {
|
||||
const data = new Uint8ClampedArray(1 * 1 * 4)
|
||||
data[0] = 200 // R
|
||||
data[1] = 100 // G
|
||||
data[2] = 50 // B
|
||||
data[3] = 128 // A (50% opacity)
|
||||
return { data, width: 1, height: 1 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
mockRgbCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
mockImgCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
// Verify pendingGPUMaskData contains premultiplied values
|
||||
expect(mockStore.pendingGPUMaskData).not.toBeNull()
|
||||
const maskData = mockStore.pendingGPUMaskData!
|
||||
|
||||
// Expected premultiplied values: RGB * alpha / 255
|
||||
// R: 200 * 128 / 255 ≈ 100
|
||||
// G: 100 * 128 / 255 ≈ 50
|
||||
// B: 50 * 128 / 255 ≈ 25
|
||||
// A: 128 (preserved)
|
||||
expect(maskData[0]).toBeCloseTo(100, 0) // R premultiplied
|
||||
expect(maskData[1]).toBeCloseTo(50, 0) // G premultiplied
|
||||
expect(maskData[2]).toBeCloseTo(25, 0) // B premultiplied
|
||||
expect(maskData[3]).toBe(128) // A preserved
|
||||
|
||||
// Also verify RGB canvas data
|
||||
expect(mockStore.pendingGPURgbData).not.toBeNull()
|
||||
const rgbData = mockStore.pendingGPURgbData!
|
||||
expect(rgbData[0]).toBeCloseTo(100, 0)
|
||||
expect(rgbData[1]).toBeCloseTo(50, 0)
|
||||
expect(rgbData[2]).toBeCloseTo(25, 0)
|
||||
expect(rgbData[3]).toBe(128)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,359 +0,0 @@
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
/**
|
||||
* Composable for canvas transformation operations (rotate, mirror)
|
||||
*/
|
||||
export function useCanvasTransform() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
/**
|
||||
* Rotates a canvas 90 degrees clockwise or counter-clockwise
|
||||
*/
|
||||
const rotateCanvas = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
clockwise: boolean
|
||||
): ImageData => {
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
// Get current canvas data
|
||||
const sourceData = ctx.getImageData(0, 0, width, height)
|
||||
|
||||
// Create new ImageData with swapped dimensions
|
||||
const rotatedData = new ImageData(height, width)
|
||||
const src = sourceData.data
|
||||
const dst = rotatedData.data
|
||||
|
||||
// Rotate pixel by pixel
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = (y * width + x) * 4
|
||||
|
||||
// Calculate destination coordinates
|
||||
let dstX: number, dstY: number
|
||||
if (clockwise) {
|
||||
// Rotate 90° clockwise: (x,y) -> (height-1-y, x)
|
||||
dstX = height - 1 - y
|
||||
dstY = x
|
||||
} else {
|
||||
// Rotate 90° counter-clockwise: (x,y) -> (y, width-1-x)
|
||||
dstX = y
|
||||
dstY = width - 1 - x
|
||||
}
|
||||
|
||||
const dstIdx = (dstY * height + dstX) * 4
|
||||
|
||||
// Copy RGBA values
|
||||
dst[dstIdx] = src[srcIdx]
|
||||
dst[dstIdx + 1] = src[srcIdx + 1]
|
||||
dst[dstIdx + 2] = src[srcIdx + 2]
|
||||
dst[dstIdx + 3] = src[srcIdx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
return rotatedData
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a canvas horizontally or vertically
|
||||
*/
|
||||
const mirrorCanvas = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
horizontal: boolean
|
||||
): ImageData => {
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
// Get current canvas data
|
||||
const sourceData = ctx.getImageData(0, 0, width, height)
|
||||
const mirroredData = new ImageData(width, height)
|
||||
const src = sourceData.data
|
||||
const dst = mirroredData.data
|
||||
|
||||
// Mirror pixel by pixel
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = (y * width + x) * 4
|
||||
|
||||
// Calculate destination coordinates
|
||||
let dstX: number, dstY: number
|
||||
if (horizontal) {
|
||||
// Mirror horizontally: flip X axis
|
||||
dstX = width - 1 - x
|
||||
dstY = y
|
||||
} else {
|
||||
// Mirror vertically: flip Y axis
|
||||
dstX = x
|
||||
dstY = height - 1 - y
|
||||
}
|
||||
|
||||
const dstIdx = (dstY * width + dstX) * 4
|
||||
|
||||
// Copy RGBA values
|
||||
dst[dstIdx] = src[srcIdx]
|
||||
dst[dstIdx + 1] = src[srcIdx + 1]
|
||||
dst[dstIdx + 2] = src[srcIdx + 2]
|
||||
dst[dstIdx + 3] = src[srcIdx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
return mirroredData
|
||||
}
|
||||
|
||||
/**
|
||||
* Premultiplies alpha for GPU upload
|
||||
*/
|
||||
const premultiplyData = (data: Uint8ClampedArray): void => {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3] / 255
|
||||
data[i] = Math.round(data[i] * a)
|
||||
data[i + 1] = Math.round(data[i + 1] * a)
|
||||
data[i + 2] = Math.round(data[i + 2] * a)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates and updates GPU textures after transformation
|
||||
* This is required because GPU textures have immutable dimensions
|
||||
*/
|
||||
const recreateGPUTextures = async (
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> => {
|
||||
if (
|
||||
!store.tgpuRoot ||
|
||||
!store.maskCanvas ||
|
||||
!store.rgbCanvas ||
|
||||
!store.maskCtx ||
|
||||
!store.rgbCtx
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get references to GPU resources from useBrushDrawing
|
||||
// These are stored as module-level variables in useBrushDrawing
|
||||
// We need to trigger a reinitialization through the store
|
||||
|
||||
// Signal to useBrushDrawing that textures need recreation
|
||||
store.gpuTexturesNeedRecreation = true
|
||||
store.gpuTextureWidth = width
|
||||
store.gpuTextureHeight = height
|
||||
|
||||
// Get current canvas data
|
||||
const maskImageData = store.maskCtx.getImageData(0, 0, width, height)
|
||||
const rgbImageData = store.rgbCtx.getImageData(0, 0, width, height)
|
||||
|
||||
// Create new Uint8ClampedArray with ArrayBuffer (not SharedArrayBuffer)
|
||||
// This ensures compatibility with WebGPU writeTexture
|
||||
const maskData = new Uint8ClampedArray(
|
||||
new ArrayBuffer(maskImageData.data.length)
|
||||
)
|
||||
const rgbData = new Uint8ClampedArray(
|
||||
new ArrayBuffer(rgbImageData.data.length)
|
||||
)
|
||||
|
||||
// Copy data
|
||||
maskData.set(maskImageData.data)
|
||||
rgbData.set(rgbImageData.data)
|
||||
|
||||
// Runtime check to ensure we have ArrayBuffer backing
|
||||
if (
|
||||
maskData.buffer instanceof SharedArrayBuffer ||
|
||||
rgbData.buffer instanceof SharedArrayBuffer
|
||||
) {
|
||||
console.error(
|
||||
'[useCanvasTransform] SharedArrayBuffer detected, WebGPU writeTexture will fail'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Premultiply alpha for GPU
|
||||
premultiplyData(maskData)
|
||||
premultiplyData(rgbData)
|
||||
|
||||
// Store the premultiplied data for useBrushDrawing to pick up
|
||||
store.pendingGPUMaskData = maskData
|
||||
store.pendingGPURgbData = rgbData
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates all canvas layers 90 degrees clockwise and updates GPU
|
||||
*/
|
||||
const rotateClockwise = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Store original dimensions
|
||||
const origWidth = maskCanvas.width
|
||||
const origHeight = maskCanvas.height
|
||||
|
||||
// Rotate all three layers clockwise
|
||||
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, true)
|
||||
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, true)
|
||||
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, true)
|
||||
|
||||
// Update canvas dimensions (swap width/height)
|
||||
maskCanvas.width = origHeight
|
||||
maskCanvas.height = origWidth
|
||||
rgbCanvas.width = origHeight
|
||||
rgbCanvas.height = origWidth
|
||||
imgCanvas.width = origHeight
|
||||
imgCanvas.height = origWidth
|
||||
|
||||
// Apply rotated data
|
||||
maskCtx.putImageData(rotatedMask, 0, 0)
|
||||
rgbCtx.putImageData(rotatedRgb, 0, 0)
|
||||
imgCtx.putImageData(rotatedImg, 0, 0)
|
||||
|
||||
// Recreate GPU textures with new dimensions if GPU is active
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(origHeight, origWidth)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates all canvas layers 90 degrees counter-clockwise and updates GPU
|
||||
*/
|
||||
const rotateCounterclockwise = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Store original dimensions
|
||||
const origWidth = maskCanvas.width
|
||||
const origHeight = maskCanvas.height
|
||||
|
||||
// Rotate all three layers counter-clockwise
|
||||
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, false)
|
||||
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, false)
|
||||
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, false)
|
||||
|
||||
// Update canvas dimensions (swap width/height)
|
||||
maskCanvas.width = origHeight
|
||||
maskCanvas.height = origWidth
|
||||
rgbCanvas.width = origHeight
|
||||
rgbCanvas.height = origWidth
|
||||
imgCanvas.width = origHeight
|
||||
imgCanvas.height = origWidth
|
||||
|
||||
// Apply rotated data
|
||||
maskCtx.putImageData(rotatedMask, 0, 0)
|
||||
rgbCtx.putImageData(rotatedRgb, 0, 0)
|
||||
imgCtx.putImageData(rotatedImg, 0, 0)
|
||||
|
||||
// Recreate GPU textures with new dimensions if GPU is active
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(origHeight, origWidth)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors all canvas layers horizontally and updates GPU
|
||||
*/
|
||||
const mirrorHorizontal = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror all three layers horizontally
|
||||
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, true)
|
||||
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, true)
|
||||
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, true)
|
||||
|
||||
// Apply mirrored data (dimensions stay the same)
|
||||
maskCtx.putImageData(mirroredMask, 0, 0)
|
||||
rgbCtx.putImageData(mirroredRgb, 0, 0)
|
||||
imgCtx.putImageData(mirroredImg, 0, 0)
|
||||
|
||||
// Update GPU textures if GPU is active (dimensions unchanged, just data)
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors all canvas layers vertically and updates GPU
|
||||
*/
|
||||
const mirrorVertical = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror all three layers vertically
|
||||
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, false)
|
||||
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, false)
|
||||
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, false)
|
||||
|
||||
// Apply mirrored data (dimensions stay the same)
|
||||
maskCtx.putImageData(mirroredMask, 0, 0)
|
||||
rgbCtx.putImageData(mirroredRgb, 0, 0)
|
||||
imgCtx.putImageData(mirroredImg, 0, 0)
|
||||
|
||||
// Update GPU textures if GPU is active (dimensions unchanged, just data)
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
|
||||
}
|
||||
|
||||
return {
|
||||
rotateClockwise,
|
||||
rotateCounterclockwise,
|
||||
mirrorHorizontal,
|
||||
mirrorVertical
|
||||
}
|
||||
}
|
||||
@@ -1664,41 +1664,31 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'gemini-2.5-pro-preview-05-06',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-pro',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-3-pro-preview',
|
||||
expected: creditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
expected: creditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
expected: creditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{ model: 'unknown-gemini-model', expected: 'Token-based' }
|
||||
@@ -1712,6 +1702,16 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return per-second pricing for Gemini Veo models', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('GeminiNode', [
|
||||
{ name: 'model', value: 'veo-2.0-generate-001' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
|
||||
})
|
||||
|
||||
it('should return fallback for GeminiNode without model widget', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('GeminiNode', [])
|
||||
@@ -1737,97 +1737,73 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'o4-mini',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-pro',
|
||||
expected: creditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1',
|
||||
expected: creditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3-mini',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3',
|
||||
expected: creditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
expected: creditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-nano',
|
||||
expected: creditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-mini',
|
||||
expected: creditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1',
|
||||
expected: creditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5-nano',
|
||||
expected: creditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5-mini',
|
||||
expected: creditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -1848,49 +1824,37 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'gpt-4.1-nano-test',
|
||||
expected: creditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-mini-test',
|
||||
expected: creditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-test',
|
||||
expected: creditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-pro-test',
|
||||
expected: creditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-test',
|
||||
expected: creditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3-mini-test',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
},
|
||||
{ model: 'unknown-model', expected: 'Token-based' }
|
||||
|
||||
@@ -2,17 +2,6 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Meshy credit pricing constant.
|
||||
* 1 Meshy credit = $0.04 USD
|
||||
* Change this value to update all Meshy node prices.
|
||||
*/
|
||||
const MESHY_CREDIT_PRICE_USD = 0.04
|
||||
|
||||
/** Convert Meshy credits to USD */
|
||||
const meshyCreditsToUsd = (credits: number): number =>
|
||||
credits * MESHY_CREDIT_PRICE_USD
|
||||
|
||||
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
@@ -220,24 +209,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget | undefined
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const generateAudio =
|
||||
generateAudioWidget &&
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
const priceByModel: Record<string, Record<string, [number, number]>> = {
|
||||
'seedance-1-5-pro': {
|
||||
'480p': [0.12, 0.12],
|
||||
'720p': [0.26, 0.26],
|
||||
'1080p': [0.58, 0.59]
|
||||
},
|
||||
'seedance-1-0-pro': {
|
||||
'480p': [0.23, 0.24],
|
||||
'720p': [0.51, 0.56],
|
||||
@@ -255,15 +233,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-5-pro')
|
||||
? 'seedance-1-5-pro'
|
||||
: model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -279,10 +255,8 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
|
||||
const [min10s, max10s] = baseRange
|
||||
const scale = seconds / 10
|
||||
const audioMultiplier =
|
||||
modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1
|
||||
const minCost = min10s * scale * audioMultiplier
|
||||
const maxCost = max10s * scale * audioMultiplier
|
||||
const minCost = min10s * scale
|
||||
const maxCost = max10s * scale
|
||||
|
||||
if (minCost === maxCost) return formatCreditsLabel(minCost)
|
||||
return formatCreditsRangeLabel(minCost, maxCost)
|
||||
@@ -551,54 +525,6 @@ const calculateTripo3DGenerationPrice = (
|
||||
return formatCreditsLabel(dollars)
|
||||
}
|
||||
|
||||
/**
|
||||
* Meshy Image to 3D pricing calculator.
|
||||
* Pricing based on should_texture widget:
|
||||
* - Without texture: 20 credits
|
||||
* - With texture: 30 credits
|
||||
*/
|
||||
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
|
||||
const shouldTextureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'should_texture'
|
||||
) as IComboWidget
|
||||
|
||||
if (!shouldTextureWidget) {
|
||||
return formatCreditsRangeLabel(
|
||||
meshyCreditsToUsd(20),
|
||||
meshyCreditsToUsd(30),
|
||||
{ note: '(varies with texture)' }
|
||||
)
|
||||
}
|
||||
|
||||
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||
const credits = shouldTexture === 'true' ? 30 : 20
|
||||
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||
}
|
||||
|
||||
/**
|
||||
* Meshy Multi-Image to 3D pricing calculator.
|
||||
* Pricing based on should_texture widget:
|
||||
* - Without texture: 5 credits
|
||||
* - With texture: 15 credits
|
||||
*/
|
||||
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
|
||||
const shouldTextureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'should_texture'
|
||||
) as IComboWidget
|
||||
|
||||
if (!shouldTextureWidget) {
|
||||
return formatCreditsRangeLabel(
|
||||
meshyCreditsToUsd(5),
|
||||
meshyCreditsToUsd(15),
|
||||
{ note: '(varies with texture)' }
|
||||
)
|
||||
}
|
||||
|
||||
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||
const credits = shouldTexture === 'true' ? 15 : 5
|
||||
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pricing data for API nodes, now supporting both strings and functions
|
||||
*/
|
||||
@@ -1886,27 +1812,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
TripoRefineNode: {
|
||||
displayPrice: formatCreditsLabel(0.3)
|
||||
},
|
||||
MeshyTextToModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
|
||||
},
|
||||
MeshyRefineNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||
},
|
||||
MeshyImageToModelNode: {
|
||||
displayPrice: calculateMeshyImageToModelPrice
|
||||
},
|
||||
MeshyMultiImageToModelNode: {
|
||||
displayPrice: calculateMeshyMultiImageToModelPrice
|
||||
},
|
||||
MeshyRigModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
|
||||
},
|
||||
MeshyAnimateModelNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
|
||||
},
|
||||
MeshyTextureNode: {
|
||||
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||
},
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
@@ -1918,35 +1823,28 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return formatCreditsLabel(0.5, { suffix: '/second' })
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return formatCreditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -2001,75 +1899,51 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||
if (model.includes('o4-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o1-pro')) {
|
||||
return formatCreditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o1')) {
|
||||
return formatCreditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o3-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o3')) {
|
||||
return formatCreditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4o')) {
|
||||
return formatCreditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-nano')) {
|
||||
return formatCreditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-mini')) {
|
||||
return formatCreditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return formatCreditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return formatCreditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return formatCreditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
}
|
||||
return 'Token-based'
|
||||
@@ -2227,267 +2101,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
LtxvApiImageToVideo: {
|
||||
displayPrice: ltxvPricingCalculator
|
||||
},
|
||||
WanReferenceVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const sizeWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !sizeWidget) {
|
||||
return formatCreditsRangeLabel(0.7, 1.5, {
|
||||
note: '(varies with size & duration)'
|
||||
})
|
||||
}
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const sizeStr = String(sizeWidget.value).toLowerCase()
|
||||
|
||||
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
|
||||
const inputMin = 2 * rate
|
||||
const inputMax = 5 * rate
|
||||
const outputPrice = seconds * rate
|
||||
|
||||
const minTotal = inputMin + outputPrice
|
||||
const maxTotal = inputMax + outputPrice
|
||||
|
||||
return formatCreditsRangeLabel(minTotal, maxTotal)
|
||||
}
|
||||
},
|
||||
Vidu2TextToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
// Text-to-Video uses Q2 model only
|
||||
// 720P: Starts at $0.075, +$0.025/sec
|
||||
// 1080P: Starts at $0.10, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (resolution.includes('1080')) {
|
||||
basePrice = 0.1
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.075
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ReferenceVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const audioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) {
|
||||
return formatCreditsRangeLabel(0.125, 1.5, {
|
||||
note: '(varies with duration, resolution & audio)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
// Check if audio is enabled (adds $0.75)
|
||||
const audioValue = audioWidget?.value
|
||||
const hasAudio =
|
||||
audioValue !== undefined &&
|
||||
audioValue !== null &&
|
||||
String(audioValue).toLowerCase() !== 'false' &&
|
||||
String(audioValue).toLowerCase() !== 'none' &&
|
||||
audioValue !== ''
|
||||
|
||||
// Reference-to-Video uses Q2 model
|
||||
// 720P: Starts at $0.125, +$0.025/sec
|
||||
// 1080P: Starts at $0.375, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (is1080p) {
|
||||
basePrice = 0.375
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.125
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
let cost = basePrice
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
cost = basePrice + pricePerSecond * (duration - 1)
|
||||
}
|
||||
|
||||
// Audio adds $0.75 on top
|
||||
if (hasAudio) {
|
||||
cost += 0.075
|
||||
}
|
||||
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2StartEndToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (!Number.isFinite(duration) || duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsLabel(basePrice)
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2622,9 +2235,6 @@ export const useNodePricing = () => {
|
||||
'animate_in_place'
|
||||
],
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Meshy nodes
|
||||
MeshyImageToModelNode: ['should_texture'],
|
||||
MeshyMultiImageToModelNode: ['should_texture'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
@@ -2638,34 +2248,14 @@ export const useNodePricing = () => {
|
||||
'sequential_image_generation',
|
||||
'max_images'
|
||||
],
|
||||
ByteDanceTextToVideoNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceImageToVideoNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceFirstLastFrameNode: [
|
||||
'model',
|
||||
'duration',
|
||||
'resolution',
|
||||
'generate_audio'
|
||||
],
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution'],
|
||||
WanReferenceVideoApi: ['duration', 'size'],
|
||||
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
|
||||
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
|
||||
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
export type JobAction = {
|
||||
icon: string
|
||||
label: string
|
||||
variant: 'destructive' | 'secondary' | 'textonly'
|
||||
}
|
||||
|
||||
export function useJobActions(
|
||||
job: MaybeRefOrGetter<JobListItem | null | undefined>
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { cancelJob } = useJobMenu()
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
|
||||
|
||||
const jobRef = computed(() => toValue(job) ?? null)
|
||||
|
||||
const canCancelJob = computed(() => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
currentJob.showClear !== false &&
|
||||
cancellableStates.includes(currentJob.state)
|
||||
)
|
||||
})
|
||||
|
||||
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return
|
||||
}
|
||||
|
||||
await cancelJob(currentJob)
|
||||
})
|
||||
|
||||
return {
|
||||
cancelAction,
|
||||
canCancelJob,
|
||||
runCancelJob
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export type MenuEntry =
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null = () => null,
|
||||
currentMenuItem: () => JobListItem | null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -52,40 +52,37 @@ export function useJobMenu(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
|
||||
item ?? currentMenuItem()
|
||||
|
||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
const openJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
const filename = `Job ${target.id}.json`
|
||||
const filename = `Job ${item.id}.json`
|
||||
const temp = workflowStore.createTemporary(filename, data)
|
||||
await workflowService.openWorkflow(temp)
|
||||
}
|
||||
|
||||
const copyJobId = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
await copyToClipboard(target.id)
|
||||
const copyJobId = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
await copyToClipboard(item.id)
|
||||
}
|
||||
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
await api.interrupt(target.id)
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
const cancelJob = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
await api.interrupt(item.id)
|
||||
} else if (item.state === 'pending') {
|
||||
await api.deleteItem('queue', item.id)
|
||||
}
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
const copyErrorMessage = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const msgs = target.taskRef?.status?.messages as any[] | undefined
|
||||
const copyErrorMessage = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
@@ -93,10 +90,10 @@ export function useJobMenu(
|
||||
if (message) await copyToClipboard(String(message))
|
||||
}
|
||||
|
||||
const reportError = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const msgs = target.taskRef?.status?.messages as any[] | undefined
|
||||
const reportError = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
@@ -105,10 +102,10 @@ export function useJobMenu(
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -156,10 +153,10 @@ export function useJobMenu(
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -167,14 +164,14 @@ export function useJobMenu(
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${target.id}.json`
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -191,10 +188,10 @@ export function useJobMenu(
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -205,8 +202,8 @@ export function useJobMenu(
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async (item?: JobListItem | null) => {
|
||||
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
@@ -237,8 +234,8 @@ export function useJobMenu(
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const current = resolveItem()
|
||||
if (current) onInspectAsset(current)
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -249,33 +246,33 @@ export function useJobMenu(
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: () => addOutputLoaderNode(resolveItem())
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: () => downloadPreviewAsset(resolveItem())
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: () => exportJobWorkflow(resolveItem())
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasDeletableAsset
|
||||
@@ -284,7 +281,7 @@ export function useJobMenu(
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: () => deleteJobAsset(resolveItem())
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -296,33 +293,33 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyErrorMessage(resolveItem())
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: () => reportError(resolveItem())
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: () => removeFailedJob(resolveItem())
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -331,21 +328,21 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: () => cancelJob(resolveItem())
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -64,9 +64,6 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
@@ -85,9 +82,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const maskEditorStore = useMaskEditorStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -188,26 +182,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.RenameWorkflow',
|
||||
icon: 'pi pi-pencil',
|
||||
label: 'Rename Workflow',
|
||||
menubarLabel: 'Rename',
|
||||
function: async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow || !workflow.isPersisted) return
|
||||
|
||||
const newName = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: workflow.filename
|
||||
})
|
||||
if (!newName || newName === workflow.filename) return
|
||||
|
||||
const newPath = workflow.directory + '/' + newName + '.json'
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ExportWorkflow',
|
||||
icon: 'pi pi-download',
|
||||
@@ -233,12 +207,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
// If Mask Editor is open, use its history instead of the graph
|
||||
if (dialogStore.isDialogOpen('global-mask-editor')) {
|
||||
maskEditorStore.canvasHistory.undo()
|
||||
} else {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -247,11 +216,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
if (dialogStore.isDialogOpen('global-mask-editor')) {
|
||||
maskEditorStore.canvasHistory.redo()
|
||||
} else {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -940,6 +905,15 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.User.OpenSignInDialog',
|
||||
icon: 'pi pi-user',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -17,8 +16,7 @@ export enum ServerFeatureFlag {
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,14 +85,6 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Use conflict acknowledgment state from composable - call only once
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
})
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle What's New popup dismissal
|
||||
* Check if conflict modal should be shown after ComfyUI update
|
||||
*/
|
||||
const handleWhatsNewDismissed = async () => {
|
||||
try {
|
||||
// Check if conflict modal should be shown after update
|
||||
const shouldShow =
|
||||
await conflictDetection.shouldShowConflictModalAfterUpdate()
|
||||
if (shouldShow) {
|
||||
showConflictModal()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Error checking conflict modal:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the node conflict dialog with current conflict data
|
||||
*/
|
||||
const showConflictModal = () => {
|
||||
showNodeConflictDialog({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
LGraphCanvas,
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isImageNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, usePaste } from './usePaste'
|
||||
|
||||
function createMockNode() {
|
||||
return {
|
||||
pos: [0, 0],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createImageFile(
|
||||
name: string = 'test.png',
|
||||
type: string = 'image/png'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createAudioFile(
|
||||
name: string = 'test.mp3',
|
||||
type: string = 'audio/mpeg'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createDataTransfer(files: File[] = []): DataTransfer {
|
||||
const dataTransfer = new DataTransfer()
|
||||
files.forEach((file) => dataTransfer.items.add(file))
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
const mockCanvas = {
|
||||
current_node: null as LGraphNode | null,
|
||||
graph: {
|
||||
add: vi.fn(),
|
||||
change: vi.fn()
|
||||
} as Partial<LGraph> as LGraph,
|
||||
graph_mouse: [100, 200],
|
||||
pasteFromClipboard: vi.fn(),
|
||||
_deserializeItems: vi.fn()
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
const mockCanvasStore = {
|
||||
canvas: mockCanvas,
|
||||
getCanvas: vi.fn(() => mockCanvas)
|
||||
}
|
||||
|
||||
const mockWorkspaceStore = {
|
||||
shiftDown: false
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn((target, event, handler) => {
|
||||
target.addEventListener(event, handler)
|
||||
return () => target.removeEventListener(event, handler)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => mockWorkspaceStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
createNode: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isAudioNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn()
|
||||
}))
|
||||
|
||||
describe('pasteImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should create new LoadImage node when no image node provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
|
||||
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should handle multiple image files', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = createDataTransfer([file1, file2])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
|
||||
})
|
||||
|
||||
it('should do nothing when no image files present', () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter non-image items', () => {
|
||||
const mockNode = createMockNode()
|
||||
const imageFile = createImageFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, imageFile])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle image paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle audio paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle workflow JSON paste', async () => {
|
||||
const workflow = { version: '1.0', nodes: [], extra: {} }
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', JSON.stringify(workflow))
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore paste when shift is down', () => {
|
||||
mockWorkspaceStore.shiftDown = true
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
const mockNode = {
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
} as unknown as Partial<LGraphNode> as LGraphNode
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isImageNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call canvas pasteFromClipboard for non-workflow text', () => {
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'just some text')
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle clipboard items with metadata', async () => {
|
||||
const data = { test: 'data' }
|
||||
const encoded = btoa(JSON.stringify(data))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
|
||||
data,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||