Merge branch 'main' into feat/new-workflow-templates
6
.gitattributes
vendored
@@ -2,9 +2,13 @@
|
||||
* text=auto
|
||||
|
||||
# Force TS to LF to make the unixy scripts not break on Windows
|
||||
*.cjs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.mts text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.vue text eol=lf
|
||||
*.js text eol=lf
|
||||
|
||||
# Generated files
|
||||
src/types/comfyRegistryTypes.ts linguist-generated=true
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Bug Report
|
||||
description: 'Report something that is not working correctly'
|
||||
title: '[Bug]: '
|
||||
labels: ['Potential Bug']
|
||||
type: Bug
|
||||
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Feature Request
|
||||
description: Report a problem or limitation you're experiencing
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
labels: []
|
||||
type: Feature
|
||||
|
||||
body:
|
||||
|
||||
20
.github/workflows/chromatic.yaml
vendored
@@ -3,14 +3,15 @@ name: 'Chromatic'
|
||||
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
chromatic-deployment:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for PRs from version-bump-* branches or manual triggers
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
@@ -32,6 +33,7 @@ jobs:
|
||||
|
||||
- name: Comment PR - Build Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
@@ -49,6 +51,19 @@ jobs:
|
||||
---
|
||||
*This comment will be updated when the build completes*
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
storybook-static
|
||||
tsconfig.tsbuildinfo
|
||||
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
|
||||
restore-keys: |
|
||||
storybook-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
|
||||
storybook-cache-${{ runner.os }}-
|
||||
storybook-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -68,6 +83,7 @@ jobs:
|
||||
|
||||
- name: Comment PR - Build Complete
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
||||
13
.github/workflows/dev-release.yaml
vendored
@@ -19,6 +19,19 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
dist
|
||||
tsconfig.tsbuildinfo
|
||||
key: dev-release-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
dev-release-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
10
.github/workflows/i18n.yaml
vendored
@@ -17,6 +17,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.3
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
ComfyUI_frontend/.cache
|
||||
ComfyUI_frontend/.cache
|
||||
key: i18n-tools-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
i18n-tools-cache-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
17
.github/workflows/lint-and-format.yaml
vendored
@@ -25,6 +25,21 @@ jobs:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
.eslintcache
|
||||
tsconfig.tsbuildinfo
|
||||
.prettierCache
|
||||
.knip-cache
|
||||
key: lint-format-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**/*.{ts,vue,js,mts}', '*.config.*', '.eslintrc.*', '.prettierrc.*', 'tsconfig.json') }}
|
||||
restore-keys: |
|
||||
lint-format-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
|
||||
lint-format-cache-${{ runner.os }}-
|
||||
ci-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -60,6 +75,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
@@ -72,6 +88,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
25
.github/workflows/release.yaml
vendored
@@ -22,6 +22,18 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
tsconfig.tsbuildinfo
|
||||
key: release-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
release-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -116,7 +128,20 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
tsconfig.tsbuildinfo
|
||||
dist
|
||||
key: types-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
types-tools-cache-${{ runner.os }}-
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build:types
|
||||
- name: Publish package
|
||||
|
||||
66
.github/workflows/test-ui.yaml
vendored
@@ -40,6 +40,8 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/package-lock.json'
|
||||
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
@@ -47,6 +49,7 @@ jobs:
|
||||
|
||||
- name: Comment PR - Tests Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
@@ -64,6 +67,18 @@ jobs:
|
||||
---
|
||||
*This comment will be updated when tests complete*
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
ComfyUI_frontend/.cache
|
||||
ComfyUI_frontend/tsconfig.tsbuildinfo
|
||||
key: playwright-setup-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/package-lock.json') }}-${{ hashFiles('ComfyUI_frontend/src/**/*.{ts,vue,js}', 'ComfyUI_frontend/*.config.*') }}
|
||||
restore-keys: |
|
||||
playwright-setup-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/package-lock.json') }}-
|
||||
playwright-setup-cache-${{ runner.os }}-
|
||||
playwright-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build ComfyUI_frontend
|
||||
run: |
|
||||
npm ci
|
||||
@@ -117,6 +132,7 @@ jobs:
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
@@ -134,6 +150,7 @@ jobs:
|
||||
|
||||
- name: Comment PR - Browser Test Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
@@ -158,10 +175,22 @@ jobs:
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/package-lock.json') }}-${{ matrix.browser }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/package-lock.json') }}-
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Install Wrangler
|
||||
run: npm install -g wrangler
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
|
||||
@@ -177,11 +206,36 @@ jobs:
|
||||
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
|
||||
id: cloudflare-deploy
|
||||
if: always()
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Retry logic for wrangler deploy (3 attempts)
|
||||
RETRY_COUNT=0
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
if npx wrangler pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
|
||||
SUCCESS=true
|
||||
echo "Deployment successful on attempt $RETRY_COUNT"
|
||||
else
|
||||
echo "Deployment failed on attempt $RETRY_COUNT"
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS = false ]; then
|
||||
echo "All deployment attempts failed"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
- name: Save deployment info for summary
|
||||
if: always()
|
||||
@@ -211,6 +265,7 @@ jobs:
|
||||
|
||||
- name: Comment PR - Browser Test Complete
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
@@ -296,6 +351,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Comment PR - Tests Complete
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
||||
9
.github/workflows/update-electron-types.yaml
vendored
@@ -20,6 +20,15 @@ jobs:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
key: electron-types-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
electron-types-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Update electron types
|
||||
run: npm install @comfyorg/comfyui-electron-types@latest
|
||||
|
||||
|
||||
17
.github/workflows/update-manager-types.yaml
vendored
@@ -25,9 +25,26 @@ jobs:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
key: update-manager-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
update-manager-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache ComfyUI-Manager repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ComfyUI-Manager
|
||||
key: comfyui-manager-repo-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
comfyui-manager-repo-${{ runner.os }}-
|
||||
|
||||
- name: Checkout ComfyUI-Manager repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
17
.github/workflows/update-registry-types.yaml
vendored
@@ -24,9 +24,26 @@ jobs:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
key: update-registry-tools-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
update-registry-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache comfy-api repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: comfy-api
|
||||
key: comfy-api-repo-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
comfy-api-repo-${{ runner.os }}-
|
||||
|
||||
- name: Checkout comfy-api repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
14
.github/workflows/vitest.yaml
vendored
@@ -17,6 +17,20 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
coverage
|
||||
.vitest-cache
|
||||
key: vitest-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**/*.{ts,vue,js}', 'vitest.config.*', 'tsconfig.json') }}
|
||||
restore-keys: |
|
||||
vitest-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
|
||||
vitest-cache-${{ runner.os }}-
|
||||
test-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
1
.gitignore
vendored
@@ -23,7 +23,6 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
.claude/settings.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -209,4 +209,22 @@ This Storybook setup includes:
|
||||
- PrimeVue component library integration
|
||||
- Proper alias resolution for `@/` imports
|
||||
|
||||
For component-specific examples, see the NodePreview stories in `src/components/node/`.
|
||||
## Icon Usage in Storybook
|
||||
|
||||
In this project, the `<i-lucide:... />` syntax from unplugin-icons is not supported in Storybook.
|
||||
|
||||
**Example:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { Trophy, Settings } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Trophy :size="16" class="text-neutral" />
|
||||
<Settings :size="16" class="text-neutral" />
|
||||
</template>
|
||||
```
|
||||
|
||||
This approach ensures icons render correctly in Storybook and remain consistent with the rest of the app.
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ The project supports three types of icons, all with automatic imports (no manual
|
||||
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
|
||||
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
|
||||
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/`.
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/` and processed by `build/customIconCollection.ts` with automatic validation.
|
||||
|
||||
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export class ComfyPage {
|
||||
public readonly url: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
public readonly widgetTextBox: Locator
|
||||
|
||||
// Buttons
|
||||
@@ -158,6 +159,7 @@ export class ComfyPage {
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.locator('.selection-toolbox')
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
|
||||
@@ -65,6 +65,7 @@ export class Topbar {
|
||||
}
|
||||
|
||||
async openTopbarMenu() {
|
||||
await this.page.waitForTimeout(1000)
|
||||
await this.page.locator('.comfyui-logo-wrapper').click()
|
||||
const menu = this.page.locator('.comfy-command-menu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
|
||||
57
browser_tests/tests/customIcons.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function verifyCustomIconSvg(iconElement: Locator) {
|
||||
const svgVariable = await iconElement.evaluate((element) => {
|
||||
const styles = getComputedStyle(element)
|
||||
return styles.getPropertyValue('--svg')
|
||||
})
|
||||
|
||||
expect(svgVariable).toBeTruthy()
|
||||
const dataUrlMatch = svgVariable.match(
|
||||
/url\("data:image\/svg\+xml,([^"]+)"\)/
|
||||
)
|
||||
expect(dataUrlMatch).toBeTruthy()
|
||||
|
||||
const encodedSvg = dataUrlMatch![1]
|
||||
const decodedSvg = decodeURIComponent(encodedSvg)
|
||||
|
||||
// Check for SVG header to confirm it's a valid SVG
|
||||
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
||||
}
|
||||
|
||||
test.describe('Custom Icons', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('sidebar tab icons use custom SVGs', async ({ comfyPage }) => {
|
||||
// Find the icon in the sidebar
|
||||
const icon = comfyPage.page.locator(
|
||||
'.icon-\\[comfy--ai-model\\].side-bar-button-icon'
|
||||
)
|
||||
await expect(icon).toBeVisible()
|
||||
|
||||
// Verify the custom SVG content
|
||||
await verifyCustomIconSvg(icon)
|
||||
})
|
||||
|
||||
test('Browse Templates menu item uses custom template icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the topbar menu
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
const menuItem = comfyPage.menu.topbar.getMenuItem('Browse Templates')
|
||||
|
||||
// Find the icon as a previous sibling of the menu item label
|
||||
const templateIcon = menuItem
|
||||
.locator('..')
|
||||
.locator('.icon-\\[comfy--template\\]')
|
||||
await expect(templateIcon).toBeVisible()
|
||||
|
||||
// Verify the custom SVG content
|
||||
await verifyCustomIconSvg(templateIcon)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 88 KiB |
@@ -7,13 +7,11 @@ test.describe('Graph Canvas Menu', () => {
|
||||
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||
// side effects.
|
||||
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
|
||||
// Enable canvas menu for all tests
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
})
|
||||
|
||||
test('Can toggle link visibility', async ({ comfyPage }) => {
|
||||
// Note: `Comfy.Graph.CanvasMenu` is disabled in comfyPage setup.
|
||||
// so no cleanup is needed.
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
|
||||
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -36,4 +34,45 @@ test.describe('Graph Canvas Menu', () => {
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
})
|
||||
|
||||
test('Focus mode button is clickable and has correct test id', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
|
||||
await expect(focusButton).toBeVisible()
|
||||
await expect(focusButton).toBeEnabled()
|
||||
|
||||
// Test that the button can be clicked without error
|
||||
await focusButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Zoom controls popup opens and closes', async ({ comfyPage }) => {
|
||||
// Find the zoom button by its percentage text content
|
||||
const zoomButton = comfyPage.page.locator('button').filter({
|
||||
hasText: '%'
|
||||
})
|
||||
await expect(zoomButton).toBeVisible()
|
||||
|
||||
// Click to open zoom controls
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Zoom controls modal should be visible
|
||||
const zoomModal = comfyPage.page
|
||||
.locator('div')
|
||||
.filter({
|
||||
hasText: 'Zoom To Fit'
|
||||
})
|
||||
.first()
|
||||
await expect(zoomModal).toBeVisible()
|
||||
|
||||
// Click backdrop to close
|
||||
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
|
||||
await backdrop.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Modal should be hidden
|
||||
await expect(zoomModal).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -780,9 +780,18 @@ test.describe('Viewport settings', () => {
|
||||
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
await toggleButton.click()
|
||||
// close zoom menu
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
@@ -35,34 +35,44 @@ test.describe('Minimap', () => {
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
await expect(toggleButton).not.toHaveClass(/minimap-active/)
|
||||
await expect(toggleButton).toContainText('Show Minimap')
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
await expect(toggleButton).toContainText('Hide Minimap')
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
|
||||
@@ -41,15 +41,12 @@ test.describe('Node Help', () => {
|
||||
// Select the node with panning to ensure toolbox is visible
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Wait for selection overlay container and toolbox to appear
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
// Wait for selection toolbox to appear
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
// Click the help button in the selection toolbox
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
const helpButton = comfyPage.selectionToolbox.locator(
|
||||
'button:has(.pi-question-circle)'
|
||||
)
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
@@ -14,20 +14,17 @@ test.describe('Selection Toolbox', () => {
|
||||
|
||||
test('shows selection toolbox', async ({ comfyPage }) => {
|
||||
// By default, selection toolbox should be enabled
|
||||
expect(
|
||||
await comfyPage.page.locator('.selection-overlay-container').isVisible()
|
||||
).toBe(false)
|
||||
await expect(comfyPage.selectionToolbox).not.toBeVisible()
|
||||
|
||||
// Select multiple nodes
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
// Selection toolbox should be visible with multiple nodes selected
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container.show-border')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-nodes-border.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('shows at correct position when node is pasted', async ({
|
||||
@@ -39,18 +36,16 @@ test.describe('Selection Toolbox', () => {
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.ctrlV()
|
||||
|
||||
const overlayContainer = comfyPage.page.locator(
|
||||
'.selection-overlay-container'
|
||||
)
|
||||
await expect(overlayContainer).toBeVisible()
|
||||
const toolboxContainer = comfyPage.selectionToolbox
|
||||
await expect(toolboxContainer).toBeVisible()
|
||||
|
||||
// Verify the absolute position
|
||||
const boundingBox = await overlayContainer.boundingBox()
|
||||
// Verify toolbox is positioned (canvas-based positioning has different coordinates)
|
||||
const boundingBox = await toolboxContainer.boundingBox()
|
||||
expect(boundingBox).not.toBeNull()
|
||||
// 10px offset for the pasted node
|
||||
expect(Math.round(boundingBox!.x)).toBeCloseTo(90, -1) // Allow ~10px tolerance
|
||||
// 30px offset of node title height
|
||||
expect(Math.round(boundingBox!.y)).toBeCloseTo(60, -1)
|
||||
// Canvas-based positioning can vary, just verify toolbox appears in reasonable bounds
|
||||
expect(boundingBox!.x).toBeGreaterThan(-100) // Not too far off-screen left
|
||||
expect(boundingBox!.x).toBeLessThan(1000) // Not too far off-screen right
|
||||
expect(boundingBox!.y).toBeGreaterThan(-100) // Not too far off-screen top
|
||||
})
|
||||
|
||||
test('hide when select and drag happen at the same time', async ({
|
||||
@@ -65,38 +60,35 @@ test.describe('Selection Toolbox', () => {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(nodePos.x + 200, nodePos.y + 200)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).not.toBeVisible()
|
||||
await expect(comfyPage.selectionToolbox).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows border only with multiple selections', async ({ comfyPage }) => {
|
||||
// Select single node
|
||||
await comfyPage.selectNodes(['KSampler'])
|
||||
|
||||
// Selection overlay should be visible but without border
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container.show-border')
|
||||
).not.toBeVisible()
|
||||
// Selection toolbox should be visible but without border
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-single-node-no-border.png'
|
||||
)
|
||||
|
||||
// Select multiple nodes
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
// Selection overlay should show border with multiple selections
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container.show-border')
|
||||
).toBeVisible()
|
||||
// Selection border should show with multiple selections (canvas-based)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-selections-border.png'
|
||||
)
|
||||
|
||||
// Deselect to single node
|
||||
await comfyPage.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
|
||||
// Border should be hidden again
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container.show-border')
|
||||
).not.toBeVisible()
|
||||
// Border should be hidden again (canvas-based)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-single-selection-no-border.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('displays bypass button in toolbox when nodes are selected', async ({
|
||||
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -193,6 +193,7 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
@@ -256,6 +256,7 @@ test.describe('Animated image widget', () => {
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
|
||||
|
||||
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 168 KiB |
100
build/customIconCollection.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const fileName = fileURLToPath(import.meta.url)
|
||||
const dirName = dirname(fileName)
|
||||
const customIconsPath = join(dirName, '..', 'src', 'assets', 'icons', 'custom')
|
||||
|
||||
// Iconify collection structure
|
||||
interface IconifyIcon {
|
||||
body: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
interface IconifyCollection {
|
||||
prefix: string
|
||||
icons: Record<string, IconifyIcon>
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
// Create an Iconify collection for custom icons
|
||||
export const iconCollection: IconifyCollection = {
|
||||
prefix: 'comfy',
|
||||
icons: {},
|
||||
width: 16,
|
||||
height: 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an SVG file contains valid SVG content
|
||||
*/
|
||||
function validateSvgContent(content: string, filename: string): void {
|
||||
if (!content.trim()) {
|
||||
throw new Error(`Empty SVG file: ${filename}`)
|
||||
}
|
||||
|
||||
if (!content.includes('<svg')) {
|
||||
throw new Error(`Invalid SVG file (missing <svg> tag): ${filename}`)
|
||||
}
|
||||
|
||||
// Basic XML structure validation
|
||||
const openTags = (content.match(/<svg[^>]*>/g) || []).length
|
||||
const closeTags = (content.match(/<\/svg>/g) || []).length
|
||||
|
||||
if (openTags !== closeTags) {
|
||||
throw new Error(`Malformed SVG file (mismatched svg tags): ${filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom SVG icons from the icons directory
|
||||
*/
|
||||
function loadCustomIcons(): void {
|
||||
if (!existsSync(customIconsPath)) {
|
||||
console.warn(`Custom icons directory not found: ${customIconsPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const files = readdirSync(customIconsPath)
|
||||
const svgFiles = files.filter((file) => file.endsWith('.svg'))
|
||||
|
||||
if (svgFiles.length === 0) {
|
||||
console.warn('No SVG files found in custom icons directory')
|
||||
return
|
||||
}
|
||||
|
||||
svgFiles.forEach((file) => {
|
||||
const name = file.replace('.svg', '')
|
||||
const filePath = join(customIconsPath, file)
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
validateSvgContent(content, file)
|
||||
|
||||
iconCollection.icons[name] = {
|
||||
body: content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load custom icon ${file}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
// Continue loading other icons instead of failing the entire build
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to read custom icons directory:',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
// Don't throw here - allow build to continue without custom icons
|
||||
}
|
||||
}
|
||||
|
||||
// Load icons when this module is imported
|
||||
loadCustomIcons()
|
||||
50
docs/adr/0002-monorepo-conversion.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 2. Restructure ComfyUI_frontend as a monorepo
|
||||
|
||||
Date: 2025-08-25
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
[Most of the context is in here](https://github.com/Comfy-Org/ComfyUI_frontend/issues/4661)
|
||||
|
||||
TL;DR: As we're merging more subprojects like litegraph, devtools, and soon a fork of PrimeVue,
|
||||
a monorepo structure will help a lot with code sharing and organization.
|
||||
|
||||
For more information on Monorepos, check out [monorepo.tools](https://monorepo.tools/)
|
||||
|
||||
## Decision
|
||||
|
||||
- Swap out NPM for PNPM
|
||||
- Add a workspace for the PrimeVue fork
|
||||
- Move the frontend code into its own app workspace
|
||||
- Longer term: Extract and reorganize common infrastructure to take advantage of the new monorepo tooling
|
||||
|
||||
### Tools proposed
|
||||
|
||||
[PNPM](https://pnpm.io/) and [PNPM workspaces](https://pnpm.io/workspaces)
|
||||
|
||||
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
|
||||
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Adding new projects with shared dependencies becomes really easy
|
||||
- Makes the process of forking and customizing projects more structured, if not strictly easier
|
||||
- It *could* speed up the build and development process (not guaranteed)
|
||||
- It would let us cleanly organize and release packages like `comfyui-frontend-types`
|
||||
|
||||
### Negative
|
||||
|
||||
- Monorepos take some getting used to
|
||||
- Reviews and code contribution management has to account for the different projects' situations and constraints
|
||||
|
||||
<!-- ## Notes
|
||||
|
||||
Optional section for additional information, references, or clarifications. -->
|
||||
@@ -11,6 +11,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
637
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.5",
|
||||
"version": "1.26.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.5",
|
||||
"version": "1.26.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -55,6 +55,7 @@
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
@@ -84,6 +85,7 @@
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"knip": "^5.62.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"lucide-vue-next": "^0.540.0",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"storybook": "^9.1.1",
|
||||
@@ -2337,119 +2339,24 @@
|
||||
"pathe": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/tailwind": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/tailwind/-/tailwind-1.2.0.tgz",
|
||||
"integrity": "sha512-KgpIHWOTcRYw1XcoUqyNSrmYyfLLqZYu3AmP8zdfLk0F5TqRO8YerhlvlQmGfn7rJXgPeZN569xPAJnJ53zZxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@iconify/types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyberalien"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@iconify/utils": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz",
|
||||
"integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@antfu/install-pkg": "^1.0.0",
|
||||
"@antfu/utils": "^8.1.0",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"globals": "^15.14.0",
|
||||
"kolorist": "^1.8.0",
|
||||
"local-pkg": "^1.0.0",
|
||||
"mlly": "^1.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/@antfu/install-pkg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"package-manager-detector": "^1.3.0",
|
||||
"tinyexec": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/@antfu/utils": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz",
|
||||
"integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/confbox": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/local-pkg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz",
|
||||
"integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.0.1",
|
||||
"quansync": "^0.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/package-manager-detector": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz",
|
||||
"integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/pkg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@inkjs/ui": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@inkjs/ui/-/ui-1.0.0.tgz",
|
||||
@@ -6579,13 +6486,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
||||
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -6982,7 +6889,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -7134,6 +7040,201 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
|
||||
"integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.1.0",
|
||||
"encoding-sniffer": "^0.2.0",
|
||||
"htmlparser2": "^9.1.0",
|
||||
"parse5": "^7.1.2",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^6.19.5",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select/node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/undici": {
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -8232,7 +8333,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -8345,6 +8445,37 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -8391,7 +8522,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -8400,7 +8530,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -8410,7 +8539,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -8418,6 +8546,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.9",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz",
|
||||
@@ -9750,12 +9893,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9880,7 +10026,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -9926,7 +10071,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -9950,7 +10094,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -10103,7 +10246,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -10193,7 +10335,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -10205,7 +10346,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -10228,7 +10368,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -10282,6 +10421,80 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
||||
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.1.0",
|
||||
"entities": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -12224,6 +12437,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.540.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.540.0.tgz",
|
||||
"integrity": "sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -12300,7 +12523,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -14044,6 +14266,55 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -14998,9 +15269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
|
||||
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -15011,7 +15282,8 @@
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
@@ -17642,6 +17914,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/@iconify/utils": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz",
|
||||
"integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@antfu/install-pkg": "^1.0.0",
|
||||
"@antfu/utils": "^8.1.0",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"globals": "^15.14.0",
|
||||
"kolorist": "^1.8.0",
|
||||
"local-pkg": "^1.0.0",
|
||||
"mlly": "^1.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/@iconify/utils/node_modules/@antfu/install-pkg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"package-manager-detector": "^1.3.0",
|
||||
"tinyexec": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/@iconify/utils/node_modules/@antfu/utils": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz",
|
||||
"integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/@iconify/utils/node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/confbox": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -17659,6 +17997,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/package-manager-detector": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz",
|
||||
"integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-icons/node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.28.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.26.5",
|
||||
"version": "1.26.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
@@ -68,6 +69,7 @@
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"knip": "^5.62.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"lucide-vue-next": "^0.540.0",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"storybook": "^9.1.1",
|
||||
|
||||
@@ -247,9 +247,29 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
|
||||
|
||||
### Configuration
|
||||
|
||||
The icon system is configured in `vite.config.mts`:
|
||||
The icon system has two layers:
|
||||
|
||||
1. **Build-time Processing** (`build/customIconCollection.ts`):
|
||||
- Scans `src/assets/icons/custom/` for SVG files
|
||||
- Validates SVG content and structure
|
||||
- Creates Iconify collection for Tailwind CSS
|
||||
- Provides error handling for malformed files
|
||||
|
||||
2. **Vite Runtime** (`vite.config.mts`):
|
||||
- Enables direct SVG import as Vue components
|
||||
- Supports dynamic icon loading
|
||||
|
||||
```typescript
|
||||
// Build script creates Iconify collection
|
||||
export const iconCollection: IconifyCollection = {
|
||||
prefix: 'comfy',
|
||||
icons: {
|
||||
'workflow': { body: '<svg>...</svg>' },
|
||||
'node': { body: '<svg>...</svg>' }
|
||||
}
|
||||
}
|
||||
|
||||
// Vite configuration for component-based usage
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
@@ -271,8 +291,9 @@ Icons are fully typed. If TypeScript doesn't recognize a new custom icon:
|
||||
### Icon Not Showing
|
||||
1. **Check filename**: Must be kebab-case without special characters
|
||||
2. **Restart dev server**: Required after adding new icons
|
||||
3. **Verify SVG**: Ensure it's valid SVG syntax
|
||||
3. **Verify SVG**: Ensure it's valid SVG syntax (build script validates automatically)
|
||||
4. **Check console**: Look for Vue component resolution errors
|
||||
5. **Build script errors**: Check console during build - malformed SVGs are logged but don't break builds
|
||||
|
||||
### Icon Wrong Color
|
||||
- Replace hardcoded colors with `currentColor`
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.91396 12.7428L5.41396 10.7428C5.57175 10.1116 5.09439 9.50024 4.44382 9.50024H2.50538C2.04651 9.50024 1.64652 9.81253 1.53523 10.2577L1.03523 12.2577C0.877446 12.8888 1.3548 13.5002 2.00538 13.5002H3.94382C4.40269 13.5002 4.80267 13.1879 4.91396 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M5.91396 6.74277L6.41396 4.74277C6.57175 4.11163 6.09439 3.50024 5.44382 3.50024H3.50538C3.04651 3.50024 2.64652 3.81253 2.53523 4.2577L2.03523 6.2577C1.87745 6.88885 2.3548 7.50024 3.00538 7.50024H4.94382C5.40269 7.50024 5.80267 7.18794 5.91396 6.74277Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M10.914 12.7428L11.414 10.7428C11.5718 10.1116 11.0944 9.50024 10.4438 9.50024H8.50538C8.04651 9.50024 7.64652 9.81253 7.53523 10.2577L7.03523 12.2577C6.87745 12.8888 7.3548 13.5002 8.00538 13.5002H9.94382C10.4027 13.5002 10.8027 13.1879 10.914 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M12.2342 5.46739L11.5287 7.11354C11.4248 7.35597 11.0811 7.35597 10.9772 7.11354L10.2717 5.46739C10.2414 5.39659 10.185 5.34017 10.1141 5.30983L8.468 4.60433C8.22557 4.50044 8.22557 4.15675 8.468 4.05285L10.1141 3.34736C10.185 3.31701 10.2414 3.26059 10.2717 3.18979L10.9772 1.54364C11.0811 1.30121 11.4248 1.30121 11.5287 1.54364L12.2342 3.18979C12.2645 3.26059 12.3209 3.31701 12.3918 3.34736L14.0379 4.05285C14.2803 4.15675 14.2803 4.50044 14.0379 4.60433L12.3918 5.30983C12.3209 5.34017 12.2645 5.39659 12.2342 5.46739Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><g stroke="currentColor" stroke-linecap="round" stroke-width="1.3" clip-path="url(#a)"><path d="m4.998 13.909.557-2.225a1.112 1.112 0 0 0-1.08-1.382H2.32c-.51 0-.955.347-1.079.842L.684 13.37a1.112 1.112 0 0 0 1.079 1.382h2.156c.51 0 .956-.347 1.08-.842ZM6.11 7.234l.557-2.224a1.112 1.112 0 0 0-1.08-1.383H3.433c-.51 0-.956.348-1.08.843l-.556 2.225a1.112 1.112 0 0 0 1.08 1.382h2.156c.51 0 .955-.347 1.079-.843ZM11.673 13.909l.556-2.225a1.112 1.112 0 0 0-1.08-1.382H8.994c-.51 0-.955.347-1.079.842l-.556 2.225a1.112 1.112 0 0 0 1.08 1.382h2.156c.51 0 .955-.347 1.079-.842ZM13.141 5.816l-.784 1.83a.334.334 0 0 1-.614 0l-.785-1.83a.333.333 0 0 0-.175-.176l-1.831-.784a.334.334 0 0 1 0-.614l1.831-.785a.333.333 0 0 0 .175-.175l.785-1.831a.334.334 0 0 1 .614 0l.784 1.831a.334.334 0 0 0 .176.175l1.83.785c.27.116.27.498 0 .614l-1.83.784a.334.334 0 0 0-.176.176Z"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1,3 +1 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 10L10.598 10.2577C10.4812 10.6954 10.0848 11 9.63172 11H5.30161C4.64458 11 4.16608 10.3772 4.33538 9.74234L5.40204 5.74234C5.51878 5.30458 5.91523 5 6.36828 5H10.8286C11.4199 5 11.8505 5.56051 11.6982 6.13185L11.6736 6.22389M14 8H10M4.5 8H2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.3" d="m11.2667 10.45-.0842.3156c-.143.5363-.6286.9094-1.18362.9094H4.6945c-.80486 0-1.39102-.7629-1.18364-1.5406l1.30667-4.90002c.143-.53625.62865-.90937 1.18364-.90937h5.46393c.7243 0 1.2518.68663 1.0652 1.38652l-.0301.11275M15.35 8.00001h-4.9m-6.73748 0H.65002"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 411 B |
@@ -1,5 +1 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.1894 6.24254L13.6894 4.24254C13.8471 3.61139 13.3698 3 12.7192 3H3.78077C3.3219 3 2.92192 3.3123 2.81062 3.75746L2.31062 5.75746C2.15284 6.38861 2.63019 7 3.28077 7H12.2192C12.6781 7 13.0781 6.6877 13.1894 6.24254Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M13.1894 12.2425L13.6894 10.2425C13.8471 9.61139 13.3698 9 12.7192 9H8.78077C8.3219 9 7.92192 9.3123 7.81062 9.75746L7.31062 11.7575C7.15284 12.3886 7.6302 13 8.28077 13H12.2192C12.6781 13 13.0781 12.6877 13.1894 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M5.18936 12.2425L5.68936 10.2425C5.84714 9.61139 5.36978 9 4.71921 9H3.78077C3.3219 9 2.92192 9.3123 2.81062 9.75746L2.31062 11.7575C2.15284 12.3886 2.6302 13 3.28077 13H4.21921C4.67808 13 5.07806 12.6877 5.18936 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><g stroke="currentColor" stroke-linecap="round" stroke-width="1.3" clip-path="url(#a)"><path d="m14.6685 5.7416.6425-2.57c.2028-.811-.4106-1.5967-1.2466-1.5967H2.5782a1.285 1.285 0 0 0-1.2467.9733l-.6425 2.57c-.2027.8111.4107 1.5968 1.2467 1.5968h11.4861a1.285 1.285 0 0 0 1.2467-.9734Zm0 7.7102.6425-2.5701c.2028-.811-.4106-1.5967-1.2466-1.5967h-5.061a1.285 1.285 0 0 0-1.2467.9734l-.6425 2.5701c-.2028.811.4106 1.5966 1.2466 1.5966h5.061a1.285 1.285 0 0 0 1.2467-.9733Zm-10.2802 0 .6425-2.5701c.2027-.811-.4107-1.5967-1.2467-1.5967H2.5782a1.285 1.285 0 0 0-1.2467.9734L.689 12.8285c-.2027.811.4107 1.5966 1.2467 1.5966h1.206a1.285 1.285 0 0 0 1.2466-.9733Z"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 830 B |
@@ -1,3 +1 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.99999 4H6.99999M8.99999 12H7.6231C5.02081 12 3.11138 9.55445 3.74252 7.02986L3.99999 6M13.6894 3.24254L13.1894 5.24254C13.0781 5.6877 12.6781 6 12.2192 6H10.2808C9.63019 6 9.15284 5.38861 9.31062 4.75746L9.81062 2.75746C9.92192 2.3123 10.3219 2 10.7808 2H12.7192C13.3698 2 13.8471 2.61139 13.6894 3.24254ZM6.68936 3.24254L6.18936 5.24254C6.07806 5.6877 5.67808 6 5.21921 6H3.28077C2.63019 6 2.15284 5.38861 2.31062 4.75746L2.81062 2.75746C2.92191 2.3123 3.3219 2 3.78077 2H5.71921C6.36978 2 6.84714 2.61139 6.68936 3.24254ZM13.6894 11.2425L13.1894 13.2425C13.0781 13.6877 12.6781 14 12.2192 14H10.2808C9.63019 14 9.15284 13.3886 9.31062 12.7575L9.81062 10.7575C9.92192 10.3123 10.3219 10 10.7808 10H12.7192C13.3698 10 13.8471 10.6114 13.6894 11.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.3" d="M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 910 B After Width: | Height: | Size: 857 B |
145
src/components/button/IconButton.stories.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Components/Button/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md']
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Trophy },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Trophy :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Settings },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Settings :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, X },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<X :size="16" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Bell },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Bell :size="12" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'secondary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="sm" @click="() => {}">
|
||||
<Trophy :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<Trophy :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="secondary" size="sm" @click="() => {}">
|
||||
<Settings :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<Settings :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="transparent" size="sm" @click="() => {}">
|
||||
<X :size="12" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<X :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<Bell :size="16" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<Heart :size="16" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<Download :size="16" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
35
src/components/button/IconGroup.stories.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
import IconGroup from './IconGroup.vue'
|
||||
|
||||
const meta: Meta<typeof IconGroup> = {
|
||||
title: 'Components/Button/IconGroup',
|
||||
component: IconGroup,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
}
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof IconGroup>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
|
||||
template: `
|
||||
<IconGroup>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<Heart :size="16" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<Download :size="16" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<ExternalLink :size="16" />
|
||||
</IconButton>
|
||||
</IconGroup>
|
||||
`
|
||||
})
|
||||
}
|
||||
221
src/components/button/IconTextButton.stories.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Package,
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
|
||||
const meta: Meta<typeof IconTextButton> = {
|
||||
title: 'Components/Button/IconTextButton',
|
||||
component: IconTextButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md']
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Package },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Package :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Deploy',
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Settings },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Settings :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Settings',
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, X },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<X :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Cancel',
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithIconRight: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, ChevronRight },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<ChevronRight :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Next',
|
||||
type: 'primary',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Save },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Save :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Save',
|
||||
type: 'primary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
IconTextButton,
|
||||
Download,
|
||||
Settings,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Save
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Download :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Settings :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Settings :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Trash2 :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Trash2 :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
|
||||
<template #icon>
|
||||
<ChevronRight :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<ChevronLeft :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Save :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
50
src/components/button/MoreButton.stories.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ScrollText } from 'lucide-vue-next'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
import MoreButton from './MoreButton.vue'
|
||||
|
||||
const meta: Meta<typeof MoreButton> = {
|
||||
title: 'Components/Button/MoreButton',
|
||||
component: MoreButton,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {}
|
||||
}
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof MoreButton>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { MoreButton, IconTextButton, Download, ScrollText },
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<ScrollText />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
83
src/components/button/TextButton.stories.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TextButton from './TextButton.vue'
|
||||
|
||||
const meta: Meta<typeof TextButton> = {
|
||||
title: 'Components/Button/TextButton',
|
||||
component: TextButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
defaultValue: 'Click me'
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md'],
|
||||
defaultValue: 'md'
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent'],
|
||||
defaultValue: 'primary'
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
label: 'Primary Button',
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
label: 'Secondary Button',
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
args: {
|
||||
label: 'Transparent Button',
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
label: 'Small Button',
|
||||
type: 'primary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { TextButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
|
||||
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
|
||||
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
|
||||
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
665
src/components/card/Card.stories.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
Download,
|
||||
Folder,
|
||||
Heart,
|
||||
Info,
|
||||
MoreVertical,
|
||||
Star,
|
||||
Upload
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconButton from '../button/IconButton.vue'
|
||||
import SquareChip from '../chip/SquareChip.vue'
|
||||
import CardBottom from './CardBottom.vue'
|
||||
import CardContainer from './CardContainer.vue'
|
||||
import CardDescription from './CardDescription.vue'
|
||||
import CardTitle from './CardTitle.vue'
|
||||
import CardTop from './CardTop.vue'
|
||||
|
||||
interface CardStoryArgs {
|
||||
// CardContainer props
|
||||
containerRatio: 'square' | 'portrait' | 'tallPortrait'
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
|
||||
// CardTop props
|
||||
topRatio: 'square' | 'landscape'
|
||||
|
||||
// Content props
|
||||
showTopLeft: boolean
|
||||
showTopRight: boolean
|
||||
showBottomLeft: boolean
|
||||
showBottomRight: boolean
|
||||
showTitle: boolean
|
||||
showDescription: boolean
|
||||
title: string
|
||||
description: string
|
||||
|
||||
// Visual props
|
||||
backgroundColor: string
|
||||
showImage: boolean
|
||||
imageUrl: string
|
||||
|
||||
// Tag props
|
||||
tags: string[]
|
||||
showFileSize: boolean
|
||||
fileSize: string
|
||||
showFileType: boolean
|
||||
fileType: string
|
||||
}
|
||||
|
||||
const meta: Meta<CardStoryArgs> = {
|
||||
title: 'Components/Card/Card',
|
||||
argTypes: {
|
||||
containerRatio: {
|
||||
control: 'select',
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
},
|
||||
maxWidth: {
|
||||
control: { type: 'range', min: 200, max: 600, step: 10 },
|
||||
description: 'Maximum width in pixels'
|
||||
},
|
||||
minWidth: {
|
||||
control: { type: 'range', min: 150, max: 400, step: 10 },
|
||||
description: 'Minimum width in pixels'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
options: ['square', 'landscape'],
|
||||
description: 'Top section aspect ratio'
|
||||
},
|
||||
showTopLeft: {
|
||||
control: 'boolean',
|
||||
description: 'Show top-left slot content'
|
||||
},
|
||||
showTopRight: {
|
||||
control: 'boolean',
|
||||
description: 'Show top-right slot content'
|
||||
},
|
||||
showBottomLeft: {
|
||||
control: 'boolean',
|
||||
description: 'Show bottom-left slot content'
|
||||
},
|
||||
showBottomRight: {
|
||||
control: 'boolean',
|
||||
description: 'Show bottom-right slot content'
|
||||
},
|
||||
showTitle: {
|
||||
control: 'boolean',
|
||||
description: 'Show card title'
|
||||
},
|
||||
showDescription: {
|
||||
control: 'boolean',
|
||||
description: 'Show card description'
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Card title text'
|
||||
},
|
||||
description: {
|
||||
control: 'text',
|
||||
description: 'Card description text'
|
||||
},
|
||||
backgroundColor: {
|
||||
control: 'color',
|
||||
description: 'Background color for card top'
|
||||
},
|
||||
showImage: {
|
||||
control: 'boolean',
|
||||
description: 'Show image instead of color background'
|
||||
},
|
||||
imageUrl: {
|
||||
control: 'text',
|
||||
description: 'Image URL for card top'
|
||||
},
|
||||
tags: {
|
||||
control: 'object',
|
||||
description: 'Tags to display (array of strings)'
|
||||
},
|
||||
showFileSize: {
|
||||
control: 'boolean',
|
||||
description: 'Show file size tag'
|
||||
},
|
||||
fileSize: {
|
||||
control: 'text',
|
||||
description: 'File size text'
|
||||
},
|
||||
showFileType: {
|
||||
control: 'boolean',
|
||||
description: 'Show file type tag'
|
||||
},
|
||||
fileType: {
|
||||
control: 'text',
|
||||
description: 'File type text'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download,
|
||||
Star,
|
||||
Upload,
|
||||
MoreVertical
|
||||
},
|
||||
setup() {
|
||||
const favorited = ref(false)
|
||||
const toggleFavorite = () => {
|
||||
favorited.value = !favorited.value
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
favorited,
|
||||
toggleFavorite
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
:max-width="args.maxWidth"
|
||||
:min-width="args.minWidth"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
<template #default>
|
||||
<div
|
||||
v-if="!args.showImage"
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: args.backgroundColor }"
|
||||
></div>
|
||||
<img
|
||||
v-else
|
||||
:src="args.imageUrl || 'https://via.placeholder.com/400'"
|
||||
class="w-full h-full object-cover"
|
||||
alt="Card image"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="args.showTopLeft" #top-left>
|
||||
<SquareChip label="Featured" />
|
||||
</template>
|
||||
|
||||
<template v-if="args.showTopRight" #top-right>
|
||||
<IconButton
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info clicked')"
|
||||
>
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="!bg-white/90"
|
||||
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<template v-if="args.showBottomLeft" #bottom-left>
|
||||
<SquareChip label="New" />
|
||||
</template>
|
||||
|
||||
<template v-if="args.showBottomRight" #bottom-right>
|
||||
<SquareChip v-if="args.showFileType" :label="args.fileType" />
|
||||
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
|
||||
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Model Name',
|
||||
description:
|
||||
'This is a detailed description of the model that can span multiple lines',
|
||||
backgroundColor: '#3b82f6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['LoRA', 'SDXL'],
|
||||
showFileSize: true,
|
||||
fileSize: '1.2 MB',
|
||||
showFileType: true,
|
||||
fileType: 'safetensors'
|
||||
}
|
||||
}
|
||||
|
||||
export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 400,
|
||||
minWidth: 250,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Workflow Bundle',
|
||||
description:
|
||||
'Complete workflow for image generation with all necessary nodes',
|
||||
backgroundColor: '#10b981',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Workflow'],
|
||||
showFileSize: true,
|
||||
fileSize: '245 KB',
|
||||
showFileType: true,
|
||||
fileType: 'json'
|
||||
}
|
||||
}
|
||||
|
||||
export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 280,
|
||||
minWidth: 180,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Premium Model',
|
||||
description:
|
||||
'High-quality photorealistic model trained on professional photography',
|
||||
backgroundColor: '#8b5cf6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['SD 1.5', 'Checkpoint'],
|
||||
showFileSize: true,
|
||||
fileSize: '2.1 GB',
|
||||
showFileType: true,
|
||||
fileType: 'ckpt'
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 350,
|
||||
minWidth: 220,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Generated Image',
|
||||
description: 'Created with DreamShaper XL',
|
||||
backgroundColor: '#3b82f6',
|
||||
showImage: true,
|
||||
imageUrl: 'https://picsum.photos/400/400',
|
||||
tags: ['Output'],
|
||||
showFileSize: true,
|
||||
fileSize: '856 KB',
|
||||
showFileType: true,
|
||||
fileType: 'png'
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Simple Card',
|
||||
description: '',
|
||||
backgroundColor: '#64748b',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 320,
|
||||
minWidth: 240,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
showBottomLeft: true,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Ultimate Model Pack',
|
||||
description:
|
||||
'Complete collection with checkpoints, LoRAs, embeddings, and VAE models for professional use',
|
||||
backgroundColor: '#ef4444',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Bundle', 'Premium', 'SDXL'],
|
||||
showFileSize: true,
|
||||
fileSize: '5.4 GB',
|
||||
showFileType: true,
|
||||
fileType: 'pack'
|
||||
}
|
||||
}
|
||||
|
||||
export const GridOfCards: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download
|
||||
},
|
||||
setup() {
|
||||
const cards = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Realistic Vision',
|
||||
description: 'Photorealistic model for portraits',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SD 1.5'],
|
||||
size: '2.1 GB'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'DreamShaper XL',
|
||||
description: 'Artistic style model with enhanced details',
|
||||
color: 'from-purple-400 to-pink-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SDXL'],
|
||||
size: '6.5 GB'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Anime LoRA',
|
||||
description: 'Character style LoRA',
|
||||
color: 'from-green-400 to-teal-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['LoRA'],
|
||||
size: '144 MB'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'VAE Model',
|
||||
description: 'Enhanced color VAE',
|
||||
color: 'from-orange-400 to-red-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['VAE'],
|
||||
size: '335 MB'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Workflow Bundle',
|
||||
description: 'Complete workflow setup',
|
||||
color: 'from-indigo-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Workflow'],
|
||||
size: '45 KB'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Embedding Pack',
|
||||
description: 'Negative embeddings collection',
|
||||
color: 'from-yellow-400 to-orange-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Embedding'],
|
||||
size: '2.3 MB'
|
||||
}
|
||||
])
|
||||
|
||||
return { cards }
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="300"
|
||||
:min-width="180"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-gray-600"
|
||||
:class="card.color"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<template #top-right>
|
||||
<IconButton
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info:', card.title)"
|
||||
>
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in card.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
>
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
<SquareChip :label="card.size" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ResponsiveGrid: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
SquareChip
|
||||
},
|
||||
setup() {
|
||||
const generateCards = (
|
||||
count: number,
|
||||
ratio: 'square' | 'portrait' | 'tallPortrait'
|
||||
) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Model ${i + 1}`,
|
||||
description: `Description for model ${i + 1}`,
|
||||
ratio,
|
||||
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
|
||||
}))
|
||||
}
|
||||
|
||||
const squareCards = ref(generateCards(4, 'square'))
|
||||
const portraitCards = ref(generateCards(6, 'portrait'))
|
||||
const tallCards = ref(generateCards(5, 'tallPortrait'))
|
||||
|
||||
return {
|
||||
squareCards,
|
||||
portraitCards,
|
||||
tallCards
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in squareCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="400"
|
||||
:min-width="200"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in portraitCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="280"
|
||||
:min-width="160"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-2">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in tallCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="260"
|
||||
:min-width="150"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip :label="'#' + card.id" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ const {
|
||||
maxWidth,
|
||||
minWidth
|
||||
} = defineProps<{
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
maxWidth?: number
|
||||
minWidth?: number
|
||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||
}>()
|
||||
|
||||
@@ -31,8 +31,12 @@ const containerClasses = computed(() => {
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
maxWidth: `${maxWidth}px`,
|
||||
minWidth: `${minWidth}px`
|
||||
}))
|
||||
const containerStyle = computed(() =>
|
||||
maxWidth || minWidth
|
||||
? {
|
||||
maxWidth: `${maxWidth}px`,
|
||||
minWidth: `${minWidth}px`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
</script>
|
||||
|
||||
36
src/components/chip/SquareChip.stories.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import SquareChip from './SquareChip.vue'
|
||||
|
||||
const meta: Meta<typeof SquareChip> = {
|
||||
title: 'Components/SquareChip',
|
||||
component: SquareChip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
defaultValue: 'Tag'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const TagList: Story = {
|
||||
render: () => ({
|
||||
components: { SquareChip },
|
||||
template: `
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<SquareChip label="JavaScript" />
|
||||
<SquareChip label="TypeScript" />
|
||||
<SquareChip label="Vue.js" />
|
||||
<SquareChip label="React" />
|
||||
<SquareChip label="Node.js" />
|
||||
<SquareChip label="Python" />
|
||||
<SquareChip label="Docker" />
|
||||
<SquareChip label="Kubernetes" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -41,7 +41,6 @@
|
||||
>
|
||||
<template #header>
|
||||
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
|
||||
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
|
||||
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
|
||||
</template>
|
||||
<SettingsPanel :setting-groups="sortedGroups(category)" />
|
||||
@@ -76,7 +75,6 @@ import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
import ColorPaletteMessage from './setting/ColorPaletteMessage.vue'
|
||||
import CurrentUserMessage from './setting/CurrentUserMessage.vue'
|
||||
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
|
||||
import PanelTemplate from './setting/PanelTemplate.vue'
|
||||
import SettingsPanel from './setting/SettingsPanel.vue'
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="show"
|
||||
class="first-time-ui-message"
|
||||
severity="info"
|
||||
:closable="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
{{ $t('g.firstTimeUIMessage') }}
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const show = computed(() => !settingStore.exists('Comfy.UseNewMenu'))
|
||||
const handleClose = async () => {
|
||||
// Explicitly write the current value to the store.
|
||||
const currentValue = settingStore.get('Comfy.UseNewMenu')
|
||||
await settingStore.set('Comfy.UseNewMenu', currentValue)
|
||||
}
|
||||
</script>
|
||||
@@ -2,13 +2,11 @@
|
||||
<!-- Load splitter overlay only after comfyApp is ready. -->
|
||||
<!-- If load immediately, the top-level splitter stateKey won't be correctly
|
||||
synced with the stateStorage (localStorage). -->
|
||||
<LiteGraphCanvasSplitterOverlay
|
||||
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
|
||||
>
|
||||
<template #side-bar-panel>
|
||||
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady && betaMenuEnabled">
|
||||
<template v-if="!workspaceStore.focusMode" #side-bar-panel>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template #bottom-panel>
|
||||
<template v-if="!workspaceStore.focusMode" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
@@ -34,22 +32,20 @@
|
||||
/>
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
canvasStore.canvas to be initialized. -->
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionOverlay v-if="selectionToolboxEnabled">
|
||||
<SelectionToolbox />
|
||||
</SelectionOverlay>
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<DomWidgets />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -57,7 +53,6 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import MiniMap from '@/components/graph/MiniMap.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
@@ -91,12 +86,16 @@ import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
}>()
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
typeof NodeSearchboxPopover
|
||||
> | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -320,6 +319,7 @@ onMounted(async () => {
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
@@ -1,125 +1,278 @@
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
|
||||
severity="secondary"
|
||||
icon="pi pi-plus"
|
||||
:aria-label="$t('graphCanvasMenu.zoomIn')"
|
||||
@mousedown="repeat('Comfy.Canvas.ZoomIn')"
|
||||
@mouseup="stopRepeat"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.zoomOut')"
|
||||
severity="secondary"
|
||||
icon="pi pi-minus"
|
||||
:aria-label="$t('graphCanvasMenu.zoomOut')"
|
||||
@mousedown="repeat('Comfy.Canvas.ZoomOut')"
|
||||
@mouseup="stopRepeat"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.fitView')"
|
||||
severity="secondary"
|
||||
icon="pi pi-expand"
|
||||
:aria-label="$t('graphCanvasMenu.fitView')"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="
|
||||
t(
|
||||
'graphCanvasMenu.' +
|
||||
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
|
||||
) + ' (Space)'
|
||||
"
|
||||
severity="secondary"
|
||||
:aria-label="
|
||||
t(
|
||||
'graphCanvasMenu.' +
|
||||
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
|
||||
)
|
||||
"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLock')"
|
||||
<div>
|
||||
<ZoomControlsModal :visible="isModalVisible" />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
v-if="hasActivePopup"
|
||||
class="fixed inset-0 z-[1200]"
|
||||
@click="hideModal"
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
class="p-buttongroup-vertical p-1 absolute bottom-4 right-2 md:right-4"
|
||||
:style="stringifiedMinimapStyles.buttonGroupStyles"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-material-symbols:pan-tool-outline
|
||||
v-if="canvasStore.canvas?.read_only"
|
||||
/>
|
||||
<i-simple-line-icons:cursor v-else />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.toggleLinkVisibility')"
|
||||
severity="secondary"
|
||||
:icon="linkHidden ? 'pi pi-eye-slash' : 'pi pi-eye'"
|
||||
:aria-label="$t('graphCanvasMenu.toggleLinkVisibility')"
|
||||
data-testid="toggle-link-visibility-button"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="minimapTooltip"
|
||||
severity="secondary"
|
||||
:icon="'pi pi-map'"
|
||||
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
|
||||
:class="{ 'minimap-active': minimapVisible }"
|
||||
data-testid="toggle-minimap-button"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-tooltip.top="selectTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
severity="secondary"
|
||||
:aria-label="selectTooltip"
|
||||
:pressed="isCanvasReadOnly"
|
||||
icon="i-material-symbols:pan-tool-outline"
|
||||
:class="selectButtonClass"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.Unlock')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:mouse-pointer-2 />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="handTooltip"
|
||||
severity="secondary"
|
||||
:aria-label="handTooltip"
|
||||
:pressed="isCanvasUnlocked"
|
||||
:class="handButtonClass"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.Lock')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:hand />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<!-- vertical line with bg E1DED5 -->
|
||||
<div class="w-px my-1 bg-[#E1DED5] dark-theme:bg-[#2E3037] mx-2" />
|
||||
|
||||
<Button
|
||||
v-tooltip.top="fitViewTooltip"
|
||||
severity="secondary"
|
||||
icon="pi pi-expand"
|
||||
:aria-label="fitViewTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
class="hover:dark-theme:!bg-[#444444] hover:!bg-[#E7E6E6]"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:focus />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ref="zoomButton"
|
||||
v-tooltip.top="t('zoomControls.label')"
|
||||
severity="secondary"
|
||||
:label="t('zoomControls.label')"
|
||||
:class="zoomButtonClass"
|
||||
:aria-label="t('zoomControls.label')"
|
||||
data-testid="zoom-controls-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="toggleModal"
|
||||
>
|
||||
<span class="inline-flex text-xs">
|
||||
<span>{{ canvasStore.appScalePercentage }}%</span>
|
||||
<i-lucide:chevron-down />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div class="w-px my-1 bg-[#E1DED5] dark-theme:bg-[#2E3037] mx-2" />
|
||||
|
||||
<Button
|
||||
ref="focusButton"
|
||||
v-tooltip.top="focusModeTooltip"
|
||||
severity="secondary"
|
||||
:aria-label="focusModeTooltip"
|
||||
data-testid="focus-mode-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
:class="focusButtonClass"
|
||||
@click="() => commandStore.execute('Workspace.ToggleFocusMode')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:lightbulb />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: linkVisibilityTooltip,
|
||||
pt: {
|
||||
root: {
|
||||
style: 'z-index: 2; transform: translateY(-20px);'
|
||||
}
|
||||
}
|
||||
}"
|
||||
severity="secondary"
|
||||
:class="linkVisibleClass"
|
||||
:aria-label="linkVisibilityAriaLabel"
|
||||
data-testid="toggle-link-visibility-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:route-off />
|
||||
</template>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ButtonGroup from 'primevue/buttongroup'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useZoomControls } from '@/composables/useZoomControls'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { formatKeySequence } = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const minimap = useMinimap()
|
||||
|
||||
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
const minimapTooltip = computed(() => {
|
||||
const baseText = t('graphCanvasMenu.toggleMinimap')
|
||||
const keybinding = keybindingStore.getKeybindingByCommandId(
|
||||
'Comfy.Canvas.ToggleMinimap'
|
||||
)
|
||||
return keybinding ? `${baseText} (${keybinding.combo.toString()})` : baseText
|
||||
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
|
||||
useZoomControls()
|
||||
|
||||
const stringifiedMinimapStyles = computed(() => {
|
||||
const buttonGroupKeys = ['backgroundColor', 'borderRadius', '']
|
||||
const buttonKeys = ['backgroundColor', 'borderRadius']
|
||||
const additionalButtonStyles = {
|
||||
border: 'none',
|
||||
width: '35px',
|
||||
height: '35px',
|
||||
'margin-right': '2px',
|
||||
'margin-left': '2px'
|
||||
}
|
||||
|
||||
const containerStyles = minimap.containerStyles.value
|
||||
|
||||
const buttonStyles = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(containerStyles).filter(([key]) =>
|
||||
buttonKeys.includes(key)
|
||||
)
|
||||
),
|
||||
...additionalButtonStyles
|
||||
}
|
||||
const buttonGroupStyles = Object.entries(containerStyles)
|
||||
.filter(([key]) => buttonGroupKeys.includes(key))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
|
||||
|
||||
return { buttonStyles, buttonGroupStyles }
|
||||
})
|
||||
|
||||
// Computed properties for reactive states
|
||||
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
|
||||
const isCanvasUnlocked = computed(() => !isCanvasReadOnly.value)
|
||||
const linkHidden = computed(
|
||||
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
|
||||
let interval: number | null = null
|
||||
const repeat = async (command: string) => {
|
||||
if (interval) return
|
||||
const cmd = () => commandStore.execute(command)
|
||||
await cmd()
|
||||
interval = window.setInterval(cmd, 100)
|
||||
}
|
||||
const stopRepeat = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
// Computed properties for command text
|
||||
const unlockCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Comfy.Canvas.Unlock')
|
||||
).toUpperCase()
|
||||
)
|
||||
const lockCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock')).toUpperCase()
|
||||
)
|
||||
const fitViewCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Comfy.Canvas.FitView')
|
||||
).toUpperCase()
|
||||
)
|
||||
const focusCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Workspace.ToggleFocusMode')
|
||||
).toUpperCase()
|
||||
)
|
||||
|
||||
// Computed properties for button classes and states
|
||||
const selectButtonClass = computed(() =>
|
||||
isCanvasUnlocked.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
: ''
|
||||
)
|
||||
|
||||
const handButtonClass = computed(() =>
|
||||
isCanvasReadOnly.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
: ''
|
||||
)
|
||||
|
||||
const zoomButtonClass = computed(() => [
|
||||
'!w-16',
|
||||
isModalVisible.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
: '',
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
|
||||
])
|
||||
|
||||
const focusButtonClass = computed(() => ({
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]': true,
|
||||
'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]':
|
||||
workspaceStore.focusMode
|
||||
}))
|
||||
|
||||
// Computed properties for tooltip and aria-label texts
|
||||
const selectTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.select')} (${unlockCommandText.value})`
|
||||
)
|
||||
const handTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.hand')} (${lockCommandText.value})`
|
||||
)
|
||||
const fitViewTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.fitView')} (${fitViewCommandText.value})`
|
||||
)
|
||||
const focusModeTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.focusMode')} (${focusCommandText.value})`
|
||||
)
|
||||
const linkVisibilityTooltip = computed(() =>
|
||||
linkHidden.value
|
||||
? t('graphCanvasMenu.showLinks')
|
||||
: t('graphCanvasMenu.hideLinks')
|
||||
)
|
||||
const linkVisibilityAriaLabel = computed(() =>
|
||||
linkHidden.value
|
||||
? t('graphCanvasMenu.showLinks')
|
||||
: t('graphCanvasMenu.hideLinks')
|
||||
)
|
||||
const linkVisibleClass = computed(() => [
|
||||
linkHidden.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
: '',
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
canvasStore.initScaleSync()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
canvasStore.cleanupScaleSync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-buttongroup-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
z-index: 1200;
|
||||
border-radius: var(--p-button-border-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
@@ -129,15 +282,4 @@ const stopRepeat = () => {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.p-button.minimap-active {
|
||||
background-color: var(--p-button-primary-background);
|
||||
border-color: var(--p-button-primary-border-color);
|
||||
color: var(--p-button-primary-color);
|
||||
}
|
||||
|
||||
.p-button.minimap-active:hover {
|
||||
background-color: var(--p-button-primary-hover-background);
|
||||
border-color: var(--p-button-primary-hover-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="minimapRef"
|
||||
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
class="minimap-main-container flex absolute bottom-[66px] right-2 md:right-11 z-[1000]"
|
||||
>
|
||||
<MiniMapPanel
|
||||
v-if="showOptionsPanel"
|
||||
@@ -31,6 +31,25 @@
|
||||
<i-lucide:settings-2 />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
class="absolute z-10 right-0"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
data-testid="close-minmap-button"
|
||||
@click.stop="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:x />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<hr
|
||||
class="absolute top-5 bg-[#E1DED5] dark-theme:bg-[#262729] h-[1px] border-0"
|
||||
:style="{
|
||||
width: containerStyles.width
|
||||
}"
|
||||
/>
|
||||
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@@ -58,9 +77,12 @@ import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import MiniMapPanel from './MiniMapPanel.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const minimapRef = ref<HTMLDivElement>()
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<!-- This component is used to bound the selected items on the canvas. -->
|
||||
<template>
|
||||
<div
|
||||
v-show="visible"
|
||||
class="selection-overlay-container pointer-events-none z-40"
|
||||
:class="{
|
||||
'show-border': showBorder
|
||||
}"
|
||||
:style="style"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { provide, readonly, ref, watch } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
|
||||
const visible = ref(false)
|
||||
const showBorder = ref(false)
|
||||
// Increment counter to notify child components of position/visibility change
|
||||
// This does not include viewport changes.
|
||||
const overlayUpdateCount = ref(0)
|
||||
provide(SelectionOverlayInjectionKey, {
|
||||
visible: readonly(visible),
|
||||
updateCount: readonly(overlayUpdateCount)
|
||||
})
|
||||
|
||||
const positionSelectionOverlay = () => {
|
||||
const selectableItems = getSelectableItems()
|
||||
showBorder.value = selectableItems.size > 1
|
||||
|
||||
if (!selectableItems.size) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
if (bounds) {
|
||||
updatePosition({
|
||||
pos: [bounds[0], bounds[1]],
|
||||
size: [bounds[2], bounds[3]]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
positionSelectionOverlay()
|
||||
overlayUpdateCount.value++
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
|
||||
|
||||
watch(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
(draggingItems) => {
|
||||
// Litegraph draggingItems state can end early before the bounding boxes of
|
||||
// the selected items are updated. Delay to make sure we put the overlay in
|
||||
// the correct position.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
|
||||
if (draggingItems === false) {
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
positionSelectionOverlay()
|
||||
overlayUpdateCount.value++
|
||||
})
|
||||
} else {
|
||||
// Selection change update to visible state is delayed by a frame. Here
|
||||
// we also delay a frame so that the order of events is correct when
|
||||
// the initial selection and dragging happens at the same time.
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = false
|
||||
overlayUpdateCount.value++
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selection-overlay-container > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.show-border {
|
||||
@apply border-dashed rounded-md border-2 border-[var(--border-color)];
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,45 @@
|
||||
<template>
|
||||
<Panel
|
||||
class="selection-toolbox absolute left-1/2 rounded-lg"
|
||||
:class="{ 'animate-slide-up': shouldAnimate }"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshSelectionButton />
|
||||
<ExtensionCommandButton
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
<Transition name="slide-up">
|
||||
<!-- Wrapping panel in div to get correct ref because panel ref is not of raw dom el -->
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="toolboxRef"
|
||||
style="
|
||||
transform: translate(calc(var(--tb-x) - 50%), calc(var(--tb-y) - 120%));
|
||||
"
|
||||
class="selection-toolbox fixed left-0 top-0 z-40"
|
||||
>
|
||||
<Panel
|
||||
class="rounded-lg"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshSelectionButton />
|
||||
<ExtensionCommandButton
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
@@ -41,23 +52,19 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
|
||||
const { shouldAnimate } = useRetriggerableAnimation(
|
||||
selectionOverlayState?.updateCount,
|
||||
{ animateOnMount: true }
|
||||
)
|
||||
const toolboxRef = ref<HTMLElement | undefined>()
|
||||
const { visible } = useSelectionToolboxPosition(toolboxRef)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
@@ -77,23 +84,22 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selection-toolbox {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
.slide-up-enter-active {
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Slide up animation using CSS animation */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
opacity: 1;
|
||||
}
|
||||
.slide-up-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(0);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
237
src/components/graph/modals/ZoomControlsModal.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="w-[250px] absolute flex justify-center right-2 md:right-11 z-[1300] bottom-[66px] !bg-inherit !border-0"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark-theme:bg-[#2b2b2b] border border-gray-200 dark-theme:border-gray-700 rounded-lg shadow-lg p-4 w-4/5"
|
||||
:style="filteredMinimapStyles"
|
||||
@click.stop
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-sm font-medium block">{{
|
||||
$t('graphCanvasMenu.zoomIn')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 block">{{
|
||||
zoomInCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-sm font-medium block">{{
|
||||
$t('graphCanvasMenu.zoomOut')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 block">{{
|
||||
zoomOutCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
@click="executeCommand('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-sm font-medium block">{{
|
||||
$t('zoomControls.zoomToFit')
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 block">{{
|
||||
zoomToFitCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<hr class="border-[#E1DED5] mb-1 dark-theme:border-[#2E3037]" />
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="toggle-minimap-button"
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
@click="executeCommand('Comfy.Canvas.ToggleMinimap')"
|
||||
>
|
||||
<template #default>
|
||||
<span class="text-sm font-medium block">{{
|
||||
minimapToggleText
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 block">{{
|
||||
showMinimapCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<hr class="border-[#E1DED5] mt-1 dark-theme:border-[#2E3037]" />
|
||||
<div
|
||||
ref="zoomInputContainer"
|
||||
class="flex items-center px-2 bg-[#E7E6E6] focus-within:bg-[#F3F3F3] dark-theme:bg-[#8282821A] rounded p-2 zoomInputContainer"
|
||||
>
|
||||
<InputNumber
|
||||
ref="zoomInput"
|
||||
:default-value="canvasStore.appScalePercentage"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:show-buttons="false"
|
||||
:use-grouping="false"
|
||||
:unstyled="true"
|
||||
input-class="flex-1 bg-transparent border-none outline-none text-sm shadow-none my-0 "
|
||||
fluid
|
||||
@input="applyZoom"
|
||||
@keyup.enter="applyZoom"
|
||||
/>
|
||||
<span class="text-sm text-gray-500 -ml-4">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button, InputNumber, InputNumberInputEvent } from 'primevue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const minimap = useMinimap()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { formatKeySequence } = useCommandStore()
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const interval = ref<number | null>(null)
|
||||
|
||||
// Computed properties for reactive states
|
||||
const minimapToggleText = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.Visible')
|
||||
? t('zoomControls.hideMinimap')
|
||||
: t('zoomControls.showMinimap')
|
||||
)
|
||||
|
||||
const applyZoom = (val: InputNumberInputEvent) => {
|
||||
const inputValue = val.value as number
|
||||
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
|
||||
return
|
||||
}
|
||||
canvasStore.setAppZoomFromPercentage(inputValue)
|
||||
}
|
||||
|
||||
const executeCommand = (command: string) => {
|
||||
void commandStore.execute(command)
|
||||
}
|
||||
|
||||
const startRepeat = (command: string) => {
|
||||
if (interval.value) return
|
||||
const cmd = () => commandStore.execute(command)
|
||||
void cmd()
|
||||
interval.value = window.setInterval(cmd, 100)
|
||||
}
|
||||
|
||||
const stopRepeat = () => {
|
||||
if (interval.value) {
|
||||
clearInterval(interval.value)
|
||||
interval.value = null
|
||||
}
|
||||
}
|
||||
const filteredMinimapStyles = computed(() => {
|
||||
return {
|
||||
...minimap.containerStyles.value,
|
||||
height: undefined,
|
||||
width: undefined
|
||||
}
|
||||
})
|
||||
const zoomInCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ZoomIn'))
|
||||
)
|
||||
const zoomOutCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ZoomOut'))
|
||||
)
|
||||
const zoomToFitCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
|
||||
)
|
||||
const showMinimapCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ToggleMinimap'))
|
||||
)
|
||||
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
|
||||
const zoomInputContainer = ref<HTMLDivElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
const input = zoomInputContainer.value?.querySelector(
|
||||
'input'
|
||||
) as HTMLInputElement
|
||||
input?.focus()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
<style>
|
||||
.zoomInputContainer:focus-within {
|
||||
border: 1px solid rgb(204, 204, 204);
|
||||
}
|
||||
|
||||
.dark-theme .zoomInputContainer:focus-within {
|
||||
border: 1px solid rgb(204, 204, 204);
|
||||
}
|
||||
</style>
|
||||
151
src/components/input/MultiSelect.stories.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
label="Select Frameworks"
|
||||
v-bind="args"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
label="Select Languages"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
])
|
||||
|
||||
const projectOptions = ref([
|
||||
{ name: 'Project A', value: 'proj-a' },
|
||||
{ name: 'Project B', value: 'proj-b' },
|
||||
{ name: 'Project C', value: 'proj-c' },
|
||||
{ name: 'Project D', value: 'proj-d' }
|
||||
])
|
||||
|
||||
const tagOptions = ref([
|
||||
{ name: 'Frontend', value: 'frontend' },
|
||||
{ name: 'Backend', value: 'backend' },
|
||||
{ name: 'Database', value: 'database' },
|
||||
{ name: 'DevOps', value: 'devops' },
|
||||
{ name: 'Testing', value: 'testing' }
|
||||
])
|
||||
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedTags = ref([])
|
||||
|
||||
return {
|
||||
frameworkOptions,
|
||||
projectOptions,
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<h4 class="font-medium mb-2">Current Selection:</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
33
src/components/input/SearchBox.stories.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from './SearchBox.vue'
|
||||
|
||||
const meta: Meta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeHolder: {
|
||||
control: 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchText = ref('')
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SearchBox v-model:="searchQuery" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
116
src/components/input/SingleSelect.stories.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ArrowUpDown } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
export type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions = [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'Oldest', value: 'oldest' },
|
||||
{ name: 'A → Z', value: 'az' },
|
||||
{ name: 'Z → A', value: 'za' }
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = sampleOptions
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: { label: 'Sorting Type' }
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect, ArrowUpDown },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
|
||||
<template #icon>
|
||||
<ArrowUpDown :size="14" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('newest')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect, ArrowUpDown },
|
||||
setup() {
|
||||
const options = sampleOptions
|
||||
const a = ref<string | null>(null)
|
||||
const b = ref<string | null>('popular')
|
||||
const c = ref<string | null>('az')
|
||||
return { options, a, b, c }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="a" :options="options" label="No Icon" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="b" :options="options" label="With Icon">
|
||||
<template #icon>
|
||||
<ArrowUpDown :size="14" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
@@ -61,9 +61,10 @@ let listenerController: AbortController | null = null
|
||||
let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
|
||||
const { visible } = storeToRefs(useSearchBoxStore())
|
||||
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
|
||||
const dismissable = ref(true)
|
||||
const getNewNodeLocation = (): Point => {
|
||||
return triggerEvent
|
||||
@@ -107,12 +108,9 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
const newSearchBoxEnabled = computed(
|
||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
const showSearchBox = (e: CanvasPointerEvent) => {
|
||||
const showSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
if (newSearchBoxEnabled.value) {
|
||||
if (e.pointerType === 'touch') {
|
||||
if (e?.pointerType === 'touch') {
|
||||
setTimeout(() => {
|
||||
showNewSearchBox(e)
|
||||
}, 128)
|
||||
@@ -128,7 +126,7 @@ const getFirstLink = () =>
|
||||
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const showNewSearchBox = (e: CanvasPointerEvent) => {
|
||||
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
const firstLink = getFirstLink()
|
||||
if (firstLink) {
|
||||
const filter =
|
||||
@@ -304,6 +302,7 @@ watch(visible, () => {
|
||||
})
|
||||
|
||||
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
|
||||
defineExpose({ showSearchBox })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -109,7 +109,7 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
}
|
||||
|
||||
.side-bar-button-label {
|
||||
@apply text-[10px] text-center whitespace-nowrap;
|
||||
@apply text-[10px] text-center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="
|
||||
$t('shortcuts.keyboardShortcuts') +
|
||||
' (' +
|
||||
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
|
||||
')'
|
||||
"
|
||||
:tooltip="tooltipText"
|
||||
:selected="isShortcutsPanelVisible"
|
||||
@click="toggleShortcutsPanel"
|
||||
>
|
||||
@@ -17,28 +12,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const command = useCommandStore().getCommand(
|
||||
'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
)
|
||||
const commandStore = useCommandStore()
|
||||
const command = commandStore.getCommand('Workspace.ToggleBottomPanel.Shortcuts')
|
||||
const { formatKeySequence } = commandStore
|
||||
|
||||
const isShortcutsPanelVisible = computed(
|
||||
() => bottomPanelStore.activePanel === 'shortcuts'
|
||||
)
|
||||
|
||||
const tooltipText = computed(
|
||||
() => `${t('shortcuts.keyboardShortcuts')} (${formatKeySequence(command)})`
|
||||
)
|
||||
|
||||
const toggleShortcutsPanel = () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
|
||||
const formatKeySequence = (sequences: string[]): string => {
|
||||
return sequences
|
||||
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
|
||||
.join(' + ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:icon="TemplateIcon"
|
||||
icon="icon-[comfy--template]"
|
||||
:tooltip="$t('sideToolbar.templates')"
|
||||
:label="$t('sideToolbar.labels.templates')"
|
||||
:is-small="isSmall"
|
||||
@@ -10,18 +10,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
// Import the custom template icon
|
||||
const TemplateIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ const cancelledWithoutResults = computed(() => {
|
||||
|
||||
.task-item-details {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -94,18 +94,16 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
// Check base image
|
||||
const baseImageClass = lazyImages[0].props('imageClass')
|
||||
const baseClassString = Array.isArray(baseImageClass)
|
||||
? baseImageClass.join(' ')
|
||||
: baseImageClass
|
||||
expect(baseClassString).toContain('absolute')
|
||||
expect(baseClassString).toContain('inset-0')
|
||||
const baseClassList = Array.isArray(baseImageClass)
|
||||
? baseImageClass
|
||||
: baseImageClass.split(' ')
|
||||
expect(baseClassList).toContain('size-full')
|
||||
|
||||
// Check overlay image
|
||||
const overlayImageClass = lazyImages[1].props('imageClass')
|
||||
const overlayClassString = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass.join(' ')
|
||||
: overlayImageClass
|
||||
expect(overlayClassString).toContain('absolute')
|
||||
expect(overlayClassString).toContain('inset-0')
|
||||
const overlayClassList = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass
|
||||
: overlayImageClass.split(' ')
|
||||
expect(overlayClassList).toContain('size-full')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<div class="relative w-full h-full">
|
||||
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="overlayImageClass"
|
||||
/>
|
||||
<div class="absolute inset-0">
|
||||
<LazyImage
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="baseImageClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 z-10">
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="overlayImageClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
@@ -32,14 +40,15 @@ const isVideoType =
|
||||
false
|
||||
|
||||
const baseImageClass = computed(() => {
|
||||
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
|
||||
const sizeClasses = isVideoType
|
||||
? 'size-full object-cover'
|
||||
: 'size-full object-contain'
|
||||
return sizeClasses
|
||||
})
|
||||
|
||||
const overlayImageClass = computed(() => {
|
||||
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
|
||||
const sizeClasses = isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
const baseClasses = 'size-full transition-opacity duration-300'
|
||||
const sizeClasses = isVideoType ? 'object-cover' : 'object-contain'
|
||||
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
|
||||
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
|
||||
})
|
||||
|
||||
@@ -187,7 +187,7 @@ const extraMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
key: 'browse-templates',
|
||||
label: t('menuLabels.Browse Templates'),
|
||||
icon: 'pi pi-folder-open',
|
||||
icon: 'icon-[comfy--template]',
|
||||
command: () => commandStore.execute('Comfy.BrowseTemplates')
|
||||
},
|
||||
{
|
||||
|
||||
47
src/components/topbar/WorkflowOverflowMenu.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreWorkflows'), showDelay: 300 }"
|
||||
class="rounded-none"
|
||||
icon="pi pi-ellipsis-h"
|
||||
text
|
||||
severity="secondary"
|
||||
:aria-label="$t('g.moreWorkflows')"
|
||||
@click="menu?.toggle($event)"
|
||||
/>
|
||||
<Menu
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
class="max-h-[40vh] overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
const props = defineProps<{
|
||||
workflows: ComfyWorkflow[]
|
||||
activeWorkflow: ComfyWorkflow | null
|
||||
}>()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
const menuItems = computed(() =>
|
||||
props.workflows.map((workflow: ComfyWorkflow) => ({
|
||||
label: workflow.filename,
|
||||
icon:
|
||||
props.activeWorkflow?.key === workflow.key ? 'pi pi-check' : undefined,
|
||||
command: () => {
|
||||
void workflowService.openWorkflow(workflow)
|
||||
}
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
ref="scrollPanelRef"
|
||||
class="overflow-hidden no-drag"
|
||||
:pt:content="{
|
||||
class: 'p-0 w-full',
|
||||
class: 'p-0 w-full flex',
|
||||
onwheel: handleWheel
|
||||
}"
|
||||
pt:bar-x="h-1"
|
||||
@@ -48,6 +48,11 @@
|
||||
:disabled="!rightArrowEnabled"
|
||||
@mousedown="whileMouseDown($event, () => scroll(1))"
|
||||
/>
|
||||
<WorkflowOverflowMenu
|
||||
v-if="showOverflowArrows"
|
||||
:workflows="workflowStore.openWorkflows"
|
||||
:active-workflow="workflowStore.activeWorkflow"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
|
||||
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
|
||||
@@ -85,6 +90,8 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
|
||||
interface WorkflowOption {
|
||||
value: string
|
||||
workflow: ComfyWorkflow
|
||||
|
||||
556
src/components/widget/layout/BaseWidget.stories.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
Download,
|
||||
Filter,
|
||||
Folder,
|
||||
Info,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
Puzzle,
|
||||
Scroll,
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '../panel/RightSidePanel.vue'
|
||||
import BaseWidgetLayout from './BaseWidgetLayout.vue'
|
||||
|
||||
interface StoryArgs {
|
||||
contentTitle: string
|
||||
hasLeftPanel: boolean
|
||||
hasRightPanel: boolean
|
||||
hasHeader: boolean
|
||||
hasContentFilter: boolean
|
||||
hasHeaderRightArea: boolean
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Widget/Layout/BaseWidgetLayout',
|
||||
argTypes: {
|
||||
contentTitle: {
|
||||
control: 'text',
|
||||
description: 'Title shown when no left panel is present'
|
||||
},
|
||||
hasLeftPanel: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle left panel visibility'
|
||||
},
|
||||
hasRightPanel: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle right panel visibility'
|
||||
},
|
||||
hasHeader: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle header visibility'
|
||||
},
|
||||
hasContentFilter: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle content filter visibility'
|
||||
},
|
||||
hasHeaderRightArea: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle header right area visibility'
|
||||
},
|
||||
cardCount: {
|
||||
control: { type: 'range', min: 0, max: 50, step: 1 },
|
||||
description: 'Number of cards to display in content'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseWidgetLayout,
|
||||
LeftSidePanel,
|
||||
RightSidePanel,
|
||||
SearchBox,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
IconButton,
|
||||
IconTextButton,
|
||||
MoreButton,
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
SquareChip,
|
||||
Settings,
|
||||
Upload,
|
||||
Download,
|
||||
Scroll,
|
||||
Info,
|
||||
Filter,
|
||||
Folder,
|
||||
Puzzle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
X
|
||||
},
|
||||
setup() {
|
||||
const t = (k: string) => k
|
||||
|
||||
const onClose = () => {
|
||||
console.log('OnClose invoked')
|
||||
}
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{
|
||||
title: 'TAGS',
|
||||
items: [
|
||||
{ id: 'tag-sd15', label: 'SD 1.5' },
|
||||
{ id: 'tag-sdxl', label: 'SDXL' },
|
||||
{ id: 'tag-utility', label: 'Utility' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'CATEGORIES',
|
||||
items: [
|
||||
{ id: 'cat-models', label: 'Models' },
|
||||
{ id: 'cat-nodes', label: 'Nodes' }
|
||||
]
|
||||
}
|
||||
])
|
||||
const selectedNavItem = ref<string | null>('installed')
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
])
|
||||
const projectOptions = ref([
|
||||
{ name: 'Project A', value: 'proj-a' },
|
||||
{ name: 'Project B', value: 'proj-b' },
|
||||
{ name: 'Project C', value: 'proj-c' }
|
||||
])
|
||||
const sortOptions = ref([
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Latest', value: 'latest' },
|
||||
{ name: 'A → Z', value: 'az' }
|
||||
])
|
||||
|
||||
const selectedFrameworks = ref<string[]>([])
|
||||
const selectedProjects = ref<string[]>([])
|
||||
const selectedSort = ref<string>('popular')
|
||||
|
||||
return {
|
||||
args,
|
||||
t,
|
||||
tempNavigation,
|
||||
selectedNavItem,
|
||||
searchQuery,
|
||||
frameworkOptions,
|
||||
projectOptions,
|
||||
sortOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedSort
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<Puzzle :size="16" class="text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
:modelValue="searchQuery"
|
||||
@update:modelValue="searchQuery = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Header Right Area -->
|
||||
<template v-if="args.hasHeaderRightArea" #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
|
||||
<template #icon>
|
||||
<Upload :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Scroll :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content Filter -->
|
||||
<template v-if="args.hasContentFilter" #contentFilter>
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
label="Select Projects"
|
||||
:options="projectOptions"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-model="selectedSort"
|
||||
label="Sorting Type"
|
||||
:options="sortOptions"
|
||||
class="w-[135px]"
|
||||
>
|
||||
<template #icon>
|
||||
<Filter :size="12" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<template #content>
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
||||
<CardContainer
|
||||
v-for="i in args.cardCount"
|
||||
:key="i"
|
||||
ratio="square"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div class="w-full h-full bg-blue-500"></div>
|
||||
</template>
|
||||
<template #top-right>
|
||||
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip label="png" />
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<template #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom />
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</template>
|
||||
</BaseWidgetLayout>
|
||||
|
||||
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Same content but WITH right panel -->
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<Puzzle :size="16" class="text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
:modelValue="searchQuery"
|
||||
@update:modelValue="searchQuery = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Header Right Area -->
|
||||
<template v-if="args.hasHeaderRightArea" #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
|
||||
<template #icon>
|
||||
<Upload :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Scroll :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content Filter -->
|
||||
<template v-if="args.hasContentFilter" #contentFilter>
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
label="Select Projects"
|
||||
:options="projectOptions"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-model="selectedSort"
|
||||
label="Sorting Type"
|
||||
:options="sortOptions"
|
||||
class="w-[135px]"
|
||||
>
|
||||
<template #icon>
|
||||
<Filter :size="12" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<template #content>
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
||||
<CardContainer
|
||||
v-for="i in args.cardCount"
|
||||
:key="i"
|
||||
ratio="square"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div class="w-full h-full bg-blue-500"></div>
|
||||
</template>
|
||||
<template #top-right>
|
||||
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip label="png" />
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<template #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom />
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Right Panel - Only when hasRightPanel is true -->
|
||||
<template #rightPanel>
|
||||
<RightSidePanel />
|
||||
</template>
|
||||
</BaseWidgetLayout>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Content Title',
|
||||
hasLeftPanel: true,
|
||||
hasRightPanel: true,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 12
|
||||
}
|
||||
}
|
||||
|
||||
export const BothPanels: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Content Title',
|
||||
hasLeftPanel: true,
|
||||
hasRightPanel: true,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 12
|
||||
}
|
||||
}
|
||||
|
||||
export const LeftPanelOnly: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Content Title',
|
||||
hasLeftPanel: true,
|
||||
hasRightPanel: false,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 12
|
||||
}
|
||||
}
|
||||
|
||||
export const RightPanelOnly: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Content Title',
|
||||
hasLeftPanel: false,
|
||||
hasRightPanel: true,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 12
|
||||
}
|
||||
}
|
||||
|
||||
export const NoPanels: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Content Title',
|
||||
hasLeftPanel: false,
|
||||
hasRightPanel: false,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 12
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalLayout: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Simple Content',
|
||||
hasLeftPanel: false,
|
||||
hasRightPanel: false,
|
||||
hasHeader: false,
|
||||
hasContentFilter: false,
|
||||
hasHeaderRightArea: false,
|
||||
cardCount: 6
|
||||
}
|
||||
}
|
||||
|
||||
export const NoContent: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Empty State',
|
||||
hasLeftPanel: true,
|
||||
hasRightPanel: true,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const HeaderOnly: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Header Layout',
|
||||
hasLeftPanel: false,
|
||||
hasRightPanel: false,
|
||||
hasHeader: true,
|
||||
hasContentFilter: false,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 8
|
||||
}
|
||||
}
|
||||
|
||||
export const FilterOnly: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Filter Layout',
|
||||
hasLeftPanel: false,
|
||||
hasRightPanel: false,
|
||||
hasHeader: false,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: false,
|
||||
cardCount: 8
|
||||
}
|
||||
}
|
||||
|
||||
export const MaxContent: Story = {
|
||||
render: (args: StoryArgs) => createStoryTemplate(args),
|
||||
args: {
|
||||
contentTitle: 'Full Content',
|
||||
hasLeftPanel: true,
|
||||
hasRightPanel: true,
|
||||
hasHeader: true,
|
||||
hasContentFilter: true,
|
||||
hasHeaderRightArea: true,
|
||||
cardCount: 50
|
||||
}
|
||||
}
|
||||
138
src/components/widget/nav/Navigation.stories.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
GraduationCap,
|
||||
Home,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
User,
|
||||
Users
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import NavItem from './NavItem.vue'
|
||||
import NavTitle from './NavTitle.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Widget/Navigation',
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const NavigationItem: Story = {
|
||||
render: () => ({
|
||||
components: { NavItem },
|
||||
template: `
|
||||
<div class="space-y-2">
|
||||
<NavItem>Dashboard</NavItem>
|
||||
<NavItem>Projects</NavItem>
|
||||
<NavItem>Messages</NavItem>
|
||||
<NavItem>Settings</NavItem>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomNavigation: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
NavTitle,
|
||||
NavItem,
|
||||
Home,
|
||||
FolderOpen,
|
||||
BarChart3,
|
||||
Users,
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
User,
|
||||
Bell,
|
||||
LogOut
|
||||
},
|
||||
template: `
|
||||
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
|
||||
<NavTitle title="Main Menu" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<NavTitle title="Resources" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<NavTitle title="Account" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
|
||||
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LeftSidePanelDemo: Story = {
|
||||
render: () => ({
|
||||
components: { LeftSidePanel, FolderOpen },
|
||||
setup() {
|
||||
const navItems = [
|
||||
{
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'projects', label: 'Projects' },
|
||||
{ id: 'workflows', label: 'Workflows' },
|
||||
{ id: 'models', label: 'Models' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{ id: 'node-editor', label: 'Node Editor' },
|
||||
{ id: 'image-browser', label: 'Image Browser' },
|
||||
{ id: 'queue-manager', label: 'Queue Manager' },
|
||||
{ id: 'extensions', label: 'Extensions' }
|
||||
]
|
||||
},
|
||||
{ id: 'settings', label: 'Settings' }
|
||||
]
|
||||
const active = ref<string | null>(null)
|
||||
return { navItems, active }
|
||||
},
|
||||
template: `
|
||||
<div class="w-full h-[560px] flex">
|
||||
<div class="w-64 rounded-lg">
|
||||
<LeftSidePanel v-model="active" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<FolderOpen :size="14" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
Navigation
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
|
||||
Active: {{ active ?? 'None' }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
113
src/composables/canvas/useSelectionToolboxPosition.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
* Uses CSS custom properties for performant transform updates.
|
||||
*/
|
||||
export function useSelectionToolboxPosition(
|
||||
toolboxRef: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
|
||||
// World position of selection center
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
/**
|
||||
* Update position based on selection
|
||||
*/
|
||||
const updateSelectionBounds = () => {
|
||||
const selectableItems = getSelectableItems()
|
||||
|
||||
if (!selectableItems.size) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const [xBase, y, width] = bounds
|
||||
|
||||
worldPosition.value = {
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
}
|
||||
|
||||
const updateTransform = () => {
|
||||
if (!visible.value) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
const canvasRect = lgCanvas.canvas.getBoundingClientRect()
|
||||
|
||||
const screenX =
|
||||
(worldPosition.value.x + offset[0]) * scale + canvasRect.left
|
||||
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasRect.top
|
||||
|
||||
// Update CSS custom properties directly for best performance
|
||||
if (toolboxRef.value) {
|
||||
toolboxRef.value.style.setProperty('--tb-x', `${screenX}px`)
|
||||
toolboxRef.value.style.setProperty('--tb-y', `${screenY}px`)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with canvas transform
|
||||
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, {
|
||||
autoStart: false
|
||||
})
|
||||
|
||||
// Watch for selection changes
|
||||
watch(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
(changed) => {
|
||||
if (changed) {
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
|
||||
// Start transform sync if we have selection
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for dragging state
|
||||
watch(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
(dragging) => {
|
||||
if (dragging) {
|
||||
// Hide during node dragging
|
||||
visible.value = false
|
||||
} else {
|
||||
// Update after dragging ends
|
||||
requestAnimationFrame(() => {
|
||||
updateSelectionBounds()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
visible
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import type { Ref, WatchSource } from 'vue'
|
||||
|
||||
/**
|
||||
* A composable that manages retriggerable CSS animations.
|
||||
* Provides a boolean ref that can be toggled to restart CSS animations.
|
||||
*
|
||||
* @param trigger - Optional reactive source that triggers the animation when it changes
|
||||
* @param options - Configuration options
|
||||
* @returns An object containing the animation state ref
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <div :class="{ 'animate-slide-up': shouldAnimate }">
|
||||
* Content
|
||||
* </div>
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const { shouldAnimate } = useRetriggerableAnimation(someReactiveTrigger)
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useRetriggerableAnimation<T = any>(
|
||||
trigger?: WatchSource<T> | Ref<T>,
|
||||
options: {
|
||||
animateOnMount?: boolean
|
||||
animationDelay?: number
|
||||
} = {}
|
||||
) {
|
||||
const { animateOnMount = true, animationDelay = 0 } = options
|
||||
|
||||
const shouldAnimate = ref(false)
|
||||
|
||||
/**
|
||||
* Retriggers the animation by removing and re-adding the animation class
|
||||
*/
|
||||
const retriggerAnimation = () => {
|
||||
// Remove animation class
|
||||
shouldAnimate.value = false
|
||||
// Force browser reflow to ensure the class removal is processed
|
||||
void document.body.offsetHeight
|
||||
// Re-add animation class in the next frame
|
||||
requestAnimationFrame(() => {
|
||||
if (animationDelay > 0) {
|
||||
setTimeout(() => {
|
||||
shouldAnimate.value = true
|
||||
}, animationDelay)
|
||||
} else {
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger animation on mount if requested
|
||||
if (animateOnMount) {
|
||||
onMounted(() => {
|
||||
if (animationDelay > 0) {
|
||||
setTimeout(() => {
|
||||
shouldAnimate.value = true
|
||||
}, animationDelay)
|
||||
} else {
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for trigger changes to retrigger animation
|
||||
if (trigger) {
|
||||
watch(trigger, () => {
|
||||
retriggerAnimation()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
shouldAnimate,
|
||||
retriggerAnimation
|
||||
}
|
||||
}
|
||||
@@ -1383,6 +1383,38 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ViduStartEndToVideoNode: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
ByteDanceImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('seedream-3-0-t2i')) {
|
||||
return '$0.03/Run'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
ByteDanceImageEditNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('seededit-3-0-i2i')) {
|
||||
return '$0.03/Run'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1470,7 +1502,10 @@ export const useNodePricing = () => {
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model']
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
ByteDanceImageNode: ['model'],
|
||||
ByteDanceImageEditNode: ['model']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const AiModelIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/ai-model'))
|
||||
)
|
||||
|
||||
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'model-library',
|
||||
icon: AiModelIcon,
|
||||
icon: 'icon-[comfy--ai-model]',
|
||||
title: 'sideToolbar.modelLibrary',
|
||||
tooltip: 'sideToolbar.modelLibrary',
|
||||
label: 'sideToolbar.labels.models',
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
const NodeIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/node'))
|
||||
)
|
||||
|
||||
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'node-library',
|
||||
icon: NodeIcon,
|
||||
icon: 'icon-[comfy--node]',
|
||||
title: 'sideToolbar.nodeLibrary',
|
||||
tooltip: 'sideToolbar.nodeLibrary',
|
||||
label: 'sideToolbar.labels.nodes',
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
const WorkflowIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/workflow'))
|
||||
)
|
||||
|
||||
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
id: 'workflows',
|
||||
icon: WorkflowIcon,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
iconBadge: () => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
|
||||
|
||||
@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const { copy, copied } = useClipboard()
|
||||
const toast = useToast()
|
||||
const showSuccessToast = () => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
const showErrorToast = () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.setAttribute('aria-hidden', 'true')
|
||||
textarea.setAttribute('tabindex', '-1')
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
// using legacy document.execCommand for fallback for old and linux browsers
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showSuccessToast()
|
||||
} else {
|
||||
showErrorToast()
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorToast()
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
await copy(text)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
try {
|
||||
await copy(text)
|
||||
if (copied.value) {
|
||||
showSuccessToast()
|
||||
} else {
|
||||
// If VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
}
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorNotSupported')
|
||||
})
|
||||
} catch (err) {
|
||||
// VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -298,8 +298,26 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ToggleLock',
|
||||
icon: 'pi pi-lock',
|
||||
label: 'Canvas Toggle Lock',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
app.canvas['read_only'] = !app.canvas['read_only']
|
||||
app.canvas.state.readOnly = !app.canvas.state.readOnly
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.Lock',
|
||||
icon: 'pi pi-lock',
|
||||
label: 'Lock Canvas',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
app.canvas.state.readOnly = true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.Unlock',
|
||||
icon: 'pi pi-lock-open',
|
||||
label: 'Unlock Canvas',
|
||||
function: () => {
|
||||
app.canvas.state.readOnly = false
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
27
src/composables/useZoomControls.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export function useZoomControls() {
|
||||
const isModalVisible = ref(false)
|
||||
|
||||
const showModal = () => {
|
||||
isModalVisible.value = true
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
isModalVisible.value = false
|
||||
}
|
||||
|
||||
const toggleModal = () => {
|
||||
isModalVisible.value = !isModalVisible.value
|
||||
}
|
||||
|
||||
const hasActivePopup = computed(() => isModalVisible.value)
|
||||
|
||||
return {
|
||||
isModalVisible,
|
||||
showModal,
|
||||
hideModal,
|
||||
toggleModal,
|
||||
hasActivePopup
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,18 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.Unlock'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'h'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.Lock'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Escape'
|
||||
|
||||
@@ -791,11 +791,11 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: 'standard', text: 'Standard (New)' },
|
||||
{ value: 'legacy', text: 'Left-Click Pan (Legacy)' }
|
||||
{ value: 'legacy', text: 'Drag Navigation' }
|
||||
],
|
||||
versionAdded: '1.25.0',
|
||||
defaultsByInstallVersion: {
|
||||
'1.25.0': 'standard'
|
||||
'1.25.0': 'legacy'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
import './saveMesh'
|
||||
import './selectionBorder'
|
||||
import './simpleTouchSupport'
|
||||
import './slotDefaults'
|
||||
import './uploadAudio'
|
||||
|
||||
70
src/extensions/core/selectionBorder.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { type LGraphCanvas, createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Draws a dashed border around selected items that maintains constant pixel size
|
||||
* regardless of zoom level, similar to the DOM selection overlay.
|
||||
*/
|
||||
function drawSelectionBorder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: LGraphCanvas
|
||||
) {
|
||||
const selectedItems = canvas.selectedItems
|
||||
|
||||
// Only draw if multiple items selected
|
||||
if (selectedItems.size <= 1) return
|
||||
|
||||
// Use the same bounds calculation as the toolbox
|
||||
const bounds = createBounds(selectedItems, 10)
|
||||
if (!bounds) return
|
||||
|
||||
const [x, y, width, height] = bounds
|
||||
|
||||
// Save context state
|
||||
ctx.save()
|
||||
|
||||
// Set up dashed line style that doesn't scale with zoom
|
||||
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
|
||||
ctx.lineWidth = borderWidth
|
||||
ctx.strokeStyle =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--border-color')
|
||||
.trim() || '#ffffff66'
|
||||
|
||||
// Create dash pattern that maintains visual size
|
||||
const dashSize = 5 / canvas.ds.scale
|
||||
ctx.setLineDash([dashSize, dashSize])
|
||||
|
||||
// Draw the border using the bounds directly
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, width, height, 8 / canvas.ds.scale)
|
||||
ctx.stroke()
|
||||
|
||||
// Restore context
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension that adds a dashed selection border for multiple selected nodes
|
||||
*/
|
||||
const ext = {
|
||||
name: 'Comfy.SelectionBorder',
|
||||
|
||||
async init() {
|
||||
// Hook into the canvas drawing
|
||||
const originalDrawForeground = app.canvas.onDrawForeground
|
||||
|
||||
app.canvas.onDrawForeground = function (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
visibleArea: any
|
||||
) {
|
||||
// Call original if it exists
|
||||
originalDrawForeground?.call(this, ctx, visibleArea)
|
||||
|
||||
// Draw our selection border
|
||||
drawSelectionBorder(ctx, app.canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension(ext)
|
||||
@@ -54,7 +54,10 @@ export class CanvasPointer {
|
||||
* After a flick gesture is complete, the automatic wheel events are sent with
|
||||
* reduced frequency, but much higher deltaX and deltaY values.
|
||||
*/
|
||||
static trackpadMaxGap = 200
|
||||
static trackpadMaxGap = 500
|
||||
|
||||
/** The maximum time in milliseconds to buffer a high-res wheel event. */
|
||||
static maxHighResBufferTime = 10
|
||||
|
||||
/** The element this PointerState should capture input against when dragging. */
|
||||
element: Element
|
||||
@@ -90,8 +93,23 @@ export class CanvasPointer {
|
||||
/** The last pointerup event for the primary button */
|
||||
eUp?: CanvasPointerEvent
|
||||
|
||||
/** The last pointermove event that was treated as a trackpad gesture. */
|
||||
lastTrackpadEvent?: WheelEvent
|
||||
/** Currently detected input device type */
|
||||
detectedDevice: 'mouse' | 'trackpad' = 'mouse'
|
||||
|
||||
/** Timestamp of last wheel event for cooldown tracking */
|
||||
lastWheelEventTime: number = 0
|
||||
|
||||
/** Flag to track if we've received the first wheel event */
|
||||
hasReceivedWheelEvent: boolean = false
|
||||
|
||||
/** Buffered Linux wheel event awaiting confirmation */
|
||||
bufferedLinuxEvent?: WheelEvent
|
||||
|
||||
/** Timestamp when Linux event was buffered */
|
||||
bufferedLinuxEventTime: number = 0
|
||||
|
||||
/** Timer ID for Linux buffer clearing */
|
||||
linuxBufferTimeoutId?: ReturnType<typeof setTimeout>
|
||||
|
||||
/**
|
||||
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
|
||||
@@ -273,33 +291,179 @@ export class CanvasPointer {
|
||||
delete this.onDragStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a continued trackpad gesture.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
|
||||
*/
|
||||
#isContinuationOfGesture(e: WheelEvent): boolean {
|
||||
const { lastTrackpadEvent } = this
|
||||
if (!lastTrackpadEvent) return false
|
||||
|
||||
return (
|
||||
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a trackpad gesture.
|
||||
* This method now uses the new device detection internally for improved accuracy.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
|
||||
*/
|
||||
isTrackpadGesture(e: WheelEvent): boolean {
|
||||
if (this.#isContinuationOfGesture(e)) {
|
||||
this.lastTrackpadEvent = e
|
||||
// Use the new device detection
|
||||
const now = performance.now()
|
||||
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
|
||||
this.lastWheelEventTime = now
|
||||
|
||||
if (this.#isHighResWheelEvent(e, now)) {
|
||||
this.detectedDevice = 'mouse'
|
||||
} else if (this.#isWithinCooldown(timeSinceLastEvent)) {
|
||||
if (this.#shouldBufferLinuxEvent(e)) {
|
||||
this.#bufferLinuxEvent(e, now)
|
||||
}
|
||||
} else {
|
||||
this.#updateDeviceMode(e, now)
|
||||
this.hasReceivedWheelEvent = true
|
||||
}
|
||||
|
||||
return this.detectedDevice === 'trackpad'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates buffered high res wheel events and switches to mouse mode if pattern matches.
|
||||
* @returns `true` if switched to mouse mode
|
||||
*/
|
||||
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
|
||||
if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const timeSinceBuffer = now - this.bufferedLinuxEventTime
|
||||
|
||||
if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) {
|
||||
this.#clearLinuxBuffer()
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
event.deltaX === 0 &&
|
||||
this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
|
||||
) {
|
||||
this.#clearLinuxBuffer()
|
||||
return true
|
||||
}
|
||||
|
||||
const threshold = CanvasPointer.trackpadThreshold
|
||||
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're within the cooldown period where mode switching is disabled.
|
||||
*/
|
||||
#isWithinCooldown(timeSinceLastEvent: number): boolean {
|
||||
const isFirstEvent = !this.hasReceivedWheelEvent
|
||||
const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap
|
||||
return !isFirstEvent && !cooldownExpired
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the device mode based on event patterns.
|
||||
*/
|
||||
#updateDeviceMode(event: WheelEvent, now: number): void {
|
||||
if (this.#isTrackpadPattern(event)) {
|
||||
this.detectedDevice = 'trackpad'
|
||||
} else if (this.#isMousePattern(event)) {
|
||||
this.detectedDevice = 'mouse'
|
||||
} else if (
|
||||
this.detectedDevice === 'trackpad' &&
|
||||
this.#shouldBufferLinuxEvent(event)
|
||||
) {
|
||||
this.#bufferLinuxEvent(event, now)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the buffered Linux wheel event and associated timer.
|
||||
*/
|
||||
#clearLinuxBuffer(): void {
|
||||
this.bufferedLinuxEvent = undefined
|
||||
this.bufferedLinuxEventTime = 0
|
||||
if (this.linuxBufferTimeoutId !== undefined) {
|
||||
clearTimeout(this.linuxBufferTimeoutId)
|
||||
this.linuxBufferTimeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the event matches trackpad input patterns.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
#isTrackpadPattern(event: WheelEvent): boolean {
|
||||
// Two-finger panning: non-zero deltaX AND deltaY
|
||||
if (event.deltaX !== 0 && event.deltaY !== 0) return true
|
||||
|
||||
// Pinch-to-zoom: ctrlKey with small deltaY
|
||||
if (event.ctrlKey && Math.abs(event.deltaY) < 10) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the event matches mouse wheel input patterns.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
#isMousePattern(event: WheelEvent): boolean {
|
||||
const absoluteDeltaY = Math.abs(event.deltaY)
|
||||
|
||||
// Primary threshold for switching from trackpad to mouse
|
||||
if (absoluteDeltaY > 80) return true
|
||||
|
||||
// Secondary threshold when already in mouse mode
|
||||
return (
|
||||
absoluteDeltaY >= 60 &&
|
||||
event.deltaX === 0 &&
|
||||
this.detectedDevice === 'mouse'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the event should be buffered as a potential Linux wheel event.
|
||||
* @param event The wheel event to check
|
||||
*/
|
||||
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
|
||||
const absoluteDeltaY = Math.abs(event.deltaY)
|
||||
const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60
|
||||
const isVerticalOnly = event.deltaX === 0
|
||||
const hasIntegerDelta = Number.isInteger(event.deltaY)
|
||||
|
||||
return (
|
||||
this.detectedDevice === 'trackpad' &&
|
||||
isInLinuxRange &&
|
||||
isVerticalOnly &&
|
||||
hasIntegerDelta
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers a potential Linux wheel event for later confirmation.
|
||||
* @param event The event to buffer
|
||||
* @param now The current timestamp
|
||||
*/
|
||||
#bufferLinuxEvent(event: WheelEvent, now: number): void {
|
||||
if (this.linuxBufferTimeoutId !== undefined) {
|
||||
clearTimeout(this.linuxBufferTimeoutId)
|
||||
}
|
||||
|
||||
this.bufferedLinuxEvent = event
|
||||
this.bufferedLinuxEventTime = now
|
||||
|
||||
// Set timeout to clear buffer after 10ms
|
||||
this.linuxBufferTimeoutId = setTimeout(() => {
|
||||
this.#clearLinuxBuffer()
|
||||
}, CanvasPointer.maxHighResBufferTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two deltaY values follow a Linux wheel pattern (divisibility).
|
||||
* @param deltaY1 The first deltaY value
|
||||
* @param deltaY2 The second deltaY value
|
||||
*/
|
||||
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
|
||||
const absolute1 = Math.abs(deltaY1)
|
||||
const absolute2 = Math.abs(deltaY2)
|
||||
|
||||
if (absolute1 === 0 || absolute2 === 0) return false
|
||||
if (absolute1 === absolute2) return true
|
||||
|
||||
// Check if one value is a multiple of the other
|
||||
return absolute1 % absolute2 === 0 || absolute2 % absolute1 === 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
INodeSlot,
|
||||
INodeSlotContextItem,
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
NullableProperties,
|
||||
Point,
|
||||
@@ -134,7 +135,7 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
|
||||
interface HasShowSearchCallback {
|
||||
/** See {@link LGraphCanvas.showSearchBox} */
|
||||
showSearchBox: (
|
||||
event: MouseEvent,
|
||||
event: MouseEvent | null,
|
||||
options?: IShowSearchOptions
|
||||
) => HTMLDivElement | void
|
||||
}
|
||||
@@ -2575,16 +2576,27 @@ export class LGraphCanvas
|
||||
} else if (!node.flags.collapsed) {
|
||||
const { inputs, outputs } = node
|
||||
|
||||
function hasRelevantOutputLinks(
|
||||
output: INodeOutputSlot,
|
||||
network: LinkNetwork
|
||||
): boolean {
|
||||
const outputLinks = [
|
||||
...(output.links ?? []),
|
||||
...[...(output._floatingLinks ?? new Set())]
|
||||
]
|
||||
return outputLinks.some(
|
||||
(linkId) =>
|
||||
typeof linkId === 'number' && network.getLink(linkId) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Outputs
|
||||
if (outputs) {
|
||||
for (const [i, output] of outputs.entries()) {
|
||||
const link_pos = node.getOutputPos(i)
|
||||
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
|
||||
// Drag multiple output links
|
||||
if (
|
||||
e.shiftKey &&
|
||||
(output.links?.length || output._floatingLinks?.size)
|
||||
) {
|
||||
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
|
||||
linkConnector.moveOutputLink(graph, output)
|
||||
this.#linkConnectorDrop()
|
||||
return
|
||||
@@ -3486,8 +3498,11 @@ export class LGraphCanvas
|
||||
|
||||
// Detect if this is a trackpad gesture or mouse wheel
|
||||
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
||||
const isCtrlOrMacMeta =
|
||||
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
|
||||
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
|
||||
|
||||
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||
if (isTrackpad) {
|
||||
// Trackpad gesture - use smooth scaling
|
||||
@@ -6855,7 +6870,7 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
showSearchBox(
|
||||
event: MouseEvent,
|
||||
event: MouseEvent | null,
|
||||
searchOptions?: IShowSearchOptions
|
||||
): HTMLDivElement {
|
||||
// proposed defaults
|
||||
@@ -7090,14 +7105,25 @@ export class LGraphCanvas
|
||||
// compute best position
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
|
||||
const left = (event ? event.clientX : rect.left + rect.width * 0.5) - 80
|
||||
const top = (event ? event.clientY : rect.top + rect.height * 0.5) - 20
|
||||
// Handles cases where the searchbox is initiated by
|
||||
// non-click events. e.g. Keyboard shortcuts
|
||||
const safeEvent =
|
||||
event ??
|
||||
new MouseEvent('click', {
|
||||
clientX: rect.left + rect.width * 0.5,
|
||||
clientY: rect.top + rect.height * 0.5,
|
||||
// @ts-expect-error layerY is a nonstandard property
|
||||
layerY: rect.top + rect.height * 0.5
|
||||
})
|
||||
|
||||
const left = safeEvent.clientX - 80
|
||||
const top = safeEvent.clientY - 20
|
||||
dialog.style.left = `${left}px`
|
||||
dialog.style.top = `${top}px`
|
||||
|
||||
// To avoid out of screen problems
|
||||
if (event.layerY > rect.height - 200) {
|
||||
helper.style.maxHeight = `${rect.height - event.layerY - 20}px`
|
||||
if (safeEvent.layerY > rect.height - 200) {
|
||||
helper.style.maxHeight = `${rect.height - safeEvent.layerY - 20}px`
|
||||
}
|
||||
requestAnimationFrame(function () {
|
||||
input.focus()
|
||||
@@ -7107,14 +7133,14 @@ export class LGraphCanvas
|
||||
function select(name: string) {
|
||||
if (name) {
|
||||
if (that.onSearchBoxSelection) {
|
||||
that.onSearchBoxSelection(name, event, graphcanvas)
|
||||
that.onSearchBoxSelection(name, safeEvent, graphcanvas)
|
||||
} else {
|
||||
if (!graphcanvas.graph) throw new NullGraphError()
|
||||
|
||||
graphcanvas.graph.beforeChange()
|
||||
const node = LiteGraph.createNode(name)
|
||||
if (node) {
|
||||
node.pos = graphcanvas.convertEventToCanvasOffset(event)
|
||||
node.pos = graphcanvas.convertEventToCanvasOffset(safeEvent)
|
||||
graphcanvas.graph.add(node, false)
|
||||
}
|
||||
|
||||
|
||||