Compare commits

..

15 Commits

Author SHA1 Message Date
bymyself
20a91cf29d Merge main: resolve conflict in update-playwright-expectations.yaml
- Keep 'Pull Request Checkout (from label)' from main (fixes detached HEAD)
- Keep 'Setup ComfyUI Server' from PR branch (refactored action)
- Both steps are needed for proper workflow functionality
2025-10-09 10:53:59 -07:00
snomiao
fdbd35eb06 chore(setup-comfyui-server): change launch_server default to false 2025-10-09 01:32:58 +00:00
snomiao
e5a991aaa9 Merge branch 'main' into sno-setup-frontend 2025-10-09 10:16:08 +09:00
snomiao
53f44f48fe refactor: change setup-frontend action to use include_build_step (default false)
- Renamed parameter from skip_build to include_build_step
- Changed default from 'false' to 'false' (building now opt-in)
- Updated all workflow usages to explicitly set include_build_step: 'true' where building is needed
- Removed explicit parameter for workflows that don't need building

As suggested by @DrJKL in PR review
2025-10-09 00:57:34 +00:00
snomiao
4fc2f68b5a docs(setup-frontend): update input description for skip_build to clarify its use case in CI jobs 2025-10-08 23:55:41 +00:00
snomiao
423e1a9e16 chore(setup-frontend): update action name and description for clarity and detail 2025-10-08 23:52:25 +00:00
snomiao
84ba7a6837 refactor: rename not_build to skip_build in setup-frontend action
Improves clarity by using positive naming convention for the parameter.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 22:40:34 +00:00
snomiao
ca77c48957 ci: invert frontend build flag 2025-10-08 22:06:57 +00:00
snomiao
a812c678ee ci: explicitly opt into frontend build 2025-10-08 22:00:35 +00:00
snomiao
b309eb8f40 chore(workflow): enhance Playwright expectations update workflow to support issue comments and improve conditional checks for triggering updates 2025-10-08 21:53:37 +00:00
snomiao
064d5a67b2 refactor: improve .cache directory caching strategy
Changes:
- Move cache restore to setup-frontend action BEFORE build operations
- Remove duplicate cache step from setup-playwright action
- Use cache/restore instead of cache to avoid auto-save behavior
- Rename cache key from 'playwright-setup-cache' to 'tool-cache' for clarity
- Include source file hashes in cache key for proper invalidation

Benefits:
- Cache is now restored before tools run, allowing them to use cached data
- Eliminates duplicate caching of ./.cache directory
- Cache properly invalidates when source files or configs change
- Follows GitHub Actions best practice of restore before, save after pattern
- The workspace cache in tests-ci.yaml handles saving the complete state

Note: The .cache directory contains outputs from ESLint, Prettier, Stylelint,
Knip, and TypeScript incremental builds. These should be restored before any
build/lint operations run.
2025-10-08 21:27:03 +00:00
snomiao
a73bba63f4 improve: add error handling for devtools copy in setup-comfyui-server
Add helpful error message when cp fails to copy devtools files.
This clarifies the requirement that the frontend repo must be
checked out before using this action.
2025-10-08 21:27:03 +00:00
snomiao
d78944c895 fix: typo 2025-10-08 21:27:03 +00:00
snomiao
70fb432742 fix: correct blob report output path for new workflow structure
The PLAYWRIGHT_BLOB_OUTPUT_DIR was set to ../blob-report which was correct
when working-directory was ComfyUI_frontend, but now that we removed the
working-directory, the path should be ./blob-report to output in the repo root.
2025-10-08 21:27:03 +00:00
snomiao
c1649426a9 refactor: reorganize GitHub Actions for better reusability
This refactoring improves the CI/CD workflow structure by:

- Split the monolithic setup-frontend action into two focused actions:
  - setup-frontend: Now only handles frontend dependency installation and building
  - setup-comfyui-server: New action for ComfyUI server setup and launch

- Simplified workflow structure with better separation of concerns:
  - Frontend and server setup are now independent and reusable
  - Each action has clearer responsibilities and inputs
  - Removed duplicate setup code across workflows

- Improved tests-ci.yaml workflow:
  - Uses cache/save and cache/restore for the entire workspace
  - Test jobs now restore cached build instead of rebuilding
  - Reduced redundant setup steps in each test shard
  - Better parallelization with faster test execution

- Updated all locale update workflows to use new action structure
- Made setup-playwright a standalone reusable action

Benefits:
- Faster CI runs by reducing redundant builds
- More maintainable with DRY principle
- Easier to debug individual components
- Better action reusability across workflows
2025-10-08 21:27:03 +00:00
264 changed files with 2979 additions and 9317 deletions

View File

@@ -1,21 +0,0 @@
# GitHub Workflows
## Naming Convention
Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.yaml`
### Category Prefixes
| Prefix | Purpose | Example |
| ---------- | ----------------------------------- | ------------------------------------ |
| `ci-` | Testing, linting, validation | `ci-tests-e2e.yaml` |
| `release-` | Version management, publishing | `release-version-bump.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-claude-review.yaml` |
| `api-` | External Api type generation | `api-update-registry-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
## Documentation
Each workflow file contains comments explaining its purpose, triggers, and behavior. For specific details about what each workflow does, refer to the comments at the top of each `.yaml` file.
For GitHub Actions documentation, see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows).

View File

@@ -1,4 +1,4 @@
name: PR Backport
name: Auto Backport
on:
pull_request_target:

View File

@@ -1,5 +1,4 @@
name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
name: Claude PR Review
permissions:
contents: read
@@ -12,10 +11,6 @@ on:
pull_request:
types: [labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
wait-for-ci:
runs-on: ubuntu-latest

View File

@@ -1,4 +1,4 @@
name: Release PyPI Dev
name: Create Dev PyPI Package
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Release Branch Create
name: Create Release Branch
on:
pull_request:

View File

@@ -1,4 +1,4 @@
name: Release Draft Create
name: Create Release Draft
on:
pull_request:
@@ -126,7 +126,7 @@ jobs:
publish_types:
needs: build
uses: ./.github/workflows/release-npm-types.yaml
uses: ./.github/workflows/publish-frontend-types.yaml
with:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}

View File

@@ -1,5 +1,4 @@
name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
name: Devtools Python Check
on:
pull_request:

View File

@@ -1,14 +1,9 @@
name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
name: Lint and Format
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: write

View File

@@ -1,9 +1,8 @@
name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
name: PR Playwright Deploy (Forks)
on:
workflow_run:
workflows: ["CI: Tests E2E"]
workflows: ["Tests CI"]
types: [requested, completed]
env:

View File

@@ -1,9 +1,8 @@
name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
name: PR Storybook Deploy (Forks)
on:
workflow_run:
workflows: ["CI: Tests Storybook"]
workflows: ['Storybook and Chromatic CI']
types: [requested, completed]
env:

