diff --git a/.claude/commands/comprehensive-pr-review.md b/.claude/commands/comprehensive-pr-review.md index 84708564ea..1b4047e782 100644 --- a/.claude/commands/comprehensive-pr-review.md +++ b/.claude/commands/comprehensive-pr-review.md @@ -67,9 +67,9 @@ This is critical for better file inspection: Use git locally for much faster analysis: -1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt` -2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt` -3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt` +1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt` +2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt` +3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt` ### Step 1.5: Create Analysis Cache diff --git a/.gitattributes b/.gitattributes index de05efbf41..0f538ae762 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,5 +12,5 @@ *.yaml text eol=lf # Generated files -src/types/comfyRegistryTypes.ts linguist-generated=true -src/types/generatedManagerTypes.ts linguist-generated=true +packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true +src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 907695e573..178bd4ee81 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -4,10 +4,25 @@ on: pull_request_target: types: [closed, labeled] branches: [main] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to backport' + required: true + type: string + force_rerun: + description: 'Force rerun even if backports exist' + required: false + type: boolean + default: false jobs: backport: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport') + if: > + (github.event_name == 'pull_request_target' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'needs-backport')) || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -15,6 +30,35 @@ jobs: issues: write steps: + - name: Validate inputs for manual triggers + if: github.event_name == 'workflow_dispatch' + run: | + # Validate PR number format + if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number format. Must be a positive integer." + exit 1 + fi + + # Validate PR exists and is merged + if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then + echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible." + exit 1 + fi + + MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged') + if [ "$MERGED" != "true" ]; then + echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported." + exit 1 + fi + + # Validate PR has needs-backport label + if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then + echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label." + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout repository uses: actions/checkout@v4 with: @@ -29,7 +73,7 @@ jobs: id: check-existing env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | # Check for existing backport PRs for this PR number EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName') @@ -39,6 +83,13 @@ jobs: exit 0 fi + # For manual triggers with force_rerun, proceed anyway + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then + echo "skip=false" >> $GITHUB_OUTPUT + echo "::warning::Force rerun requested - existing backports will be updated" + exit 0 + fi + echo "Found existing backport PRs:" echo "$EXISTING_BACKPORTS" echo "skip=true" >> $GITHUB_OUTPUT @@ -50,8 +101,17 @@ jobs: run: | # Extract version labels (e.g., "1.24", "1.22") VERSIONS="" - LABELS='${{ toJSON(github.event.pull_request.labels) }}' - for label in $(echo "$LABELS" | jq -r '.[].name'); do + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # For manual triggers, get labels from the PR + LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name') + else + # For automatic triggers, extract from PR event + LABELS='${{ toJSON(github.event.pull_request.labels) }}' + LABELS=$(echo "$LABELS" | jq -r '.[].name') + fi + + for label in $LABELS; do # Match version labels like "1.24" (major.minor only) if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then # Validate the branch exists before adding to list @@ -75,12 +135,20 @@ jobs: if: steps.check-existing.outputs.skip != 'true' id: backport env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | FAILED="" SUCCESS="" + + # Get PR data for manual triggers + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit) + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') + MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid') + else + PR_TITLE="${{ github.event.pull_request.title }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + fi for version in ${{ steps.versions.outputs.versions }}; do echo "::group::Backporting to core/${version}" @@ -133,10 +201,18 @@ jobs: if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success env: GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | + # Get PR data for manual triggers + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author) + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login') + else + PR_TITLE="${{ github.event.pull_request.title }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + fi + for backport in ${{ steps.backport.outputs.success }}; do IFS=':' read -r version branch <<< "${backport}" @@ -165,9 +241,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - PR_AUTHOR="${{ github.event.pull_request.user.login }}" - MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit) + PR_NUMBER="${{ inputs.pr_number }}" + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login') + MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid') + else + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + fi for failure in ${{ steps.backport.outputs.failed }}; do IFS=':' read -r version reason conflicts <<< "${failure}" diff --git a/.github/workflows/devtools-python.yaml b/.github/workflows/devtools-python.yaml new file mode 100644 index 0000000000..49ec4c0fe7 --- /dev/null +++ b/.github/workflows/devtools-python.yaml @@ -0,0 +1,26 @@ +name: Devtools Python Check + +on: + pull_request: + paths: + - 'tools/devtools/**' + push: + branches: [ main ] + paths: + - 'tools/devtools/**' + +jobs: + syntax: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate Python syntax + run: python3 -m compileall -q tools/devtools diff --git a/.github/workflows/i18n-custom-nodes.yaml b/.github/workflows/i18n-custom-nodes.yaml index a5617c1964..959d017395 100644 --- a/.github/workflows/i18n-custom-nodes.yaml +++ b/.github/workflows/i18n-custom-nodes.yaml @@ -32,11 +32,10 @@ jobs: with: repository: Comfy-Org/ComfyUI_frontend path: ComfyUI_frontend - - name: Checkout ComfyUI_devtools - uses: actions/checkout@v4 - with: - repository: Comfy-Org/ComfyUI_devtools - path: ComfyUI/custom_nodes/ComfyUI_devtools + - name: Copy ComfyUI_devtools from frontend repo + run: | + mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools + cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/ - name: Checkout custom node repository uses: actions/checkout@v4 with: @@ -79,7 +78,7 @@ jobs: wait-for-it --service 127.0.0.1:8188 -t 600 working-directory: ComfyUI - name: Install Playwright Browsers - run: npx playwright install chromium --with-deps + run: pnpm exec playwright install chromium --with-deps working-directory: ComfyUI_frontend - name: Start dev server # Run electron dev server as it is a superset of the web dev server @@ -87,7 +86,7 @@ jobs: run: pnpm dev:electron & working-directory: ComfyUI_frontend - name: Capture base i18n - run: npx tsx scripts/diff-i18n capture + run: pnpm exec tsx scripts/diff-i18n capture working-directory: ComfyUI_frontend - name: Update en.json run: pnpm collect-i18n @@ -100,7 +99,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} working-directory: ComfyUI_frontend - name: Diff base vs updated i18n - run: npx tsx scripts/diff-i18n diff + run: pnpm exec tsx scripts/diff-i18n diff working-directory: ComfyUI_frontend - name: Update i18n in custom node repository run: | diff --git a/.github/workflows/i18n-node-defs.yaml b/.github/workflows/i18n-node-defs.yaml index 1327db3cf7..d9105a4ac2 100644 --- a/.github/workflows/i18n-node-defs.yaml +++ b/.github/workflows/i18n-node-defs.yaml @@ -15,7 +15,7 @@ jobs: steps: - uses: Comfy-Org/ComfyUI_frontend_setup_action@v3 - name: Install Playwright Browsers - run: npx playwright install chromium --with-deps + run: pnpm exec playwright install chromium --with-deps working-directory: ComfyUI_frontend - name: Start dev server # Run electron dev server as it is a superset of the web dev server diff --git a/.github/workflows/i18n.yaml b/.github/workflows/i18n.yaml index d7df815ff6..566a335b50 100644 --- a/.github/workflows/i18n.yaml +++ b/.github/workflows/i18n.yaml @@ -33,7 +33,7 @@ jobs: restore-keys: | playwright-browsers-${{ runner.os }}- - name: Install Playwright Browsers - run: npx playwright install chromium --with-deps + run: pnpm exec playwright install chromium --with-deps working-directory: ComfyUI_frontend - name: Start dev server # Run electron dev server as it is a superset of the web dev server diff --git a/.github/workflows/json-validate.yaml b/.github/workflows/json-validate.yaml new file mode 100644 index 0000000000..a29499528c --- /dev/null +++ b/.github/workflows/json-validate.yaml @@ -0,0 +1,15 @@ +name: Validate JSON + +on: + push: + branches: + - main + pull_request: + +jobs: + json-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate JSON syntax + run: ./scripts/cicd/check-json.sh diff --git a/.github/workflows/publish-frontend-types.yaml b/.github/workflows/publish-frontend-types.yaml index 398f5e0a7c..142a22a93c 100644 --- a/.github/workflows/publish-frontend-types.yaml +++ b/.github/workflows/publish-frontend-types.yaml @@ -88,6 +88,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' - name: Build types run: pnpm build:types @@ -131,7 +133,7 @@ jobs: - name: Publish package if: steps.check_npm.outputs.exists == 'false' - run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" + run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks working-directory: dist env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test-browser-exp.yaml b/.github/workflows/test-browser-exp.yaml index 63052c3e46..e174e89c33 100644 --- a/.github/workflows/test-browser-exp.yaml +++ b/.github/workflows/test-browser-exp.yaml @@ -19,11 +19,11 @@ jobs: restore-keys: | playwright-browsers-${{ runner.os }}- - name: Install Playwright Browsers - run: npx playwright install chromium --with-deps + run: pnpm exec playwright install chromium --with-deps working-directory: ComfyUI_frontend - name: Run Playwright tests and update snapshots id: playwright-tests - run: npx playwright test --update-snapshots + run: pnpm exec playwright test --update-snapshots continue-on-error: true working-directory: ComfyUI_frontend - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index eaaaefee09..640615d99e 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -27,12 +27,10 @@ jobs: repository: 'Comfy-Org/ComfyUI_frontend' path: 'ComfyUI_frontend' - - name: Checkout ComfyUI_devtools - uses: actions/checkout@v4 - with: - repository: 'Comfy-Org/ComfyUI_devtools' - path: 'ComfyUI/custom_nodes/ComfyUI_devtools' - ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684' + - name: Copy ComfyUI_devtools from frontend repo + run: | + mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools + cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/ - name: Install pnpm uses: pnpm/action-setup@v4 @@ -150,7 +148,7 @@ jobs: - name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) id: playwright - run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob + run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob working-directory: ComfyUI_frontend env: PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report @@ -232,7 +230,7 @@ jobs: run: | # Run tests with both HTML and JSON reporters PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ - npx playwright test --project=${{ matrix.browser }} \ + pnpm exec playwright test --project=${{ matrix.browser }} \ --reporter=list \ --reporter=html \ --reporter=json @@ -283,10 +281,10 @@ jobs: - name: Merge into HTML Report run: | # Generate HTML report - npx playwright merge-reports --reporter=html ./all-blob-reports + pnpm exec playwright merge-reports --reporter=html ./all-blob-reports # Generate JSON report separately with explicit output path PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ - npx playwright merge-reports --reporter=json ./all-blob-reports + pnpm exec playwright merge-reports --reporter=json ./all-blob-reports working-directory: ComfyUI_frontend - name: Upload HTML report diff --git a/.github/workflows/update-manager-types.yaml b/.github/workflows/update-manager-types.yaml index 244127dc2a..de5b799da5 100644 --- a/.github/workflows/update-manager-types.yaml +++ b/.github/workflows/update-manager-types.yaml @@ -68,7 +68,7 @@ jobs: - name: Generate Manager API types run: | echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..." - npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts + pnpm dlx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts - name: Validate generated types run: | diff --git a/.github/workflows/update-registry-types.yaml b/.github/workflows/update-registry-types.yaml index 0cd2c41dae..c7d31d1d91 100644 --- a/.github/workflows/update-registry-types.yaml +++ b/.github/workflows/update-registry-types.yaml @@ -68,17 +68,18 @@ jobs: - name: Generate API types run: | echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..." - npx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts + mkdir -p ./packages/registry-types/src + pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts - name: Validate generated types run: | - if [ ! -f ./src/types/comfyRegistryTypes.ts ]; then + if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then echo "Error: Types file was not generated." exit 1 fi # Check if file is not empty - if [ ! -s ./src/types/comfyRegistryTypes.ts ]; then + if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then echo "Error: Generated types file is empty." exit 1 fi @@ -86,12 +87,12 @@ jobs: - name: Lint generated types run: | echo "Linting generated Comfy Registry API types..." - pnpm lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts + pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts - name: Check for changes id: check-changes run: | - if [[ -z $(git status --porcelain ./src/types/comfyRegistryTypes.ts) ]]; then + if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then echo "No changes to Comfy Registry API types detected." echo "changed=false" >> $GITHUB_OUTPUT exit 0 @@ -121,4 +122,4 @@ jobs: labels: CNR delete-branch: true add-paths: | - src/types/comfyRegistryTypes.ts + packages/registry-types/src/comfyRegistryTypes.ts diff --git a/.gitignore b/.gitignore index 5a58d1b1a1..e5bb5f1075 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ dist-ssr *.local # Claude configuration .claude/*.local.json +.claude/*.local.md +.claude/*.local.txt CLAUDE.local.md # Editor directories and files @@ -44,6 +46,7 @@ components.d.ts tests-ui/data/* tests-ui/ComfyUI_examples tests-ui/workflows/examples +coverage/ # Browser tests /test-results/ @@ -78,8 +81,8 @@ vite.config.mts.timestamp-*.mjs *storybook.log storybook-static - - +# MCP Servers +.playwright-mcp/* .nx/cache .nx/workspace-data diff --git a/.husky/pre-commit b/.husky/pre-commit index 5782715092..c0b5cf4376 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env bash pnpm exec lint-staged -pnpm exec tsx scripts/check-unused-i18n-keys.ts +pnpm exec tsx scripts/check-unused-i18n-keys.ts \ No newline at end of file diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 0429c35781..2efe5f9662 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -9,7 +9,7 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'], + outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. diff --git a/.prettierignore b/.prettierignore index cccae51c94..4403edd8ec 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ -src/types/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts \ No newline at end of file +packages/registry-types/src/comfyRegistryTypes.ts +src/types/generatedManagerTypes.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index a799ec143e..e8021974b9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -15,26 +15,37 @@ const config: StorybookConfig = { async viteFinal(config) { // Use dynamic import to avoid CJS deprecation warning const { mergeConfig } = await import('vite') + const { default: tailwindcss } = await import('@tailwindcss/vite') // Filter out any plugins that might generate import maps if (config.plugins) { - config.plugins = config.plugins.filter((plugin: any) => { - if (plugin && plugin.name && plugin.name.includes('import-map')) { - return false - } - return true - }) + config.plugins = config.plugins + // Type guard: ensure we have valid plugin objects with names + .filter( + (plugin): plugin is NonNullable & { name: string } => { + return ( + plugin !== null && + plugin !== undefined && + typeof plugin === 'object' && + 'name' in plugin && + typeof plugin.name === 'string' + ) + } + ) + // Business logic: filter out import-map plugins + .filter((plugin) => !plugin.name.includes('import-map')) } return mergeConfig(config, { // Replace plugins entirely to avoid inheritance issues plugins: [ // Only include plugins we explicitly need for Storybook + tailwindcss(), Icons({ compiler: 'vue3', customCollections: { comfy: FileSystemIconLoader( - process.cwd() + '/src/assets/icons/custom' + process.cwd() + '/packages/design-system/src/icons' ) } }), diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 2c04b5c9b0..e3c130b6ec 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,7 +1,7 @@ import { definePreset } from '@primevue/themes' import Aura from '@primevue/themes/aura' import { setup } from '@storybook/vue3' -import type { Preview } from '@storybook/vue3-vite' +import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite' import { createPinia } from 'pinia' import 'primeicons/primeicons.css' import PrimeVue from 'primevue/config' @@ -9,12 +9,12 @@ import ConfirmationService from 'primevue/confirmationservice' import ToastService from 'primevue/toastservice' import Tooltip from 'primevue/tooltip' -import '../src/assets/css/style.css' -import { i18n } from '../src/i18n' -import '../src/lib/litegraph/public/css/litegraph.css' -import { useSettingStore } from '../src/stores/settingStore' -import { useWidgetStore } from '../src/stores/widgetStore' -import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore' +import '@/assets/css/style.css' +import { i18n } from '@/i18n' +import '@/lib/litegraph/public/css/litegraph.css' +import { useSettingStore } from '@/stores/settingStore' +import { useWidgetStore } from '@/stores/widgetStore' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' const ComfyUIPreset = definePreset(Aura, { semantic: { @@ -26,7 +26,10 @@ const ComfyUIPreset = definePreset(Aura, { // Setup Vue app for Storybook setup((app) => { app.directive('tooltip', Tooltip) + + // Create Pinia instance const pinia = createPinia() + app.use(pinia) // Initialize stores @@ -162,8 +165,8 @@ setup((app) => { app.use(ToastService) }) -// Dark theme decorator -export const withTheme = (Story: any, context: any) => { +// Theme and dialog decorator +export const withTheme = (Story: StoryFn, context: StoryContext) => { const theme = context.globals.theme || 'light' // Apply theme class to document root @@ -175,7 +178,7 @@ export const withTheme = (Story: any, context: any) => { document.body.classList.remove('dark-theme') } - return Story() + return Story(context.args, context) } const preview: Preview = { diff --git a/AGENTS.md b/AGENTS.md index 5cec6b8109..dd6b3daabf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,9 @@ - Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config. ## Commit & Pull Request Guidelines -- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate. -- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable. +- Commits: Use `[skip ci]` for locale-only updates when appropriate. +- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes. - Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small. ## Security & Configuration Tips - Secrets: Use `.env` (see `.env_example`); do not commit secrets. -- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`. diff --git a/CODEOWNERS b/CODEOWNERS index 8d4e4a90fe..cd1b4e5087 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,17 +1,61 @@ -# Admins -* @Comfy-Org/comfy_frontend_devs +# Desktop/Electron +/src/types/desktop/ @webfiltered +/src/constants/desktopDialogs.ts @webfiltered +/src/constants/desktopMaintenanceTasks.ts @webfiltered +/src/stores/electronDownloadStore.ts @webfiltered +/src/extensions/core/electronAdapter.ts @webfiltered +/src/views/DesktopDialogView.vue @webfiltered +/src/components/install/ @webfiltered +/src/components/maintenance/ @webfiltered +/vite.electron.config.mts @webfiltered -# Maintainers -*.md @Comfy-Org/comfy_maintainer -/tests-ui/ @Comfy-Org/comfy_maintainer -/browser_tests/ @Comfy-Org/comfy_maintainer -/.env_example @Comfy-Org/comfy_maintainer +# Common UI Components +/src/components/chip/ @viva-jinyi +/src/components/card/ @viva-jinyi +/src/components/button/ @viva-jinyi +/src/components/input/ @viva-jinyi -# Translations (AIGODLIKE team + shinshin86) -/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer +# Topbar +/src/components/topbar/ @pythongosssss -# Load 3D extension -/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs +# Thumbnail +/src/renderer/core/thumbnail/ @pythongosssss -# Mask Editor extension -/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs +# Legacy UI +/scripts/ui/ @pythongosssss + +# Link rendering +/src/renderer/core/canvas/links/ @benceruleanlu + +# Node help system +/src/utils/nodeHelpUtil.ts @benceruleanlu +/src/stores/workspace/nodeHelpStore.ts @benceruleanlu +/src/services/nodeHelpService.ts @benceruleanlu + +# Selection toolbox +/src/components/graph/selectionToolbox/ @Myestery + +# Minimap +/src/renderer/extensions/minimap/ @jtydhr88 + +# Assets +/src/platform/assets/ @arjansingh + +# Workflow Templates +/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki +/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki + +# Mask Editor +/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp +/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp +/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp + +# 3D +/src/extensions/core/load3d.ts @jtydhr88 +/src/components/load3d/ @jtydhr88 + +# Manager +/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata + +# Translations +/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f4fd8db8d..6614fe619d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -265,9 +265,9 @@ The project supports three types of icons, all with automatic imports (no manual 2. **Iconify Icons** - 200,000+ icons from various libraries: ``, `` 3. **Custom Icons** - Your own SVG icons: `` -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. +Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation. -For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md). +For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md). ## Working with litegraph.js diff --git a/browser_tests/README.md b/browser_tests/README.md index ede6a303a9..4954ba9fd0 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -16,15 +16,20 @@ Without this flag, parallel tests will conflict and fail randomly. ### ComfyUI devtools -Clone to your `custom_nodes` directory. +ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory. _ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._ +For local development, copy the devtools files to your ComfyUI installation: +```bash +cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/ +``` + ### Node.js & Playwright Prerequisites Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver: ```bash -npx playwright install chromium --with-deps +pnpm exec playwright install chromium --with-deps ``` ### Environment Configuration @@ -51,14 +56,6 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI ### Common Setup Issues -**Most tests require the new menu system** - Add to your test: - -```typescript -test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') -}) -``` - ### Release API Mocking By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions. @@ -76,7 +73,7 @@ For tests that specifically need to test release functionality, see the example **Always use UI mode for development:** ```bash -npx playwright test --ui +pnpm exec playwright test --ui ``` UI mode features: @@ -92,8 +89,8 @@ UI mode features: For CI or headless testing: ```bash -npx playwright test # Run all tests -npx playwright test widget.spec.ts # Run specific test file +pnpm exec playwright test # Run all tests +pnpm exec playwright test widget.spec.ts # Run specific test file ``` ### Local Development Config @@ -389,7 +386,7 @@ export default defineConfig({ Option 2 - Generate local baselines for comparison: ```bash -npx playwright test --update-snapshots +pnpm exec playwright test --update-snapshots ``` ### Creating New Screenshot Baselines diff --git a/browser_tests/assets/vueNodes/simple-triple.json b/browser_tests/assets/vueNodes/simple-triple.json new file mode 100644 index 0000000000..9b665191db --- /dev/null +++ b/browser_tests/assets/vueNodes/simple-triple.json @@ -0,0 +1 @@ +{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4} \ No newline at end of file diff --git a/browser_tests/assets/widgets/all_load_widgets.json b/browser_tests/assets/widgets/all_load_widgets.json new file mode 100644 index 0000000000..b2d47fcbab --- /dev/null +++ b/browser_tests/assets/widgets/all_load_widgets.json @@ -0,0 +1,221 @@ +{ + "id": "e74f5af9-b886-4a21-abbf-ed535d12e2fb", + "revision": 0, + "last_node_id": 8, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "LoadAudio", + "pos": [ + 41.52964782714844, + 16.930862426757812 + ], + "size": [ + 444, + 125 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "AUDIO", + "type": "AUDIO", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadAudio" + }, + "widgets_values": [ + null, + null, + "" + ] + }, + { + "id": 2, + "type": "LoadVideo", + "pos": [ + 502.28570556640625, + 16.857147216796875 + ], + "size": [ + 444, + 525 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VIDEO", + "type": "VIDEO", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": [ + null, + "image" + ] + }, + { + "id": 3, + "type": "DevToolsLoadAnimatedImageTest", + "pos": [ + 41.71427917480469, + 188.0000457763672 + ], + "size": [ + 444, + 553 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "DevToolsLoadAnimatedImageTest" + }, + "widgets_values": [ + null, + "image" + ] + }, + { + "id": 5, + "type": "LoadImage", + "pos": [ + 958.285888671875, + 16.57145118713379 + ], + "size": [ + 444, + 553 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + null, + "image" + ] + }, + { + "id": 6, + "type": "LoadImageMask", + "pos": [ + 503.4285888671875, + 588 + ], + "size": [ + 444, + 563 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImageMask" + }, + "widgets_values": [ + null, + "alpha", + "image" + ] + }, + { + "id": 7, + "type": "LoadImageOutput", + "pos": [ + 965.1429443359375, + 612 + ], + "size": [ + 444, + 553 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImageOutput" + }, + "widgets_values": [ + null, + false, + "refresh", + "image" + ] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 0, + 0 + ] + }, + "frontendVersion": "1.28.3" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index c9a8820f5e..19796f4c4c 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{ try { await comfyPage.setupSettings({ - 'Comfy.UseNewMenu': 'Disabled', + 'Comfy.UseNewMenu': 'Top', // Hide canvas menu/info/selection toolbox by default. 'Comfy.Graph.CanvasInfo': false, 'Comfy.Graph.CanvasMenu': false, diff --git a/browser_tests/fixtures/UserSelectPage.ts b/browser_tests/fixtures/UserSelectPage.ts index 62a9613757..ff0735e171 100644 --- a/browser_tests/fixtures/UserSelectPage.ts +++ b/browser_tests/fixtures/UserSelectPage.ts @@ -1,4 +1,5 @@ -import { Page, test as base } from '@playwright/test' +import type { Page } from '@playwright/test' +import { test as base } from '@playwright/test' export class UserSelectPage { constructor( diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts index e3b3de5425..b517502997 100644 --- a/browser_tests/fixtures/VueNodeHelpers.ts +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -22,6 +22,13 @@ export class VueNodeHelpers { ) } + /** + * Get locator for a Vue node by the node's title (displayed name in the header) + */ + getNodeByTitle(title: string): Locator { + return this.page.locator(`[data-node-id]`).filter({ hasText: title }) + } + /** * Get total count of Vue nodes in the DOM */ diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBox.ts b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts index 23dc104cf4..fd40ca9113 100644 --- a/browser_tests/fixtures/components/ComfyNodeSearchBox.ts +++ b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' export class ComfyNodeSearchFilterSelectionPanel { constructor(public readonly page: Page) {} diff --git a/browser_tests/fixtures/components/SettingDialog.ts b/browser_tests/fixtures/components/SettingDialog.ts index afaf86154b..e9040a3a9a 100644 --- a/browser_tests/fixtures/components/SettingDialog.ts +++ b/browser_tests/fixtures/components/SettingDialog.ts @@ -1,6 +1,6 @@ -import { Page } from '@playwright/test' +import type { Page } from '@playwright/test' -import { ComfyPage } from '../ComfyPage' +import type { ComfyPage } from '../ComfyPage' export class SettingDialog { constructor( diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index 7baaa1ef99..f3fbe42cfb 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' class SidebarTab { constructor( diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts index 04a9117ce2..6d0cd1fb34 100644 --- a/browser_tests/fixtures/components/Topbar.ts +++ b/browser_tests/fixtures/components/Topbar.ts @@ -1,4 +1,5 @@ -import { Locator, Page, expect } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' export class Topbar { private readonly menuLocator: Locator diff --git a/browser_tests/fixtures/ws.ts b/browser_tests/fixtures/ws.ts index e12c534652..f1ab1a538c 100644 --- a/browser_tests/fixtures/ws.ts +++ b/browser_tests/fixtures/ws.ts @@ -12,9 +12,10 @@ export const webSocketFixture = base.extend<{ // so we can look it up to trigger messages const store: Record = ((window as any).__ws__ = {}) window.WebSocket = class extends window.WebSocket { - constructor() { - // @ts-expect-error - super(...arguments) + constructor( + ...rest: ConstructorParameters + ) { + super(...rest) store[this.url] = this } } diff --git a/browser_tests/globalSetup.ts b/browser_tests/globalSetup.ts index 12033fce34..881ef11c43 100644 --- a/browser_tests/globalSetup.ts +++ b/browser_tests/globalSetup.ts @@ -1,4 +1,4 @@ -import { FullConfig } from '@playwright/test' +import type { FullConfig } from '@playwright/test' import dotenv from 'dotenv' import { backupPath } from './utils/backupUtils' diff --git a/browser_tests/globalTeardown.ts b/browser_tests/globalTeardown.ts index 47bab3db97..aeed77294c 100644 --- a/browser_tests/globalTeardown.ts +++ b/browser_tests/globalTeardown.ts @@ -1,4 +1,4 @@ -import { FullConfig } from '@playwright/test' +import type { FullConfig } from '@playwright/test' import dotenv from 'dotenv' import { restorePath } from './utils/backupUtils' diff --git a/browser_tests/helpers/fitToView.ts b/browser_tests/helpers/fitToView.ts new file mode 100644 index 0000000000..af6c10e9d3 --- /dev/null +++ b/browser_tests/helpers/fitToView.ts @@ -0,0 +1,104 @@ +import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces' +import type { ComfyPage } from '../fixtures/ComfyPage' + +interface FitToViewOptions { + selectionOnly?: boolean + zoom?: number + padding?: number +} + +/** + * Instantly fits the canvas view to graph content without waiting for UI animation. + * + * Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented. + */ +export async function fitToViewInstant( + comfyPage: ComfyPage, + options: FitToViewOptions = {} +) { + const { selectionOnly = false, zoom = 0.75, padding = 10 } = options + + const rectangles = await comfyPage.page.evaluate< + ReadOnlyRect[] | null, + { selectionOnly: boolean } + >( + ({ selectionOnly }) => { + const app = window['app'] + if (!app?.canvas) return null + + const canvas = app.canvas + const items = (() => { + if (selectionOnly && canvas.selectedItems?.size) { + return Array.from(canvas.selectedItems) + } + try { + return Array.from(canvas.positionableItems ?? []) + } catch { + return [] + } + })() + + if (!items.length) return null + + const rects: ReadOnlyRect[] = [] + + for (const item of items) { + const rect = item?.boundingRect + if (!rect) continue + + const x = Number(rect[0]) + const y = Number(rect[1]) + const width = Number(rect[2]) + const height = Number(rect[3]) + + rects.push([x, y, width, height] as const) + } + + return rects.length ? rects : null + }, + { selectionOnly } + ) + + if (!rectangles || rectangles.length === 0) return + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const [x, y, width, height] of rectangles) { + minX = Math.min(minX, Number(x)) + minY = Math.min(minY, Number(y)) + maxX = Math.max(maxX, Number(x) + Number(width)) + maxY = Math.max(maxY, Number(y) + Number(height)) + } + + const hasFiniteBounds = + Number.isFinite(minX) && + Number.isFinite(minY) && + Number.isFinite(maxX) && + Number.isFinite(maxY) + + if (!hasFiniteBounds) return + + const bounds: ReadOnlyRect = [ + minX - padding, + minY - padding, + maxX - minX + 2 * padding, + maxY - minY + 2 * padding + ] + + await comfyPage.page.evaluate( + ({ bounds, zoom }) => { + const app = window['app'] + if (!app?.canvas) return + + const canvas = app.canvas + canvas.ds.fitToBounds(bounds, { zoom }) + canvas.setDirty(true, true) + }, + { bounds, zoom } + ) + + await comfyPage.nextFrame() +} diff --git a/browser_tests/helpers/manageGroupNode.ts b/browser_tests/helpers/manageGroupNode.ts index a444a97c6a..45010b979f 100644 --- a/browser_tests/helpers/manageGroupNode.ts +++ b/browser_tests/helpers/manageGroupNode.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' export class ManageGroupNode { footer: Locator diff --git a/browser_tests/helpers/templates.ts b/browser_tests/helpers/templates.ts index 0d2c9f31ea..c690b8702a 100644 --- a/browser_tests/helpers/templates.ts +++ b/browser_tests/helpers/templates.ts @@ -1,7 +1,7 @@ -import { Locator, Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import path from 'path' -import { +import type { TemplateInfo, WorkflowTemplates } from '../../src/platform/workflow/templates/types/template' diff --git a/browser_tests/tests/actionbar.spec.ts b/browser_tests/tests/actionbar.spec.ts index a504ea4fc1..b23e4466d0 100644 --- a/browser_tests/tests/actionbar.spec.ts +++ b/browser_tests/tests/actionbar.spec.ts @@ -29,9 +29,9 @@ test.describe('Actionbar', () => { // Intercept the prompt queue endpoint let promptNumber = 0 - comfyPage.page.route('**/api/prompt', async (route, req) => { + await comfyPage.page.route('**/api/prompt', async (route, req) => { await new Promise((r) => setTimeout(r, 100)) - route.fulfill({ + await route.fulfill({ status: 200, body: JSON.stringify({ prompt_id: promptNumber, diff --git a/browser_tests/tests/backgroundImageUpload.spec.ts b/browser_tests/tests/backgroundImageUpload.spec.ts index 24af9e8acd..7f3ed6a3d4 100644 --- a/browser_tests/tests/backgroundImageUpload.spec.ts +++ b/browser_tests/tests/backgroundImageUpload.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Background Image Upload', () => { test.beforeEach(async ({ comfyPage }) => { // Reset the background image setting before each test diff --git a/browser_tests/tests/changeTracker.spec.ts b/browser_tests/tests/changeTracker.spec.ts index 7a32833e4f..8e39154f15 100644 --- a/browser_tests/tests/changeTracker.spec.ts +++ b/browser_tests/tests/changeTracker.spec.ts @@ -1,5 +1,5 @@ +import type { ComfyPage } from '../fixtures/ComfyPage' import { - ComfyPage, comfyExpect as expect, comfyPageFixture as test } from '../fixtures/ComfyPage' @@ -15,6 +15,10 @@ async function afterChange(comfyPage: ComfyPage) { }) } +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Change Tracker', () => { test.describe('Undo/Redo', () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/chatHistory.spec.ts b/browser_tests/tests/chatHistory.spec.ts index db33975143..c47a4d19b0 100644 --- a/browser_tests/tests/chatHistory.spec.ts +++ b/browser_tests/tests/chatHistory.spec.ts @@ -1,7 +1,12 @@ -import { Page, expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + interface ChatHistoryEntry { prompt: string response: string diff --git a/browser_tests/tests/colorPalette.spec.ts b/browser_tests/tests/colorPalette.spec.ts index 901cce9137..6dd53c194f 100644 --- a/browser_tests/tests/colorPalette.spec.ts +++ b/browser_tests/tests/colorPalette.spec.ts @@ -3,6 +3,10 @@ import { expect } from '@playwright/test' import type { Palette } from '../../src/schemas/colorPaletteSchema' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + const customColorPalettes: Record = { obsidian: { version: 102, diff --git a/browser_tests/tests/commands.spec.ts b/browser_tests/tests/commands.spec.ts index 4225ad228c..e271f2e15c 100644 --- a/browser_tests/tests/commands.spec.ts +++ b/browser_tests/tests/commands.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Keybindings', () => { test('Should execute command', async ({ comfyPage }) => { await comfyPage.registerCommand('TestCommand', () => { diff --git a/browser_tests/tests/copyPaste.spec.ts b/browser_tests/tests/copyPaste.spec.ts index 3bcee65f0e..cabb849e80 100644 --- a/browser_tests/tests/copyPaste.spec.ts +++ b/browser_tests/tests/copyPaste.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Copy Paste', () => { test('Can copy and paste node', async ({ comfyPage }) => { await comfyPage.clickEmptyLatentNode() diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index cf2e5e6be0..7459acf585 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -1,8 +1,13 @@ -import { Locator, expect } from '@playwright/test' +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' import type { Keybinding } from '../../src/schemas/keyBindingSchema' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Load workflow warning', () => { test('Should display a warning when loading a workflow with missing nodes', async ({ comfyPage diff --git a/browser_tests/tests/domWidget.spec.ts b/browser_tests/tests/domWidget.spec.ts index 91d53c4078..6517b9170d 100644 --- a/browser_tests/tests/domWidget.spec.ts +++ b/browser_tests/tests/domWidget.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('DOM Widget', () => { test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/collapsed_multiline') diff --git a/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png index 90bc677fcf..2b89be5c53 100644 Binary files a/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png and b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png differ diff --git a/browser_tests/tests/execution.spec.ts b/browser_tests/tests/execution.spec.ts index 4adab98b60..075025a3ab 100644 --- a/browser_tests/tests/execution.spec.ts +++ b/browser_tests/tests/execution.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Execution', () => { test('Report error on unconnected slot', async ({ comfyPage }) => { await comfyPage.disconnectEdge() diff --git a/browser_tests/tests/extensionAPI.spec.ts b/browser_tests/tests/extensionAPI.spec.ts index 09a08384c8..38f4a6c1de 100644 --- a/browser_tests/tests/extensionAPI.spec.ts +++ b/browser_tests/tests/extensionAPI.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { SettingParams } from '../../src/platform/settings/types' +import type { SettingParams } from '../../src/platform/settings/types' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Topbar commands', () => { diff --git a/browser_tests/tests/featureFlags.spec.ts b/browser_tests/tests/featureFlags.spec.ts index 73eb35f472..38286b3990 100644 --- a/browser_tests/tests/featureFlags.spec.ts +++ b/browser_tests/tests/featureFlags.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Feature Flags', () => { test('Client and server exchange feature flags on connection', async ({ comfyPage diff --git a/browser_tests/tests/graph.spec.ts b/browser_tests/tests/graph.spec.ts index 25e166bab8..cd89e92d5f 100644 --- a/browser_tests/tests/graph.spec.ts +++ b/browser_tests/tests/graph.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Graph', () => { // Should be able to fix link input slot index after swap the input order // Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348 diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts b/browser_tests/tests/graphCanvasMenu.spec.ts index 9ae090975b..daa165fa47 100644 --- a/browser_tests/tests/graphCanvasMenu.spec.ts +++ b/browser_tests/tests/graphCanvasMenu.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Graph Canvas Menu', () => { test.beforeEach(async ({ comfyPage }) => { // Set link render mode to spline to make sure it's not affected by other tests' diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png index f5ca69e541..2736a50c57 100644 Binary files a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png and b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png differ diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png index af9081d0a9..fd72c2d0a7 100644 Binary files a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png and b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png differ diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts index 41b50224ad..9a23102312 100644 --- a/browser_tests/tests/groupNode.spec.ts +++ b/browser_tests/tests/groupNode.spec.ts @@ -1,8 +1,13 @@ import { expect } from '@playwright/test' -import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage' +import type { ComfyPage } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' import type { NodeReference } from '../fixtures/utils/litegraphUtils' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Group Node', () => { test.describe('Node library sidebar', () => { const groupNodeName = 'DefautWorkflowGroupNode' diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index de46bca2e1..2fc7534909 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -1,12 +1,17 @@ -import { Locator, expect } from '@playwright/test' -import { Position } from '@vueuse/core' +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' +import type { Position } from '@vueuse/core' import { type ComfyPage, comfyPageFixture as test, testComfySnapToGridGridSize } from '../fixtures/ComfyPage' -import { type NodeReference } from '../fixtures/utils/litegraphUtils' +import type { NodeReference } from '../fixtures/utils/litegraphUtils' + +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) test.describe('Item Interaction', () => { test('Can select/delete all items', async ({ comfyPage }) => { @@ -1012,6 +1017,8 @@ test.describe('Canvas Navigation', () => { test('Shift + mouse wheel should pan canvas horizontally', async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning') + await comfyPage.page.click('canvas') await comfyPage.nextFrame() diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png index 57b6438aeb..a9d0efb749 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png index a9d0efb749..57a92edc52 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png index 57b6438aeb..e607294e32 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png differ diff --git a/browser_tests/tests/keybindings.spec.ts b/browser_tests/tests/keybindings.spec.ts index ced2936378..f4244ae669 100644 --- a/browser_tests/tests/keybindings.spec.ts +++ b/browser_tests/tests/keybindings.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Keybindings', () => { test('Should not trigger non-modifier keybinding when typing in input fields', async ({ comfyPage diff --git a/browser_tests/tests/litegraphEvent.spec.ts b/browser_tests/tests/litegraphEvent.spec.ts index 8d8f6c2e85..184943fe05 100644 --- a/browser_tests/tests/litegraphEvent.spec.ts +++ b/browser_tests/tests/litegraphEvent.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + function listenForEvent(): Promise { return new Promise((resolve) => { document.addEventListener('litegraph:canvas', (e) => resolve(e), { diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts b/browser_tests/tests/loadWorkflowInMedia.spec.ts index 678cb60f07..f091058d24 100644 --- a/browser_tests/tests/loadWorkflowInMedia.spec.ts +++ b/browser_tests/tests/loadWorkflowInMedia.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Load Workflow in Media', () => { const fileNames = [ 'workflow.webp', diff --git a/browser_tests/tests/lodThreshold.spec.ts b/browser_tests/tests/lodThreshold.spec.ts index 025347e4de..154ac3c16f 100644 --- a/browser_tests/tests/lodThreshold.spec.ts +++ b/browser_tests/tests/lodThreshold.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('LOD Threshold', () => { test('Should switch to low quality mode at correct zoom threshold', async ({ comfyPage diff --git a/browser_tests/tests/nodeBadge.spec.ts b/browser_tests/tests/nodeBadge.spec.ts index 984dd6ea1f..111efe29cf 100644 --- a/browser_tests/tests/nodeBadge.spec.ts +++ b/browser_tests/tests/nodeBadge.spec.ts @@ -4,6 +4,10 @@ import type { ComfyApp } from '../../src/scripts/app' import { NodeBadgeMode } from '../../src/types/nodeSource' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Node Badge', () => { test('Can add badge', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { diff --git a/browser_tests/tests/nodeDisplay.spec.ts b/browser_tests/tests/nodeDisplay.spec.ts index 2b76d45427..fdaae14bcb 100644 --- a/browser_tests/tests/nodeDisplay.spec.ts +++ b/browser_tests/tests/nodeDisplay.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + // If an input is optional by node definition, it should be shown as // a hollow circle no matter what shape it was defined in the workflow JSON. test.describe('Optional input', () => { diff --git a/browser_tests/tests/nodeSearchBox.spec.ts b/browser_tests/tests/nodeSearchBox.spec.ts index 3c5e3cbe24..98ba335836 100644 --- a/browser_tests/tests/nodeSearchBox.spec.ts +++ b/browser_tests/tests/nodeSearchBox.spec.ts @@ -3,6 +3,10 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Node search box', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box') diff --git a/browser_tests/tests/noteNode.spec.ts b/browser_tests/tests/noteNode.spec.ts index 0f3d6a3178..52dc575423 100644 --- a/browser_tests/tests/noteNode.spec.ts +++ b/browser_tests/tests/noteNode.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Note Node', () => { test('Can load node nodes', async ({ comfyPage }) => { await comfyPage.loadWorkflow('nodes/note_nodes') diff --git a/browser_tests/tests/primitiveNode.spec.ts b/browser_tests/tests/primitiveNode.spec.ts index 7fc408e8b8..0584a3bec2 100644 --- a/browser_tests/tests/primitiveNode.spec.ts +++ b/browser_tests/tests/primitiveNode.spec.ts @@ -3,6 +3,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' import type { NodeReference } from '../fixtures/utils/litegraphUtils' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Primitive Node', () => { test('Can load with correct size', async ({ comfyPage }) => { await comfyPage.loadWorkflow('primitive/primitive_node') diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 05bb578df9..7a54cae07b 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test' -import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage' +import type { ComfyPage } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Remote COMBO Widget', () => { const mockOptions = ['d', 'c', 'b', 'a'] diff --git a/browser_tests/tests/rerouteNode.spec.ts b/browser_tests/tests/rerouteNode.spec.ts index 89fdf38b29..0b2b1e0f62 100644 --- a/browser_tests/tests/rerouteNode.spec.ts +++ b/browser_tests/tests/rerouteNode.spec.ts @@ -40,6 +40,7 @@ test.describe('Reroute Node', () => { test.describe('LiteGraph Native Reroute Node', () => { test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80) }) diff --git a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png index 6e63295b85..a9e9926bfa 100644 Binary files a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png and b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts b/browser_tests/tests/rightClickMenu.spec.ts index db21ecd360..f7718122b7 100644 --- a/browser_tests/tests/rightClickMenu.spec.ts +++ b/browser_tests/tests/rightClickMenu.spec.ts @@ -3,6 +3,10 @@ import { expect } from '@playwright/test' import { NodeBadgeMode } from '../../src/types/nodeSource' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Canvas Right Click Menu', () => { test('Can add node', async ({ comfyPage }) => { await comfyPage.rightClickCanvas() diff --git a/browser_tests/tests/selectionToolbox.spec.ts b/browser_tests/tests/selectionToolbox.spec.ts index a9a5fc9c20..6b85769826 100644 --- a/browser_tests/tests/selectionToolbox.spec.ts +++ b/browser_tests/tests/selectionToolbox.spec.ts @@ -4,6 +4,9 @@ import { comfyPageFixture } from '../fixtures/ComfyPage' const test = comfyPageFixture +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) const BLUE_COLOR = 'rgb(51, 51, 85)' const RED_COLOR = 'rgb(85, 51, 51)' diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts index a7311c15a3..db63261528 100644 --- a/browser_tests/tests/selectionToolboxSubmenus.spec.ts +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Selection Toolbox - More Options Submenus', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) diff --git a/browser_tests/tests/sidebar/queue.spec.ts b/browser_tests/tests/sidebar/queue.spec.ts index 2d9dd10bae..39e2ced6e1 100644 --- a/browser_tests/tests/sidebar/queue.spec.ts +++ b/browser_tests/tests/sidebar/queue.spec.ts @@ -160,7 +160,9 @@ test.describe.skip('Queue sidebar', () => { comfyPage }) => { await comfyPage.nextFrame() - expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible() + await expect( + comfyPage.menu.queueTab.getGalleryImage(firstImage) + ).toBeVisible() }) test('maintains active gallery item when new tasks are added', async ({ @@ -174,7 +176,9 @@ test.describe.skip('Queue sidebar', () => { const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage) await newTask.waitFor({ state: 'visible' }) // The active gallery item should still be the initial image - expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible() + await expect( + comfyPage.menu.queueTab.getGalleryImage(firstImage) + ).toBeVisible() }) test.describe('Gallery navigation', () => { @@ -196,7 +200,9 @@ test.describe.skip('Queue sidebar', () => { delay: 256 }) await comfyPage.nextFrame() - expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible() + await expect( + comfyPage.menu.queueTab.getGalleryImage(end) + ).toBeVisible() }) }) }) diff --git a/browser_tests/tests/templates.spec.ts b/browser_tests/tests/templates.spec.ts index f2c2e2bb5d..6252332135 100644 --- a/browser_tests/tests/templates.spec.ts +++ b/browser_tests/tests/templates.spec.ts @@ -1,4 +1,5 @@ -import { Page, expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' @@ -79,6 +80,12 @@ test.describe('Templates', () => { // Load a template await comfyPage.executeCommand('Comfy.BrowseTemplates') await expect(comfyPage.templates.content).toBeVisible() + + await comfyPage.page + .locator( + 'nav > div:nth-child(2) > div > span:has-text("Getting Started")' + ) + .click() await comfyPage.templates.loadTemplate('default') await expect(comfyPage.templates.content).toBeHidden() @@ -101,48 +108,72 @@ test.describe('Templates', () => { expect(await comfyPage.templates.content.isVisible()).toBe(true) }) - test('Uses title field as fallback when the key is not found in locales', async ({ + test('Uses proper locale files for templates', async ({ comfyPage }) => { + // Set locale to French before opening templates + await comfyPage.setSetting('Comfy.Locale', 'fr') + + // Load the templates dialog and wait for the French index file request + const requestPromise = comfyPage.page.waitForRequest( + '**/templates/index.fr.json' + ) + + await comfyPage.executeCommand('Comfy.BrowseTemplates') + + const request = await requestPromise + + // Verify French index was requested + expect(request.url()).toContain('templates/index.fr.json') + + await expect(comfyPage.templates.content).toBeVisible() + }) + + test('Falls back to English templates when locale file not found', async ({ comfyPage }) => { - // Capture request for the index.json - await comfyPage.page.route('**/templates/index.json', async (route, _) => { - // Add a new template that won't have a translation pre-generated - const response = [ - { - moduleName: 'default', - title: 'FALLBACK CATEGORY', - type: 'image', - templates: [ - { - name: 'unknown_key_has_no_translation_available', - title: 'FALLBACK TEMPLATE NAME', - mediaType: 'image', - mediaSubtype: 'webp', - description: 'No translations found' - } - ] - } - ] + // Set locale to a language that doesn't have a template file + await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists + + // Wait for the German request (expected to 404) + const germanRequestPromise = comfyPage.page.waitForRequest( + '**/templates/index.de.json' + ) + + // Wait for the fallback English request + const englishRequestPromise = comfyPage.page.waitForRequest( + '**/templates/index.json' + ) + + // Intercept the German file to simulate a 404 + await comfyPage.page.route('**/templates/index.de.json', async (route) => { await route.fulfill({ - status: 200, - body: JSON.stringify(response), - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store' - } + status: 404, + headers: { 'Content-Type': 'text/plain' }, + body: 'Not Found' }) }) + // Allow the English index to load normally + await comfyPage.page.route('**/templates/index.json', (route) => + route.continue() + ) + // Load the templates dialog await comfyPage.executeCommand('Comfy.BrowseTemplates') + await expect(comfyPage.templates.content).toBeVisible() - // Expect the title to be used as fallback for template cards + // Verify German was requested first, then English as fallback + const germanRequest = await germanRequestPromise + const englishRequest = await englishRequestPromise + + expect(germanRequest.url()).toContain('templates/index.de.json') + expect(englishRequest.url()).toContain('templates/index.json') + + // Verify English titles are shown as fallback await expect( - comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME') + comfyPage.templates.content.getByRole('heading', { + name: 'Image Generation' + }) ).toBeVisible() - - // Expect the title to be used as fallback for the template categories - await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible() }) test('template cards are dynamically sized and responsive', async ({ @@ -150,46 +181,33 @@ test.describe('Templates', () => { }) => { // Open templates dialog await comfyPage.executeCommand('Comfy.BrowseTemplates') - await expect(comfyPage.templates.content).toBeVisible() + await comfyPage.templates.content.waitFor({ state: 'visible' }) - // Wait for at least one template card to appear - await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({ - timeout: 5000 - }) + const templateGrid = comfyPage.page.locator( + '[data-testid="template-workflows-content"]' + ) + const nav = comfyPage.page + .locator('header') + .filter({ hasText: 'Templates' }) - // Take snapshot of the template grid - const templateGrid = comfyPage.templates.content.locator('.grid').first() + const cardCount = await comfyPage.page + .locator('[data-testid^="template-workflow-"]') + .count() + expect(cardCount).toBeGreaterThan(0) await expect(templateGrid).toBeVisible() - await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png') + await expect(nav).toBeVisible() // Nav should be visible at desktop size - // Check cards at mobile viewport size - await comfyPage.page.setViewportSize({ width: 640, height: 800 }) + const mobileSize = { width: 640, height: 800 } + await comfyPage.page.setViewportSize(mobileSize) + expect(cardCount).toBeGreaterThan(0) await expect(templateGrid).toBeVisible() - await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png') + await expect(nav).not.toBeVisible() // Nav should collapse at mobile size - // Check cards at tablet size - await comfyPage.page.setViewportSize({ width: 1024, height: 800 }) + const tabletSize = { width: 1024, height: 800 } + await comfyPage.page.setViewportSize(tabletSize) + expect(cardCount).toBeGreaterThan(0) await expect(templateGrid).toBeVisible() - await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png') - }) - - test('hover effects work on template cards', async ({ comfyPage }) => { - // Open templates dialog - await comfyPage.executeCommand('Comfy.BrowseTemplates') - await expect(comfyPage.templates.content).toBeVisible() - - // Get a template card - const firstCard = comfyPage.page.locator('.template-card').first() - await expect(firstCard).toBeVisible({ timeout: 5000 }) - - // Take snapshot before hover - await expect(firstCard).toHaveScreenshot('template-card-before-hover.png') - - // Hover over the card - await firstCard.hover() - - // Take snapshot after hover to verify hover effect - await expect(firstCard).toHaveScreenshot('template-card-after-hover.png') + await expect(nav).toBeVisible() // Nav should be visible at tablet size }) test('template cards descriptions adjust height dynamically', async ({ @@ -256,21 +274,42 @@ test.describe('Templates', () => { await comfyPage.executeCommand('Comfy.BrowseTemplates') await expect(comfyPage.templates.content).toBeVisible() - // Verify cards are visible with varying content lengths + // Wait for cards to load await expect( - comfyPage.page.getByText('This is a short description.') - ).toBeVisible({ timeout: 5000 }) - await expect( - comfyPage.page.getByText('This is a medium length description') - ).toBeVisible({ timeout: 5000 }) - await expect( - comfyPage.page.getByText('This is a much longer description') + comfyPage.page.locator( + '[data-testid="template-workflow-short-description"]' + ) ).toBeVisible({ timeout: 5000 }) - // Take snapshot of a grid with specific cards - const templateGrid = comfyPage.templates.content - .locator('.grid:has-text("Short Description")') - .first() + // Verify all three cards with different descriptions are visible + const shortDescCard = comfyPage.page.locator( + '[data-testid="template-workflow-short-description"]' + ) + const mediumDescCard = comfyPage.page.locator( + '[data-testid="template-workflow-medium-description"]' + ) + const longDescCard = comfyPage.page.locator( + '[data-testid="template-workflow-long-description"]' + ) + + await expect(shortDescCard).toBeVisible() + await expect(mediumDescCard).toBeVisible() + await expect(longDescCard).toBeVisible() + + // Verify descriptions are visible and have line-clamp class + // The description is in a p tag with text-muted class + const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2') + const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2') + const longDesc = longDescCard.locator('p.text-muted.line-clamp-2') + + await expect(shortDesc).toContainText('short description') + await expect(mediumDesc).toContainText('medium length description') + await expect(longDesc).toContainText('much longer description') + + // Verify grid layout maintains consistency + const templateGrid = comfyPage.page.locator( + '[data-testid="template-workflows-content"]' + ) await expect(templateGrid).toBeVisible() await expect(templateGrid).toHaveScreenshot( 'template-grid-varying-content.png' diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png index ce88325aad..137cb4d8c4 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png index bf1d18934d..080855e15c 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png index 6c330c2f46..2548e66ae8 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png differ diff --git a/browser_tests/tests/useSettingSearch.spec.ts b/browser_tests/tests/useSettingSearch.spec.ts index 69a40ced92..a817616f8b 100644 --- a/browser_tests/tests/useSettingSearch.spec.ts +++ b/browser_tests/tests/useSettingSearch.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Settings Search functionality', () => { test.beforeEach(async ({ comfyPage }) => { // Register test settings to verify hidden/deprecated filtering diff --git a/browser_tests/tests/versionMismatchWarnings.spec.ts b/browser_tests/tests/versionMismatchWarnings.spec.ts index d85f187238..4b3ff3e309 100644 --- a/browser_tests/tests/versionMismatchWarnings.spec.ts +++ b/browser_tests/tests/versionMismatchWarnings.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { SystemStats } from '../../src/schemas/apiSchema' +import type { SystemStats } from '../../src/schemas/apiSchema' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Version Mismatch Warnings', () => { @@ -85,6 +85,7 @@ test.describe('Version Mismatch Warnings', () => { test('should persist dismissed state across sessions', async ({ comfyPage }) => { + test.setTimeout(30_000) // Mock system_stats route to indicate that the installed version is always ahead of the required version await comfyPage.page.route('**/system_stats**', async (route) => { await route.fulfill({ @@ -106,6 +107,11 @@ test.describe('Version Mismatch Warnings', () => { const dismissButton = warningToast.getByRole('button', { name: 'Close' }) await dismissButton.click() + // Wait for the dismissed state to be persisted + await comfyPage.page.waitForFunction( + () => !!localStorage.getItem('comfy.versionMismatch.dismissals') + ) + // Reload the page, keeping local storage await comfyPage.setup({ clearStorage: false }) diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts b/browser_tests/tests/vueNodes/groups/groups.spec.ts new file mode 100644 index 0000000000..a43e96a5de --- /dev/null +++ b/browser_tests/tests/vueNodes/groups/groups.spec.ts @@ -0,0 +1,33 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const CREATE_GROUP_HOTKEY = 'Control+g' + +test.describe('Vue Node Groups', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should allow creating groups with hotkey', async ({ comfyPage }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] }) + await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-groups-create-group.png' + ) + }) + + test('should allow fitting group to contents', async ({ comfyPage }) => { + await comfyPage.setup() + await comfyPage.loadWorkflow('groups/oversized_group') + await comfyPage.ctrlA() + await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents') + await comfyPage.nextFrame() + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-groups-fit-to-contents.png' + ) + }) +}) diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png new file mode 100644 index 0000000000..0a7817cd9d Binary files /dev/null and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png new file mode 100644 index 0000000000..955397d55d Binary files /dev/null and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts new file mode 100644 index 0000000000..a91a9b928c --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts @@ -0,0 +1,18 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Nodes Canvas Pan', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('@mobile Can pan with touch', async ({ comfyPage }) => { + await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 }) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-nodes-paned-with-touch.png' + ) + }) +}) diff --git a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png new file mode 100644 index 0000000000..8fd4569248 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts new file mode 100644 index 0000000000..b87309f10f --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts @@ -0,0 +1,33 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Nodes Zoom', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should not capture drag while zooming with ctrl+shift+drag', async ({ + comfyPage + }) => { + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const nodeBoundingBox = await checkpointNode.boundingBox() + if (!nodeBoundingBox) throw new Error('Node bounding box not available') + + const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2 + const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2 + + // Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over + // the node. The node should not capture the drag while drag-zooming. + await comfyPage.page.keyboard.down('Control') + await comfyPage.page.keyboard.down('Shift') + await comfyPage.dragAndDrop( + { x: 200, y: 300 }, + { x: nodeMidpointX, y: nodeMidpointY } + ) + + await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png') + }) +}) diff --git a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png new file mode 100644 index 0000000000..e23bdb1688 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts new file mode 100644 index 0000000000..ebc09cf2eb --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -0,0 +1,696 @@ +import type { Locator, Page } from '@playwright/test' + +import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema' +import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' +import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils' +import { fitToViewInstant } from '../../../../helpers/fitToView' + +async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (!box) throw new Error('Slot bounding box not available') + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + } +} + +async function getInputLinkDetails( + page: Page, + nodeId: NodeId, + slotIndex: number +) { + return await page.evaluate( + ([targetNodeId, targetSlot]) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return null + + const node = graph.getNodeById(targetNodeId) + if (!node) return null + + const input = node.inputs?.[targetSlot] + if (!input) return null + + const linkId = input.link + if (linkId == null) return null + + const link = graph.getLink?.(linkId) + if (!link) return null + + return { + id: link.id, + originId: link.origin_id, + originSlot: + typeof link.origin_slot === 'string' + ? Number.parseInt(link.origin_slot, 10) + : link.origin_slot, + targetId: link.target_id, + targetSlot: + typeof link.target_slot === 'string' + ? Number.parseInt(link.target_slot, 10) + : link.target_slot, + parentId: link.parentId ?? null + } + }, + [nodeId, slotIndex] as const + ) +} + +// Test helpers to reduce repetition across cases +function slotLocator( + page: Page, + nodeId: NodeId, + slotIndex: number, + isInput: boolean +) { + const key = getSlotKey(String(nodeId), slotIndex, isInput) + return page.locator(`[data-slot-key="${key}"]`) +} + +async function expectVisibleAll(...locators: Locator[]) { + await Promise.all(locators.map((l) => expect(l).toBeVisible())) +} + +async function getSlotCenter( + page: Page, + nodeId: NodeId, + slotIndex: number, + isInput: boolean +) { + const locator = slotLocator(page, nodeId, slotIndex, isInput) + await expect(locator).toBeVisible() + return await getCenter(locator) +} + +async function connectSlots( + page: Page, + from: { nodeId: NodeId; index: number }, + to: { nodeId: NodeId; index: number }, + nextFrame: () => Promise +) { + const fromLoc = slotLocator(page, from.nodeId, from.index, false) + const toLoc = slotLocator(page, to.nodeId, to.index, true) + await expectVisibleAll(fromLoc, toLoc) + await fromLoc.dragTo(toLoc) + await nextFrame() +} + +test.describe('Vue Node Link Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + await comfyPage.loadWorkflow('vueNodes/simple-triple') + await comfyPage.vueNodes.waitForNodes() + await fitToViewInstant(comfyPage) + }) + + test('should show a link dragging out from a slot when dragging on a slot', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(samplerNode).toBeTruthy() + + const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false) + await expect(slot).toBeVisible() + + const start = await getCenter(slot) + + // Arbitrary value + const dragTarget = { + x: start.x + 180, + y: start.y - 140 + } + + await comfyMouse.move(start) + await comfyMouse.drag(dragTarget) + await comfyPage.nextFrame() + + try { + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-dragging-link.png' + ) + } finally { + await comfyMouse.drop() + } + }) + + test('should create a link when dropping on a compatible slot', async ({ + comfyPage + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + expect(samplerNode && vaeNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + await connectSlots( + comfyPage.page, + { nodeId: samplerNode.id, index: 0 }, + { nodeId: vaeNode.id, index: 0 }, + () => comfyPage.nextFrame() + ) + + expect(await samplerOutput.getLinkCount()).toBe(1) + expect(await vaeInput.getLinkCount()).toBe(1) + + const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0) + expect(linkDetails).not.toBeNull() + expect(linkDetails).toMatchObject({ + originId: samplerNode.id, + originSlot: 0, + targetId: vaeNode.id, + targetSlot: 0 + }) + }) + + test('should not create a link when slot types are incompatible', async ({ + comfyPage + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0] + expect(samplerNode && clipNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const clipInput = await clipNode.getInput(0) + + const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false) + const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true) + await expectVisibleAll(outputSlot, inputSlot) + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await clipInput.getLinkCount()).toBe(0) + + const graphLinkDetails = await getInputLinkDetails( + comfyPage.page, + clipNode.id, + 0 + ) + expect(graphLinkDetails).toBeNull() + }) + + test('should not create a link when dropping onto a slot on the same node', async ({ + comfyPage + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(samplerNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const samplerInput = await samplerNode.getInput(3) + + const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false) + const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true) + await expectVisibleAll(outputSlot, inputSlot) + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await samplerInput.getLinkCount()).toBe(0) + }) + + test('should reuse the existing origin when dragging an input link', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + expect(samplerNode && vaeNode).toBeTruthy() + const samplerOutputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const vaeInputCenter = await getSlotCenter( + comfyPage.page, + vaeNode.id, + 0, + true + ) + + await comfyMouse.move(samplerOutputCenter) + await comfyMouse.drag(vaeInputCenter) + await comfyMouse.drop() + + const dragTarget = { + x: vaeInputCenter.x + 160, + y: vaeInputCenter.y - 100 + } + + await comfyMouse.move(vaeInputCenter) + await comfyMouse.drag(dragTarget) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-input-drag-reuses-origin.png' + ) + await comfyMouse.drop() + }) + + test('ctrl+alt drag from an input starts a fresh link', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + expect(samplerNode && vaeNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + const samplerOutputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const vaeInputCenter = await getSlotCenter( + comfyPage.page, + vaeNode.id, + 0, + true + ) + + await comfyMouse.move(samplerOutputCenter) + await comfyMouse.drag(vaeInputCenter) + await comfyMouse.drop() + + await comfyPage.nextFrame() + + const dragTarget = { + x: vaeInputCenter.x + 140, + y: vaeInputCenter.y - 110 + } + + await comfyMouse.move(vaeInputCenter) + await comfyPage.page.keyboard.down('Control') + await comfyPage.page.keyboard.down('Alt') + + try { + await comfyMouse.drag(dragTarget) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-input-drag-ctrl-alt.png' + ) + } finally { + await comfyMouse.drop().catch(() => {}) + await comfyPage.page.keyboard.up('Alt').catch(() => {}) + await comfyPage.page.keyboard.up('Control').catch(() => {}) + } + + await comfyPage.nextFrame() + + // Tcehnically intended to disconnect existing as well + expect(await vaeInput.getLinkCount()).toBe(0) + expect(await samplerOutput.getLinkCount()).toBe(0) + }) + + test('dropping an input link back on its slot restores the original connection', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + expect(samplerNode && vaeNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + const samplerOutputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const vaeInputCenter = await getSlotCenter( + comfyPage.page, + vaeNode.id, + 0, + true + ) + + await comfyMouse.move(samplerOutputCenter) + try { + await comfyMouse.drag(vaeInputCenter) + } finally { + await comfyMouse.drop() + } + + await comfyPage.nextFrame() + + const originalLink = await getInputLinkDetails( + comfyPage.page, + vaeNode.id, + 0 + ) + expect(originalLink).not.toBeNull() + + const dragTarget = { + x: vaeInputCenter.x + 150, + y: vaeInputCenter.y - 100 + } + + // To prevent needing a screenshot expectation for whether the link's off + const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true) + const inputBox = await vaeInputLocator.boundingBox() + if (!inputBox) throw new Error('Input slot bounding box not available') + const isOutsideX = + dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width + const isOutsideY = + dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height + expect(isOutsideX || isOutsideY).toBe(true) + + await comfyMouse.move(vaeInputCenter) + await comfyMouse.drag(dragTarget) + await comfyMouse.move(vaeInputCenter) + await comfyMouse.drop() + + await comfyPage.nextFrame() + + const restoredLink = await getInputLinkDetails( + comfyPage.page, + vaeNode.id, + 0 + ) + + expect(restoredLink).not.toBeNull() + if (!restoredLink || !originalLink) { + throw new Error('Expected both original and restored links to exist') + } + expect(restoredLink).toMatchObject({ + originId: originalLink.originId, + originSlot: originalLink.originSlot, + targetId: originalLink.targetId, + targetSlot: originalLink.targetSlot, + parentId: originalLink.parentId + }) + expect(await samplerOutput.getLinkCount()).toBe(1) + expect(await vaeInput.getLinkCount()).toBe(1) + }) + + test('rerouted input drag preview remains anchored to reroute', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + await connectSlots( + comfyPage.page, + { nodeId: samplerNode.id, index: 0 }, + { nodeId: vaeNode.id, index: 0 }, + () => comfyPage.nextFrame() + ) + + const outputPosition = await samplerOutput.getPosition() + const inputPosition = await vaeInput.getPosition() + const reroutePoint = getMiddlePoint(outputPosition, inputPosition) + + // Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0]. + // This avoids relying on an exact path hit-test position. + await comfyPage.page.evaluate( + ([targetNodeId, targetSlot, clientPoint]) => { + const app = (window as any)['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) throw new Error('Graph not available') + const node = graph.getNodeById(targetNodeId) + if (!node) throw new Error('Target node not found') + const input = node.inputs?.[targetSlot] + if (!input) throw new Error('Target input slot not found') + + const linkId = input.link + if (linkId == null) throw new Error('Expected existing link on input') + const link = graph.getLink(linkId) + if (!link) throw new Error('Link not found') + + // Convert the client/canvas pixel coordinates to graph space + const pos = app.canvas.ds.convertCanvasToOffset([ + clientPoint.x, + clientPoint.y + ]) + graph.createReroute(pos, link) + }, + [vaeNode.id, 0, reroutePoint] as const + ) + + await comfyPage.nextFrame() + + const vaeInputCenter = await getSlotCenter( + comfyPage.page, + vaeNode.id, + 0, + true + ) + const dragTarget = { + x: vaeInputCenter.x + 160, + y: vaeInputCenter.y - 120 + } + + let dropped = false + try { + await comfyMouse.move(vaeInputCenter) + await comfyMouse.drag(dragTarget) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-reroute-input-drag.png' + ) + await comfyMouse.move(vaeInputCenter) + await comfyMouse.drop() + dropped = true + } finally { + if (!dropped) { + await comfyMouse.drop().catch(() => {}) + } + } + + await comfyPage.nextFrame() + + const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0) + expect(linkDetails).not.toBeNull() + expect(linkDetails?.originId).toBe(samplerNode.id) + expect(linkDetails?.parentId).not.toBeNull() + }) + + test('rerouted output shift-drag preview remains anchored to reroute', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] + expect(samplerNode && vaeNode).toBeTruthy() + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + await connectSlots( + comfyPage.page, + { nodeId: samplerNode.id, index: 0 }, + { nodeId: vaeNode.id, index: 0 }, + () => comfyPage.nextFrame() + ) + + const outputPosition = await samplerOutput.getPosition() + const inputPosition = await vaeInput.getPosition() + const reroutePoint = getMiddlePoint(outputPosition, inputPosition) + + // Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0]. + // This avoids relying on an exact path hit-test position. + await comfyPage.page.evaluate( + ([targetNodeId, targetSlot, clientPoint]) => { + const app = (window as any)['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) throw new Error('Graph not available') + const node = graph.getNodeById(targetNodeId) + if (!node) throw new Error('Target node not found') + const input = node.inputs?.[targetSlot] + if (!input) throw new Error('Target input slot not found') + + const linkId = input.link + if (linkId == null) throw new Error('Expected existing link on input') + const link = graph.getLink(linkId) + if (!link) throw new Error('Link not found') + + // Convert the client/canvas pixel coordinates to graph space + const pos = app.canvas.ds.convertCanvasToOffset([ + clientPoint.x, + clientPoint.y + ]) + graph.createReroute(pos, link) + }, + [vaeNode.id, 0, reroutePoint] as const + ) + + await comfyPage.nextFrame() + + const outputCenter = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 0, + false + ) + const dragTarget = { + x: outputCenter.x + 150, + y: outputCenter.y - 140 + } + + let dropPending = false + let shiftHeld = false + try { + await comfyMouse.move(outputCenter) + await comfyPage.page.keyboard.down('Shift') + shiftHeld = true + dropPending = true + await comfyMouse.drag(dragTarget) + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-reroute-output-shift-drag.png' + ) + await comfyMouse.move(outputCenter) + await comfyMouse.drop() + dropPending = false + } finally { + if (dropPending) await comfyMouse.drop().catch(() => {}) + if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {}) + } + + await comfyPage.nextFrame() + + const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0) + expect(linkDetails).not.toBeNull() + expect(linkDetails?.originId).toBe(samplerNode.id) + expect(linkDetails?.parentId).not.toBeNull() + }) + + test('dragging input to input drags existing link', async ({ + comfyPage, + comfyMouse + }) => { + const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0] + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(clipNode && samplerNode).toBeTruthy() + + // Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1) + await connectSlots( + comfyPage.page, + { nodeId: clipNode.id, index: 0 }, + { nodeId: samplerNode.id, index: 1 }, + () => comfyPage.nextFrame() + ) + + // Verify initial link exists between CLIP -> KSampler input[1] + const initialLink = await getInputLinkDetails( + comfyPage.page, + samplerNode.id, + 1 + ) + expect(initialLink).not.toBeNull() + expect(initialLink).toMatchObject({ + originId: clipNode.id, + targetId: samplerNode.id, + targetSlot: 1 + }) + + // Step 2: Drag from KSampler's second input to its third input (index 2) + const input2Center = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 1, + true + ) + const input3Center = await getSlotCenter( + comfyPage.page, + samplerNode.id, + 2, + true + ) + + await comfyMouse.move(input2Center) + await comfyMouse.drag(input3Center) + await comfyMouse.drop() + await comfyPage.nextFrame() + + // Expect old link removed from input[1] + const afterSecondInput = await getInputLinkDetails( + comfyPage.page, + samplerNode.id, + 1 + ) + expect(afterSecondInput).toBeNull() + + // Expect new link exists at input[2] from CLIP + const afterThirdInput = await getInputLinkDetails( + comfyPage.page, + samplerNode.id, + 2 + ) + expect(afterThirdInput).not.toBeNull() + expect(afterThirdInput).toMatchObject({ + originId: clipNode.id, + targetId: samplerNode.id, + targetSlot: 2 + }) + }) + + test('shift-dragging an output with multiple links should drag all links', async ({ + comfyPage, + comfyMouse + }) => { + const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0] + const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] + expect(clipNode && samplerNode).toBeTruthy() + + const clipOutput = await clipNode.getOutput(0) + + // Connect output[0] -> inputs[1] and [2] + await connectSlots( + comfyPage.page, + { nodeId: clipNode.id, index: 0 }, + { nodeId: samplerNode.id, index: 1 }, + () => comfyPage.nextFrame() + ) + await connectSlots( + comfyPage.page, + { nodeId: clipNode.id, index: 0 }, + { nodeId: samplerNode.id, index: 2 }, + () => comfyPage.nextFrame() + ) + + expect(await clipOutput.getLinkCount()).toBe(2) + + const outputCenter = await getSlotCenter( + comfyPage.page, + clipNode.id, + 0, + false + ) + const dragTarget = { + x: outputCenter.x + 40, + y: outputCenter.y - 140 + } + + let dropPending = false + let shiftHeld = false + try { + await comfyMouse.move(outputCenter) + await comfyPage.page.keyboard.down('Shift') + shiftHeld = true + await comfyMouse.drag(dragTarget) + dropPending = true + + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-shift-output-multi-link.png' + ) + } finally { + if (dropPending) await comfyMouse.drop().catch(() => {}) + if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {}) + } + }) +}) diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 0000000000..c4db539bcd Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png new file mode 100644 index 0000000000..6856373843 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png new file mode 100644 index 0000000000..0f660f23eb Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png new file mode 100644 index 0000000000..22f5c69f4b Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png new file mode 100644 index 0000000000..7eeef693db Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png new file mode 100644 index 0000000000..64d1404492 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/node/remove.spec.ts similarity index 96% rename from browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts rename to browser_tests/tests/vueNodes/interactions/node/remove.spec.ts index a00d93eb04..e7a6106435 100644 --- a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/remove.spec.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test' -import { comfyPageFixture as test } from '../../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage' + +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) test.describe('Vue Nodes - Delete Key Interaction', () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts new file mode 100644 index 0000000000..3984989e1b --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts @@ -0,0 +1,76 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' +import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures' + +test.describe('Vue Nodes Renaming', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + }) + + test('should display node title', async ({ comfyPage }) => { + // Get the KSampler node from the default workflow + const nodes = await comfyPage.getNodeRefsByType('KSampler') + expect(nodes.length).toBeGreaterThanOrEqual(1) + + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + const title = await vueNode.getTitle() + expect(title).toBe('KSampler') + + // Verify title is visible in the header + const header = await vueNode.getHeader() + await expect(header).toContainText('KSampler') + }) + + test('should allow title renaming by double clicking on the node header', async ({ + comfyPage + }) => { + const nodes = await comfyPage.getNodeRefsByType('KSampler') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Test renaming with Enter + await vueNode.setTitle('My Custom Sampler') + const newTitle = await vueNode.getTitle() + expect(newTitle).toBe('My Custom Sampler') + + // Verify the title is displayed + const header = await vueNode.getHeader() + await expect(header).toContainText('My Custom Sampler') + + // Test cancel with Escape + const titleElement = await vueNode.getTitleElement() + await titleElement.dblclick() + await comfyPage.nextFrame() + + // Type a different value but cancel + const input = (await vueNode.getHeader()).locator( + '[data-testid="node-title-input"]' + ) + await input.fill('This Should Be Cancelled') + await input.press('Escape') + await comfyPage.nextFrame() + + // Title should remain as the previously saved value + const titleAfterCancel = await vueNode.getTitle() + expect(titleAfterCancel).toBe('My Custom Sampler') + }) + + test('Double click node body does not trigger edit', async ({ + comfyPage + }) => { + const loadCheckpointNode = + comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const nodeBbox = await loadCheckpointNode.boundingBox() + if (!nodeBbox) throw new Error('Node not found') + await loadCheckpointNode.dblclick() + + const editingTitleInput = comfyPage.page.getByTestId('node-title-input') + await expect(editingTitleInput).not.toBeVisible() + }) +}) diff --git a/browser_tests/tests/vueNodes/interactions/node/select.spec.ts b/browser_tests/tests/vueNodes/interactions/node/select.spec.ts new file mode 100644 index 0000000000..2af6765896 --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/node/select.spec.ts @@ -0,0 +1,52 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + +test.describe('Vue Node Selection', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + const modifiers = [ + { key: 'Control', name: 'ctrl' }, + { key: 'Shift', name: 'shift' }, + { key: 'Meta', name: 'meta' } + ] as const + + for (const { key: modifier, name } of modifiers) { + test(`should allow selecting multiple nodes with ${name}+click`, async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1) + + await comfyPage.page.getByText('Empty Latent Image').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2) + + await comfyPage.page.getByText('KSampler').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3) + }) + + test(`should allow de-selecting nodes with ${name}+click`, async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1) + + await comfyPage.page.getByText('Load Checkpoint').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0) + }) + } +}) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 0000000000..90df443abc Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png new file mode 100644 index 0000000000..a5e4e35c3c Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png new file mode 100644 index 0000000000..80b96815e9 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png new file mode 100644 index 0000000000..3be98b50fe Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png new file mode 100644 index 0000000000..de003b4ed3 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png new file mode 100644 index 0000000000..05a22b8d21 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts new file mode 100644 index 0000000000..74ec17cc90 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts @@ -0,0 +1,45 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const BYPASS_HOTKEY = 'Control+b' +const BYPASS_CLASS = /before:bg-bypass\/60/ + +test.describe('Vue Node Bypass', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should allow toggling bypass on a selected node with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + await expect(checkpointNode).toHaveClass(BYPASS_CLASS) + + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS) + }) + + test('should allow toggling bypass on multiple selected nodes with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] }) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler') + + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).toHaveClass(BYPASS_CLASS) + await expect(ksamplerNode).toHaveClass(BYPASS_CLASS) + + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS) + await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS) + }) +}) diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts similarity index 55% rename from browser_tests/tests/vueNodes/NodeHeader.spec.ts rename to browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts index 7a8ae5dd2b..a339a0a25a 100644 --- a/browser_tests/tests/vueNodes/NodeHeader.spec.ts +++ b/browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts @@ -1,67 +1,20 @@ import { comfyExpect as expect, comfyPageFixture as test -} from '../../fixtures/ComfyPage' -import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' +} from '../../../fixtures/ComfyPage' +import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures' -test.describe('NodeHeader', () => { +test.describe('Vue Node Collapse', () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled') await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setup() }) - test('displays node title', async ({ comfyPage }) => { - // Get the KSampler node from the default workflow - const nodes = await comfyPage.getNodeRefsByType('KSampler') - expect(nodes.length).toBeGreaterThanOrEqual(1) - - const node = nodes[0] - const vueNode = new VueNodeFixture(node, comfyPage.page) - - const title = await vueNode.getTitle() - expect(title).toBe('KSampler') - - // Verify title is visible in the header - const header = await vueNode.getHeader() - await expect(header).toContainText('KSampler') - }) - - test('allows title renaming', async ({ comfyPage }) => { - const nodes = await comfyPage.getNodeRefsByType('KSampler') - const node = nodes[0] - const vueNode = new VueNodeFixture(node, comfyPage.page) - - // Test renaming with Enter - await vueNode.setTitle('My Custom Sampler') - const newTitle = await vueNode.getTitle() - expect(newTitle).toBe('My Custom Sampler') - - // Verify the title is displayed - const header = await vueNode.getHeader() - await expect(header).toContainText('My Custom Sampler') - - // Test cancel with Escape - const titleElement = await vueNode.getTitleElement() - await titleElement.dblclick() - await comfyPage.nextFrame() - - // Type a different value but cancel - const input = (await vueNode.getHeader()).locator( - '[data-testid="node-title-input"]' - ) - await input.fill('This Should Be Cancelled') - await input.press('Escape') - await comfyPage.nextFrame() - - // Title should remain as the previously saved value - const titleAfterCancel = await vueNode.getTitle() - expect(titleAfterCancel).toBe('My Custom Sampler') - }) - - test('handles node collapsing', async ({ comfyPage }) => { + test('should allow collapsing node with collapse icon', async ({ + comfyPage + }) => { // Get the KSampler node from the default workflow const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] @@ -90,7 +43,7 @@ test.describe('NodeHeader', () => { expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height) }) - test('shows collapse/expand icon state', async ({ comfyPage }) => { + test('should show collapse/expand icon state', async ({ comfyPage }) => { const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] const vueNode = new VueNodeFixture(node, comfyPage.page) @@ -110,7 +63,9 @@ test.describe('NodeHeader', () => { expect(iconClass).toContain('pi-chevron-down') }) - test('preserves title when collapsing/expanding', async ({ comfyPage }) => { + test('should preserve title when collapsing/expanding', async ({ + comfyPage + }) => { const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] const vueNode = new VueNodeFixture(node, comfyPage.page) diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts new file mode 100644 index 0000000000..e61e3ca013 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +test.describe('Vue Node Custom Colors', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('displays color picker button and allows color selection', async ({ + comfyPage + }) => { + const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'Load Checkpoint' + }) + await loadCheckpointNode.getByText('Load Checkpoint').click() + + await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click() + await comfyPage.page + .locator('.color-picker-container') + .locator('i[data-testid="blue"]') + .click() + + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-color-blue.png' + ) + }) + + test('should load node colors from workflow', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('nodes/every_node_color') + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-colors-dark-all-colors.png' + ) + }) + + test('should show brightened node colors on light theme', async ({ + comfyPage + }) => { + await comfyPage.setSetting('Comfy.ColorPalette', 'light') + await comfyPage.loadWorkflow('nodes/every_node_color') + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-colors-light-all-colors.png' + ) + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png new file mode 100644 index 0000000000..ceb84698cf Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png new file mode 100644 index 0000000000..5997ea26ea Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png new file mode 100644 index 0000000000..0df00782fc Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts new file mode 100644 index 0000000000..f4f8e10fe0 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts @@ -0,0 +1,32 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const ERROR_CLASS = /border-error/ + +test.describe('Vue Node Error', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should display error state when node is missing (node from workflow is not installed)', async ({ + comfyPage + }) => { + await comfyPage.setup() + await comfyPage.loadWorkflow('missing/missing_nodes') + + // Close missing nodes warning dialog + await comfyPage.page.getByRole('button', { name: 'Close' }).click() + await comfyPage.page.waitForSelector('.comfy-missing-nodes', { + state: 'hidden' + }) + + // Expect error state on missing unknown node + const unknownNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'UNKNOWN NODE' + }) + await expect(unknownNode).toHaveClass(ERROR_CLASS) + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts new file mode 100644 index 0000000000..f8c94aba59 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../../fixtures/ComfyPage' + +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + +test.describe('Vue Nodes - LOD', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + await comfyPage.loadWorkflow('default') + }) + + test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => { + await comfyPage.vueNodes.waitForNodes() + + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png') + + const vueNodesContainer = comfyPage.vueNodes.nodes + const textboxesInNodes = vueNodesContainer.getByRole('textbox') + const buttonsInNodes = vueNodesContainer.getByRole('button') + + await expect(textboxesInNodes.first()).toBeVisible() + await expect(buttonsInNodes.first()).toBeVisible() + + await comfyPage.zoom(120, 10) + await comfyPage.nextFrame() + + await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png') + + await expect(textboxesInNodes.first()).toBeHidden() + await expect(buttonsInNodes.first()).toBeHidden() + + await comfyPage.zoom(-120, 10) + await comfyPage.nextFrame() + + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-nodes-lod-inactive.png' + ) + await expect(textboxesInNodes.first()).toBeVisible() + await expect(buttonsInNodes.first()).toBeVisible() + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png new file mode 100644 index 0000000000..c61bcbc466 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png new file mode 100644 index 0000000000..2bbaeaa642 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png new file mode 100644 index 0000000000..57bca67fa2 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts new file mode 100644 index 0000000000..37dcfd37b5 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts @@ -0,0 +1,45 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const MUTE_HOTKEY = 'Control+m' +const MUTE_CLASS = /opacity-50/ + +test.describe('Vue Node Mute', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should allow toggling mute on a selected node with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.keyboard.press(MUTE_HOTKEY) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + await expect(checkpointNode).toHaveClass(MUTE_CLASS) + + await comfyPage.page.keyboard.press(MUTE_HOTKEY) + await expect(checkpointNode).not.toHaveClass(MUTE_CLASS) + }) + + test('should allow toggling mute on multiple selected nodes with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] }) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler') + + await comfyPage.page.keyboard.press(MUTE_HOTKEY) + await expect(checkpointNode).toHaveClass(MUTE_CLASS) + await expect(ksamplerNode).toHaveClass(MUTE_CLASS) + + await comfyPage.page.keyboard.press(MUTE_HOTKEY) + await expect(checkpointNode).not.toHaveClass(MUTE_CLASS) + await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS) + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts b/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts new file mode 100644 index 0000000000..27f1ad1ac9 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts @@ -0,0 +1,85 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const PIN_HOTKEY = 'p' +const PIN_INDICATOR = '[data-testid="node-pin-indicator"]' + +test.describe('Vue Node Pin', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should allow toggling pin on a selected node with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.keyboard.press(PIN_HOTKEY) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const pinIndicator = checkpointNode.locator(PIN_INDICATOR) + + await expect(pinIndicator).toBeVisible() + + await comfyPage.page.keyboard.press(PIN_HOTKEY) + await expect(pinIndicator).not.toBeVisible() + }) + + test('should allow toggling pin on multiple selected nodes with hotkey', async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] }) + + const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler') + + await comfyPage.page.keyboard.press(PIN_HOTKEY) + const pinIndicator1 = checkpointNode.locator(PIN_INDICATOR) + await expect(pinIndicator1).toBeVisible() + const pinIndicator2 = ksamplerNode.locator(PIN_INDICATOR) + await expect(pinIndicator2).toBeVisible() + + await comfyPage.page.keyboard.press(PIN_HOTKEY) + await expect(pinIndicator1).not.toBeVisible() + await expect(pinIndicator2).not.toBeVisible() + }) + + test('should not allow dragging pinned nodes', async ({ comfyPage }) => { + const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint') + await checkpointNodeHeader.click() + await comfyPage.page.keyboard.press(PIN_HOTKEY) + + // Try to drag the node + const headerPos = await checkpointNodeHeader.boundingBox() + if (!headerPos) throw new Error('Failed to get header position') + await comfyPage.dragAndDrop( + { x: headerPos.x, y: headerPos.y }, + { x: headerPos.x + 256, y: headerPos.y + 256 } + ) + + // Verify the node is not dragged (same position before and after click-and-drag) + const headerPosAfterDrag = await checkpointNodeHeader.boundingBox() + if (!headerPosAfterDrag) + throw new Error('Failed to get header position after drag') + expect(headerPosAfterDrag).toEqual(headerPos) + + // Unpin the node with the hotkey + await checkpointNodeHeader.click() + await comfyPage.page.keyboard.press(PIN_HOTKEY) + + // Try to drag the node again + await comfyPage.dragAndDrop( + { x: headerPos.x, y: headerPos.y }, + { x: headerPos.x + 256, y: headerPos.y + 256 } + ) + + // Verify the node is dragged + const headerPosAfterDrag2 = await checkpointNodeHeader.boundingBox() + if (!headerPosAfterDrag2) + throw new Error('Failed to get header position after drag') + expect(headerPosAfterDrag2).not.toEqual(headerPos) + }) +}) diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts new file mode 100644 index 0000000000..8fcc3360df --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts @@ -0,0 +1,21 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Upload Widgets', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should hide canvas-only upload buttons', async ({ comfyPage }) => { + await comfyPage.setup() + await comfyPage.loadWorkflow('widgets/all_load_widgets') + await comfyPage.vueNodes.waitForNodes() + + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-nodes-upload-widgets.png' + ) + }) +}) diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png new file mode 100644 index 0000000000..ac5b685cac Binary files /dev/null and b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts new file mode 100644 index 0000000000..bb08232a29 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts @@ -0,0 +1,49 @@ +import { + type ComfyPage, + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Multiline String Widget', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + const getFirstClipNode = (comfyPage: ComfyPage) => + comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first() + + const getFirstMultilineStringWidget = (comfyPage: ComfyPage) => + getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' }) + + test('should allow entering text', async ({ comfyPage }) => { + const textarea = getFirstMultilineStringWidget(comfyPage) + await textarea.fill('Hello World') + await expect(textarea).toHaveValue('Hello World') + await textarea.fill('Hello World 2') + await expect(textarea).toHaveValue('Hello World 2') + }) + + test('should support entering multiline content', async ({ comfyPage }) => { + const textarea = getFirstMultilineStringWidget(comfyPage) + + const multilineValue = ['Line 1', 'Line 2', 'Line 3'].join('\n') + + await textarea.fill(multilineValue) + await expect(textarea).toHaveValue(multilineValue) + }) + + test('should retain value after focus changes', async ({ comfyPage }) => { + const textarea = getFirstMultilineStringWidget(comfyPage) + + await textarea.fill('Keep me around') + + // Click another node + const loadCheckpointNode = + comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + await loadCheckpointNode.click() + await getFirstClipNode(comfyPage).click() + + await expect(textarea).toHaveValue('Keep me around') + }) +}) diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index 728b5d0285..3b9c057847 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -2,6 +2,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') +}) + test.describe('Combo text widget', () => { test('Truncates text when resized', async ({ comfyPage }) => { await comfyPage.resizeLoadCheckpointNode(0.2, 1) @@ -318,6 +322,9 @@ test.describe('Animated image widget', () => { test.describe('Load audio widget', () => { test('Can load audio', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/load_audio_widget') + // Wait for the audio widget to be rendered in the DOM + await comfyPage.page.waitForSelector('.comfy-audio', { state: 'attached' }) + await comfyPage.nextFrame() await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png') }) }) diff --git a/browser_tests/tsconfig.json b/browser_tests/tsconfig.json new file mode 100644 index 0000000000..391298333b --- /dev/null +++ b/browser_tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + /* Test files should not be compiled */ + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts", + ] +} diff --git a/build/plugins/comfyAPIPlugin.ts b/build/plugins/comfyAPIPlugin.ts index 3f795a2194..5b7f4bec42 100644 --- a/build/plugins/comfyAPIPlugin.ts +++ b/build/plugins/comfyAPIPlugin.ts @@ -1,5 +1,5 @@ import path from 'path' -import { Plugin } from 'vite' +import type { Plugin } from 'vite' interface ShimResult { code: string diff --git a/build/plugins/generateImportMapPlugin.ts b/build/plugins/generateImportMapPlugin.ts index 80ccb6c9f7..bbbf14c2c6 100644 --- a/build/plugins/generateImportMapPlugin.ts +++ b/build/plugins/generateImportMapPlugin.ts @@ -1,7 +1,7 @@ import glob from 'fast-glob' import fs from 'fs-extra' import { dirname, join } from 'node:path' -import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite' +import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite' interface ImportMapSource { name: string diff --git a/build/tsconfig.json b/build/tsconfig.json new file mode 100644 index 0000000000..1c24810a8e --- /dev/null +++ b/build/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + /* Build scripts configuration */ + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ] +} \ No newline at end of file diff --git a/docs/extensions/development.md b/docs/extensions/development.md index 47c83ecf0e..e988d1d453 100644 --- a/docs/extensions/development.md +++ b/docs/extensions/development.md @@ -110,7 +110,7 @@ pnpm build For faster iteration during development, use watch mode: ```bash -npx vite build --watch +pnpm exec vite build --watch ``` Note: Watch mode provides faster rebuilds than full builds, but still no hot reload diff --git a/eslint.config.js b/eslint.config.ts similarity index 61% rename from eslint.config.js rename to eslint.config.ts index cddba3bbd3..8998522481 100644 --- a/eslint.config.js +++ b/eslint.config.ts @@ -5,54 +5,75 @@ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' import storybook from 'eslint-plugin-storybook' import unusedImports from 'eslint-plugin-unused-imports' import pluginVue from 'eslint-plugin-vue' +import { defineConfig } from 'eslint/config' import globals from 'globals' import tseslint from 'typescript-eslint' +import vueParser from 'vue-eslint-parser' -export default [ - { - files: ['src/**/*.{js,mjs,cjs,ts,vue}'] - }, +const extraFileExtensions = ['.vue'] + +export default defineConfig([ { ignores: [ 'src/scripts/*', 'src/extensions/core/*', 'src/types/vue-shim.d.ts', - 'src/types/comfyRegistryTypes.ts', + 'packages/registry-types/src/comfyRegistryTypes.ts', 'src/types/generatedManagerTypes.ts', '**/vite.config.*.timestamp*', '**/vitest.config.*.timestamp*' ] }, { + files: ['./**/*.{ts,mts}'], languageOptions: { globals: { ...globals.browser, __COMFYUI_FRONTEND_VERSION__: 'readonly' }, - parser: tseslint.parser, parserOptions: { - project: ['./tsconfig.json', './tsconfig.eslint.json'], + parser: tseslint.parser, + projectService: { + allowDefaultProject: [ + 'vite.config.mts', + 'vite.electron.config.mts', + 'vite.types.config.mts' + ] + }, + tsConfigRootDir: import.meta.dirname, ecmaVersion: 2020, sourceType: 'module', - extraFileExtensions: ['.vue'] + extraFileExtensions + } + } + }, + { + files: ['./**/*.vue'], + languageOptions: { + globals: { + ...globals.browser, + __COMFYUI_FRONTEND_VERSION__: 'readonly' + }, + parser: vueParser, + parserOptions: { + parser: tseslint.parser, + projectService: true, + tsConfigRootDir: import.meta.dirname, + ecmaVersion: 2020, + sourceType: 'module', + extraFileExtensions } } }, pluginJs.configs.recommended, - ...tseslint.configs.recommended, - ...pluginVue.configs['flat/recommended'], + tseslint.configs.recommended, + pluginVue.configs['flat/recommended'], eslintPluginPrettierRecommended, - { - files: ['src/**/*.vue'], - languageOptions: { - parserOptions: { - parser: tseslint.parser - } - } - }, + storybook.configs['flat/recommended'], { plugins: { 'unused-imports': unusedImports, + // @ts-expect-error Bad types in the plugin '@intlify/vue-i18n': pluginI18n }, rules: { @@ -60,13 +81,29 @@ export default [ '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/prefer-as-const': 'off', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/no-empty-object-type': [ + 'error', + { + allowInterfaces: 'always' + } + ], 'unused-imports/no-unused-imports': 'error', 'vue/no-v-html': 'off', // Enforce dark-theme: instead of dark: prefix 'vue/no-restricted-class': ['error', '/^dark:/'], 'vue/multi-word-component-names': 'off', // TODO: fix 'vue/no-template-shadow': 'off', // TODO: fix + /* Toggle on to do additional until we can clean up existing violations. + 'vue/no-unused-emit-declarations': 'error', + 'vue/no-unused-properties': 'error', + 'vue/no-unused-refs': 'error', + 'vue/no-use-v-else-with-v-for': 'error', + 'vue/no-useless-v-bind': 'error', + // */ 'vue/one-component-per-file': 'off', // TODO: fix + 'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile // Restrict deprecated PrimeVue components 'no-restricted-imports': [ 'error', @@ -136,5 +173,39 @@ export default [ ] } }, - ...storybook.configs['flat/recommended'] -] + { + files: ['tests-ui/**/*'], + rules: { + '@typescript-eslint/consistent-type-imports': [ + 'error', + { disallowTypeAnnotations: false } + ] + } + }, + { + files: ['**/*.spec.ts'], + ignores: ['browser_tests/tests/**/*.spec.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Program', + message: '.spec.ts files are only allowed under browser_tests/tests/' + } + ] + } + }, + { + files: ['browser_tests/tests/**/*.test.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Program', + message: + '.test.ts files are not allowed in browser_tests/tests/; use .spec.ts instead' + } + ] + } + } +]) diff --git a/index.html b/index.html index de7710c63d..8684af476b 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,8 @@ - - + + diff --git a/knip.config.ts b/knip.config.ts index 9df077d772..3b44579fd5 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -1,15 +1,30 @@ import type { KnipConfig } from 'knip' const config: KnipConfig = { - entry: [ - '{build,scripts}/**/*.{js,ts}', - 'src/assets/css/style.css', - 'src/main.ts', - 'src/scripts/ui/menu/index.ts', - 'src/types/index.ts' - ], - project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'], - ignoreBinaries: ['only-allow', 'openapi-typescript'], + workspaces: { + '.': { + entry: [ + '{build,scripts}/**/*.{js,ts}', + 'src/assets/css/style.css', + 'src/main.ts', + 'src/scripts/ui/menu/index.ts', + 'src/types/index.ts' + ], + project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'] + }, + 'packages/tailwind-utils': { + project: ['src/**/*.{js,ts}'] + }, + 'packages/design-system': { + entry: ['src/**/*.ts'], + project: ['src/**/*.{js,ts}', '*.{js,ts,mts}'] + }, + 'packages/registry-types': { + entry: ['src/comfyRegistryTypes.ts'], + project: ['src/**/*.{js,ts}'] + } + }, + ignoreBinaries: ['python3'], ignoreDependencies: [ // Weird importmap things '@iconify/json', @@ -22,8 +37,8 @@ const config: KnipConfig = { ], ignore: [ // Auto generated manager types - 'src/types/generatedManagerTypes.ts', - 'src/types/comfyRegistryTypes.ts', + 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', + 'packages/registry-types/src/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) 'src/scripts/ui/components/splitButton.ts' ], diff --git a/lint-staged.config.js b/lint-staged.config.js index 2d1a6f0511..0f3808700a 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -3,13 +3,13 @@ export default { './**/*.{ts,tsx,vue,mts}': (stagedFiles) => [ ...formatAndEslint(stagedFiles), - 'vue-tsc --noEmit' + 'pnpm typecheck' ] } function formatAndEslint(fileNames) { return [ - `eslint --fix ${fileNames.join(' ')}`, - `prettier --write ${fileNames.join(' ')}` + `pnpm exec eslint --cache --fix ${fileNames.join(' ')}`, + `pnpm exec prettier --cache --write ${fileNames.join(' ')}` ] } diff --git a/package.json b/package.json index e568820c0d..bdfdc0378f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.27.4", + "version": "1.28.3", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -14,34 +14,36 @@ "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js", "zipdist": "node scripts/zipdist.js", "typecheck": "vue-tsc --noEmit", - "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache", + "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different", "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", - "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'", + "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different", "format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", - "test:browser": "npx nx e2e", - "test:unit": "nx run test tests-ui/tests", + "test:all": "nx run test", + "test:browser": "pnpm exec nx e2e", "test:component": "nx run test src/components/", "test:litegraph": "vitest run --config vitest.litegraph.config.ts", - "preinstall": "npx only-allow pnpm", + "test:unit": "nx run test tests-ui/tests", + "preinstall": "pnpm dlx only-allow pnpm", "prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", "preview": "nx preview", "lint": "eslint src --cache", "lint:fix": "eslint src --cache --fix", + "lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache", + "lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix", "lint:no-cache": "eslint src", "lint:fix:no-cache": "eslint src --fix", "knip": "knip --cache", "knip:no-cache": "knip", "locale": "lobe-i18n locale", - "collect-i18n": "npx playwright test --config=playwright.i18n.config.ts", + "collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts", "json-schema": "tsx scripts/generate-json-schema.ts", "storybook": "nx storybook -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "devtools:pycheck": "python3 -m compileall -q tools/devtools" }, "devDependencies": { - "@eslint/js": "^9.8.0", - "@iconify-json/lucide": "^1.2.66", - "@iconify/tailwind": "^1.2.0", - "@intlify/eslint-plugin-vue-i18n": "^3.2.0", + "@eslint/js": "^9.35.0", + "@intlify/eslint-plugin-vue-i18n": "^4.1.0", "@lobehub/i18n-cli": "^1.25.1", "@nx/eslint": "21.4.1", "@nx/playwright": "21.4.1", @@ -64,11 +66,11 @@ "@vitest/ui": "^3.0.0", "@vue/test-utils": "^2.4.6", "eslint": "^9.34.0", - "eslint-config-prettier": "^10.1.2", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-storybook": "^9.1.1", - "eslint-plugin-unused-imports": "^4.1.4", - "eslint-plugin-vue": "^9.27.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-storybook": "^9.1.6", + "eslint-plugin-unused-imports": "^4.2.0", + "eslint-plugin-vue": "^10.4.0", "fs-extra": "^11.2.0", "globals": "^15.9.0", "happy-dom": "^15.11.0", @@ -79,22 +81,24 @@ "lint-staged": "^15.2.7", "nx": "21.4.1", "prettier": "^3.3.2", - "storybook": "^9.1.1", + "storybook": "^9.1.6", "tailwindcss": "^4.1.12", "tailwindcss-primeui": "^0.6.1", "tsx": "^4.15.6", "tw-animate-css": "^1.3.8", "typescript": "^5.4.5", - "typescript-eslint": "^8.42.0", + "typescript-eslint": "^8.44.0", "unplugin-icons": "^0.22.0", "unplugin-vue-components": "^0.28.0", "uuid": "^11.1.0", "vite": "^5.4.19", - "vite-plugin-dts": "^4.3.0", + "vite-plugin-dts": "^4.5.4", "vite-plugin-html": "^3.2.2", "vite-plugin-vue-devtools": "^7.7.6", "vitest": "^3.2.4", - "vue-tsc": "^2.1.10", + "vue-component-type-helpers": "^3.0.7", + "vue-eslint-parser": "^10.2.0", + "vue-tsc": "^3.0.7", "zip-dir": "^2.0.0", "zod-to-json-schema": "^3.24.1" }, @@ -102,6 +106,9 @@ "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@comfyorg/comfyui-electron-types": "0.4.73-0", + "@comfyorg/design-system": "workspace:*", + "@comfyorg/registry-types": "workspace:*", + "@comfyorg/tailwind-utils": "workspace:*", "@iconify/json": "^2.2.380", "@primeuix/forms": "0.0.2", "@primeuix/styled": "0.3.2", @@ -119,13 +126,13 @@ "@tiptap/extension-table-row": "^2.10.4", "@tiptap/starter-kit": "^2.10.4", "@vueuse/core": "^11.0.0", + "@vueuse/integrations": "^13.9.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/xterm": "^5.5.0", "algoliasearch": "^5.21.0", "axios": "^1.8.2", "chart.js": "^4.5.0", - "clsx": "^2.1.1", "dompurify": "^3.2.5", "dotenv": "^16.4.5", "es-toolkit": "^1.39.9", @@ -143,7 +150,6 @@ "primevue": "^4.2.5", "reka-ui": "^2.5.0", "semver": "^7.7.2", - "tailwind-merge": "^3.3.1", "three": "^0.170.0", "tiptap-markdown": "^0.8.10", "vue": "^3.5.13", diff --git a/packages/design-system/package.json b/packages/design-system/package.json new file mode 100644 index 0000000000..e2868d054e --- /dev/null +++ b/packages/design-system/package.json @@ -0,0 +1,31 @@ +{ + "name": "@comfyorg/design-system", + "version": "1.0.0", + "description": "Shared design system for ComfyUI Frontend", + "type": "module", + "exports": { + "./tailwind-config": { + "import": "./tailwind.config.ts", + "types": "./tailwind.config.ts" + }, + "./css/*": "./src/css/*" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "nx": { + "tags": [ + "scope:shared", + "type:design" + ] + }, + "dependencies": { + "@iconify-json/lucide": "^1.1.178", + "@iconify/tailwind": "^1.1.3" + }, + "devDependencies": { + "tailwindcss": "^3.4.17", + "typescript": "^5.4.5" + }, + "packageManager": "pnpm@10.17.1" +} diff --git a/packages/design-system/src/css/fonts.css b/packages/design-system/src/css/fonts.css new file mode 100644 index 0000000000..cea388ee75 --- /dev/null +++ b/packages/design-system/src/css/fonts.css @@ -0,0 +1,17 @@ +/* Inter Font Family */ + +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-latin-normal.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-latin-italic.woff2') format('woff2'); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css new file mode 100644 index 0000000000..0f2bca812e --- /dev/null +++ b/packages/design-system/src/css/style.css @@ -0,0 +1,1003 @@ +@layer theme, base, primevue, components, utilities; + +@import './fonts.css'; +@import 'tailwindcss/theme' layer(theme); +@import 'tailwindcss/utilities' layer(utilities); +@import 'tw-animate-css'; + +@plugin 'tailwindcss-primeui'; + +@config '../../tailwind.config.ts'; + +:root { + --fg-color: #000; + --bg-color: #fff; + --comfy-menu-bg: #353535; + --comfy-menu-secondary-bg: #292929; + --comfy-topbar-height: 2.5rem; + --comfy-input-bg: #222; + --input-text: #ddd; + --descrip-text: #999; + --drag-text: #ccc; + --error-text: #ff4444; + --border-color: #4e4e4e; + --tr-even-bg-color: #222; + --tr-odd-bg-color: #353535; + --primary-bg: #236692; + --primary-fg: #ffffff; + --primary-hover-bg: #3485bb; + --primary-hover-fg: #ffffff; + --content-bg: #e0e0e0; + --content-fg: #000; + --content-hover-bg: #adadad; + --content-hover-fg: #000; + + /* Code styling colors for help menu*/ + --code-text-color: rgba(0, 122, 255, 1); + --code-bg-color: rgba(96, 165, 250, 0.2); + --code-block-bg-color: rgba(60, 60, 60, 0.12); +} + +@media (prefers-color-scheme: dark) { + :root { + --fg-color: #fff; + --bg-color: #202020; + --content-bg: #4e4e4e; + --content-fg: #fff; + --content-hover-bg: #222; + --content-hover-fg: #fff; + } +} + +@theme { + --text-xxs: 0.625rem; + --text-xxs--line-height: calc(1 / 0.625); + + /* Font Families */ + --font-inter: 'Inter', sans-serif; + + /* Palette Colors */ + --color-charcoal-100: #55565e; + --color-charcoal-200: #494a50; + --color-charcoal-300: #3c3d42; + --color-charcoal-400: #313235; + --color-charcoal-500: #2d2e32; + --color-charcoal-600: #262729; + --color-charcoal-700: #202121; + --color-charcoal-800: #171718; + + --color-neutral-550: #636363; + + --color-stone-100: #444444; + --color-stone-200: #828282; + --color-stone-300: #bbbbbb; + + --color-ivory-100: #fdfbfa; + --color-ivory-200: #faf9f5; + --color-ivory-300: #f0eee6; + + --color-gray-100: #f3f3f3; + --color-gray-200: #e9e9e9; + --color-gray-300: #e1e1e1; + --color-gray-400: #d9d9d9; + --color-gray-500: #c5c5c5; + --color-gray-600: #b4b4b4; + --color-gray-700: #a0a0a0; + --color-gray-800: #8a8a8a; + + --color-sand-100: #e1ded5; + --color-sand-200: #d6cfc2; + --color-sand-300: #888682; + + --color-pure-white: #ffffff; + + --color-slate-100: #9c9eab; + --color-slate-200: #9fa2bd; + --color-slate-300: #5b5e7d; + + --color-brand-yellow: #f0ff41; + --color-brand-blue: #172dd7; + + --color-blue-100: #0b8ce9; + --color-blue-200: #31b9f4; + --color-success-100: #00cd72; + --color-success-200: #47e469; + --color-warning-100: #fd9903; + --color-warning-200: #fcbf64; + --color-danger-100: #c02323; + --color-danger-200: #d62952; + + --color-coral-red-600: #973a40; + --color-coral-red-500: #c53f49; + --color-coral-red-400: #dd424e; + + --color-bypass: #6a246a; + --color-error: #962a2a; + + --color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3); + --color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15); + --color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1); + --color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4); + + /* PrimeVue pulled colors */ + --color-muted: var(--p-text-muted-color); + --color-highlight: var(--p-primary-color); + + /* Special Colors (temporary) */ + --color-dark-elevation-1.5: rgba(from white r g b/ 0.015); + --color-dark-elevation-2: rgba(from white r g b / 0.03); +} + +@theme inline { + --color-node-component-surface: var(--color-charcoal-600); + --color-node-component-surface-highlight: var(--color-slate-100); + --color-node-component-surface-hovered: var(--color-charcoal-400); + --color-node-component-surface-selected: var(--color-charcoal-200); + --color-node-stroke: var(--color-stone-100); +} + +@custom-variant dark-theme { + .dark-theme & { + @slot; + } +} + +@utility scrollbar-hide { + scrollbar-width: none; + &::-webkit-scrollbar { + width: 1px; + } + &::-webkit-scrollbar-thumb { + background-color: transparent; + } +} + +/* Everthing below here to be cleaned up over time. */ + +body { + width: 100vw; + height: 100vh; + margin: 0; + overflow: hidden; + background: var(--bg-color) var(--bg-img); + color: var(--fg-color); + min-height: -webkit-fill-available; + max-height: -webkit-fill-available; + min-width: -webkit-fill-available; + max-width: -webkit-fill-available; + font-family: Arial, sans-serif; +} + +.comfy-multiline-input { + background-color: var(--comfy-input-bg); + color: var(--input-text); + overflow: hidden; + overflow-y: auto; + padding: 2px; + resize: none; + border: none; + box-sizing: border-box; + font-size: var(--comfy-textarea-font-size); +} + +.comfy-markdown { + /* We assign the textarea and the Tiptap editor to the same CSS grid area to stack them on top of one another. */ + display: grid; +} + +.comfy-markdown > textarea { + grid-area: 1 / 1 / 2 / 2; +} + +.comfy-markdown .tiptap { + grid-area: 1 / 1 / 2 / 2; + background-color: var(--comfy-input-bg); + color: var(--input-text); + overflow: hidden; + overflow-y: auto; + resize: none; + border: none; + box-sizing: border-box; + font-size: var(--comfy-textarea-font-size); + height: 100%; + padding: 0.5em; +} + +.comfy-markdown.editing .tiptap { + display: none; +} + +.comfy-markdown .tiptap :first-child { + margin-top: 0; +} + +.comfy-markdown .tiptap :last-child { + margin-bottom: 0; +} + +.comfy-markdown .tiptap blockquote { + border-left: medium solid; + margin-left: 1em; + padding-left: 0.5em; +} + +.comfy-markdown .tiptap pre { + border: thin dotted; + border-radius: 0.5em; + margin: 0.5em; + padding: 0.5em; +} + +.comfy-markdown .tiptap table { + border-collapse: collapse; +} + +.comfy-markdown .tiptap th { + text-align: left; + background: var(--comfy-menu-bg); +} + +.comfy-markdown .tiptap th, +.comfy-markdown .tiptap td { + padding: 0.5em; + border: thin solid; +} + +/* Shared markdown content styling for consistent rendering across components */ +.comfy-markdown-content { + /* Typography */ + font-size: 0.875rem; /* text-sm */ + line-height: 1.6; + word-wrap: break-word; +} + +/* Headings */ +.comfy-markdown-content h1 { + font-size: 22px; /* text-[22px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h1:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h2 { + font-size: 18px; /* text-[18px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h2:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h3 { + font-size: 16px; /* text-[16px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h3:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h4, +.comfy-markdown-content h5, +.comfy-markdown-content h6 { + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h4:first-child, +.comfy-markdown-content h5:first-child, +.comfy-markdown-content h6:first-child { + margin-top: 0; /* first:mt-0 */ +} + +/* Paragraphs */ +.comfy-markdown-content p { + margin: 0 0 0.5em; +} + +.comfy-markdown-content p:last-child { + margin-bottom: 0; +} + +/* First child reset */ +.comfy-markdown-content *:first-child { + margin-top: 0; /* mt-0 */ +} + +/* Lists */ +.comfy-markdown-content ul, +.comfy-markdown-content ol { + padding-left: 2rem; /* pl-8 */ + margin: 0.5rem 0; /* my-2 */ +} + +/* Nested lists */ +.comfy-markdown-content ul ul, +.comfy-markdown-content ol ol, +.comfy-markdown-content ul ol, +.comfy-markdown-content ol ul { + padding-left: 1.5rem; /* pl-6 */ + margin: 0.5rem 0; /* my-2 */ +} + +.comfy-markdown-content li { + margin: 0.5rem 0; /* my-2 */ +} + +/* Code */ +.comfy-markdown-content code { + color: var(--code-text-color); + background-color: var(--code-bg-color); + border-radius: 0.25rem; /* rounded */ + padding: 0.125rem 0.375rem; /* px-1.5 py-0.5 */ + font-family: monospace; +} + +.comfy-markdown-content pre { + background-color: var(--code-block-bg-color); + border-radius: 0.25rem; /* rounded */ + padding: 1rem; /* p-4 */ + margin: 1rem 0; /* my-4 */ + overflow-x: auto; /* overflow-x-auto */ +} + +.comfy-markdown-content pre code { + background-color: transparent; /* bg-transparent */ + padding: 0; /* p-0 */ + color: var(--p-text-color); +} + +/* Tables */ +.comfy-markdown-content table { + width: 100%; /* w-full */ + border-collapse: collapse; /* border-collapse */ +} + +.comfy-markdown-content th, +.comfy-markdown-content td { + padding: 0.5rem; /* px-2 py-2 */ +} + +.comfy-markdown-content th { + color: var(--fg-color); +} + +.comfy-markdown-content td { + color: var(--drag-text); +} + +.comfy-markdown-content tr { + border-bottom: 1px solid var(--content-bg); +} + +.comfy-markdown-content tr:last-child { + border-bottom: none; +} + +.comfy-markdown-content thead { + border-bottom: 1px solid var(--p-text-color); +} + +/* Links */ +.comfy-markdown-content a { + color: var(--drag-text); + text-decoration: underline; +} + +/* Media */ +.comfy-markdown-content img, +.comfy-markdown-content video { + max-width: 100%; /* max-w-full */ + height: auto; /* h-auto */ + display: block; /* block */ + margin-bottom: 1rem; /* mb-4 */ +} + +/* Blockquotes */ +.comfy-markdown-content blockquote { + border-left: 3px solid var(--p-primary-color, var(--primary-bg)); + padding-left: 0.75em; + margin: 0.5em 0; + opacity: 0.8; +} + +/* Horizontal rule */ +.comfy-markdown-content hr { + border: none; + border-top: 1px solid var(--p-border-color, var(--border-color)); + margin: 1em 0; +} + +/* Strong and emphasis */ +.comfy-markdown-content strong { + font-weight: bold; +} + +.comfy-markdown-content em { + font-style: italic; +} + +.comfy-modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 100; /* Sit on top */ + padding: 30px 30px 10px 30px; + background-color: var(--comfy-menu-bg); /* Modal background */ + color: var(--error-text); + box-shadow: 0 0 20px #888888; + border-radius: 10px; + top: 50%; + left: 50%; + max-width: 80vw; + max-height: 80vh; + transform: translate(-50%, -50%); + overflow: hidden; + justify-content: center; + font-family: monospace; + font-size: 15px; +} + +.comfy-modal-content { + display: flex; + flex-direction: column; +} + +.comfy-modal p { + overflow: auto; + white-space: pre-line; /* This will respect line breaks */ + margin-bottom: 20px; /* Add some margin between the text and the close button*/ +} + +.comfy-modal select, +.comfy-modal input[type='button'], +.comfy-modal input[type='checkbox'] { + margin: 3px 3px 3px 4px; +} + +.comfy-menu { + font-size: 15px; + position: absolute; + top: 50%; + right: 0; + text-align: center; + z-index: 999; + width: 190px; + display: flex; + flex-direction: column; + align-items: center; + color: var(--descrip-text); + background-color: var(--comfy-menu-bg); + font-family: sans-serif; + padding: 10px; + border-radius: 0 8px 8px 8px; + box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4); +} + +.comfy-menu-header { + display: flex; +} + +.comfy-menu-actions { + display: flex; + gap: 3px; + align-items: center; + height: 20px; + position: relative; + top: -1px; + font-size: 22px; +} + +.comfy-menu .comfy-menu-actions button { + background-color: rgba(0, 0, 0, 0); + padding: 0; + border: none; + cursor: pointer; + font-size: inherit; +} + +.comfy-menu .comfy-menu-actions .comfy-settings-btn { + font-size: 0.6em; +} + +button.comfy-close-menu-btn { + font-size: 1em; + line-height: 12px; + color: #ccc; + position: relative; + top: -1px; +} + +.comfy-menu-queue-size { + flex: auto; +} + +.comfy-menu button, +.comfy-modal button { + font-size: 20px; +} + +.comfy-menu-btns { + margin-bottom: 10px; + width: 100%; +} + +.comfy-menu-btns button { + font-size: 10px; + width: 50%; + color: var(--descrip-text) !important; +} + +.comfy-menu > button { + width: 100%; +} + +.comfy-btn, +.comfy-menu > button, +.comfy-menu-btns button, +.comfy-menu .comfy-list button, +.comfy-modal button { + color: var(--input-text); + background-color: var(--comfy-input-bg); + border-width: initial; + border-radius: 8px; + border-color: var(--border-color); + border-style: solid; + margin-top: 2px; +} + +.comfy-btn:hover:not(:disabled), +.comfy-menu > button:hover, +.comfy-menu-btns button:hover, +.comfy-menu .comfy-list button:hover, +.comfy-modal button:hover, +.comfy-menu-actions button:hover { + filter: brightness(1.2); + will-change: transform; + cursor: pointer; +} + +span.drag-handle { + width: 10px; + height: 20px; + display: inline-block; + overflow: hidden; + line-height: 5px; + padding: 3px 4px; + cursor: move; + vertical-align: middle; + margin-top: -0.4em; + margin-left: -0.2em; + font-size: 12px; + font-family: sans-serif; + letter-spacing: 2px; + color: var(--drag-text); + text-shadow: 1px 0 1px black; + touch-action: none; +} + +span.drag-handle::after { + content: '.. .. ..'; +} + +.comfy-queue-btn { + width: 100%; +} + +.comfy-list { + color: var(--descrip-text); + background-color: var(--comfy-menu-bg); + margin-bottom: 10px; + border-color: var(--border-color); + border-style: solid; +} + +.comfy-list-items { + overflow-y: scroll; + max-height: 100px; + min-height: 25px; + background-color: var(--comfy-input-bg); + padding: 5px; +} + +.comfy-list h4 { + min-width: 160px; + margin: 0; + padding: 3px; + font-weight: normal; +} + +.comfy-list-items button { + font-size: 10px; +} + +.comfy-list-actions { + margin: 5px; + display: flex; + gap: 5px; + justify-content: center; +} + +.comfy-list-actions button { + font-size: 12px; +} + +button.comfy-queue-btn { + margin: 6px 0 !important; +} + +.comfy-modal.comfy-settings, +.comfy-modal.comfy-manage-templates { + text-align: center; + font-family: sans-serif; + color: var(--descrip-text); + z-index: 99; +} + +.comfy-modal.comfy-settings input[type='range'] { + vertical-align: middle; +} + +.comfy-modal.comfy-settings input[type='range'] + input[type='number'] { + width: 3.5em; +} + +.comfy-modal input, +.comfy-modal select { + color: var(--input-text); + background-color: var(--comfy-input-bg); + border-radius: 8px; + border-color: var(--border-color); + border-style: solid; + font-size: inherit; +} + +.comfy-tooltip-indicator { + text-decoration: underline; + text-decoration-style: dashed; +} + +@media only screen and (max-height: 850px) { + .comfy-menu { + top: 0 !important; + bottom: 0 !important; + left: auto !important; + right: 0 !important; + border-radius: 0; + } + + .comfy-menu span.drag-handle { + display: none; + } + + .comfy-menu-queue-size { + flex: unset; + } + + .comfy-menu-header { + justify-content: space-between; + } + .comfy-menu-actions { + gap: 10px; + font-size: 28px; + } +} + +/* Input popup */ + +.graphdialog { + min-height: 1em; + background-color: var(--comfy-menu-bg); + z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */ +} + +.graphdialog .name { + font-size: 14px; + font-family: sans-serif; + color: var(--descrip-text); +} + +.graphdialog button { + margin-top: unset; + vertical-align: unset; + height: 1.6em; + padding-right: 8px; +} + +.graphdialog input, +.graphdialog textarea, +.graphdialog select { + background-color: var(--comfy-input-bg); + border: 2px solid; + border-color: var(--border-color); + color: var(--input-text); + border-radius: 12px 0 0 12px; +} + +/* Dialogs */ + +dialog { + box-shadow: 0 0 20px #888888; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +.comfy-dialog.comfyui-dialog.comfy-modal { + top: 0; + left: 0; + right: 0; + bottom: 0; + transform: none; +} + +.comfy-dialog.comfy-modal { + font-family: Arial, sans-serif; + border-color: var(--bg-color); + box-shadow: none; + border: 2px solid var(--border-color); +} + +.comfy-dialog .comfy-modal-content { + flex-direction: row; + flex-wrap: wrap; + gap: 10px; + color: var(--fg-color); +} + +.comfy-dialog .comfy-modal-content h3 { + margin-top: 0; +} + +.comfy-dialog .comfy-modal-content > p { + width: 100%; +} + +.comfy-dialog .comfy-modal-content > .comfyui-button { + flex: 1; + justify-content: center; +} + +/* Context menu */ + +.litegraph .dialog { + z-index: 1; + font-family: Arial, sans-serif; +} + +.litegraph .litemenu-entry.has_submenu { + position: relative; + padding-right: 20px; +} + +.litemenu-entry.has_submenu::after { + content: '>'; + position: absolute; + top: 0; + right: 2px; +} + +.litegraph.litecontextmenu, +.litegraph.litecontextmenu.dark { + z-index: 9999 !important; + background-color: var(--comfy-menu-bg) !important; +} + +.litegraph.litecontextmenu + .litemenu-entry:hover:not(.disabled):not(.separator) { + background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important; + color: var(--fg-color); +} + +.litegraph.litecontextmenu .litemenu-entry.submenu, +.litegraph.litecontextmenu.dark .litemenu-entry.submenu { + background-color: var(--comfy-menu-bg) !important; + color: var(--input-text); +} + +.litegraph.litecontextmenu input { + background-color: var(--comfy-input-bg) !important; + color: var(--input-text) !important; +} + +.comfy-context-menu-filter { + box-sizing: border-box; + border: 1px solid #999; + margin: 0 0 5px 5px; + width: calc(100% - 10px); +} + +.comfy-img-preview { + pointer-events: none; + overflow: hidden; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + justify-content: center; +} + +.comfy-img-preview img { + object-fit: contain; + width: var(--comfy-img-preview-width); + height: var(--comfy-img-preview-height); +} + +.comfy-img-preview video { + pointer-events: auto; + object-fit: contain; + height: 100%; + width: 100%; +} + +.comfy-missing-nodes li button { + font-size: 12px; + margin-left: 5px; +} + +/* Search box */ + +.litegraph.litesearchbox { + z-index: 9999 !important; + background-color: var(--comfy-menu-bg) !important; + overflow: hidden; + display: block; +} + +.litegraph.litesearchbox input, +.litegraph.litesearchbox select { + background-color: var(--comfy-input-bg) !important; + color: var(--input-text); +} + +.litegraph.lite-search-item { + color: var(--input-text); + background-color: var(--comfy-input-bg); + filter: brightness(80%); + will-change: transform; + padding-left: 0.2em; +} + +.litegraph.lite-search-item.generic_type { + color: var(--input-text); + filter: brightness(50%); + will-change: transform; +} + +audio.comfy-audio.empty-audio-widget { + display: none; +} + +#vue-app { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* Set auto complete panel's width as it is not accessible within vue-root */ +.p-autocomplete-overlay { + max-width: 25vw; +} + +.p-tree-node-content { + padding: var(--comfy-tree-explorer-item-padding) !important; +} + +/* Load3d styles */ +.comfy-load-3d, +.comfy-load-3d-animation, +.comfy-preview-3d, +.comfy-preview-3d-animation { + display: flex; + flex-direction: column; + background: transparent; + flex: 1; + position: relative; + overflow: hidden; +} + +.comfy-load-3d canvas, +.comfy-load-3d-animation canvas, +.comfy-preview-3d canvas, +.comfy-preview-3d-animation canvas, +.comfy-load-3d-viewer canvas { + display: flex; + width: 100% !important; + height: 100% !important; +} + +/* End of Load3d styles */ + +/* [Desktop] Electron window specific styles */ +.app-drag { + app-region: drag; +} + +.no-drag { + app-region: no-drag; +} + +.window-actions-spacer { + width: calc(100vw - env(titlebar-area-width, 100vw)); +} +/* End of [Desktop] Electron window specific styles */ + +.lg-node { + /* Disable text selection on all nodes */ + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.lg-node .lg-slot, +.lg-node .lg-widget { + transition: + opacity 0.1s ease, + font-size 0.1s ease; +} + +/* Performance optimization during canvas interaction */ +.transform-pane--interacting .lg-node * { + transition: none !important; +} + +.transform-pane--interacting .lg-node { + will-change: transform; +} + +/* START LOD specific styles */ +/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */ + +.isLOD .lg-node { + box-shadow: none; + filter: none; + backdrop-filter: none; + text-shadow: none; + -webkit-mask-image: none; + mask-image: none; + clip-path: none; + background-image: none; + text-rendering: optimizeSpeed; + border-radius: 0; + contain: layout style; + transition: none; +} + +.isLOD .lg-node-widgets { + pointer-events: none; +} + +.lod-toggle { + visibility: visible; +} + +.isLOD .lod-toggle { + visibility: hidden; +} + +.lod-fallback { + display: none; +} + +.isLOD .lod-fallback { + display: block; +} + +.isLOD .image-preview img { + image-rendering: pixelated; +} + +.isLOD .slot-dot { + border-radius: 0; +} +/* END LOD specific styles */ diff --git a/build/customIconCollection.ts b/packages/design-system/src/iconCollection.ts similarity index 96% rename from build/customIconCollection.ts rename to packages/design-system/src/iconCollection.ts index f2d823ed5d..170a5465fb 100644 --- a/build/customIconCollection.ts +++ b/packages/design-system/src/iconCollection.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url' const fileName = fileURLToPath(import.meta.url) const dirName = dirname(fileName) -const customIconsPath = join(dirName, '..', 'src', 'assets', 'icons', 'custom') +const customIconsPath = join(dirName, 'icons') // Iconify collection structure interface IconifyIcon { diff --git a/src/assets/icons/README.md b/packages/design-system/src/icons/README.md similarity index 95% rename from src/assets/icons/README.md rename to packages/design-system/src/icons/README.md index b01a3e3ef6..ba7cdb3e49 100644 --- a/src/assets/icons/README.md +++ b/packages/design-system/src/icons/README.md @@ -51,7 +51,7 @@ ComfyUI supports three types of icons that can be used throughout the interface. ```vue + diff --git a/src/components/card/CardContainer.vue b/src/components/card/CardContainer.vue index 1a17d5659b..390e441a0b 100644 --- a/src/components/card/CardContainer.vue +++ b/src/components/card/CardContainer.vue @@ -8,15 +8,21 @@ diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index 844a8e7427..ac841d31fb 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -1,7 +1,7 @@ + + + + diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/LoadWorkflowWarning.vue index 2698f17e67..17c38c29b6 100644 --- a/src/components/dialog/content/LoadWorkflowWarning.vue +++ b/src/components/dialog/content/LoadWorkflowWarning.vue @@ -58,15 +58,14 @@ import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue' -import { useMissingNodes } from '@/composables/nodePack/useMissingNodes' -import { useManagerState } from '@/composables/useManagerState' import { useToastStore } from '@/platform/updates/common/toastStore' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useDialogStore } from '@/stores/dialogStore' import type { MissingNodeType } from '@/types/comfy' -import { ManagerTab } from '@/types/comfyManagerTypes' - -import PackInstallButton from './manager/button/PackInstallButton.vue' +import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue' +import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' +import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' const props = defineProps<{ missingNodeTypes: MissingNodeType[] @@ -138,7 +137,7 @@ const allMissingNodesInstalled = computed(() => { }) // Watch for completion and close dialog watch(allMissingNodesInstalled, async (allInstalled) => { - if (allInstalled) { + if (allInstalled && showInstallAllButton.value) { // Use nextTick to ensure state updates are complete await nextTick() diff --git a/src/components/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue index cf81441f19..10030a9e93 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.vue +++ b/src/components/dialog/content/MissingCoreNodesMessage.vue @@ -43,11 +43,11 @@ diff --git a/src/components/dialog/content/setting/SettingItem.spec.ts b/src/components/dialog/content/setting/SettingItem.test.ts similarity index 100% rename from src/components/dialog/content/setting/SettingItem.spec.ts rename to src/components/dialog/content/setting/SettingItem.test.ts diff --git a/src/components/dialog/content/setting/UsageLogsTable.spec.ts b/src/components/dialog/content/setting/UsageLogsTable.test.ts similarity index 100% rename from src/components/dialog/content/setting/UsageLogsTable.spec.ts rename to src/components/dialog/content/setting/UsageLogsTable.test.ts diff --git a/src/components/dialog/content/setting/UsageLogsTable.vue b/src/components/dialog/content/setting/UsageLogsTable.vue index 161268b91b..082fb200c7 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.vue +++ b/src/components/dialog/content/setting/UsageLogsTable.vue @@ -96,8 +96,8 @@ import Message from 'primevue/message' import ProgressSpinner from 'primevue/progressspinner' import { computed, ref } from 'vue' +import type { AuditLog } from '@/services/customerEventsService' import { - AuditLog, EventType, useCustomerEventsService } from '@/services/customerEventsService' diff --git a/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue index 0947af884a..ded8cb18b0 100644 --- a/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue +++ b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue @@ -13,7 +13,7 @@ import Tag from 'primevue/tag' import { computed } from 'vue' -import { KeyComboImpl } from '@/stores/keybindingStore' +import type { KeyComboImpl } from '@/stores/keybindingStore' const { keyCombo, isModified = false } = defineProps<{ keyCombo: KeyComboImpl diff --git a/src/components/dialog/content/signin/ApiKeyForm.vue b/src/components/dialog/content/signin/ApiKeyForm.vue index 01e2e0a3f3..dc0b196a4e 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.vue +++ b/src/components/dialog/content/signin/ApiKeyForm.vue @@ -79,7 +79,8 @@ diff --git a/src/components/graph/selectionToolbox/NodeOptionsButton.vue b/src/components/graph/selectionToolbox/NodeOptionsButton.vue new file mode 100644 index 0000000000..5fb5425043 --- /dev/null +++ b/src/components/graph/selectionToolbox/NodeOptionsButton.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue index 056f0f90bc..2f1e25229a 100644 --- a/src/components/graph/selectionToolbox/SubmenuPopover.vue +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -48,9 +48,9 @@ import Popover from 'primevue/popover' import { computed, ref } from 'vue' -import { - type MenuOption, - type SubMenuOption +import type { + MenuOption, + SubMenuOption } from '@/composables/graph/useMoreOptionsMenu' import { useNodeCustomization } from '@/composables/graph/useNodeCustomization' diff --git a/src/components/graph/widgets/ChatHistoryWidget.spec.ts b/src/components/graph/widgets/ChatHistoryWidget.test.ts similarity index 100% rename from src/components/graph/widgets/ChatHistoryWidget.spec.ts rename to src/components/graph/widgets/ChatHistoryWidget.test.ts diff --git a/src/components/graph/widgets/ChatHistoryWidget.vue b/src/components/graph/widgets/ChatHistoryWidget.vue index fe9c808b98..8696614d88 100644 --- a/src/components/graph/widgets/ChatHistoryWidget.vue +++ b/src/components/graph/widgets/ChatHistoryWidget.vue @@ -56,10 +56,10 @@ import { computed, nextTick, ref, watch } from 'vue' import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue' import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue' -import { ComponentWidget } from '@/scripts/domWidget' +import type { ComponentWidget } from '@/scripts/domWidget' import { linkifyHtml, nl2br } from '@/utils/formatUtil' -const { widget, history = '[]' } = defineProps<{ +const { widget, history } = defineProps<{ widget?: ComponentWidget history: string }>() diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 305af06210..a0ef642758 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -19,14 +19,15 @@ - - diff --git a/src/components/install/HardwareOption.stories.ts b/src/components/install/HardwareOption.stories.ts new file mode 100644 index 0000000000..d830af49fa --- /dev/null +++ b/src/components/install/HardwareOption.stories.ts @@ -0,0 +1,73 @@ +// eslint-disable-next-line storybook/no-renderer-packages +import type { Meta, StoryObj } from '@storybook/vue3' + +import HardwareOption from './HardwareOption.vue' + +const meta: Meta = { + title: 'Desktop/Components/HardwareOption', + component: HardwareOption, + parameters: { + layout: 'centered', + backgrounds: { + default: 'dark', + values: [{ name: 'dark', value: '#1a1a1a' }] + } + }, + argTypes: { + selected: { control: 'boolean' }, + imagePath: { control: 'text' }, + placeholderText: { control: 'text' }, + subtitle: { control: 'text' } + } +} + +export default meta +type Story = StoryObj + +export const AppleMetalSelected: Story = { + args: { + imagePath: '/assets/images/apple-mps-logo.png', + placeholderText: 'Apple Metal', + subtitle: 'Apple Metal', + value: 'mps', + selected: true + } +} + +export const AppleMetalUnselected: Story = { + args: { + imagePath: '/assets/images/apple-mps-logo.png', + placeholderText: 'Apple Metal', + subtitle: 'Apple Metal', + value: 'mps', + selected: false + } +} + +export const CPUOption: Story = { + args: { + placeholderText: 'CPU', + subtitle: 'Subtitle', + value: 'cpu', + selected: false + } +} + +export const ManualInstall: Story = { + args: { + placeholderText: 'Manual Install', + subtitle: 'Subtitle', + value: 'unsupported', + selected: false + } +} + +export const NvidiaSelected: Story = { + args: { + imagePath: '/assets/images/nvidia-logo-square.jpg', + placeholderText: 'NVIDIA', + subtitle: 'NVIDIA', + value: 'nvidia', + selected: true + } +} diff --git a/src/components/install/HardwareOption.vue b/src/components/install/HardwareOption.vue new file mode 100644 index 0000000000..ae254fd8f3 --- /dev/null +++ b/src/components/install/HardwareOption.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/install/InstallFooter.vue b/src/components/install/InstallFooter.vue new file mode 100644 index 0000000000..ef9ab698c9 --- /dev/null +++ b/src/components/install/InstallFooter.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/install/InstallLocationPicker.stories.ts b/src/components/install/InstallLocationPicker.stories.ts new file mode 100644 index 0000000000..e6ef924ae0 --- /dev/null +++ b/src/components/install/InstallLocationPicker.stories.ts @@ -0,0 +1,148 @@ +// eslint-disable-next-line storybook/no-renderer-packages +import type { Meta, StoryObj } from '@storybook/vue3' +import { ref } from 'vue' + +import InstallLocationPicker from './InstallLocationPicker.vue' + +const meta: Meta = { + title: 'Desktop/Components/InstallLocationPicker', + component: InstallLocationPicker, + parameters: { + layout: 'padded', + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: '#0a0a0a' }, + { name: 'neutral-900', value: '#171717' }, + { name: 'neutral-950', value: '#0a0a0a' } + ] + } + }, + decorators: [ + () => { + // Mock electron API + ;(window as any).electronAPI = { + getSystemPaths: () => + Promise.resolve({ + defaultInstallPath: '/Users/username/ComfyUI' + }), + validateInstallPath: () => + Promise.resolve({ + isValid: true, + exists: false, + canWrite: true, + freeSpace: 100000000000, + requiredSpace: 10000000000, + isNonDefaultDrive: false + }), + validateComfyUISource: () => + Promise.resolve({ + isValid: true + }), + showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI') + } + return { template: '' } + } + ] +} + +export default meta +type Story = StoryObj + +// Default story with accordion expanded +export const Default: Story = { + render: (args) => ({ + components: { InstallLocationPicker }, + setup() { + const installPath = ref('/Users/username/ComfyUI') + const pathError = ref('') + const migrationSourcePath = ref('/Users/username/ComfyUI-old') + const migrationItemIds = ref(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+ +
+ ` + }) +} + +// Story with different background to test transparency +export const OnNeutral900: Story = { + render: (args) => ({ + components: { InstallLocationPicker }, + setup() { + const installPath = ref('/Users/username/ComfyUI') + const pathError = ref('') + const migrationSourcePath = ref('/Users/username/ComfyUI-old') + const migrationItemIds = ref(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+ +
+ ` + }) +} + +// Story with debug overlay showing background colors +export const DebugBackgrounds: Story = { + render: (args) => ({ + components: { InstallLocationPicker }, + setup() { + const installPath = ref('/Users/username/ComfyUI') + const pathError = ref('') + const migrationSourcePath = ref('/Users/username/ComfyUI-old') + const migrationItemIds = ref(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+
+
Parent bg: neutral-950 (#0a0a0a)
+
Accordion content: bg-transparent
+
Migration options: bg-transparent + p-4 rounded-lg
+
+ +
+ ` + }) +} diff --git a/src/components/install/InstallLocationPicker.vue b/src/components/install/InstallLocationPicker.vue index 33b32a5f98..0e22f34a96 100644 --- a/src/components/install/InstallLocationPicker.vue +++ b/src/components/install/InstallLocationPicker.vue @@ -1,103 +1,215 @@ + + diff --git a/src/components/install/MigrationPicker.stories.ts b/src/components/install/MigrationPicker.stories.ts new file mode 100644 index 0000000000..ad09e1871b --- /dev/null +++ b/src/components/install/MigrationPicker.stories.ts @@ -0,0 +1,45 @@ +// eslint-disable-next-line storybook/no-renderer-packages +import type { Meta, StoryObj } from '@storybook/vue3' +import { ref } from 'vue' + +import MigrationPicker from './MigrationPicker.vue' + +const meta: Meta = { + title: 'Desktop/Components/MigrationPicker', + component: MigrationPicker, + parameters: { + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: '#0a0a0a' }, + { name: 'neutral-900', value: '#171717' } + ] + } + }, + decorators: [ + () => { + ;(window as any).electronAPI = { + validateComfyUISource: () => Promise.resolve({ isValid: true }), + showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI') + } + + return { template: '' } + } + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { MigrationPicker }, + setup() { + const sourcePath = ref('') + const migrationItemIds = ref([]) + return { sourcePath, migrationItemIds } + }, + template: + '' + }) +} diff --git a/src/components/install/MigrationPicker.vue b/src/components/install/MigrationPicker.vue index 934ffc2f32..ba542ca697 100644 --- a/src/components/install/MigrationPicker.vue +++ b/src/components/install/MigrationPicker.vue @@ -2,10 +2,6 @@
-

- {{ $t('install.migrateFromExistingInstallation') }} -

-

{{ $t('install.migrationSourcePathDescription') }}

@@ -13,7 +9,7 @@
-
+

{{ $t('install.selectItemsToMigrate') }}

diff --git a/src/components/install/MirrorsConfiguration.vue b/src/components/install/MirrorsConfiguration.vue deleted file mode 100644 index 0053f973a4..0000000000 --- a/src/components/install/MirrorsConfiguration.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/src/components/install/mirror/MirrorItem.vue b/src/components/install/mirror/MirrorItem.vue index 3bd83074ef..204ab1034c 100644 --- a/src/components/install/mirror/MirrorItem.vue +++ b/src/components/install/mirror/MirrorItem.vue @@ -1,10 +1,10 @@ diff --git a/src/components/templates/TemplateWorkflowCard.spec.ts b/src/components/templates/TemplateWorkflowCard.spec.ts deleted file mode 100644 index 54d66db06c..0000000000 --- a/src/components/templates/TemplateWorkflowCard.spec.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' - -import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue' -import { TemplateInfo } from '@/platform/workflow/templates/types/template' - -vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({ - default: { - name: 'AudioThumbnail', - template: '
', - props: ['src'] - } -})) - -vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({ - default: { - name: 'CompareSliderThumbnail', - template: - '
', - props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered'] - } -})) - -vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({ - default: { - name: 'DefaultThumbnail', - template: '
', - props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom'] - } -})) - -vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({ - default: { - name: 'HoverDissolveThumbnail', - template: - '
', - props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered'] - } -})) - -vi.mock('@vueuse/core', () => ({ - useElementHover: () => ref(false) -})) - -vi.mock('@/scripts/api', () => ({ - api: { - fileURL: (path: string) => `/fileURL${path}`, - apiURL: (path: string) => `/apiURL${path}`, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - } -})) - -vi.mock('@/scripts/app', () => ({ - app: { - loadGraphData: vi.fn() - } -})) - -vi.mock('@/stores/dialogStore', () => ({ - useDialogStore: () => ({ - closeDialog: vi.fn() - }) -})) - -vi.mock( - '@/platform/workflow/templates/repositories/workflowTemplatesStore', - () => ({ - useWorkflowTemplatesStore: () => ({ - isLoaded: true, - loadWorkflowTemplates: vi.fn().mockResolvedValue(true), - groupedTemplates: [] - }) - }) -) - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string, fallback: string) => fallback || key - }) -})) - -vi.mock( - '@/platform/workflow/templates/composables/useTemplateWorkflows', - () => ({ - useTemplateWorkflows: () => ({ - getTemplateThumbnailUrl: ( - template: TemplateInfo, - sourceModule: string, - index = '' - ) => { - const basePath = - sourceModule === 'default' - ? `/fileURL/templates/${template.name}` - : `/apiURL/workflow_templates/${sourceModule}/${template.name}` - const indexSuffix = - sourceModule === 'default' && index ? `-${index}` : '' - return `${basePath}${indexSuffix}.${template.mediaSubtype}` - }, - getTemplateTitle: (template: TemplateInfo, sourceModule: string) => { - const fallback = - template.title ?? template.name ?? `${sourceModule} Template` - return sourceModule === 'default' - ? template.localizedTitle ?? fallback - : fallback - }, - getTemplateDescription: ( - template: TemplateInfo, - sourceModule: string - ) => { - return sourceModule === 'default' - ? template.localizedDescription ?? '' - : template.description?.replace(/[-_]/g, ' ').trim() ?? '' - }, - loadWorkflowTemplate: vi.fn() - }) - }) -) - -describe('TemplateWorkflowCard', () => { - const createTemplate = (overrides = {}): TemplateInfo => ({ - name: 'test-template', - mediaType: 'image', - mediaSubtype: 'png', - thumbnailVariant: 'default', - description: 'Test description', - ...overrides - }) - - const mountCard = (props = {}) => { - return mount(TemplateWorkflowCard, { - props: { - sourceModule: 'default', - categoryTitle: 'Test Category', - loading: false, - template: createTemplate(), - ...props - }, - global: { - stubs: { - Card: { - template: - '
', - props: ['dataTestid', 'pt'] - }, - ProgressSpinner: { - template: '
' - } - } - } - }) - } - - it('emits loadWorkflow event when clicked', async () => { - const wrapper = mountCard({ - template: createTemplate({ name: 'test-workflow' }) - }) - await wrapper.find('.card').trigger('click') - expect(wrapper.emitted('loadWorkflow')).toBeTruthy() - expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow']) - }) - - it('shows loading spinner when loading is true', () => { - const wrapper = mountCard({ loading: true }) - expect(wrapper.find('.progress-spinner').exists()).toBe(true) - }) - - it('renders audio thumbnail for audio media type', () => { - const wrapper = mountCard({ - template: createTemplate({ mediaType: 'audio' }) - }) - expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true) - }) - - it('renders compare slider thumbnail for compareSlider variant', () => { - const wrapper = mountCard({ - template: createTemplate({ thumbnailVariant: 'compareSlider' }) - }) - expect(wrapper.find('.mock-compare-slider').exists()).toBe(true) - }) - - it('renders hover dissolve thumbnail for hoverDissolve variant', () => { - const wrapper = mountCard({ - template: createTemplate({ thumbnailVariant: 'hoverDissolve' }) - }) - expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true) - }) - - it('renders default thumbnail by default', () => { - const wrapper = mountCard() - expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true) - }) - - it('passes correct props to default thumbnail for video', () => { - const wrapper = mountCard({ - template: createTemplate({ mediaType: 'video' }) - }) - const thumbnail = wrapper.find('.mock-default-thumbnail') - expect(thumbnail.exists()).toBe(true) - }) - - it('uses zoomHover scale when variant is zoomHover', () => { - const wrapper = mountCard({ - template: createTemplate({ thumbnailVariant: 'zoomHover' }) - }) - expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true) - }) - - it('displays localized title for default source module', () => { - const wrapper = mountCard({ - sourceModule: 'default', - template: createTemplate({ localizedTitle: 'My Localized Title' }) - }) - expect(wrapper.text()).toContain('My Localized Title') - }) - - it('displays template name as title for non-default source modules', () => { - const wrapper = mountCard({ - sourceModule: 'custom', - template: createTemplate({ name: 'custom-template' }) - }) - expect(wrapper.text()).toContain('custom-template') - }) - - it('displays localized description for default source module', () => { - const wrapper = mountCard({ - sourceModule: 'default', - template: createTemplate({ - localizedDescription: 'My Localized Description' - }) - }) - expect(wrapper.text()).toContain('My Localized Description') - }) - - it('processes description for non-default source modules', () => { - const wrapper = mountCard({ - sourceModule: 'custom', - template: createTemplate({ description: 'custom_module-description' }) - }) - expect(wrapper.text()).toContain('custom module description') - }) - - it('generates correct thumbnail URLs for default source module', () => { - const wrapper = mountCard({ - sourceModule: 'default', - template: createTemplate({ - name: 'my-template', - mediaSubtype: 'jpg' - }) - }) - const vm = wrapper.vm as any - expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg') - expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg') - }) - - it('generates correct thumbnail URLs for custom source module', () => { - const wrapper = mountCard({ - sourceModule: 'custom-module', - template: createTemplate({ - name: 'my-template', - mediaSubtype: 'png' - }) - }) - const vm = wrapper.vm as any - expect(vm.baseThumbnailSrc).toBe( - '/apiURL/workflow_templates/custom-module/my-template.png' - ) - expect(vm.overlayThumbnailSrc).toBe( - '/apiURL/workflow_templates/custom-module/my-template.png' - ) - }) -}) diff --git a/src/components/templates/TemplateWorkflowCard.vue b/src/components/templates/TemplateWorkflowCard.vue deleted file mode 100644 index 93bdd8a21c..0000000000 --- a/src/components/templates/TemplateWorkflowCard.vue +++ /dev/null @@ -1,139 +0,0 @@ - - - diff --git a/src/components/templates/TemplateWorkflowCardSkeleton.vue b/src/components/templates/TemplateWorkflowCardSkeleton.vue deleted file mode 100644 index 00bf738398..0000000000 --- a/src/components/templates/TemplateWorkflowCardSkeleton.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/templates/TemplateWorkflowList.vue b/src/components/templates/TemplateWorkflowList.vue deleted file mode 100644 index b6ac99c5ed..0000000000 --- a/src/components/templates/TemplateWorkflowList.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/src/components/templates/TemplateWorkflowView.spec.ts b/src/components/templates/TemplateWorkflowView.spec.ts deleted file mode 100644 index aaffd4cd0e..0000000000 --- a/src/components/templates/TemplateWorkflowView.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import { createI18n } from 'vue-i18n' - -import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue' -import { TemplateInfo } from '@/platform/workflow/templates/types/template' - -vi.mock('primevue/dataview', () => ({ - default: { - name: 'DataView', - template: ` -
-
-
- -
-
- `, - props: ['value', 'layout', 'lazy', 'pt'] - } -})) - -vi.mock('primevue/selectbutton', () => ({ - default: { - name: 'SelectButton', - template: - '
', - props: ['modelValue', 'options', 'allowEmpty'] - } -})) - -vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({ - default: { - template: ` -
- `, - props: ['sourceModule', 'categoryTitle', 'loading', 'template'], - emits: ['loadWorkflow'] - } -})) - -vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({ - default: { - template: '
', - props: ['sourceModule', 'categoryTitle', 'loading', 'templates'], - emits: ['loadWorkflow'] - } -})) - -vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({ - default: { - template: '', - props: ['searchQuery', 'filteredCount'], - emits: ['update:searchQuery', 'clearFilters'] - } -})) - -vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({ - default: { - template: '
' - } -})) - -vi.mock('@vueuse/core', () => ({ - useLocalStorage: () => 'grid' -})) - -vi.mock('@/composables/useIntersectionObserver', () => ({ - useIntersectionObserver: vi.fn() -})) - -vi.mock('@/composables/useLazyPagination', () => ({ - useLazyPagination: (items: any) => ({ - paginatedItems: items, - isLoading: { value: false }, - hasMoreItems: { value: false }, - loadNextPage: vi.fn(), - reset: vi.fn() - }) -})) - -vi.mock('@/composables/useTemplateFiltering', () => ({ - useTemplateFiltering: (templates: any) => ({ - searchQuery: { value: '' }, - filteredTemplates: templates, - filteredCount: { value: templates.value?.length || 0 } - }) -})) - -describe('TemplateWorkflowView', () => { - const createTemplate = (name: string): TemplateInfo => ({ - name, - mediaType: 'image', - mediaSubtype: 'png', - thumbnailVariant: 'default', - description: `Description for ${name}` - }) - - const mountView = (props = {}) => { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - templateWorkflows: { - loadingMore: 'Loading more...' - } - } - } - }) - - return mount(TemplateWorkflowView, { - props: { - title: 'Test Templates', - sourceModule: 'default', - categoryTitle: 'Test Category', - templates: [ - createTemplate('template-1'), - createTemplate('template-2'), - createTemplate('template-3') - ], - loading: null, - ...props - }, - global: { - plugins: [i18n] - } - }) - } - - it('renders template cards for each template', () => { - const wrapper = mountView() - const cards = wrapper.findAll('.mock-template-card') - - expect(cards.length).toBe(3) - expect(cards[0].attributes('data-name')).toBe('template-1') - expect(cards[1].attributes('data-name')).toBe('template-2') - expect(cards[2].attributes('data-name')).toBe('template-3') - }) - - it('emits loadWorkflow event when clicked', async () => { - const wrapper = mountView() - const card = wrapper.find('.mock-template-card') - - await card.trigger('click') - - expect(wrapper.emitted()).toHaveProperty('loadWorkflow') - // Check that the emitted event contains the template name - const emitted = wrapper.emitted('loadWorkflow') - expect(emitted).toBeTruthy() - expect(emitted?.[0][0]).toBe('template-1') - }) - - it('passes correct props to template cards', () => { - const wrapper = mountView({ - sourceModule: 'custom', - categoryTitle: 'Custom Category' - }) - - const card = wrapper.find('.mock-template-card') - expect(card.exists()).toBe(true) - expect(card.attributes('data-source-module')).toBe('custom') - expect(card.attributes('data-category-title')).toBe('Custom Category') - }) - - it('applies loading state correctly to cards', () => { - const wrapper = mountView({ - loading: 'template-2' - }) - - const cards = wrapper.findAll('.mock-template-card') - - // Only the second card should have loading=true since loading="template-2" - expect(cards[0].attributes('data-loading')).toBe('false') - expect(cards[1].attributes('data-loading')).toBe('true') - expect(cards[2].attributes('data-loading')).toBe('false') - }) -}) diff --git a/src/components/templates/TemplateWorkflowView.vue b/src/components/templates/TemplateWorkflowView.vue deleted file mode 100644 index 9c73ddc86c..0000000000 --- a/src/components/templates/TemplateWorkflowView.vue +++ /dev/null @@ -1,168 +0,0 @@ - - - diff --git a/src/components/templates/TemplateWorkflowsContent.vue b/src/components/templates/TemplateWorkflowsContent.vue deleted file mode 100644 index 38925b9e40..0000000000 --- a/src/components/templates/TemplateWorkflowsContent.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/src/components/templates/TemplateWorkflowsDialogHeader.vue b/src/components/templates/TemplateWorkflowsDialogHeader.vue deleted file mode 100644 index 9313ab104d..0000000000 --- a/src/components/templates/TemplateWorkflowsDialogHeader.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/components/templates/TemplateWorkflowsSideNav.vue b/src/components/templates/TemplateWorkflowsSideNav.vue deleted file mode 100644 index 07ff87990e..0000000000 --- a/src/components/templates/TemplateWorkflowsSideNav.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/src/components/templates/thumbnails/AudioThumbnail.spec.ts b/src/components/templates/thumbnails/AudioThumbnail.test.ts similarity index 100% rename from src/components/templates/thumbnails/AudioThumbnail.spec.ts rename to src/components/templates/thumbnails/AudioThumbnail.test.ts diff --git a/src/components/templates/thumbnails/AudioThumbnail.vue b/src/components/templates/thumbnails/AudioThumbnail.vue index dda6e79a52..49333d93e2 100644 --- a/src/components/templates/thumbnails/AudioThumbnail.vue +++ b/src/components/templates/thumbnails/AudioThumbnail.vue @@ -1,6 +1,12 @@ diff --git a/src/renderer/extensions/vueNodes/components/NodeSlots.spec.ts b/src/renderer/extensions/vueNodes/components/NodeSlots.test.ts similarity index 87% rename from src/renderer/extensions/vueNodes/components/NodeSlots.spec.ts rename to src/renderer/extensions/vueNodes/components/NodeSlots.test.ts index f58e115ad6..ed8bb8ed27 100644 --- a/src/renderer/extensions/vueNodes/components/NodeSlots.spec.ts +++ b/src/renderer/extensions/vueNodes/components/NodeSlots.test.ts @@ -5,6 +5,8 @@ import { type PropType, defineComponent } from 'vue' import { createI18n } from 'vue-i18n' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' import enMessages from '@/locales/en/main.json' with { type: 'json' } import NodeSlots from './NodeSlots.vue' @@ -94,15 +96,17 @@ describe('NodeSlots.vue', () => { const inputObjNoWidget = { name: 'objNoWidget', type: 'number', - boundingRect: [0, 0, 0, 0] + boundingRect: new Float32Array([0, 0, 0, 0]), + link: null } const inputObjWithWidget = { name: 'objWithWidget', type: 'number', - boundingRect: [0, 0, 0, 0], - widget: { name: 'objWithWidget' } + boundingRect: new Float32Array([0, 0, 0, 0]), + widget: { name: 'objWithWidget' }, + link: null } - const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput'] + const inputs: INodeInputSlot[] = [inputObjNoWidget, inputObjWithWidget] const wrapper = mountSlots(makeNodeData({ inputs })) @@ -143,8 +147,19 @@ describe('NodeSlots.vue', () => { }) it('maps outputs and passes correct indexes', () => { - const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] } - const outputs = [outputObj, 'outB'] + const outputObj = { + name: 'outA', + type: 'any', + boundingRect: new Float32Array([0, 0, 0, 0]), + links: [] + } + const outputObjB = { + name: 'outB', + type: 'any', + boundingRect: new Float32Array([0, 0, 0, 0]), + links: [] + } + const outputs: INodeOutputSlot[] = [outputObj, outputObjB] const wrapper = mountSlots(makeNodeData({ outputs })) const outputEls = wrapper @@ -174,7 +189,7 @@ describe('NodeSlots.vue', () => { it('passes readonly to child slots', () => { const wrapper = mountSlots( - makeNodeData({ inputs: ['a'], outputs: ['b'] }), + makeNodeData({ inputs: [], outputs: [] }), /* readonly */ true ) const all = [ diff --git a/src/renderer/extensions/vueNodes/components/NodeSlots.vue b/src/renderer/extensions/vueNodes/components/NodeSlots.vue index 68f2479321..26187899d1 100644 --- a/src/renderer/extensions/vueNodes/components/NodeSlots.vue +++ b/src/renderer/extensions/vueNodes/components/NodeSlots.vue @@ -8,7 +8,8 @@ v-for="(input, index) in filteredInputs" :key="`input-${index}`" :slot-data="input" - :node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''" + :node-type="nodeData?.type || ''" + :node-id="nodeData?.id != null ? String(nodeData.id) : ''" :index="getActualInputIndex(input, index)" :readonly="readonly" /> @@ -19,7 +20,8 @@ v-for="(output, index) in filteredOutputs" :key="`output-${index}`" :slot-data="output" - :node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''" + :node-type="nodeData?.type || ''" + :node-id="nodeData?.id != null ? String(nodeData.id) : ''" :index="index" :readonly="readonly" /> @@ -32,29 +34,24 @@ import { computed, onErrorCaptured, ref } from 'vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useErrorHandling } from '@/composables/useErrorHandling' -import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD' +import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import { isSlotObject } from '@/utils/typeGuardUtil' import InputSlot from './InputSlot.vue' import OutputSlot from './OutputSlot.vue' interface NodeSlotsProps { - node?: LGraphNode // For backwards compatibility - nodeData?: VueNodeData // New clean data structure + nodeData?: VueNodeData readonly?: boolean - lodLevel?: LODLevel } -const props = defineProps() - -const nodeInfo = computed(() => props.nodeData || props.node || null) +const { nodeData = null, readonly } = defineProps() // Filter out input slots that have corresponding widgets const filteredInputs = computed(() => { - if (!nodeInfo.value?.inputs) return [] + if (!nodeData?.inputs) return [] - return nodeInfo.value.inputs + return nodeData.inputs .filter((input) => { // Check if this slot has a widget property (indicating it has a corresponding widget) if (isSlotObject(input) && 'widget' in input && input.widget) { @@ -76,7 +73,7 @@ const filteredInputs = computed(() => { // Outputs don't have widgets, so we don't need to filter them const filteredOutputs = computed(() => { - const outputs = nodeInfo.value?.outputs || [] + const outputs = nodeData?.outputs || [] return outputs.map((output) => isSlotObject(output) ? output @@ -94,10 +91,10 @@ const getActualInputIndex = ( input: INodeSlot, filteredIndex: number ): number => { - if (!nodeInfo.value?.inputs) return filteredIndex + if (!nodeData?.inputs) return filteredIndex // Find the actual index in the unfiltered inputs array - const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input) + const actualIndex = nodeData.inputs.findIndex((i) => i === input) return actualIndex !== -1 ? actualIndex : filteredIndex } diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 0cd7a59cc5..59f4fe3bfd 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -2,13 +2,27 @@
{{ $t('Node Widgets Error') }}
-
+
+
@@ -18,7 +32,7 @@ type: widget.type, boundingRect: [0, 0, 0, 0] }" - :node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''" + :node-id="nodeData?.id != null ? String(nodeData.id) : ''" :index="getWidgetInputIndex(widget)" :readonly="readonly" :dot-only="true" @@ -27,6 +41,7 @@ diff --git a/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue index d5b8b1ad8f..85c7ec17d1 100644 --- a/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue +++ b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue @@ -24,6 +24,7 @@ defineExpose({ >
-import { computed, toRef } from 'vue' -import { useLOD } from '@/composables/graph/useLOD' +## The GPU Texture Bottleneck -const props = defineProps<{ - widget: any - zoomLevel: number - // ... other props -}>() +The key insight driving the current architecture comes from understanding how browsers handle CSS transforms: -// Get LOD information -const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel)) - -``` +When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down: -**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions -**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions +### Traditional Assumption -### Step 2: Choose What to Show at Different Zoom Levels +"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance." -#### Understanding the LOD Score -- `lodScore` is a number from 0 to 1 -- 0 = completely zoomed out (show minimal detail) -- 1 = fully zoomed in (show everything) -- 0.5 = medium zoom (show some details) +### Actual Browser Behavior -#### Understanding LOD Levels -- `'minimal'` = zoom level 0.4 or below (very zoomed out) -- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom) -- `'full'` = zoom level 0.8 or above (zoomed in close) +When all nodes are children of a single transformed parent: -### Step 3: Implement Your Widget's LOD Strategy +1. The browser creates one large GPU texture for the entire node graph +2. The texture dimensions are determined by the bounding box of all content +3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact +4. The performance bottleneck is the texture size itself, not the complexity of rasterization -Here's a complete example of a slider widget with LOD: +This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content. -```vue - +## Two Distinct Performance Concerns - +**Question:** How much memory do widget instances consume, and what's the cost of maintaining them? - -``` +The current CSS-based approach makes several deliberate trade-offs: -## Common LOD Patterns +### What We Optimize For -### Pattern 1: Essential vs. Nice-to-Have -```typescript -// Always show the main functionality -const showMainControl = computed(() => true) +1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs +2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated +3. **Simple widget development** - Widget authors don't need to implement LOD logic +4. **Reliable state preservation** - Widgets never lose state from unmounting -// Granular control with lodScore -const showLabels = computed(() => lodScore.value > 0.4) -const labelOpacity = computed(() => Math.max(0.3, lodScore.value)) +### What We Accept -// Simple control with lodLevel -const showExtras = computed(() => lodLevel.value === 'full') -``` +1. **Higher baseline memory usage** - All widgets remain mounted +2. **Less granular control** - Widgets can't optimize their own LOD behavior +3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden -### Pattern 2: Smooth Opacity Transitions -```typescript -// Gradually fade elements based on zoom -const labelOpacity = computed(() => { - // Fade in from zoom 0.3 to 0.6 - return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3)) -}) -``` +## Open Questions and Future Considerations -### Pattern 3: Progressive Detail -```typescript -const detailLevel = computed(() => { - if (lodScore.value < 0.3) return 'none' - if (lodScore.value < 0.6) return 'basic' - if (lodScore.value < 0.8) return 'standard' - return 'full' -}) -``` +### Should widgets have any LOD control? -## LOD Guidelines by Widget Type +The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions: -### Text Input Widgets -- **Always show**: The input field itself -- **Medium zoom**: Show label -- **High zoom**: Show placeholder text, validation messages -- **Full zoom**: Show character count, format hints +**Scenario:** A widget renders a complex 3D scene or runs expensive computations +**Current behavior:** Hidden via CSS but still mounted +**Question:** Should such widgets be able to opt into unmounting at distance? -### Button Widgets -- **Always show**: The button -- **Medium zoom**: Show button text -- **High zoom**: Show button description -- **Full zoom**: Show keyboard shortcuts, tooltips +The challenge is that introducing selective unmounting would require: -### Selection Widgets (Dropdown, Radio) -- **Always show**: The current selection -- **Medium zoom**: Show option labels -- **High zoom**: Show all options when expanded -- **Full zoom**: Show option descriptions, icons +- Maintaining widget state across mount/unmount cycles +- Accepting the performance cost of remounting when zooming in +- Adding complexity to the widget API -### Complex Widgets (Color Picker, File Browser) -- **Always show**: Simplified representation (color swatch, filename) -- **Medium zoom**: Show basic controls -- **High zoom**: Show full interface -- **Full zoom**: Show advanced options, previews +### Could we reduce GPU texture size? -## Design Collaboration Guidelines +Since texture dimensions are the limiting factor, could we: -### For Designers -When designing widgets, consider creating variants for different zoom levels: +- Use multiple compositor layers for different regions (chunk the transformpane)? +- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom. -1. **Minimal Design** (far away view) - - Essential elements only - - Higher contrast for visibility - - Simplified shapes and fewer details +These approaches would require significant architectural changes and might introduce their own performance trade-offs. -2. **Standard Design** (normal view) - - Balanced detail and simplicity - - Clear labels and readable text - - Good for most use cases +### Is there a hybrid approach? -3. **Full Detail Design** (close-up view) - - All labels, descriptions, and help text - - Rich visual effects and polish - - Maximum information density +Could we identify specific threshold scenarios where reactive LOD makes sense? -### Design Handoff Checklist -- [ ] Specify which elements are essential vs. nice-to-have -- [ ] Define minimum readable sizes for text elements -- [ ] Provide simplified versions for distant viewing -- [ ] Consider color contrast at different opacity levels -- [ ] Test designs at multiple zoom levels +- When node count is low (< 50 nodes) +- For specifically registered "expensive" widgets +- At extreme zoom levels only -## Testing Your LOD Implementation +## Implementation Guidelines -### Manual Testing -1. Create a workflow with your widget -2. Zoom out until nodes are very small -3. Verify essential functionality still works -4. Zoom in gradually and check that details appear smoothly -5. Test performance with 50+ nodes containing your widget +Given the current architecture, here's how to work within the system: -### Performance Considerations -- Avoid complex calculations in LOD computed properties -- Use `v-if` instead of `v-show` for elements that won't render -- Consider using `v-memo` for expensive widget content -- Test on lower-end devices +### For Widget Developers -### Common Mistakes -❌ **Don't**: Hide the main widget functionality at any zoom level -❌ **Don't**: Use complex animations that trigger at every zoom change -❌ **Don't**: Make LOD thresholds too sensitive (causes flickering) -❌ **Don't**: Forget to test with real content and edge cases +1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup +2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes +3. **Minimize background processing** - Assume your widget is always running +4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out -✅ **Do**: Keep essential functionality always visible -✅ **Do**: Use smooth transitions between LOD levels -✅ **Do**: Test with varying content lengths and types -✅ **Do**: Consider accessibility at all zoom levels +### For System Architects -## Getting Help +1. **Monitor GPU memory usage** - The single texture approach has memory implications +2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size +3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns +4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation -- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples -- Ask in the ComfyUI frontend Discord for LOD implementation questions -- Test your changes with the LOD debug panel (top-right in GraphCanvas) -- Profile performance impact using browser dev tools \ No newline at end of file +## Conclusion + +The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities. + +The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale. + +The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions. diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts index 8446496764..6adee1e894 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts @@ -8,19 +8,19 @@ * - Layout mutations for visual feedback * - Integration with LiteGraph canvas selection system */ -import type { Ref } from 'vue' +import { createSharedComposable } from '@vueuse/core' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex' -interface NodeManager { - getNode: (id: string) => any -} - -export function useNodeEventHandlers(nodeManager: Ref) { +function useNodeEventHandlersIndividual() { const canvasStore = useCanvasStore() + const { nodeManager } = useVueNodeLifecycle() const { bringNodeToFront } = useNodeZIndex() + const { shouldHandleNodePointerEvents } = useCanvasInteractions() /** * Handle node selection events @@ -31,12 +31,14 @@ export function useNodeEventHandlers(nodeManager: Ref) { nodeData: VueNodeData, wasDragging: boolean ) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return const node = nodeManager.value.getNode(nodeData.id) if (!node) return - const isMultiSelect = event.ctrlKey || event.metaKey + const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey if (isMultiSelect) { // Ctrl/Cmd+click -> toggle selection @@ -69,6 +71,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Uses LiteGraph's native collapse method for proper state management */ const handleNodeCollapse = (nodeId: string, collapsed: boolean) => { + if (!shouldHandleNodePointerEvents.value) return + if (!nodeManager.value) return const node = nodeManager.value.getNode(nodeId) @@ -86,6 +90,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Updates the title in LiteGraph for persistence across sessions */ const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => { + if (!shouldHandleNodePointerEvents.value) return + if (!nodeManager.value) return const node = nodeManager.value.getNode(nodeId) @@ -103,6 +109,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { event: PointerEvent, nodeData: VueNodeData ) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return const node = nodeManager.value.getNode(nodeData.id) @@ -123,6 +131,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Integrates with LiteGraph's context menu system */ const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return const node = nodeManager.value.getNode(nodeData.id) @@ -145,6 +155,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Prepares node for dragging and sets appropriate visual state */ const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return const node = nodeManager.value.getNode(nodeData.id) @@ -173,6 +185,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Useful for selection toolbox or area selection */ const selectNodes = (nodeIds: string[], addToSelection = false) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return if (!addToSelection) { @@ -193,6 +207,8 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Deselect specific nodes */ const deselectNodes = (nodeIds: string[]) => { + if (!shouldHandleNodePointerEvents.value) return + if (!canvasStore.canvas || !nodeManager.value) return nodeIds.forEach((nodeId) => { @@ -219,3 +235,7 @@ export function useNodeEventHandlers(nodeManager: Ref) { deselectNodes } } + +export const useNodeEventHandlers = createSharedComposable( + useNodeEventHandlersIndividual +) diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts new file mode 100644 index 0000000000..f0c046ca08 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' + +// Mock the dependencies +vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ + useCanvasInteractions: () => ({ + forwardEventToCanvas: vi.fn(), + shouldHandleNodePointerEvents: ref(true) + }) +})) + +vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({ + useNodeLayout: () => ({ + startDrag: vi.fn(), + endDrag: vi.fn().mockResolvedValue(undefined), + handleDrag: vi.fn().mockResolvedValue(undefined) + }) +})) + +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + isDraggingVueNodes: ref(false) + } +})) + +const createMockVueNodeData = ( + overrides: Partial = {} +): VueNodeData => ({ + id: 'test-node-123', + title: 'Test Node', + type: 'TestNodeType', + mode: 0, + selected: false, + executing: false, + inputs: [], + outputs: [], + widgets: [], + ...overrides +}) + +const createPointerEvent = ( + eventType: string, + overrides: Partial = {} +): PointerEvent => { + return new PointerEvent(eventType, { + pointerId: 1, + button: 0, + clientX: 100, + clientY: 100, + ...overrides + }) +} + +const createMouseEvent = ( + eventType: string, + overrides: Partial = {} +): MouseEvent => { + return new MouseEvent(eventType, { + button: 2, // Right click + clientX: 100, + clientY: 100, + ...overrides + }) +} + +describe('useNodePointerInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should only start drag on left-click', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnPointerUp = vi.fn() + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnPointerUp + ) + + // Right-click should not start drag + const rightClickEvent = createPointerEvent('pointerdown', { button: 2 }) + pointerHandlers.onPointerdown(rightClickEvent) + + expect(mockOnPointerUp).not.toHaveBeenCalled() + + // Left-click should start drag and emit callback + const leftClickEvent = createPointerEvent('pointerdown', { button: 0 }) + pointerHandlers.onPointerdown(leftClickEvent) + + const pointerUpEvent = createPointerEvent('pointerup') + pointerHandlers.onPointerup(pointerUpEvent) + + expect(mockOnPointerUp).toHaveBeenCalledWith( + pointerUpEvent, + mockNodeData, + false // wasDragging = false (same position) + ) + }) + + it('should distinguish drag from click based on distance threshold', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnPointerUp = vi.fn() + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnPointerUp + ) + + // Test drag (distance > 4px) + pointerHandlers.onPointerdown( + createPointerEvent('pointerdown', { clientX: 100, clientY: 100 }) + ) + + const dragUpEvent = createPointerEvent('pointerup', { + clientX: 200, + clientY: 200 + }) + pointerHandlers.onPointerup(dragUpEvent) + + expect(mockOnPointerUp).toHaveBeenCalledWith( + dragUpEvent, + mockNodeData, + true + ) + + mockOnPointerUp.mockClear() + + // Test click (same position) + const samePos = { clientX: 100, clientY: 100 } + pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos)) + + const clickUpEvent = createPointerEvent('pointerup', samePos) + pointerHandlers.onPointerup(clickUpEvent) + + expect(mockOnPointerUp).toHaveBeenCalledWith( + clickUpEvent, + mockNodeData, + false + ) + }) + + it('should handle drag termination via cancel and context menu', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnPointerUp = vi.fn() + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnPointerUp + ) + + // Test pointer cancel + pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) + pointerHandlers.onPointercancel(createPointerEvent('pointercancel')) + + // Should not emit callback on cancel + expect(mockOnPointerUp).not.toHaveBeenCalled() + + // Test context menu during drag prevents default + pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) + + const contextMenuEvent = createMouseEvent('contextmenu') + const preventDefaultSpy = vi.spyOn(contextMenuEvent, 'preventDefault') + + pointerHandlers.onContextmenu(contextMenuEvent) + + expect(preventDefaultSpy).toHaveBeenCalled() + }) + + it('should not emit callback when nodeData becomes null', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnPointerUp = vi.fn() + const nodeDataRef = ref(mockNodeData) + + const { pointerHandlers } = useNodePointerInteractions( + nodeDataRef, + mockOnPointerUp + ) + + pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) + + // Clear nodeData before pointerup + nodeDataRef.value = null + + pointerHandlers.onPointerup(createPointerEvent('pointerup')) + + expect(mockOnPointerUp).not.toHaveBeenCalled() + }) + + it('should integrate with layout store dragging state', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnPointerUp = vi.fn() + const { layoutStore } = await import( + '@/renderer/core/layout/store/layoutStore' + ) + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnPointerUp + ) + + // Start drag + pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) + await nextTick() + expect(layoutStore.isDraggingVueNodes.value).toBe(true) + + // End drag + pointerHandlers.onPointercancel(createPointerEvent('pointercancel')) + await nextTick() + expect(layoutStore.isDraggingVueNodes.value).toBe(false) + }) +}) diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts new file mode 100644 index 0000000000..e00e77c24d --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -0,0 +1,180 @@ +import { type MaybeRefOrGetter, computed, onUnmounted, ref, toValue } from 'vue' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' + +// Treat tiny pointer jitter as a click, not a drag +const DRAG_THRESHOLD_PX = 4 + +export function useNodePointerInteractions( + nodeDataMaybe: MaybeRefOrGetter, + onPointerUp: ( + event: PointerEvent, + nodeData: VueNodeData, + wasDragging: boolean + ) => void +) { + const nodeData = computed(() => { + const value = toValue(nodeDataMaybe) + if (!value) { + console.warn( + 'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined' + ) + return null + } + return value + }) + + // Avoid potential null access during component initialization + const nodeIdComputed = computed(() => nodeData.value?.id ?? '') + const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed) + // Use canvas interactions for proper wheel event handling and pointer event capture control + const { forwardEventToCanvas, shouldHandleNodePointerEvents } = + useCanvasInteractions() + + // Drag state for styling + const isDragging = ref(false) + const dragStyle = computed(() => { + if (nodeData.value?.flags?.pinned) { + return { cursor: 'default' } + } + return { cursor: isDragging.value ? 'grabbing' : 'grab' } + }) + const startPosition = ref({ x: 0, y: 0 }) + + const handlePointerDown = (event: PointerEvent) => { + if (!nodeData.value) { + console.warn( + 'LGraphNode: nodeData is null/undefined in handlePointerDown' + ) + return + } + + // Only start drag on left-click (button 0) + if (event.button !== 0) { + return + } + + // Don't handle pointer events when canvas is in panning mode - forward to canvas instead + if (!shouldHandleNodePointerEvents.value) { + forwardEventToCanvas(event) + return + } + + // Don't allow dragging if node is pinned (but still record position for selection) + startPosition.value = { x: event.clientX, y: event.clientY } + if (nodeData.value.flags?.pinned) { + return + } + + // Start drag using layout system + isDragging.value = true + + // Set Vue node dragging state for selection toolbox + layoutStore.isDraggingVueNodes.value = true + + startDrag(event) + } + + const handlePointerMove = (event: PointerEvent) => { + if (isDragging.value) { + void handleDrag(event) + } + } + + /** + * Centralized cleanup function for drag state + * Ensures consistent cleanup across all drag termination scenarios + */ + const cleanupDragState = () => { + isDragging.value = false + layoutStore.isDraggingVueNodes.value = false + } + + /** + * Safely ends drag operation with proper error handling + * @param event - PointerEvent to end the drag with + */ + const safeDragEnd = async (event: PointerEvent): Promise => { + try { + await endDrag(event) + } catch (error) { + console.error('Error during endDrag:', error) + } finally { + cleanupDragState() + } + } + + /** + * Common drag termination handler with fallback cleanup + */ + const handleDragTermination = (event: PointerEvent, errorContext: string) => { + safeDragEnd(event).catch((error) => { + console.error(`Failed to complete ${errorContext}:`, error) + cleanupDragState() // Fallback cleanup + }) + } + + const handlePointerUp = (event: PointerEvent) => { + if (isDragging.value) { + handleDragTermination(event, 'drag end') + } + + // Don't emit node-click when canvas is in panning mode - forward to canvas instead + if (!shouldHandleNodePointerEvents.value) { + forwardEventToCanvas(event) + return + } + + // Emit node-click for selection handling in GraphCanvas + const dx = event.clientX - startPosition.value.x + const dy = event.clientY - startPosition.value.y + const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX + + if (!nodeData?.value) return + onPointerUp(event, nodeData.value, wasDragging) + } + + /** + * Handles pointer cancellation events (e.g., touch cancelled by browser) + * Ensures drag state is properly cleaned up when pointer interaction is interrupted + */ + const handlePointerCancel = (event: PointerEvent) => { + if (!isDragging.value) return + handleDragTermination(event, 'drag cancellation') + } + + /** + * Handles right-click during drag operations + * Cancels the current drag to prevent context menu from appearing while dragging + */ + const handleContextMenu = (event: MouseEvent) => { + if (!isDragging.value) return + + event.preventDefault() + // Simply cleanup state without calling endDrag to avoid synthetic event creation + cleanupDragState() + } + + // Cleanup on unmount to prevent resource leaks + onUnmounted(() => { + if (!isDragging.value) return + cleanupDragState() + }) + + const pointerHandlers = { + onPointerdown: handlePointerDown, + onPointermove: handlePointerMove, + onPointerup: handlePointerUp, + onPointercancel: handlePointerCancel, + onContextmenu: handleContextMenu + } + + return { + isDragging, + dragStyle, + pointerHandlers + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts b/src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts new file mode 100644 index 0000000000..e69d21f564 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts @@ -0,0 +1,121 @@ +import type { TooltipDirectivePassThroughOptions } from 'primevue' +import { type MaybeRef, type Ref, computed, unref } from 'vue' + +import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager' +import { st } from '@/i18n' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { normalizeI18nKey } from '@/utils/formatUtil' + +/** + * Composable for managing Vue node tooltips + * Provides tooltip text for node headers, slots, and widgets + */ +export function useNodeTooltips( + nodeType: MaybeRef, + containerRef?: Ref +) { + const nodeDefStore = useNodeDefStore() + const settingsStore = useSettingStore() + + // Check if tooltips are globally enabled + const tooltipsEnabled = computed(() => + settingsStore.get('Comfy.EnableTooltips') + ) + + // Get node definition for tooltip data + const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)]) + + /** + * Get tooltip text for node description (header hover) + */ + const getNodeDescription = computed(() => { + if (!tooltipsEnabled.value || !nodeDef.value) return '' + + const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.description` + return st(key, nodeDef.value.description || '') + }) + + /** + * Get tooltip text for input slots + */ + const getInputSlotTooltip = (slotName: string) => { + if (!tooltipsEnabled.value || !nodeDef.value) return '' + + const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip` + const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? '' + return st(key, inputTooltip) + } + + /** + * Get tooltip text for output slots + */ + const getOutputSlotTooltip = (slotIndex: number) => { + if (!tooltipsEnabled.value || !nodeDef.value) return '' + + const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip` + const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? '' + return st(key, outputTooltip) + } + + /** + * Get tooltip text for widgets + */ + const getWidgetTooltip = (widget: SafeWidgetData) => { + if (!tooltipsEnabled.value || !nodeDef.value) return '' + + // First try widget-specific tooltip + const widgetTooltip = (widget as { tooltip?: string }).tooltip + if (widgetTooltip) return widgetTooltip + + // Then try input-based tooltip lookup + const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip` + const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? '' + return st(key, inputTooltip) + } + + /** + * Create tooltip configuration object for v-tooltip directive + */ + const createTooltipConfig = (text: string) => { + const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay') + const tooltipText = text || '' + + const config: { + value: string + showDelay: number + disabled: boolean + appendTo?: HTMLElement + pt?: TooltipDirectivePassThroughOptions + } = { + value: tooltipText, + showDelay: tooltipDelay as number, + disabled: !tooltipsEnabled.value || !tooltipText, + pt: { + text: { + class: + 'bg-pure-white dark-theme:bg-charcoal-800 border dark-theme:border-slate-300 rounded-md px-4 py-2 text-charcoal-700 dark-theme:text-pure-white text-sm font-normal leading-tight max-w-75 shadow-none' + }, + arrow: { + class: 'before:border-slate-300' + } + } + } + + // If we have a container reference, append tooltips to it + if (containerRef?.value) { + config.appendTo = containerRef.value + } + + return config + } + + return { + tooltipsEnabled, + getNodeDescription, + getInputSlotTooltip, + getOutputSlotTooltip, + getWidgetTooltip, + createTooltipConfig + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts new file mode 100644 index 0000000000..4b6cbf8117 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -0,0 +1,224 @@ +/** + * Centralized Slot Element Tracking + * + * Registers slot connector DOM elements per node, measures their canvas-space + * positions in a single batched pass, and caches offsets so that node moves + * update slot positions without DOM reads. + */ +import { type Ref, onMounted, onUnmounted, watch } from 'vue' + +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { SlotLayout } from '@/renderer/core/layout/types' +import { + isPointEqual, + isSizeEqual +} from '@/renderer/core/layout/utils/geometry' +import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore' + +// RAF batching +const pendingNodes = new Set() +let rafId: number | null = null + +function scheduleSlotLayoutSync(nodeId: string) { + pendingNodes.add(nodeId) + if (rafId == null) { + rafId = requestAnimationFrame(() => { + rafId = null + flushScheduledSlotLayoutSync() + }) + } +} + +function flushScheduledSlotLayoutSync() { + if (pendingNodes.size === 0) return + const conv = useSharedCanvasPositionConversion() + for (const nodeId of Array.from(pendingNodes)) { + pendingNodes.delete(nodeId) + syncNodeSlotLayoutsFromDOM(nodeId, conv) + } +} + +export function syncNodeSlotLayoutsFromDOM( + nodeId: string, + conv?: ReturnType +) { + const nodeSlotRegistryStore = useNodeSlotRegistryStore() + const node = nodeSlotRegistryStore.getNode(nodeId) + if (!node) return + const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value + if (!nodeLayout) return + + const batch: Array<{ key: string; layout: SlotLayout }> = [] + + for (const [slotKey, entry] of node.slots) { + const rect = entry.el.getBoundingClientRect() + const screenCenter: [number, number] = [ + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ] + const [x, y] = ( + conv ?? useSharedCanvasPositionConversion() + ).clientPosToCanvasPos(screenCenter) + const centerCanvas = { x, y } + + // Cache offset relative to node position for fast updates later + entry.cachedOffset = { + x: centerCanvas.x - nodeLayout.position.x, + y: centerCanvas.y - nodeLayout.position.y + } + + // Persist layout in canvas coordinates + const size = LiteGraph.NODE_SLOT_HEIGHT + const half = size / 2 + batch.push({ + key: slotKey, + layout: { + nodeId, + index: entry.index, + type: entry.type, + position: { x: centerCanvas.x, y: centerCanvas.y }, + bounds: { + x: centerCanvas.x - half, + y: centerCanvas.y - half, + width: size, + height: size + } + } + }) + } + if (batch.length) layoutStore.batchUpdateSlotLayouts(batch) +} + +function updateNodeSlotsFromCache(nodeId: string) { + const nodeSlotRegistryStore = useNodeSlotRegistryStore() + const node = nodeSlotRegistryStore.getNode(nodeId) + if (!node) return + const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value + if (!nodeLayout) return + + const batch: Array<{ key: string; layout: SlotLayout }> = [] + + for (const [slotKey, entry] of node.slots) { + if (!entry.cachedOffset) { + // schedule a sync to seed offset + scheduleSlotLayoutSync(nodeId) + continue + } + + const centerCanvas = { + x: nodeLayout.position.x + entry.cachedOffset.x, + y: nodeLayout.position.y + entry.cachedOffset.y + } + const size = LiteGraph.NODE_SLOT_HEIGHT + const half = size / 2 + batch.push({ + key: slotKey, + layout: { + nodeId, + index: entry.index, + type: entry.type, + position: { x: centerCanvas.x, y: centerCanvas.y }, + bounds: { + x: centerCanvas.x - half, + y: centerCanvas.y - half, + width: size, + height: size + } + } + }) + } + + if (batch.length) layoutStore.batchUpdateSlotLayouts(batch) +} + +export function useSlotElementTracking(options: { + nodeId: string + index: number + type: 'input' | 'output' + element: Ref +}) { + const { nodeId, index, type, element } = options + const nodeSlotRegistryStore = useNodeSlotRegistryStore() + + onMounted(() => { + if (!nodeId) return + const stop = watch( + element, + (el) => { + if (!el) return + + const node = nodeSlotRegistryStore.ensureNode(nodeId) + + if (!node.stopWatch) { + const layoutRef = layoutStore.getNodeLayoutRef(nodeId) + + const stopPositionWatch = watch( + () => layoutRef.value?.position, + (newPosition, oldPosition) => { + if (!newPosition) return + if (!oldPosition || !isPointEqual(newPosition, oldPosition)) { + updateNodeSlotsFromCache(nodeId) + } + } + ) + + const stopSizeWatch = watch( + () => layoutRef.value?.size, + (newSize, oldSize) => { + if (!newSize) return + if (!oldSize || !isSizeEqual(newSize, oldSize)) { + scheduleSlotLayoutSync(nodeId) + } + } + ) + + node.stopWatch = () => { + stopPositionWatch() + stopSizeWatch() + } + } + + // Register slot + const slotKey = getSlotKey(nodeId, index, type === 'input') + + el.dataset.slotKey = slotKey + node.slots.set(slotKey, { el, index, type }) + + // Seed initial sync from DOM + scheduleSlotLayoutSync(nodeId) + + // Stop watching once registered + stop() + }, + { immediate: true, flush: 'post' } + ) + }) + + onUnmounted(() => { + if (!nodeId) return + const node = nodeSlotRegistryStore.getNode(nodeId) + if (!node) return + + // Remove this slot from registry and layout + const slotKey = getSlotKey(nodeId, index, type === 'input') + const entry = node.slots.get(slotKey) + if (entry) { + delete entry.el.dataset.slotKey + node.slots.delete(slotKey) + } + layoutStore.deleteSlotLayout(slotKey) + + // If node has no more slots, clean up + if (node.slots.size === 0) { + if (node.stopWatch) node.stopWatch() + nodeSlotRegistryStore.deleteNode(nodeId) + } + }) + + return { + requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId) + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts new file mode 100644 index 0000000000..57f6fa21f1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -0,0 +1,527 @@ +import { type Fn, useEventListener } from '@vueuse/core' +import { onBeforeUnmount } from 'vue' + +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { LLink } from '@/lib/litegraph/src/LLink' +import type { Reroute } from '@/lib/litegraph/src/Reroute' +import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink' +import type { + INodeInputSlot, + INodeOutputSlot +} from '@/lib/litegraph/src/interfaces' +import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' +import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' +import { + type SlotDropCandidate, + useSlotLinkDragState +} from '@/renderer/core/canvas/links/slotLinkDragState' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { Point } from '@/renderer/core/layout/types' +import { toPoint } from '@/renderer/core/layout/utils/geometry' +import { app } from '@/scripts/app' + +interface SlotInteractionOptions { + nodeId: string + index: number + type: 'input' | 'output' + readonly?: boolean +} + +interface SlotInteractionHandlers { + onPointerDown: (event: PointerEvent) => void +} + +interface PointerSession { + begin: (pointerId: number) => void + register: (...stops: Array) => void + matches: (event: PointerEvent) => boolean + isActive: () => boolean + clear: () => void +} + +function createPointerSession(): PointerSession { + let pointerId: number | null = null + let stops: Fn[] = [] + + const begin = (id: number) => { + pointerId = id + } + + const register = (...newStops: Array) => { + for (const stop of newStops) { + if (typeof stop === 'function') { + stops.push(stop) + } + } + } + + const matches = (event: PointerEvent) => + pointerId !== null && event.pointerId === pointerId + + const isActive = () => pointerId !== null + + const clear = () => { + for (const stop of stops) { + stop() + } + stops = [] + pointerId = null + } + + return { begin, register, matches, isActive, clear } +} + +export function useSlotLinkInteraction({ + nodeId, + index, + type, + readonly +}: SlotInteractionOptions): SlotInteractionHandlers { + if (readonly) { + return { + onPointerDown: () => {} + } + } + + const { state, beginDrag, endDrag, updatePointerPosition } = + useSlotLinkDragState() + + function candidateFromTarget( + target: EventTarget | null + ): SlotDropCandidate | null { + if (!(target instanceof HTMLElement)) return null + const key = target.dataset['slotKey'] + if (!key) return null + + const layout = layoutStore.getSlotLayout(key) + if (!layout) return null + + const candidate: SlotDropCandidate = { layout, compatible: false } + + if (state.source) { + const canvas = app.canvas + const graph = canvas?.graph + const adapter = ensureActiveAdapter() + if (graph && adapter) { + if (layout.type === 'input') { + candidate.compatible = adapter.isInputValidDrop( + layout.nodeId, + layout.index + ) + } else if (layout.type === 'output') { + candidate.compatible = adapter.isOutputValidDrop( + layout.nodeId, + layout.index + ) + } + } + } + + return candidate + } + + const conversion = useSharedCanvasPositionConversion() + + const pointerSession = createPointerSession() + let activeAdapter: LinkConnectorAdapter | null = null + + const ensureActiveAdapter = (): LinkConnectorAdapter | null => { + if (!activeAdapter) activeAdapter = createLinkConnectorAdapter() + return activeAdapter + } + + function hasCanConnectToReroute( + link: RenderLink + ): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } { + return 'canConnectToReroute' in link + } + + type ToInputLink = RenderLink & { toType: 'input' } + type ToOutputLink = RenderLink & { toType: 'output' } + const isToInputLink = (link: RenderLink): link is ToInputLink => + link.toType === 'input' + const isToOutputLink = (link: RenderLink): link is ToOutputLink => + link.toType === 'output' + + function connectLinksToInput( + links: ReadonlyArray, + node: LGraphNode, + inputSlot: INodeInputSlot + ): boolean { + const validCandidates = links + .filter(isToInputLink) + .filter((link) => link.canConnectToInput(node, inputSlot)) + + for (const link of validCandidates) { + link.connectToInput(node, inputSlot, activeAdapter?.linkConnector.events) + } + + return validCandidates.length > 0 + } + + function connectLinksToOutput( + links: ReadonlyArray, + node: LGraphNode, + outputSlot: INodeOutputSlot + ): boolean { + const validCandidates = links + .filter(isToOutputLink) + .filter((link) => link.canConnectToOutput(node, outputSlot)) + + for (const link of validCandidates) { + link.connectToOutput( + node, + outputSlot, + activeAdapter?.linkConnector.events + ) + } + + return validCandidates.length > 0 + } + + const resolveLinkOrigin = ( + link: LLink | undefined + ): { position: Point; direction: LinkDirection } | null => { + if (!link) return null + + const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false) + const layout = layoutStore.getSlotLayout(slotKey) + if (!layout) return null + + return { position: { ...layout.position }, direction: LinkDirection.NONE } + } + + const resolveExistingInputLinkAnchor = ( + graph: LGraph, + inputSlot: INodeInputSlot | undefined + ): { position: Point; direction: LinkDirection } | null => { + if (!inputSlot) return null + + const directLink = graph.getLink(inputSlot.link) + if (directLink) { + const reroutes = LLink.getReroutes(graph, directLink) + const lastReroute = reroutes.at(-1) + if (lastReroute) { + const rerouteLayout = layoutStore.getRerouteLayout(lastReroute.id) + if (rerouteLayout) { + return { + position: { ...rerouteLayout.position }, + direction: LinkDirection.NONE + } + } + + const pos = lastReroute.pos + if (pos) { + return { + position: toPoint(pos[0], pos[1]), + direction: LinkDirection.NONE + } + } + } + + const directAnchor = resolveLinkOrigin(directLink) + if (directAnchor) return directAnchor + } + + const floatingLinkIterator = inputSlot._floatingLinks?.values() + const floatingLink = floatingLinkIterator + ? floatingLinkIterator.next().value + : undefined + if (!floatingLink) return null + + if (floatingLink.parentId != null) { + const rerouteLayout = layoutStore.getRerouteLayout(floatingLink.parentId) + if (rerouteLayout) { + return { + position: { ...rerouteLayout.position }, + direction: LinkDirection.NONE + } + } + + const reroute = graph.getReroute(floatingLink.parentId) + if (reroute) { + return { + position: toPoint(reroute.pos[0], reroute.pos[1]), + direction: LinkDirection.NONE + } + } + } + + return null + } + + const cleanupInteraction = () => { + activeAdapter?.reset() + pointerSession.clear() + endDrag() + activeAdapter = null + } + + const updatePointerState = (event: PointerEvent) => { + const clientX = event.clientX + const clientY = event.clientY + const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ + clientX, + clientY + ]) + + updatePointerPosition(clientX, clientY, canvasX, canvasY) + } + + const handlePointerMove = (event: PointerEvent) => { + if (!pointerSession.matches(event)) return + updatePointerState(event) + app.canvas?.setDirty(true) + } + + // Attempt to finalize by connecting to a DOM slot candidate + const tryConnectToCandidate = ( + candidate: SlotDropCandidate | null + ): boolean => { + if (!candidate?.compatible) return false + const graph = app.canvas?.graph + const adapter = ensureActiveAdapter() + if (!graph || !adapter) return false + + const nodeId = Number(candidate.layout.nodeId) + const targetNode = graph.getNodeById(nodeId) + if (!targetNode) return false + + if (candidate.layout.type === 'input') { + const inputSlot = targetNode.inputs?.[candidate.layout.index] + return ( + !!inputSlot && + connectLinksToInput(adapter.renderLinks, targetNode, inputSlot) + ) + } + + if (candidate.layout.type === 'output') { + const outputSlot = targetNode.outputs?.[candidate.layout.index] + return ( + !!outputSlot && + connectLinksToOutput(adapter.renderLinks, targetNode, outputSlot) + ) + } + + return false + } + + // Attempt to finalize by dropping on a reroute under the pointer + const tryConnectViaRerouteAtPointer = (): boolean => { + const rerouteLayout = layoutStore.queryRerouteAtPoint({ + x: state.pointer.canvas.x, + y: state.pointer.canvas.y + }) + const graph = app.canvas?.graph + const adapter = ensureActiveAdapter() + if (!rerouteLayout || !graph || !adapter) return false + + const reroute = graph.getReroute(rerouteLayout.id) + if (!reroute || !adapter.isRerouteValidDrop(reroute.id)) return false + + let didConnect = false + + const results = reroute.findTargetInputs() ?? [] + const maybeReroutes = reroute.getReroutes() + if (results.length && maybeReroutes !== null) { + const originalReroutes = maybeReroutes.slice(0, -1).reverse() + for (const link of adapter.renderLinks) { + if (!isToInputLink(link)) continue + for (const result of results) { + link.connectToRerouteInput( + reroute, + result, + adapter.linkConnector.events, + originalReroutes + ) + didConnect = true + } + } + } + + const sourceOutput = reroute.findSourceOutput() + if (sourceOutput) { + const { node, output } = sourceOutput + for (const link of adapter.renderLinks) { + if (!isToOutputLink(link)) continue + if (hasCanConnectToReroute(link) && !link.canConnectToReroute(reroute)) + continue + link.connectToRerouteOutput( + reroute, + node, + output, + adapter.linkConnector.events + ) + didConnect = true + } + } + + return didConnect + } + + const finishInteraction = (event: PointerEvent) => { + if (!pointerSession.matches(event)) return + event.preventDefault() + + if (!state.source) { + cleanupInteraction() + app.canvas?.setDirty(true) + return + } + + const candidate = candidateFromTarget(event.target) + let connected = tryConnectToCandidate(candidate) + if (!connected) connected = tryConnectViaRerouteAtPointer() || connected + + // Drop on canvas: disconnect moving input link(s) + if (!connected && !candidate && state.source.type === 'input') { + ensureActiveAdapter()?.disconnectMovingLinks() + } + + cleanupInteraction() + app.canvas?.setDirty(true) + } + + const handlePointerUp = (event: PointerEvent) => { + finishInteraction(event) + } + + const handlePointerCancel = (event: PointerEvent) => { + if (!pointerSession.matches(event)) return + cleanupInteraction() + app.canvas?.setDirty(true) + } + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (!nodeId) return + if (pointerSession.isActive()) return + + const canvas = app.canvas + const graph = canvas?.graph + if (!canvas || !graph) return + + ensureActiveAdapter() + + const layout = layoutStore.getSlotLayout( + getSlotKey(nodeId, index, type === 'input') + ) + if (!layout) return + + const numericNodeId = Number(nodeId) + const isInputSlot = type === 'input' + const isOutputSlot = type === 'output' + + const resolvedNode = graph.getNodeById(numericNodeId) + const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined + const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined + + const ctrlOrMeta = event.ctrlKey || event.metaKey + + const inputLinkId = inputSlot?.link ?? null + const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0 + const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0 + + const outputLinkCount = outputSlot?.links?.length ?? 0 + const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0 + const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0 + + const shouldBreakExistingInputLink = + isInputSlot && + hasExistingInputLink && + ctrlOrMeta && + event.altKey && + !event.shiftKey + + const existingInputLink = + isInputSlot && inputLinkId != null + ? graph.getLink(inputLinkId) + : undefined + + if (shouldBreakExistingInputLink && resolvedNode) { + resolvedNode.disconnectInput(index, true) + } + + const baseDirection = isInputSlot + ? inputSlot?.dir ?? LinkDirection.LEFT + : outputSlot?.dir ?? LinkDirection.RIGHT + + const existingAnchor = + isInputSlot && !shouldBreakExistingInputLink + ? resolveExistingInputLinkAnchor(graph, inputSlot) + : null + + const shouldMoveExistingOutput = + isOutputSlot && event.shiftKey && hasExistingOutputLink + + const shouldMoveExistingInput = + isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink + + const adapter = ensureActiveAdapter() + if (adapter) { + if (isOutputSlot) { + adapter.beginFromOutput(numericNodeId, index, { + moveExisting: shouldMoveExistingOutput + }) + } else { + adapter.beginFromInput(numericNodeId, index, { + moveExisting: shouldMoveExistingInput + }) + } + } + + const direction = existingAnchor?.direction ?? baseDirection + const startPosition = existingAnchor?.position ?? { + x: layout.position.x, + y: layout.position.y + } + + beginDrag( + { + nodeId, + slotIndex: index, + type, + direction, + position: startPosition, + linkId: !shouldBreakExistingInputLink + ? existingInputLink?.id + : undefined, + movingExistingOutput: shouldMoveExistingOutput + }, + event.pointerId + ) + + pointerSession.begin(event.pointerId) + + updatePointerState(event) + + pointerSession.register( + useEventListener(window, 'pointermove', handlePointerMove, { + capture: true + }), + useEventListener(window, 'pointerup', handlePointerUp, { + capture: true + }), + useEventListener(window, 'pointercancel', handlePointerCancel, { + capture: true + }) + ) + app.canvas?.setDirty(true) + event.preventDefault() + event.stopPropagation() + } + + onBeforeUnmount(() => { + if (pointerSession.isActive()) { + cleanupInteraction() + } + }) + + return { + onPointerDown + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index c6be502857..e8c38164d4 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -8,11 +8,21 @@ * Supports different element types (nodes, slots, widgets, etc.) with * customizable data attributes and update handlers. */ -import { getCurrentInstance, onMounted, onUnmounted } from 'vue' +import { + type MaybeRefOrGetter, + getCurrentInstance, + onMounted, + onUnmounted, + toValue +} from 'vue' +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { Bounds, NodeId } from '@/renderer/core/layout/types' +import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking' + /** * Generic update item for element bounds tracking */ @@ -54,8 +64,12 @@ const trackingConfigs: Map = new Map([ // Single ResizeObserver instance for all Vue elements const resizeObserver = new ResizeObserver((entries) => { - // Group updates by element type + // Canvas is ready when this code runs; no defensive guards needed. + const conv = useSharedCanvasPositionConversion() + // Group updates by type, then flush via each config's handler const updatesByType = new Map() + // Track nodes whose slots should be resynced after node size changes + const nodesNeedingSlotResync = new Set() for (const entry of entries) { if (!(entry.target instanceof HTMLElement)) continue @@ -76,30 +90,50 @@ const resizeObserver = new ResizeObserver((entries) => { if (!elementType || !elementId) continue - const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0] + // Use contentBoxSize when available; fall back to contentRect for older engines/tests + const contentBox = Array.isArray(entry.contentBoxSize) + ? entry.contentBoxSize[0] + : { + inlineSize: entry.contentRect.width, + blockSize: entry.contentRect.height + } + const width = contentBox.inlineSize + const height = contentBox.blockSize + + // Screen-space rect const rect = element.getBoundingClientRect() - + const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top]) + const topLeftCanvas = { x: cx, y: cy } const bounds: Bounds = { - x: rect.left, - y: rect.top, - width, - height: height + x: topLeftCanvas.x, + y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT, + width: Math.max(0, width), + height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT) } - if (!updatesByType.has(elementType)) { - updatesByType.set(elementType, []) + let updates = updatesByType.get(elementType) + if (!updates) { + updates = [] + updatesByType.set(elementType, updates) } - const updates = updatesByType.get(elementType) - if (updates) { - updates.push({ id: elementId, bounds }) + updates.push({ id: elementId, bounds }) + + // If this entry is a node, mark it for slot layout resync + if (elementType === 'node' && elementId) { + nodesNeedingSlotResync.add(elementId) } } - // Process updates by type + // Flush per-type for (const [type, updates] of updatesByType) { const config = trackingConfigs.get(type) - if (config && updates.length > 0) { - config.updateHandler(updates) + if (config && updates.length) config.updateHandler(updates) + } + + // After node bounds are updated, refresh slot cached offsets and layouts + if (nodesNeedingSlotResync.size > 0) { + for (const nodeId of nodesNeedingSlotResync) { + syncNodeSlotLayoutsFromDOM(nodeId) } } }) @@ -126,19 +160,20 @@ const resizeObserver = new ResizeObserver((entries) => { * ``` */ export function useVueElementTracking( - appIdentifier: string, + appIdentifierMaybe: MaybeRefOrGetter, trackingType: string ) { + const appIdentifier = toValue(appIdentifierMaybe) onMounted(() => { const element = getCurrentInstance()?.proxy?.$el if (!(element instanceof HTMLElement) || !appIdentifier) return const config = trackingConfigs.get(trackingType) - if (config) { - // Set the appropriate data attribute - element.dataset[config.dataAttribute] = appIdentifier - resizeObserver.observe(element) - } + if (!config) return + + // Set the data attribute expected by the RO pipeline for this type + element.dataset[config.dataAttribute] = appIdentifier + resizeObserver.observe(element) }) onUnmounted(() => { @@ -146,10 +181,10 @@ export function useVueElementTracking( if (!(element instanceof HTMLElement)) return const config = trackingConfigs.get(trackingType) - if (config) { - // Remove the data attribute - delete element.dataset[config.dataAttribute] - resizeObserver.unobserve(element) - } + if (!config) return + + // Remove the data attribute and observer + delete element.dataset[config.dataAttribute] + resizeObserver.unobserve(element) }) } diff --git a/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts b/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts deleted file mode 100644 index aae08298a7..0000000000 --- a/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { storeToRefs } from 'pinia' -import { computed, provide } from 'vue' - -import { - ExecutingNodeIdsKey, - NodeProgressStatesKey -} from '@/renderer/core/canvas/injectionKeys' -import { useExecutionStore } from '@/stores/executionStore' - -/** - * Composable for providing execution state to Vue node children - * - * This composable sets up the execution state providers that can be injected - * by child Vue nodes using useNodeExecutionState. - * - * Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas). - */ -export const useExecutionStateProvider = () => { - const executionStore = useExecutionStore() - const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } = - storeToRefs(executionStore) - - // Convert execution store data to the format expected by Vue nodes - const executingNodeIds = computed( - () => new Set(storeExecutingNodeIds.value.map(String)) - ) - - // Provide the execution state to all child Vue nodes - provide(ExecutingNodeIdsKey, executingNodeIds) - provide(NodeProgressStatesKey, nodeProgressStates) - - return { - executingNodeIds, - nodeProgressStates - } -} diff --git a/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts b/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts index 8f03e29e1a..aa4867db9f 100644 --- a/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts +++ b/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts @@ -1,10 +1,7 @@ -import { computed, inject, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { type MaybeRefOrGetter, computed, toValue } from 'vue' -import { - ExecutingNodeIdsKey, - NodeProgressStatesKey -} from '@/renderer/core/canvas/injectionKeys' -import type { NodeProgressState } from '@/schemas/apiSchema' +import { useExecutionStore } from '@/stores/executionStore' /** * Composable for managing execution state of Vue-based nodes @@ -12,18 +9,18 @@ import type { NodeProgressState } from '@/schemas/apiSchema' * Provides reactive access to execution state and progress for a specific node * by injecting execution data from the parent GraphCanvas provider. * - * @param nodeId - The ID of the node to track execution state for + * @param nodeIdMaybe - The ID of the node to track execution state for * @returns Object containing reactive execution state and progress */ -export const useNodeExecutionState = (nodeId: string) => { - const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set())) - const nodeProgressStates = inject( - NodeProgressStatesKey, - ref>({}) - ) +export const useNodeExecutionState = ( + nodeIdMaybe: MaybeRefOrGetter +) => { + const nodeId = toValue(nodeIdMaybe) + const { uniqueExecutingNodeIdStrings, nodeProgressStates } = + storeToRefs(useExecutionStore()) const executing = computed(() => { - return executingNodeIds.value.has(nodeId) + return uniqueExecutingNodeIdStrings.value.has(nodeId) }) const progress = computed(() => { diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 995d83d6f2..052c7c1b0a 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -1,12 +1,15 @@ -/** - * Composable for individual Vue node components - * - * Uses customRef for shared write access with Canvas renderer. - * Provides dragging functionality and reactive layout state. - */ -import { computed, inject } from 'vue' +import { storeToRefs } from 'pinia' +import { + type CSSProperties, + type MaybeRefOrGetter, + computed, + inject, + ref, + toValue +} from 'vue' -import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { LayoutSource, type Point } from '@/renderer/core/layout/types' @@ -15,20 +18,16 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types' * Composable for individual Vue node components * Uses customRef for shared write access with Canvas renderer */ -export function useNodeLayout(nodeId: string) { - const store = layoutStore +export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter) { + const nodeId = toValue(nodeIdMaybe) const mutations = useLayoutMutations() + const { selectedNodeIds } = storeToRefs(useCanvasStore()) // Get transform utilities from TransformPane if available - const transformState = inject('transformState') as - | { - canvasToScreen: (point: Point) => Point - screenToCanvas: (point: Point) => Point - } - | undefined + const transformState = inject(TransformStateKey) // Get the customRef for this node (shared write access) - const layoutRef = store.getNodeLayoutRef(nodeId) + const layoutRef = layoutStore.getNodeLayoutRef(nodeId) // Computed properties for easy access const position = computed(() => { @@ -52,20 +51,18 @@ export function useNodeLayout(nodeId: string) { const zIndex = computed(() => layoutRef.value?.zIndex ?? 0) // Drag state - let isDragging = false + const isDragging = ref(false) let dragStartPos: Point | null = null let dragStartMouse: Point | null = null let otherSelectedNodesStartPositions: Map | null = null - const selectedNodeIds = inject(SelectedNodeIdsKey, null) - /** * Start dragging the node */ function startDrag(event: PointerEvent) { - if (!layoutRef.value) return + if (!layoutRef.value || !transformState) return - isDragging = true + isDragging.value = true dragStartPos = { ...position.value } dragStartMouse = { x: event.clientX, y: event.clientY } @@ -91,15 +88,20 @@ export function useNodeLayout(nodeId: string) { mutations.setSource(LayoutSource.Vue) // Capture pointer - const target = event.target as HTMLElement - target.setPointerCapture(event.pointerId) + if (!(event.target instanceof HTMLElement)) return + event.target.setPointerCapture(event.pointerId) } /** * Handle drag movement */ const handleDrag = (event: PointerEvent) => { - if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) { + if ( + !isDragging.value || + !dragStartPos || + !dragStartMouse || + !transformState + ) { return } @@ -145,16 +147,16 @@ export function useNodeLayout(nodeId: string) { * End dragging */ function endDrag(event: PointerEvent) { - if (!isDragging) return + if (!isDragging.value) return - isDragging = false + isDragging.value = false dragStartPos = null dragStartMouse = null otherSelectedNodesStartPositions = null // Release pointer - const target = event.target as HTMLElement - target.releasePointerCapture(event.pointerId) + if (!(event.target instanceof HTMLElement)) return + event.target.releasePointerCapture(event.pointerId) } /** @@ -181,6 +183,7 @@ export function useNodeLayout(nodeId: string) { bounds, isVisible, zIndex, + isDragging, // Mutations moveTo, @@ -192,14 +195,16 @@ export function useNodeLayout(nodeId: string) { endDrag, // Computed styles for Vue templates - nodeStyle: computed(() => ({ - position: 'absolute' as const, - left: `${position.value.x}px`, - top: `${position.value.y}px`, - width: `${size.value.width}px`, - height: `${size.value.height}px`, - zIndex: zIndex.value, - cursor: isDragging ? 'grabbing' : 'grab' - })) + nodeStyle: computed( + (): CSSProperties => ({ + position: 'absolute' as const, + left: `${position.value.x}px`, + top: `${position.value.y}px`, + width: `${size.value.width}px`, + height: `${size.value.height}px`, + zIndex: zIndex.value, + cursor: isDragging.value ? 'grabbing' : 'grab' + }) + ) } } diff --git a/src/renderer/extensions/vueNodes/lod/useLOD.ts b/src/renderer/extensions/vueNodes/lod/useLOD.ts index 584e21f9a5..2f59ba020b 100644 --- a/src/renderer/extensions/vueNodes/lod/useLOD.ts +++ b/src/renderer/extensions/vueNodes/lod/useLOD.ts @@ -2,185 +2,33 @@ * Level of Detail (LOD) composable for Vue-based node rendering * * Provides dynamic quality adjustment based on zoom level to maintain - * performance with large node graphs. Uses zoom thresholds to determine - * how much detail to render for each node component. - * - * ## LOD Levels - * - * - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content - * - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots - * - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots - * - * ## Performance Benefits - * - * - Reduces DOM element count by up to 80% at low zoom levels - * - Minimizes layout calculations and paint operations - * - Enables smooth performance with 1000+ nodes - * - Maintains visual fidelity when detail is actually visible - * - * @example - * ```typescript - * const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef) - * - * // In template - * - * - * ``` - */ -import { type Ref, computed, readonly } from 'vue' + * performance with large node graphs. Uses zoom threshold based on DPR + * to determine how much detail to render for each node component. + * Default minFontSize = 8px + * Default zoomThreshold = 0.57 (On a DPR = 1 monitor) + **/ +import { useDevicePixelRatio } from '@vueuse/core' +import { computed } from 'vue' -export enum LODLevel { - MINIMAL = 'minimal', // zoom <= 0.4 - REDUCED = 'reduced', // 0.4 < zoom <= 0.8 - FULL = 'full' // zoom > 0.8 +import { useSettingStore } from '@/platform/settings/settingStore' + +interface Camera { + z: number // zoom level } -interface LODConfig { - renderWidgets: boolean - renderSlots: boolean - renderContent: boolean - renderSlotLabels: boolean - renderWidgetLabels: boolean - cssClass: string -} +export function useLOD(camera: Camera) { + const isLOD = computed(() => { + const { pixelRatio } = useDevicePixelRatio() + const baseFontSize = 14 + const dprAdjustment = Math.sqrt(pixelRatio.value) -// LOD configuration for each level -const LOD_CONFIGS: Record = { - [LODLevel.FULL]: { - renderWidgets: true, - renderSlots: true, - renderContent: true, - renderSlotLabels: true, - renderWidgetLabels: true, - cssClass: 'lg-node--lod-full' - }, - [LODLevel.REDUCED]: { - renderWidgets: true, - renderSlots: true, - renderContent: false, - renderSlotLabels: false, - renderWidgetLabels: false, - cssClass: 'lg-node--lod-reduced' - }, - [LODLevel.MINIMAL]: { - renderWidgets: false, - renderSlots: false, - renderContent: false, - renderSlotLabels: false, - renderWidgetLabels: false, - cssClass: 'lg-node--lod-minimal' - } -} + const settingStore = useSettingStore() + const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8 + const threshold = + Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86 -/** - * Create LOD (Level of Detail) state based on zoom level - * - * @param zoomRef - Reactive reference to current zoom level (camera.z) - * @returns LOD state and configuration - */ -export function useLOD(zoomRef: Ref) { - // Continuous LOD score (0-1) for smooth transitions - const lodScore = computed(() => { - const zoom = zoomRef.value - return Math.max(0, Math.min(1, zoom)) + return camera.z < threshold }) - // Determine current LOD level based on zoom - const lodLevel = computed(() => { - const zoom = zoomRef.value - - if (zoom > 0.8) return LODLevel.FULL - if (zoom > 0.4) return LODLevel.REDUCED - return LODLevel.MINIMAL - }) - - // Get configuration for current LOD level - const lodConfig = computed(() => LOD_CONFIGS[lodLevel.value]) - - // Convenience computed properties for common rendering decisions - const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets) - const shouldRenderSlots = computed(() => lodConfig.value.renderSlots) - const shouldRenderContent = computed(() => lodConfig.value.renderContent) - const shouldRenderSlotLabels = computed( - () => lodConfig.value.renderSlotLabels - ) - const shouldRenderWidgetLabels = computed( - () => lodConfig.value.renderWidgetLabels - ) - - // CSS class for styling based on LOD level - const lodCssClass = computed(() => lodConfig.value.cssClass) - - // Get essential widgets for reduced LOD (only interactive controls) - const getEssentialWidgets = (widgets: unknown[]): unknown[] => { - if (lodLevel.value === LODLevel.FULL) return widgets - if (lodLevel.value === LODLevel.MINIMAL) return [] - - // For reduced LOD, filter to essential widget types only - return widgets.filter((widget: any) => { - const type = widget?.type?.toLowerCase() - return [ - 'combo', - 'select', - 'toggle', - 'boolean', - 'slider', - 'number' - ].includes(type) - }) - } - - // Performance metrics for debugging - const lodMetrics = computed(() => ({ - level: lodLevel.value, - zoom: zoomRef.value, - widgetCount: shouldRenderWidgets.value ? 'full' : 'none', - slotCount: shouldRenderSlots.value ? 'full' : 'none' - })) - - return { - // Core LOD state - lodLevel: readonly(lodLevel), - lodConfig: readonly(lodConfig), - lodScore: readonly(lodScore), - - // Rendering decisions - shouldRenderWidgets, - shouldRenderSlots, - shouldRenderContent, - shouldRenderSlotLabels, - shouldRenderWidgetLabels, - - // Styling - lodCssClass, - - // Utilities - getEssentialWidgets, - lodMetrics - } -} - -/** - * Get LOD level thresholds for configuration or debugging - */ -export const LOD_THRESHOLDS = { - FULL_THRESHOLD: 0.8, - REDUCED_THRESHOLD: 0.4, - MINIMAL_THRESHOLD: 0.0 -} as const - -/** - * Check if zoom level supports a specific feature - */ -export function supportsFeatureAtZoom( - zoom: number, - feature: keyof LODConfig -): boolean { - const level = - zoom > 0.8 - ? LODLevel.FULL - : zoom > 0.4 - ? LODLevel.REDUCED - : LODLevel.MINIMAL - return LOD_CONFIGS[level][feature] as boolean + return { isLOD } } diff --git a/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts new file mode 100644 index 0000000000..cb997f2360 --- /dev/null +++ b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts @@ -0,0 +1,47 @@ +import { storeToRefs } from 'pinia' +import { type MaybeRefOrGetter, type Ref, computed, toValue } from 'vue' + +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +export const useNodePreviewState = ( + nodeIdMaybe: MaybeRefOrGetter, + options?: { + isCollapsed?: Ref + } +) => { + const nodeId = toValue(nodeIdMaybe) + const workflowStore = useWorkflowStore() + const { nodePreviewImages } = storeToRefs(useNodeOutputStore()) + + const locatorId = computed(() => workflowStore.nodeIdToNodeLocatorId(nodeId)) + + const previewUrls = computed(() => { + const key = locatorId.value + if (!key) return undefined + const urls = nodePreviewImages.value[key] + return urls?.length ? urls : undefined + }) + + const hasPreview = computed(() => !!previewUrls.value?.length) + + const latestPreviewUrl = computed(() => { + const urls = previewUrls.value + return urls?.length ? urls.at(-1) : '' + }) + + const shouldShowPreviewImg = computed(() => { + if (!options?.isCollapsed) { + return hasPreview.value + } + return !options.isCollapsed.value && hasPreview.value + }) + + return { + locatorId, + previewUrls, + hasPreview, + latestPreviewUrl, + shouldShowPreviewImg + } +} diff --git a/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts new file mode 100644 index 0000000000..c5e76d4b4c --- /dev/null +++ b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { markRaw } from 'vue' + +type SlotEntry = { + el: HTMLElement + index: number + type: 'input' | 'output' + cachedOffset?: { x: number; y: number } +} + +type NodeEntry = { + nodeId: string + slots: Map + stopWatch?: () => void +} + +export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => { + const registry = markRaw(new Map()) + + function getNode(nodeId: string) { + return registry.get(nodeId) + } + + function ensureNode(nodeId: string) { + let node = registry.get(nodeId) + if (!node) { + node = { + nodeId, + slots: markRaw(new Map()) + } + registry.set(nodeId, node) + } + return node + } + + function deleteNode(nodeId: string) { + registry.delete(nodeId) + } + + function clear() { + registry.clear() + } + + return { + getNode, + ensureNode, + deleteNode, + clear + } +}) diff --git a/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts b/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts new file mode 100644 index 0000000000..143684d137 --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts @@ -0,0 +1,14 @@ +import { adjustColor } from '@/utils/colorUtil' + +/** + * Applies light theme color adjustments to a color + */ +export function applyLightThemeColor( + color: string, + isLightTheme: boolean +): string { + if (!color || !isLightTheme) { + return color + } + return adjustColor(color, { lightness: 0.5 }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue index 81e47985f7..28be931175 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue @@ -1,7 +1,7 @@