View File

@@ -1,108 +0,0 @@
# Setting test expectation screenshots for Playwright
name: "PR: Update Playwright Expectations"
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
if: >
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-playwright') )
steps:
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: "find-update-comment"
with:
issue-number: ${{ github.event.number || github.event.issue.number }}
comment-author: "github-actions[bot]"
body-includes: "Updating Playwright Expectations"
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
body: |
Updating Playwright Expectations
edit-mode: replace
reactions: eyes
- name: Get Branch SHA
id: "get-branch"
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
env:
REPO: ${{ github.repository }}
PR_NO: ${{ github.event.number || github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Initial Checkout
uses: actions/checkout@v5
with:
ref: ${{ steps.get-branch.outputs.branch }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: pnpm exec playwright test --update-snapshots
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: ./playwright-report/
retention-days: 30
- name: Debugging info
run: |
echo "PR: ${{ github.event.issue.number }}"
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
git status
- name: Commit updated expectations
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git add browser_tests
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "[automated] Update test expectations"
git push origin ${{ steps.get-branch.outputs.branch }}
fi
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment'
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
reactions: +1
reactions-edit-mode: replace
- name: Remove New Browser Test Expectations label
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Release NPM Types
name: Publish Frontend Types
on:
workflow_dispatch:

View File

@@ -1,5 +1,6 @@
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
name: Storybook and Chromatic CI
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
on:
workflow_dispatch: # Allow manual triggering

View File

@@ -1,5 +1,4 @@
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
name: Tests CI
on:
push:
@@ -8,10 +7,6 @@ on:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
setup:
runs-on: ubuntu-latest
@@ -24,10 +19,12 @@ jobs:
# Setup Test Environment, build frontend but do not start server yet
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
include_build_step: 'true'
- name: Setup Playwright
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
@@ -66,8 +63,6 @@ jobs:
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
@@ -113,8 +108,6 @@ jobs:
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright

View File

@@ -1,5 +1,4 @@
name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
name: Update Comfy Registry API Types
on:
# Manual trigger

View File

@@ -1,5 +1,4 @@
name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
name: Update ComfyUI-Manager API Types
on:
# Manual trigger

View File

@@ -1,5 +1,4 @@
name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
name: Update Electron Types
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: i18n Update Custom Nodes
name: Update Locales for given custom node repository
on:
workflow_dispatch:
@@ -27,6 +27,8 @@ jobs:
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:

View File

@@ -1,5 +1,4 @@
name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
name: Update Locales
on:
# Manual dispatch for urgent translation updates
@@ -19,14 +18,12 @@ jobs:
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
- name: Setup ComfyUI Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
include_build_step: 'true'
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
@@ -55,5 +52,5 @@ jobs:
# Apply the stashed changes if any
git stash pop || true
git add src/locales/
git diff --staged --quiet || git commit -m "Update locales"
git diff --staged --quiet || git commit -m "Update locales [skip ci]"
git push origin HEAD:${{ github.head_ref }}

View File

@@ -1,4 +1,4 @@
name: i18n Update Nodes
name: Update Node Definitions Locales
on:
workflow_dispatch:
@@ -18,12 +18,10 @@ jobs:
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
include_build_step: 'true'
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
@@ -52,3 +50,4 @@ jobs:
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
base: main
labels: dependencies
path: ComfyUI_frontend

View File

@@ -0,0 +1,72 @@
# Setting test expectation screenshots for Playwright
name: Update Playwright Expectations
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
jobs:
test:
runs-on: ubuntu-latest
if: >
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-playwright') )
steps:
- name: Initial Checkout
uses: actions/checkout@v5
- name: Pull Request Checkout (from comment)
run: gh pr checkout ${{ github.event.issue.number }}
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Request Checkout (from label)
run: |
git fetch origin ${{ github.head_ref }}
git checkout ${{ github.head_ref }}
if: github.event_name == 'pull_request'
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: pnpm exec playwright test --update-snapshots
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: ./playwright-report/
retention-days: 30
- name: Debugging info
run: |
echo "PR: ${{ github.event.issue.number }}"
git status
- name: Commit updated expectations
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git add browser_tests
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "[automated] Update test expectations"
if [ "${{ github.event_name }}" = "pull_request" ]; then
git push origin HEAD:${{ github.head_ref }}
else
git push
fi
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,5 +1,4 @@
name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
name: Validate JSON
on:
push:

View File

@@ -1,5 +1,4 @@
name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
name: Version Bump
on:
workflow_dispatch:

View File

@@ -1,5 +1,4 @@
name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
name: Vitest Tests
on:
push:
@@ -7,10 +6,6 @@ on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest

View File

@@ -54,10 +54,3 @@
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
# LLM Instructions (blank on purpose)
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md

View File

@@ -1,90 +0,0 @@
{
"id": "95ea19ba-456c-46e8-aa40-dc3ff135b746",
"revision": 0,
"last_node_id": 11,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [494.3333740234375, 142.3333282470703],
"size": [444, 399],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveInt",
"pos": [24.333343505859375, 149.6666717529297],
"size": [444, 125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [67, "randomize"]
}
],
"links": [[10, 11, 0, 10, 4, "INT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.28.6"
},
"version": 0.4
}

View File

@@ -1,5 +1,6 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import dotenv from 'dotenv'
import * as fs from 'fs'
@@ -46,10 +47,6 @@ class ComfyMenu {
.nth(0)
}
get buttons() {
return this.sideToolbar.locator('.side-bar-button')
}
get nodeLibraryTab() {
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
return this._nodeLibraryTab
@@ -133,8 +130,7 @@ export class ComfyPage {
// Buttons
public readonly resetViewButton: Locator
public readonly queueButton: Locator // Run button in Legacy UI
public readonly runButton: Locator // Run button (renamed "Queue" -> "Run")
public readonly queueButton: Locator
// Inputs
public readonly workflowUploadInput: Locator
@@ -169,9 +165,6 @@ export class ComfyPage {
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.runButton = page
.getByTestId('queue-button')
.getByRole('button', { name: 'Run' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.visibleToasts = page.locator('.p-toast-message:visible')
@@ -1093,6 +1086,12 @@ export class ComfyPage {
const targetPosition = await targetSlot.getPosition()
// Debug: Log the positions we're trying to use
console.log('Drag positions:', {
source: sourcePosition,
target: targetPosition
})
await this.dragAndDrop(sourcePosition, targetPosition)
await this.nextFrame()
}

View File

@@ -3,8 +3,6 @@
*/
import type { Locator, Page } from '@playwright/test'
import { VueNodeFixture } from './utils/vueNodeFixtures'
export class VueNodeHelpers {
constructor(private page: Page) {}
@@ -108,24 +106,6 @@ export class VueNodeHelpers {
await this.page.keyboard.press('Backspace')
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
const nodeId = await node.evaluate((el) => el.getAttribute('data-node-id'))
if (!nodeId) {
throw new Error(
`Vue node titled "${title}" is missing its data-node-id attribute`
)
}
return new VueNodeFixture(this.getNodeLocator(nodeId))
}
/**
* Wait for Vue nodes to be rendered
*/
@@ -139,24 +119,4 @@ export class VueNodeHelpers {
await this.page.waitForSelector('[data-node-id]')
}
}
/**
* Get a specific widget by node title and widget name
*/
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
return this.getNodeByTitle(nodeTitle).locator(
`_vue=[widget.name="${widgetName}"]`
)
}
/**
* Get controls for input number widgets (increment/decrement buttons and input)
*/
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').last()
}
}
}

View File

@@ -7,7 +7,7 @@ export class Topbar {
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.menuTrigger = page.locator('.comfyui-logo-wrapper')
}
async getTabNames(): Promise<string[]> {
@@ -105,7 +105,7 @@ export class Topbar {
* Close the topbar menu by clicking outside
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 300, y: 10 } })
await this.page.locator('body').click({ position: { x: 10, y: 10 } })
await expect(this.menuLocator).not.toBeVisible()
}

View File

@@ -1,66 +1,131 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
import type { NodeReference } from './litegraphUtils'
/**
* VueNodeFixture provides Vue-specific testing utilities for interacting with
* Vue node components. It bridges the gap between litegraph node references
* and Vue UI components.
*/
export class VueNodeFixture {
constructor(private readonly locator: Locator) {}
constructor(
private readonly nodeRef: NodeReference,
private readonly page: Page
) {}
get header(): Locator {
return this.locator.locator('[data-testid^="node-header-"]')
/**
* Get the node's header element using data-testid
*/
async getHeader(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
}
get title(): Locator {
return this.locator.locator('[data-testid="node-title"]')
}
get titleInput(): Locator {
return this.locator.locator('[data-testid="node-title-input"]')
}
get body(): Locator {
return this.locator.locator('[data-testid^="node-body-"]')
}
get collapseButton(): Locator {
return this.locator.locator('[data-testid="node-collapse-button"]')
}
get collapseIcon(): Locator {
return this.collapseButton.locator('i')
}
get root(): Locator {
return this.locator
/**
* Get the node's title element
*/
async getTitleElement(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-title"]')
}
/**
* Get the current title text
*/
async getTitle(): Promise<string> {
return (await this.title.textContent()) ?? ''
const titleElement = await this.getTitleElement()
return (await titleElement.textContent()) || ''
}
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.fill(value)
/**
* Set a new title by double-clicking and entering text
*/
async setTitle(newTitle: string): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill(newTitle)
await input.press('Enter')
}
/**
* Cancel title editing
*/
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.press('Escape')
}
/**
* Check if the title is currently being edited
*/
async isEditingTitle(): Promise<boolean> {
const header = await this.getHeader()
const input = header.locator('[data-testid="node-title-input"]')
return await input.isVisible()
}
/**
* Get the collapse/expand button
*/
async getCollapseButton(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-collapse-button"]')
}
/**
* Toggle the node's collapsed state
*/
async toggleCollapse(): Promise<void> {
await this.collapseButton.click()
const button = await this.getCollapseButton()
await button.click()
}
/**
* Get the collapse icon element
*/
async getCollapseIcon(): Promise<Locator> {
const button = await this.getCollapseButton()
return button.locator('i')
}
/**
* Get the collapse icon's CSS classes
*/
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
const icon = await this.getCollapseIcon()
return (await icon.getAttribute('class')) || ''
}
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
/**
* Check if the collapse button is visible
*/
async isCollapseButtonVisible(): Promise<boolean> {
const button = await this.getCollapseButton()
return await button.isVisible()
}
/**
* Get the node's body/content element
*/
async getBody(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
}
/**
* Check if the node body is visible (not collapsed)
*/
async isBodyVisible(): Promise<boolean> {
const body = await this.getBody()
return await body.isVisible()
}
}

View File

@@ -116,10 +116,9 @@ test.describe('Actionbar', () => {
test('Can dock actionbar into top menu', async ({ comfyPage }) => {
await comfyPage.page.dragAndDrop(
'.actionbar .drag-handle',
'.actionbar-container',
'.comfyui-menu',
{
targetPosition: { x: 50, y: 20 },
force: true
targetPosition: { x: 0, y: 0 }
}
)
expect(await comfyPage.actionbar.isDocked()).toBe(true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -39,15 +39,15 @@ test.describe('Graph Canvas Menu', () => {
)
})
test('Toggle minimap button is clickable and has correct test id', async ({
test('Focus mode button is clickable and has correct test id', async ({
comfyPage
}) => {
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapButton).toBeVisible()
await expect(minimapButton).toBeEnabled()
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
await expect(focusButton).toBeVisible()
await expect(focusButton).toBeEnabled()
// Test that the button can be clicked without error
await minimapButton.click()
await focusButton.click()
await comfyPage.nextFrame()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -233,7 +233,6 @@ test.describe('Group Node', () => {
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
.count()
@@ -254,6 +253,8 @@ test.describe('Group Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
await comfyPage.menu.nodeLibraryTab.open()
groupNode = await comfyPage.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)

View File

@@ -3,10 +3,10 @@ import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
@@ -786,25 +786,24 @@ test.describe('Viewport settings', () => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
// close zoom menu
await zoomControlsButton.click()
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
await comfyPage.nextFrame()
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
await changeTab(tabA)
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
await changeTab(tabB)
await comfyMouse.move(comfyPage.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
@@ -816,6 +815,9 @@ test.describe('Viewport settings', () => {
// Ensure that the screenshots are different due to zoom level
expect(screenshotB).not.toBe(screenshotA)
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await changeTab(tabA)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(

View File

@@ -8,7 +8,9 @@ test.describe('Menu', () => {
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.buttons.count()
const initialChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
(el) => el.children.length
)
await comfyPage.page.evaluate(async () => {
window['app'].extensionManager.registerSidebarTab({
@@ -24,7 +26,9 @@ test.describe('Menu', () => {
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.buttons.count()
const newChildrenCount = await comfyPage.menu.sideToolbar.evaluate(
(el) => el.children.length
)
expect(newChildrenCount).toBe(initialChildrenCount + 1)
})

View File

@@ -35,6 +35,12 @@ test.describe('Minimap', () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
@@ -45,6 +51,13 @@ test.describe('Minimap', () => {
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
@@ -54,10 +67,22 @@ test.describe('Minimap', () => {
await expect(minimapContainer).not.toBeVisible()
// Open zoom controls dropdown again
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Show Minimap')
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
// Open zoom controls dropdown again to verify button text
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Hide Minimap')
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -60,6 +60,7 @@ async function getInputLinkDetails(
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
@@ -787,210 +788,4 @@ test.describe('Vue Node Link Interaction', () => {
targetSlot: 2
})
})
test('should batch disconnect all links with ctrl+alt+click on slot', async ({
comfyPage
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
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()
)
const clipOutput = await clipNode.getOutput(0)
expect(await clipOutput.getLinkCount()).toBe(2)
const clipOutputSlot = slotLocator(comfyPage.page, clipNode.id, 0, false)
await clipOutputSlot.dispatchEvent('pointerdown', {
button: 0,
buttons: 1,
ctrlKey: true,
altKey: true,
shiftKey: false,
bubbles: true,
cancelable: true
})
await comfyPage.nextFrame()
expect(await clipOutput.getLinkCount()).toBe(0)
})
test.describe('Release actions (Shift-drop)', () => {
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
})
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Open Search from the context menu
await comfyPage.clickContextMenuItem('Search')
// Search box opens with prefilled type filter based on link type (LATENT)
await expect(comfyPage.searchBox.input).toBeVisible()
const chips = comfyPage.searchBox.filterChips
// Ensure at least one filter chip exists and it matches the link type
const chipCount = await chips.count()
expect(chipCount).toBeGreaterThan(0)
await expect(chips.first()).toContainText('LATENT')
// Choose a compatible node and verify it auto-connects
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
// KSampler output should now have an outgoing link
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
// One of the VAEDecode nodes should have an incoming link on input[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
test('Search box opens on Shift-drop and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Search box should open directly
await expect(comfyPage.searchBox.input).toBeVisible()
await expect(comfyPage.searchBox.filterChips.first()).toContainText(
'LATENT'
)
// Select a compatible node and verify connection
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -2,46 +2,70 @@ 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()
await comfyPage.vueNodes.waitForNodes()
})
test('should display node title', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.header).toContainText('KSampler')
// 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 vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
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')
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
await expect(vueNode.header).toContainText('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
await vueNode.title.dblclick()
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
// 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
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
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')
.first()
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()

View File

@@ -49,44 +49,4 @@ test.describe('Vue Node Selection', () => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
const initialCount = await comfyPage.vueNodes.getNodeCount()
expect(initialCount).toBeGreaterThan(0)
await comfyPage.canvas.press('Control+a')
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialCount)
})
test('should select pinned node without dragging', async ({ comfyPage }) => {
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.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()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
const initialPos = await checkpointNodeHeader.boundingBox()
if (!initialPos) throw new Error('Failed to get header position')
await comfyPage.dragAndDrop(
{ x: initialPos.x + 10, y: initialPos.y + 10 },
{ x: initialPos.x + 100, y: initialPos.y + 100 }
)
const finalPos = await checkpointNodeHeader.boundingBox()
if (!finalPos) throw new Error('Failed to get header position after drag')
expect(finalPos).toEqual(initialPos)
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
})

View File

@@ -20,9 +20,6 @@ test.describe('Vue Node Bypass', () => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Collapse', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -9,50 +10,43 @@ test.describe('Vue Node Collapse', () => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Initially should not be collapsed
const body = vueNode.body
expect(await node.isCollapsed()).toBe(false)
const body = await vueNode.getBody()
await expect(body).toBeVisible()
const expandedBoundingBox = await vueNode.boundingBox()
if (!expandedBoundingBox)
throw new Error('Failed to get node bounding box before collapse')
// Collapse the node
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
// Verify node content is hidden
const collapsedSize = await node.getSize()
await expect(body).not.toBeVisible()
const collapsedBoundingBox = await vueNode.boundingBox()
if (!collapsedBoundingBox)
throw new Error('Failed to get node bounding box after collapse')
expect(collapsedBoundingBox.height).toBeLessThan(expandedBoundingBox.height)
// Expand again
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await expect(body).toBeVisible()
// Size should be restored
const expandedBoundingBoxAfter = await vueNode.boundingBox()
if (!expandedBoundingBoxAfter)
throw new Error('Failed to get node bounding box after expand')
expect(expandedBoundingBoxAfter.height).toBeGreaterThanOrEqual(
collapsedBoundingBox.height
)
const expandedSize = await node.getSize()
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
})
test('should show collapse/expand icon state', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Check initial expanded state icon
let iconClass = await vueNode.getCollapseIconClass()
@@ -72,8 +66,9 @@ test.describe('Vue Node Collapse', () => {
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Set custom title
await vueNode.setTitle('Test Sampler')
@@ -88,6 +83,7 @@ test.describe('Vue Node Collapse', () => {
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Verify title is still displayed
await expect(vueNode.header).toContainText('Test Sampler')
const header = await vueNode.getHeader()
await expect(header).toContainText('Test Sampler')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-node-stroke-error/
const ERROR_CLASS = /border-error/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -17,21 +17,16 @@ test.describe('Vue Node Error', () => {
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)
})
test('should display error state when node causes execution error', async ({
comfyPage
}) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -4,7 +4,7 @@ import {
} from '../../../fixtures/ComfyPage'
const MUTE_HOTKEY = 'Control+m'
const MUTE_OPACITY = '0.5'
const MUTE_CLASS = /opacity-50/
test.describe('Vue Node Mute', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,11 +19,10 @@ test.describe('Vue Node Mute', () => {
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
})
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
@@ -36,11 +35,11 @@ test.describe('Vue Node Mute', () => {
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).not.toHaveCSS('opacity', MUTE_OPACITY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
})
})

View File

@@ -1,42 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Integer Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should be disabled and not allow changing value when link connected to slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
await comfyPage.vueNodes.getNodeByTitle('Int').click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked
await controls.incrementButton.click()
await expect(controls.input).toHaveValue((initialValue + 1).toString())
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,51 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Widget Reactivity', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets.push(node.widgets[0])
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets[2] = node.widgets[0]
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets.splice(0, 0, node.widgets[0])
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'
)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.pop()
})
await expect(loadCheckpointNode).toHaveCount(5)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.length--
})
await expect(loadCheckpointNode).toHaveCount(4)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.splice(0, 1)
})
await expect(loadCheckpointNode).toHaveCount(3)
})
})

View File

@@ -67,8 +67,11 @@ export default defineConfig([
...commonParserOptions,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts'
'vite.types.config.mts',
'playwright.config.ts',
'playwright.i18n.config.ts'
]
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.29.3",
"version": "1.29.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -11,7 +11,6 @@
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
@@ -89,7 +88,6 @@
"nx": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",

View File

@@ -24,14 +24,11 @@
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Spacing */
--spacing-xs: 8px;
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #171718;
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
@@ -88,29 +85,10 @@
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--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);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
transparent
);
--color-alpha-stone-100-20: color-mix(
in srgb,
var(--color-stone-100) 20%,
transparent
);
--color-alpha-gray-500-50: color-mix(
in srgb,
var(--color-gray-500) 50%,
transparent
);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
@@ -151,20 +129,9 @@
/* --- */
--accent-primary: var(--color-charcoal-700);
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
--interface-menu-keybind-surface-default: var(--color-gray-500);
--interface-panel-surface: var(--color-pure-white);
--interface-stroke: var(--color-gray-300);
--nav-background: var(--color-pure-white);
--node-border: var(--color-gray-300);
--node-component-border: var(--color-gray-400);
--node-component-disabled: var(--color-alpha-stone-100-20);
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-stone-200);
@@ -176,7 +143,7 @@
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-stone-200);
--node-component-surface-highlight: var(--color-stone-100);
--node-component-surface-hovered: var(--color-gray-200);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
--node-component-tooltip: var(--color-charcoal-700);
@@ -187,34 +154,13 @@
from var(--color-zinc-500) r g b / 10%
);
--node-component-widget-skeleton-surface: var(--color-zinc-300);
--node-divider: var(--color-sand-100);
--node-icon-disabled: var(--color-alpha-gray-500-50);
--node-stroke: var(--color-gray-400);
--node-stroke-selected: var(--color-accent-primary);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-stone-100);
--text-primary: var(--color-charcoal-700);
--input-surface: rgba(0, 0, 0, 0.15);
--node-stroke: var(--color-stone-100);
}
.dark-theme {
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-panel-surface: var(--color-charcoal-100);
--interface-stroke: var(--color-charcoal-400);
--nav-background: var(--color-charcoal-100);
--node-border: var(--color-charcoal-500);
--node-component-border: var(--color-stone-200);
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
@@ -223,37 +169,19 @@
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-800);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
--node-divider: var(--color-charcoal-500);
--node-icon-disabled: var(--color-alpha-stone-100-20);
--node-stroke: var(--color-stone-200);
--node-stroke-selected: var(--color-pure-white);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-pure-white);
--input-surface: rgba(130, 130, 130, 0.1);
--node-stroke: var(--color-slate-100);
}
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-active-surface: var(--button-active-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);
--color-interface-menu-keybind-surface-default: var(--interface-menu-keybind-surface-default);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
--color-node-component-border: var(--node-component-border);
--color-node-component-executing: var(--node-component-executing);
--color-node-component-header: var(--node-component-header);
@@ -285,16 +213,7 @@
--color-node-component-widget-skeleton-surface: var(
--node-component-widget-skeleton-surface
);
--color-node-component-disabled: var(--node-component-disabled);
--color-node-divider: var(--node-divider);
--color-node-icon-disabled: var(--node-icon-disabled);
--color-node-stroke: var(--node-stroke);
--color-node-stroke-selected: var(--node-stroke-selected);
--color-node-stroke-error: var(--node-stroke-error);
--color-node-stroke-executing: var(--node-stroke-executing);
--color-text-secondary: var(--text-secondary);
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
}
@custom-variant dark-theme {
@@ -1127,11 +1046,6 @@ audio.comfy-audio.empty-audio-widget {
transition: none;
}
.isLOD .lg-node-header {
border-radius: 0px;
pointer-events: none;
}
.isLOD .lg-node-widgets {
pointer-events: none;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>

Before

Width:  |  Height:  |  Size: 221 B

View File

@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
* - filename: 'file'
* - suffix: 'txt'
*/
export function getFilenameDetails(fullFilename: string) {
function getFilenameDetails(fullFilename: string) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.split('.').slice(0, -1).join('.'),
@@ -451,26 +451,3 @@ export function stringToLocale(locale: string): SupportedLocale {
? (locale as SupportedLocale)
: 'en'
}
export function formatDuration(milliseconds: number): string {
if (!milliseconds || milliseconds < 0) return '0s'
const totalSeconds = Math.floor(milliseconds / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const remainingSeconds = Math.floor(totalSeconds % 60)
const parts: string[] = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0) {
parts.push(`${minutes}m`)
}
if (remainingSeconds > 0 || parts.length === 0) {
parts.push(`${remainingSeconds}s`)
}
return parts.join(' ')
}

34
pnpm-lock.yaml generated
View File

@@ -201,9 +201,6 @@ catalogs:
primevue:
specifier: ^4.2.5
version: 4.2.5
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
storybook:
specifier: ^9.1.6
version: 9.1.6
@@ -594,9 +591,6 @@ importers:
prettier:
specifier: 'catalog:'
version: 3.3.2
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
storybook:
specifier: 'catalog:'
version: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
@@ -6674,19 +6668,6 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup-plugin-visualizer@6.0.4:
resolution: {integrity: sha512-q8Q7J/6YofkmaGW1sH/fPRAz37x/+pd7VBuaUU7lwvOS/YikuiiEU9jeb9PH8XHiq50XFrUsBbOxeAMYQ7KZkg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.22.4:
resolution: {integrity: sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -6841,10 +6822,6 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -14756,15 +14733,6 @@ snapshots:
rfdc@1.4.1: {}
rollup-plugin-visualizer@6.0.4(rollup@4.22.4):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rollup: 4.22.4
rollup@4.22.4:
dependencies:
'@types/estree': 1.0.5
@@ -14956,8 +14924,6 @@ snapshots:
source-map@0.6.1: {}
source-map@0.7.6: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}

View File

@@ -68,7 +68,6 @@ catalog:
prettier: ^3.3.2
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.6
stylelint: ^16.24.0
tailwindcss: ^4.1.12
@@ -98,6 +97,9 @@ catalog:
cleanupUnusedCatalogs: true
overrides:
'@types/eslint': '-'
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
@@ -112,6 +114,3 @@ onlyBuiltDependencies:
- esbuild
- nx
- oxc-resolver
overrides:
'@types/eslint': '-'

View File

@@ -53,7 +53,7 @@
"comfy_base": {
"fg-color": "#222",
"bg-color": "#DDD",
"comfy-menu-bg": "#FFFFFF",
"comfy-menu-bg": "#F5F5F5",
"comfy-menu-hover-bg": "#ccc",
"comfy-menu-secondary-bg": "#EEE",
"comfy-input-bg": "#C9C9C9",

View File

@@ -1,88 +1,48 @@
<template>
<div class="splitter-overlay-root pointer-events-none flex flex-col">
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
<Splitter
:key="sidebarStateKey"
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="sidebarStateKey"
state-storage="local"
>
<SplitterPanel
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'left'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<div class="side-toolbar-container pointer-events-auto">
<slot name="side-toolbar" />
</div>
<slot name="side-bar-panel" />
</SplitterPanel>
<SplitterPanel :size="100">
<Splitter
key="main-splitter-stable"
class="splitter-overlay flex-1 overflow-hidden"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="sidebarStateKey || 'main-splitter'"
class="splitter-overlay max-w-full"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
state-key="bottom-panel-splitter"
state-storage="local"
>
<SplitterPanel
v-if="sidebarLocation === 'left'"
class="side-bar-panel pointer-events-auto"
:min-size="10"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'left'
? 'flex'
: 'none'
}"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'left'"
name="side-bar-panel"
/>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible="sidebarPanelVisible" />
<Splitter
class="splitter-overlay splitter-overlay-bottom mr-2 mb-2 ml-2 flex-1"
layout="vertical"
:pt:gutter="
'rounded-tl-lg rounded-tr-lg ' +
(bottomPanelVisible ? '' : 'hidden')
"
state-key="bottom-panel-splitter"
state-storage="local"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible"
class="bottom-panel pointer-events-auto rounded-lg"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
v-if="sidebarLocation === 'right'"
class="side-bar-panel pointer-events-auto"
:min-size="10"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'right'
? 'flex'
: 'none'
}"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'right'"
name="side-bar-panel"
/>
<SplitterPanel v-show="bottomPanelVisible" class="bottom-panel">
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</div>
</div>
</SplitterPanel>
<SplitterPanel
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'right'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<slot name="side-bar-panel" />
</SplitterPanel>
</Splitter>
</template>
<script setup lang="ts">
@@ -114,11 +74,7 @@ const activeSidebarTabId = computed(
)
const sidebarStateKey = computed(() => {
if (unifiedWidth.value) {
return 'unified-sidebar'
}
// When no tab is active, use a default key to maintain state
return activeSidebarTabId.value ?? 'default-sidebar'
return unifiedWidth.value ? 'unified-sidebar' : activeSidebarTabId.value ?? ''
})
</script>
@@ -137,17 +93,12 @@ const sidebarStateKey = computed(() => {
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.bottom-panel {
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
max-width: 100%;
overflow-x: auto;
}
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
transform: translateY(5px);
background-color: var(--bg-color);
pointer-events: auto;
}
.splitter-overlay {

View File

@@ -1,7 +1,8 @@
<template>
<div
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger no-drag top-0 right-0"
class="comfy-menu-hamburger no-drag"
:style="positionCSS"
>
<Button
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
@@ -14,13 +15,14 @@
@click="exitFocusMode"
@contextmenu="showNativeSystemMenu"
/>
<div class="window-actions-spacer" />
<div v-show="menuSetting !== 'Bottom'" class="window-actions-spacer" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { watchEffect } from 'vue'
import type { CSSProperties } from 'vue'
import { computed, watchEffect } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
@@ -43,6 +45,15 @@ watchEffect(() => {
app.ui.menuContainer.style.display = 'block'
}
})
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const positionCSS = computed<CSSProperties>(() =>
// 'Bottom' menuSetting shows the hamburger button in the bottom right corner
// 'Disabled', 'Top' menuSetting shows the hamburger button in the top right corner
menuSetting.value === 'Bottom'
? { bottom: '0px', right: '0px' }
: { top: '0px', right: '0px' }
)
</script>
<style scoped>

View File

@@ -1,54 +0,0 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="pointer-events-none ml-2 flex pt-2"
>
<div class="pointer-events-auto min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto mx-2 flex h-12 items-center rounded-lg px-2 shadow-md"
>
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<LoginButton v-if="!isLoggedIn" />
<CurrentUserButton v-else class="shrink-0" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const { isLoggedIn } = useCurrentUser()
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
}
</style>

View File

@@ -1,57 +1,38 @@
<template>
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
class="actionbar-drop-zone m-1.5 flex items-center justify-center self-stretch rounded-md"
:class="{
'drop-zone-active': isMouseOverDropZone
}"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
>
{{ t('actionbar.dockToTop') }}
<Panel
class="actionbar w-fit"
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div ref="panelRef" class="actionbar-content flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<ComfyQueueButton />
</div>
<Panel
class="actionbar"
:style="style"
:class="{
fixed: !isDocked,
'is-dragging': isDragging,
'is-docked static mr-2 border-none bg-transparent p-0': isDocked
}"
>
<div
ref="panelRef"
class="actionbar-content flex items-center select-none"
>
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<ComfyQueueButton />
</div>
</Panel>
</div>
</Panel>
</template>
<script lang="ts" setup>
import {
useDraggable,
useElementBounding,
useEventBus,
useEventListener,
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
@@ -60,9 +41,10 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -81,9 +63,11 @@ const {
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
if (event.y < minY) {
event.y = minY
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
}
}
})
@@ -218,38 +202,39 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
// Drop zone state
const isMouseOverDropZone = ref(false)
// Mouse event handlers for self-contained drop zone
const onMouseEnterDropZone = () => {
if (isDragging.value) {
isMouseOverDropZone.value = true
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {
if (!panelRef.value) {
return false
}
}
const { height } = panelRef.value.getBoundingClientRect()
const actionbarBottom = y.value + height
const topMenuBottom = topMenuBounds.bottom.value
const onMouseLeaveDropZone = () => {
if (isDragging.value) {
isMouseOverDropZone.value = false
}
}
const overlapPixels =
Math.min(actionbarBottom, topMenuBottom) -
Math.max(y.value, topMenuBounds.top.value)
return overlapPixels > overlapThreshold
})
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {
// Starting to drag - undock if docked
if (isDocked.value) {
isDocked.value = false
}
watch(isDragging, (newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
isDocked.value = isOverlappingWithTopMenu.value
} else {
// Stopped dragging - dock if mouse is over drop zone
if (isMouseOverDropZone.value) {
isDocked.value = true
}
// Reset drop zone state
isMouseOverDropZone.value = false
// Start dragging
isDocked.value = false
}
})
const eventBus = useEventBus<string>('topMenu')
watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
eventBus.emit('updateHighlight', {
isDragging: dragging,
isOverlapping: overlapping
})
})
</script>
<style scoped>
@@ -257,27 +242,17 @@ watch(isDragging, (dragging) => {
.actionbar {
pointer-events: all;
position: fixed;
z-index: 1000;
}
.actionbar-drop-zone {
width: 265px;
border: 2px dashed var(--p-primary-color);
opacity: 0.8;
}
.actionbar-drop-zone.drop-zone-active {
background: var(--p-highlight-background-focus);
border-color: var(--p-primary-color);
border-width: 3px;
box-shadow: 0 0 20px var(--p-primary-color);
opacity: 1;
transform: scale(1.05);
.actionbar.is-docked {
position: static;
@apply bg-transparent border-none p-0;
}
.actionbar.is-dragging {
user-select: none;
pointer-events: none;
}
:deep(.p-panel-content) {

View File

@@ -3,42 +3,17 @@
<Tabs
:key="$i18n.locale"
v-model:value="bottomPanelStore.activeBottomPanelTabId"
style="--p-tabs-tablist-background: var(--comfy-menu-bg)"
>
<TabList
pt:tab-list="border-none h-full flex items-center py-2 border-b-1 border-solid"
class="bg-transparent"
>
<TabList pt:tab-list="border-none">
<div class="flex w-full justify-between">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="m-1 mx-2 border-none"
:class="{
'tab-list-single-item':
bottomPanelStore.bottomPanelTabs.length === 1
}"
:pt:root="
(x: TabPassThroughMethodOptions) => ({
class: {
'p-3 rounded-lg': true,
'pointer-events-none':
bottomPanelStore.bottomPanelTabs.length === 1
},
style: {
color: 'var(--fg-color)',
backgroundColor:
!x.context.active ||
bottomPanelStore.bottomPanelTabs.length === 1
? ''
: 'var(--bg-color)'
}
})
"
class="border-none p-3"
>
<span class="font-normal">
<span class="font-bold">
{{ getTabDisplayTitle(tab) }}
</span>
</Tab>
@@ -81,7 +56,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Tab from 'primevue/tab'
import type { TabPassThroughMethodOptions } from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed } from 'vue'
@@ -121,9 +95,3 @@ const closeBottomPanel = () => {
bottomPanelStore.activePanel = null
}
</script>
<style scoped>
:deep(.p-tablist-active-bar) {
display: none;
}
</style>

View File

@@ -64,6 +64,7 @@ const terminalCreated = (
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full w-full bg-transparent">
<div class="h-full w-full bg-black">
<p v-if="errorMessage" class="p-4 text-center">
{{ errorMessage }}
</p>
@@ -94,6 +94,7 @@ const terminalCreated = (
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -1,13 +1,12 @@
<template>
<div
class="subgraph-breadcrumb w-auto drop-shadow-md"
class="subgraph-breadcrumb w-auto"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `0px`,
'--p-breadcrumb-item-margin': `${ITEM_GAP / 2}px`,
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
@@ -15,7 +14,7 @@
>
<Breadcrumb
ref="breadcrumbRef"
class="w-fit rounded-lg p-0"
class="bg-transparent p-0"
:model="items"
aria-label="Graph navigation"
>
@@ -175,65 +174,30 @@ onUpdated(() => {
@apply overflow-hidden;
}
:deep(.p-breadcrumb) {
width: 100%;
background-color: transparent;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden;
@apply flex items-center rounded-lg overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
:deep(.p-breadcrumb-item-link) {
padding: 0
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--p-panel-border-color);
border-bottom: 1px solid var(--p-panel-border-color);
background-color: var(--comfy-menu-bg);
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--p-panel-border-color);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
}
}
:deep(.p-breadcrumb-item:last-child) {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--p-panel-border-color);
}
:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,
var(--fg-color) 10%,
var(--comfy-menu-bg)
) !important;
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
color: var(--fg-color);
}
</style>
@@ -250,7 +214,7 @@ onUpdated(() => {
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply flex;
@apply block;
}
}
</style>

View File

@@ -6,7 +6,7 @@
showDelay: 512
}"
href="#"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
class="p-breadcrumb-item-link cursor-pointer"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -15,7 +15,7 @@
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
@@ -26,7 +26,7 @@
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-bg)'
style: 'background-color: var(--comfy-menu-secondary-bg)'
},
itemLink: {
class: 'py-2'
@@ -38,7 +38,7 @@
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-10000 px-2 py-2 text-[.8rem]"
@blur="inputBlur(false)"
@blur="inputBlur(true)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
@@ -240,6 +240,7 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {

View File

@@ -12,7 +12,6 @@ const iconGroupClasses = cn(
'outline-hidden border-none p-0 rounded-lg',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'transition-all duration-200',
'cursor-pointer'
)
</script>

View File

@@ -1,8 +1,7 @@
<template>
<div class="relative inline-flex items-center">
<IconButton :size="size" :type="type" @click="toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" />
<IconButton @click="toggle">
<i class="icon-[lucide--more-vertical] text-sm" />
</IconButton>
<Popover
@@ -14,8 +13,6 @@
:close-on-escape="true"
unstyled
:pt="pt"
@show="$emit('menuOpened')"
@hide="$emit('menuClosed')"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
@@ -28,28 +25,12 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue'
interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean
}
const popover = ref<InstanceType<typeof Popover>>()
const {
size = 'md',
type = 'secondary',
isVertical = false
} = defineProps<MoreButtonProps>()
defineEmits<{
menuOpened: []
menuClosed: []
}>()
const toggle = (event: Event) => {
popover.value?.toggle(event)
}
@@ -64,7 +45,7 @@ const pt = computed(() => ({
},
content: {
class: cn(
'mt-1 rounded-lg',
'mt-2 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',

View File

@@ -11,15 +11,7 @@ import CardTop from './CardTop.vue'
interface CardStoryArgs {
// CardContainer props
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
variant: 'default' | 'ghost' | 'outline'
rounded: 'none' | 'sm' | 'lg' | 'xl'
customAspectRatio?: string
hasBorder: boolean
hasBackground: boolean
hasShadow: boolean
hasCursor: boolean
customClass: string
containerRatio: 'square' | 'portrait' | 'tallPortrait'
maxWidth: number
minWidth: number
@@ -52,44 +44,10 @@ interface CardStoryArgs {
const meta: Meta<CardStoryArgs> = {
title: 'Components/Card/Card',
argTypes: {
containerSize: {
containerRatio: {
control: 'select',
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
description: 'Card container size preset'
},
variant: {
control: 'select',
options: ['default', 'ghost', 'outline'],
description: 'Card visual variant'
},
rounded: {
control: 'select',
options: ['none', 'sm', 'lg', 'xl'],
description: 'Border radius size'
},
customAspectRatio: {
control: 'text',
description: 'Custom aspect ratio (e.g., "16/9")'
},
hasBorder: {
control: 'boolean',
description: 'Add border styling'
},
hasBackground: {
control: 'boolean',
description: 'Add background styling'
},
hasShadow: {
control: 'boolean',
description: 'Add shadow styling'
},
hasCursor: {
control: 'boolean',
description: 'Add cursor pointer'
},
customClass: {
control: 'text',
description: 'Additional custom CSS classes'
options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio'
},
topRatio: {
control: 'select',
@@ -191,15 +149,8 @@ const createCardTemplate = (args: CardStoryArgs) => ({
template: `
<div class="min-h-screen">
<CardContainer
:size="args.containerSize"
:variant="args.variant"
:rounded="args.rounded"
:custom-aspect-ratio="args.customAspectRatio"
:has-border="args.hasBorder"
:has-background="args.hasBackground"
:has-shadow="args.hasShadow"
:has-cursor="args.hasCursor"
:class="args.customClass || 'max-w-[320px] mx-auto'"
:ratio="args.containerRatio"
class="max-w-[320px] mx-auto"
>
<template #top>
<CardTop :ratio="args.topRatio">
@@ -254,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template #bottom>
<CardBottom>
<CardBottom class="p-3 bg-neutral-100">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
@@ -267,15 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'portrait',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'portrait',
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -300,15 +243,7 @@ export const Default: Story = {
export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'square',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
@@ -333,15 +268,7 @@ export const SquareCard: Story = {
export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'tall',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'tallPortrait',
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -366,15 +293,7 @@ export const TallPortraitCard: Story = {
export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'portrait',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'portrait',
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -395,50 +314,10 @@ export const ImageCard: Story = {
}
}
export const MiniCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'mini',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: false,
title: 'Mini Asset',
description: '',
backgroundColor: '#06b6d4',
showImage: false,
imageUrl: '',
tags: ['Asset'],
showFileSize: true,
fileSize: '124 KB',
showFileType: false,
fileType: ''
}
}
export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'square',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
@@ -459,209 +338,10 @@ export const MinimalCard: Story = {
}
}
export const GhostVariant: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'compact',
variant: 'ghost',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Workflow Template',
description: 'Ghost variant for workflow templates',
backgroundColor: '#10b981',
showImage: false,
imageUrl: '',
tags: ['Template'],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const OutlineVariant: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'outline',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Outline Card',
description: 'Card with outline variant styling',
backgroundColor: '#f59e0b',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const CustomAspectRatio: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
customAspectRatio: '16/9',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: false,
title: 'Wide Format Card',
description: '',
backgroundColor: '#8b5cf6',
showImage: false,
imageUrl: '',
tags: ['Wide'],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const RoundedNone: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'none',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Sharp Corners',
description: 'Card with no border radius',
backgroundColor: '#dc2626',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const RoundedXL: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'xl',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Extra Rounded',
description: 'Card with extra large border radius',
backgroundColor: '#059669',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const NoStylesCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: false,
hasBackground: false,
hasShadow: false,
hasCursor: true,
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Custom Styled Card',
description: 'Card with all default styles removed and custom gradient',
backgroundColor: 'transparent',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'tall',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
containerRatio: 'tallPortrait',
topRatio: 'square',
showTopLeft: true,
showTopRight: true,

View File

@@ -8,78 +8,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
size = 'regular',
variant = 'default',
rounded = 'md',
customAspectRatio,
hasBorder = true,
hasBackground = true,
hasShadow = true,
hasCursor = true,
class: customClass = ''
} = defineProps<{
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
variant?: 'default' | 'ghost' | 'outline'
rounded?: 'none' | 'md' | 'lg' | 'xl'
customAspectRatio?: string
hasBorder?: boolean
hasBackground?: boolean
hasShadow?: boolean
hasCursor?: boolean
class?: string
const { ratio = 'square', type } = defineProps<{
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
type?: string
}>()
// Base structure classes
const structureClasses = 'flex flex-col overflow-hidden'
// Rounded corners
const roundedClasses = {
none: 'rounded-none',
md: 'rounded',
lg: 'rounded-lg',
xl: 'rounded-xl'
} as const
const containerClasses = computed(() => {
// Variant styles
const variantClasses = {
default: cn(
hasBackground && 'bg-white dark-theme:bg-zinc-800',
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
hasShadow && 'shadow-sm',
hasCursor && 'cursor-pointer'
),
ghost: cn(
hasCursor && 'cursor-pointer',
'p-2 transition-colors duration-200'
),
outline: cn(
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
hasCursor && 'cursor-pointer',
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
)
const baseClasses =
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
if (type === 'workflow-template-card') {
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
}
// Size/aspect ratio
const aspectRatio = customAspectRatio
? `aspect-[${customAspectRatio}]`
: {
mini: 'aspect-100/120',
compact: 'aspect-240/311',
regular: 'aspect-256/308',
portrait: 'aspect-256/325',
tall: 'aspect-256/353'
}[size]
const ratioClasses = {
smallSquare: 'aspect-240/311',
square: 'aspect-256/308',
portrait: 'aspect-256/325',
tallPortrait: 'aspect-256/353'
}
return cn(
structureClasses,
roundedClasses[rounded],
variantClasses[variant],
aspectRatio,
customClass
)
return `${baseClasses} ${ratioClasses[ratio]}`
})
</script>

View File

@@ -2,27 +2,31 @@
<div :class="topStyle">
<slot class="absolute top-0 left-0 h-full w-full"></slot>
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
<div
v-if="slots['top-left']"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
>
<slot name="top-left"></slot>
</div>
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
<div
v-if="slots['top-right']"
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
>
<slot name="top-right"></slot>
</div>
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
<slot name="center-left"></slot>
</div>
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
<slot name="center-right"></slot>
</div>
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
<div
v-if="slots['bottom-left']"
class="absolute bottom-2 left-2 flex flex-wrap justify-start gap-2"
>
<slot name="bottom-left"></slot>
</div>
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
<div
v-if="slots['bottom-right']"
class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-2"
>
<slot name="bottom-right"></slot>
</div>
</div>
@@ -31,26 +35,10 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const slots = useSlots()
const {
ratio = 'square',
topLeftClass,
topRightClass,
centerLeftClass,
centerRightClass,
bottomLeftClass,
bottomRightClass
} = defineProps<{
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'landscape'
topLeftClass?: string
topRightClass?: string
centerLeftClass?: string
centerRightClass?: string
bottomLeftClass?: string
bottomRightClass?: string
}>()
const topStyle = computed(() => {
@@ -63,26 +51,4 @@ const topStyle = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
// Get default classes for each slot position
const defaultSlotClasses = {
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
'center-left':
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
'center-right':
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
}
// Compute all slot classes once and cache them
const slotClasses = computed(() => ({
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
}))
</script>

View File

@@ -1,28 +1,13 @@
<template>
<div :class="chipClasses">
<slot name="icon"></slot>
<div
class="inline-flex shrink-0 items-center justify-center gap-1 rounded bg-[#D9D9D966]/40 px-2 py-1 text-xs font-bold text-white/90"
>
<slot name="icon" class="text-xs text-white/90"></slot>
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
const { label } = defineProps<{
label: string
variant?: 'dark' | 'light'
}>()
const baseClasses =
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
}
const chipClasses = computed(() => {
return cn(baseClasses, variantStyles[variant])
})
</script>

View File

@@ -2,7 +2,6 @@
<div
ref="containerRef"
class="relative flex h-full w-full items-center justify-center overflow-hidden"
:class="containerClass"
>
<Skeleton
v-if="!isImageLoaded"
@@ -42,20 +41,17 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@/utils/tailwindUtil'
const {
src,
alt = '',
containerClass = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
containerClass?: ClassValue
imageClass?: ClassValue
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()

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