Compare commits

..

42 Commits

Author SHA1 Message Date
huchenlei
5da195c925 nit 2025-08-10 14:06:52 -04:00
huchenlei
d3398944d5 Fix test 2025-08-10 14:05:40 -04:00
huchenlei
b70b2c89b2 Fix highlight 2025-08-10 12:53:37 -04:00
huchenlei
c0303a6553 tailwind style 2025-08-10 12:49:03 -04:00
huchenlei
1e6803fd65 Support i18n 2025-08-10 12:36:40 -04:00
huchenlei
38a77abecb nit 2025-08-10 12:17:18 -04:00
github-actions
fcffa51a24 Update locales [skip ci] 2025-08-10 16:16:10 +00:00
huchenlei
d0ef1bb81a nit 2025-08-10 11:01:46 -04:00
huchenlei
34b7fe14c4 Fix mode switch 2025-08-09 22:50:03 -04:00
huchenlei
a4d7b4dd55 Basic commandbox 2025-08-09 22:50:03 -04:00
Comfy Org PR Bot
109542dca3 1.26.1 (#4889)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-09 19:48:35 -07:00
Christian Byrne
ffc812a8f5 [refactor] Remove unused omitBy function (#4886)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 19:12:48 -07:00
Christian Byrne
b745f533ba [feat] Replace manual clamp function with lodash (#4874)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 15:34:18 -07:00
Christian Byrne
8f289c8e67 Fix Alt-Click-Drag-Copy of Subgraph Nodes (#4879) 2025-08-09 15:33:59 -07:00
Vivek Chavan
79b4c78116 fix: hide More menu when no submenu items are visible (#4837) 2025-08-09 15:12:31 -07:00
Vivek Chavan
48aea928e0 fix: hide Desktop User Guide menu item in web builds (#4828) 2025-08-09 15:08:33 -07:00
pythongosssss
03ad06ea14 Add preview to workflow tabs (#4290) 2025-08-09 14:39:40 -07:00
filtered
ff5943f770 Reorder subgraph context menu items (#4870)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 14:20:26 -07:00
Christian Byrne
b1117b9838 [ci] Add chromium-0.5x to test matrix (#4880)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 14:15:08 -07:00
filtered
2d11fb1f90 [CI] Pin third party GH actions to specific SHAs (#4878) 2025-08-09 13:18:43 -07:00
Christian Byrne
e70b127f2a Revert animated-image-preview-saved-webp snapshot change from #4863 (#4873)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 12:31:16 -07:00
Makki Shizu
0d8e4fe719 Fix Simplified Chinese Translation (#4865) 2025-08-09 11:23:30 -07:00
filtered
5f5f44b310 Fix execution breaks on multi/any-type slots (#4864) 2025-08-09 11:17:10 -07:00
filtered
b42878a9da Remove unused Litegraph context menu options (#4867)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:14:54 -07:00
Christian Byrne
5cc269eff1 Fix Alt+click create reroute (2/2) (#4863)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:13:37 -07:00
Vivek Chavan
16d7436883 Fix: Alt+click reroute creation on high-DPI displays (#4831) 2025-08-09 08:59:19 -07:00
AustinMroz
db452c1e63 Fix disconnection from subgraph inputs (#4800) 2025-08-09 03:45:52 -04:00
Chenlei Hu
10d80165c4 [bugfix] Fix subgraph I/O slot rename dialog showing stale label content (#4852)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-08 23:40:26 -07:00
Benjamin Lu
c3997dfdb0 docs: add AGENTS.md file (#4858)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-08 23:39:59 -07:00
Chenlei Hu
7bbbf59722 feat: Enable double-click on subgraph slot labels for renaming (#4833) 2025-08-08 18:11:21 -07:00
Christian Byrne
8bf60777e7 [CI] Exclude vue-nodes-migration branch from playwright tests (#4844) 2025-08-08 16:52:25 -07:00
AustinMroz
ba28fa4621 Support preview display on subgraphNodes (#4814) 2025-08-08 13:58:31 -07:00
Chenlei Hu
95ab88693c feat: Add smooth slide-up animation to SelectionToolbox (#4832) 2025-08-07 21:34:10 -07:00
Vivek Chavan
5d71d6f9cf fix: correct branch protection status contexts for RC branches (#4829) 2025-08-07 19:06:41 -07:00
AustinMroz
8899b425a8 Rename subgraph widgets when slot is renamed (#4821) 2025-08-07 15:18:18 -07:00
AustinMroz
1fc4fd2ca8 Remove subgraphs from add node context menu (#4820) 2025-08-07 14:54:14 -07:00
Christian Byrne
1b9bacaeef [fix] Handle fork PRs in lint-and-format workflow (#4819) 2025-08-07 13:51:02 -07:00
snomiao
65cc06771c [ci] Merge ESLint and Prettier workflows with auto-fix for faster iteration (#4638) 2025-08-07 11:58:34 -07:00
Christian Byrne
3c154d8487 [refactor] Remove 5 unused settings from apiSchema (#4811)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-07 11:52:58 -07:00
Christian Byrne
c6c20e53fb [docs] Improve icon documentation with practical examples (#4810) 2025-08-07 11:52:40 -07:00
Johnpaul Chiwetelu
70c06d10bb Keyboard Shortcut Bottom Panel (#4635) 2025-08-07 11:51:23 -07:00
Comfy Org PR Bot
f4482eb35a 1.26.0 (#4812)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-07 11:37:26 -07:00
145 changed files with 2201 additions and 15494 deletions

View File

@@ -19,10 +19,10 @@ jobs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@v1.3.1
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(eslint|prettier|test|playwright-tests)'
check-regexp: '^(lint-and-format|test|playwright-tests)'
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -30,7 +30,7 @@ jobs:
id: check-status
run: |
# Get all check runs for this commit
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format|test|playwright-tests")) | {name, conclusion}')
# Check if any required checks failed
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then

View File

@@ -145,7 +145,7 @@ jobs:
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["build", "test"]
"contexts": ["lint-and-format", "test", "playwright-tests"]
},
"enforce_admins": false,
"required_pull_request_reviews": {

View File

@@ -1,17 +0,0 @@
name: ESLint
on:
pull_request:
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- run: npm ci
- run: npm run lint

View File

@@ -1,23 +0,0 @@
name: Prettier Check
on:
pull_request:
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run Prettier check
run: npm run format:check

82
.github/workflows/lint-and-format.yaml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Lint and Format
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
permissions:
contents: write
pull-requests: write
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint with auto-fix
run: npm run lint:fix
- name: Run Prettier with auto-format
run: npm run format
- name: Check for changes
id: verify-changed-files
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .
git commit -m "[auto-fix] Apply ESLint and Prettier fixes"
git push
- name: Final validation
run: |
npm run lint
npm run format:check
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
})
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\nnpm run prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\nnpm run lint:fix\nnpm run format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
})

View File

@@ -4,7 +4,7 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration]
jobs:
setup:
@@ -60,7 +60,7 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

1
.gitignore vendored
View File

@@ -41,6 +41,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/**/*-darwin.png
.env

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Repository Guidelines
## Project Structure & Module Organization
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
- Public assets: `public/`. Build output: `dist/`.
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.js`, `.prettierrc`.
## Build, Test, and Development Commands
- `npm run dev`: Start Vite dev server.
- `npm run dev:electron`: Dev server with Electron API mocks.
- `npm run build`: Type-check then production build to `dist/`.
- `npm run preview`: Preview the production build locally.
- `npm run test:unit`: Run Vitest unit tests (`tests-ui/`).
- `npm run test:component`: Run component tests (`src/components/`).
- `npm run test:browser`: Run Playwright E2E tests (`browser_tests/`).
- `npm run lint` / `npm run lint:fix`: Lint (ESLint). `npm run format` / `format:check`: Prettier.
- `npm run typecheck`: Vue TSC type checking.
## Coding Style & Naming Conventions
- Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
- Imports: sorted/grouped by plugin; run `npm run format` before committing.
- ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
- Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
## Testing Guidelines
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
## Commit & Pull Request Guidelines
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
- Quality gates: `npm run lint`, `npm run typecheck`, and relevant tests must pass. Keep PRs focused and small.
## Security & Configuration Tips
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.

View File

@@ -255,11 +255,17 @@ npm run format
- Add translations to `src/locales/en/main.json`
- Use translation keys: `const { t } = useI18n(); t('key.path')`
## Custom Icons
## Icons
The project supports custom SVG icons through the unplugin-icons system. Custom icons are stored in `src/assets/icons/custom/` and can be used as Vue components with the `i-comfy:` prefix.
The project supports three types of icons, all with automatic imports (no manual imports needed):
For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md).
1. **PrimeIcons** - Built-in PrimeVue icons using CSS classes: `<i class="pi pi-plus" />`
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/`.
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
## Working with litegraph.js

View File

@@ -75,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
<summary>v1.5: Native translation (i18n)</summary>
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, Korean, or Arabic. This native
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
More details available here: https://blog.comfy.org/p/native-localization-support-i18n

View File

@@ -786,6 +786,164 @@ export class ComfyPage {
await this.nextFrame()
}
/**
* Core helper method for interacting with subgraph I/O slots.
* Handles both input/output slots and both right-click/double-click actions.
*
* @param slotType - 'input' or 'output'
* @param action - 'rightClick' or 'doubleClick'
* @param slotName - Optional specific slot name to target
* @private
*/
private async interactWithSubgraphSlot(
slotType: 'input' | 'output',
action: 'rightClick' | 'doubleClick',
slotName?: string
): Promise<void> {
const foundSlot = await this.page.evaluate(
async (params) => {
const { slotType, action, targetSlotName } = params
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the appropriate node and slots
const node =
slotType === 'input'
? currentGraph.inputNode
: currentGraph.outputNode
const slots =
slotType === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${slotType} node found in subgraph`)
}
if (!slots || slots.length === 0) {
throw new Error(`No ${slotType} slots found in subgraph`)
}
// Filter slots based on target name and action type
const slotsToTry = targetSlotName
? slots.filter((slot) => slot.name === targetSlotName)
: action === 'rightClick'
? slots
: [slots[0]] // Right-click tries all, double-click uses first
if (slotsToTry.length === 0) {
throw new Error(
targetSlotName
? `${slotType} slot '${targetSlotName}' not found`
: `No ${slotType} slots available to try`
)
}
// Handle the interaction based on action type
if (action === 'rightClick') {
// Right-click: try each slot until one works
for (const slot of slotsToTry) {
if (!slot.pos) continue
const event = {
canvasX: slot.pos[0],
canvasY: slot.pos[1],
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return {
success: true,
slotName: slot.name,
x: slot.pos[0],
y: slot.pos[1]
}
}
}
} else if (action === 'doubleClick') {
// Double-click: use first slot with bounding rect center
const slot = slotsToTry[0]
if (!slot.boundingRect) {
throw new Error(`${slotType} slot bounding rect not found`)
}
const rect = slot.boundingRect
const testX = rect[0] + rect[2] / 2 // x + width/2
const testY = rect[1] + rect[3] / 2 // y + height/2
const event = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(event)
}
}
// Wait briefly for dialog to appear
await new Promise((resolve) => setTimeout(resolve, 200))
return { success: true, slotName: slot.name, x: testX, y: testY }
}
return { success: false }
},
{ slotType, action, targetSlotName: slotName }
)
if (!foundSlot.success) {
const actionText =
action === 'rightClick' ? 'open context menu for' : 'double-click'
throw new Error(
slotName
? `Could not ${actionText} ${slotType} slot '${slotName}'`
: `Could not find any ${slotType} slot to ${actionText}`
)
}
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
} else {
await this.nextFrame()
}
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
@@ -800,93 +958,7 @@ export class ComfyPage {
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetInputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}
// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}
// Filter to specific input if requested
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: inputs
if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}
// Try right-clicking on each input slot position until one works
for (const input of inputsToTry) {
if (!input.pos) continue
const testX = input.pos[0]
const testY = input.pos[1]
// Create a right-click event at the input slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the input node's right-click handler
if (inputNode.onPointerDown) {
inputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, inputName: input.name, x: testX, y: testY }
}
}
return { success: false }
}, inputName)
if (!foundSlot.success) {
throw new Error(
inputName
? `Could not open context menu for input slot '${inputName}'`
: 'Could not find any input slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
}
/**
@@ -900,93 +972,31 @@ export class ComfyPage {
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
}
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
/**
* Double-clicks on a subgraph input slot to rename it.
* Must be called when inside a subgraph.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries the first available input slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
}
// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}
// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}
// Filter to specific output if requested
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: outputs
if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}
// Try right-clicking on each output slot position until one works
for (const output of outputsToTry) {
if (!output.pos) continue
const testX = output.pos[0]
const testY = output.pos[1]
// Create a right-click event at the output slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the output node's right-click handler
if (outputNode.onPointerDown) {
outputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, outputName: output.name, x: testX, y: testY }
}
}
return { success: false }
}, outputName)
if (!foundSlot.success) {
throw new Error(
outputName
? `Could not open context menu for output slot '${outputName}'`
: 'Could not find any output slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
/**
* Double-clicks on a subgraph output slot to rename it.
* Must be called when inside a subgraph.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries the first available output slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
}
/**

View File

@@ -50,7 +50,7 @@ export class Topbar {
workflowName: string,
command: 'Save' | 'Save As' | 'Export'
) {
await this.triggerTopbarCommand(['File', command])
await this.triggerTopbarCommand(['Workflow', command])
await this.getSaveDialog().fill(workflowName)
await this.page.keyboard.press('Enter')
@@ -72,8 +72,8 @@ export class Topbar {
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 1) {
throw new Error('Path cannot be empty')
if (path.length < 2) {
throw new Error('Path is too short')
}
const menu = await this.openTopbarMenu()
@@ -85,13 +85,6 @@ export class Topbar {
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
// Handle top-level commands (like "New")
if (path.length === 1) {
await topLevelMenuItem.click()
return
}
await topLevelMenu.hover()
let currentMenu = topLevelMenu

View File

@@ -0,0 +1,161 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Command search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Type ">" to enter command mode
await comfyPage.searchBox.input.fill('>')
// Verify filter button is hidden in command mode
const filterButton = comfyPage.page.locator('.filter-button')
await expect(filterButton).not.toBeVisible()
// Verify placeholder text changes
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Commands')
)
})
test('Shows command list when entering command mode', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>')
// Wait for dropdown to appear
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
// Check that commands are shown
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
await expect(firstItem).toBeVisible()
// Verify it shows a command item with icon
const commandIcon = firstItem.locator('.item-icon')
await expect(commandIcon).toBeVisible()
})
test('Can search and filter commands', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>save')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
// Get all visible command items
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
// Should have filtered results
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(10) // Should be filtered, not showing all
// Verify first result contains "save"
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
test('Shows keybindings for commands that have them', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>undo')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Find the undo command
const undoItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'Undo' })
.first()
// Check if keybinding is shown (if configured)
const keybinding = undoItem.locator('.item-keybinding')
const keybindingCount = await keybinding.count()
// Keybinding might or might not be present depending on configuration
if (keybindingCount > 0) {
await expect(keybinding).toBeVisible()
}
})
test('Executes command on selection', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>new blank')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Count nodes before
const nodesBefore = await comfyPage.page
.locator('.litegraph.litenode')
.count()
// Select the new blank workflow command
const newBlankItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'New Blank Workflow' })
.first()
await newBlankItem.click()
// Search box should close
await expect(comfyPage.searchBox.input).not.toBeVisible()
// Verify workflow was cleared (no nodes)
const nodesAfter = await comfyPage.page
.locator('.litegraph.litenode')
.count()
expect(nodesAfter).toBe(0)
})
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Enter command mode
await comfyPage.searchBox.input.fill('>')
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
// Return to node search by filling with empty string to trigger search
await comfyPage.searchBox.input.fill('')
// Small wait for UI update
await comfyPage.page.waitForTimeout(200)
// Filter button should be visible again
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
// Placeholder should change back
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Nodes')
)
})
test('Command search is case insensitive', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Search with lowercase
await comfyPage.searchBox.input.fill('>SAVE')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Should find save commands
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
expect(count).toBeGreaterThan(0)
// Verify it found save-related commands
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
})

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
test.skip('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
test.skip('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
@@ -268,7 +268,10 @@ test.describe('Group Node', () => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
@@ -277,7 +280,7 @@ test.describe('Group Node', () => {
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -293,7 +296,7 @@ test.describe('Group Node', () => {
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()

View File

@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading

View File

@@ -75,7 +75,7 @@ test.describe('Menu', () => {
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'

View File

@@ -24,14 +24,8 @@ test.describe('Minimap', () => {
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
'.minimap-main-container'
)
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {

View File

@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
[workflowName]: workflowName
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
// Insert the workflow
const workflowsTab = comfyPage.menu.workflowsTab

View File

@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can convert to group node', async ({ comfyPage }) => {
test.skip('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.clickContextMenuItem('Convert to Group Node')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,157 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const INITIAL_NAME = 'initial_slot_name'
const RENAMED_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
test.describe('Subgraph Slot Rename Dialog', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || graph.inputs?.[0]?.name || null
})
// First rename
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the rename worked
const afterFirstRename = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
const slot = graph.inputs?.[0]
return {
label: slot?.label || null,
name: slot?.name || null,
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_NAME)
// Now rename again - this is where the bug would show
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
test('Shows current output slot label in rename dialog', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || graph.outputs?.[0]?.name || null
})
// First rename
await comfyPage.rightClickSubgraphOutputSlot(initialOutputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Now rename again to check for stale content
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
})
})

View File

@@ -155,6 +155,182 @@ test.describe('Subgraph Operations', () => {
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphInputSlot(initialInputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphOutputSlot(initialOutputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
})
test('Right-click context menu still works alongside double-click', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Test that right-click still works for renaming
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can double-click on slot label text to rename', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window['app']
const graph = app.canvas.graph
const input = graph.inputs?.[0]
if (!input?.labelPos) {
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
const leftClickEvent = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
const inputNode = graph.inputNode
if (inputNode?.onPointerDown) {
inputNode.onPointerDown(
leftClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
}
})
// Wait for dialog to appear
await comfyPage.page.waitForTimeout(200)
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Creation and Deletion', () => {
@@ -528,103 +704,4 @@ test.describe('Subgraph Operations', () => {
expect(finalCount).toBe(parentCount)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.page.waitForTimeout(1024)
// Navigate into subgraph
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})

View File

@@ -63,7 +63,7 @@ test.describe('Workflow Tab Thumbnails', () => {
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
@@ -73,7 +73,7 @@ test.describe('Workflow Tab Thumbnails', () => {
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
@@ -105,7 +105,7 @@ test.describe('Workflow Tab Thumbnails', () => {
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.nextFrame()
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.11",
"version": "1.26.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.11",
"version": "1.26.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.11",
"version": "1.26.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,53 +1,148 @@
# ComfyUI Custom Icons Guide
# ComfyUI Icons Guide
This guide explains how to add and use custom SVG icons in the ComfyUI frontend.
ComfyUI supports three types of icons that can be used throughout the interface. All icons are automatically imported - no manual imports needed!
## Overview
## Quick Start - Code Examples
ComfyUI uses a hybrid icon system that supports:
- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`)
- **Iconify** - Modern icon system with 200,000+ icons
- **Custom Icons** - Your own SVG icons
Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system.
## Quick Start
### 1. Add Your SVG Icon
Place your SVG file in the `custom/` directory:
```
src/assets/icons/custom/
└── your-icon.svg
```
### 2. Use in Components
### 1. PrimeIcons
```vue
<template>
<!-- Use as a Vue component -->
<i-comfy:your-icon />
<!-- In a PrimeVue button -->
<Button>
<!-- Basic usage -->
<i class="pi pi-plus" />
<i class="pi pi-cog" />
<i class="pi pi-check text-green-500" />
<!-- In PrimeVue components -->
<button icon="pi pi-save" label="Save" />
<button icon="pi pi-times" severity="danger" />
</template>
```
[Browse all PrimeIcons →](https://primevue.org/icons/#list)
### 2. Iconify Icons (Recommended)
```vue
<template>
<!-- Primary icon set: Lucide -->
<i-lucide:download />
<i-lucide:settings />
<i-lucide:workflow class="text-2xl" />
<!-- Other popular icon sets -->
<i-mdi:folder-open />
<!-- Material Design Icons -->
<i-heroicons:document-text />
<!-- Heroicons -->
<i-tabler:brand-github />
<!-- Tabler Icons -->
<i-carbon:cloud-upload />
<!-- Carbon Icons -->
<!-- With styling -->
<i-lucide:save class="w-6 h-6 text-blue-500" />
</template>
```
[Browse 200,000+ icons →](https://icon-sets.iconify.design/)
### 3. Custom Icons
```vue
<template>
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
<i-comfy:workflow />
<i-comfy:node-tree />
<i-comfy:my-custom-icon class="text-xl" />
<!-- In PrimeVue button -->
<Button severity="secondary">
<template #icon>
<i-comfy:your-icon />
<i-comfy:workflow />
</template>
</Button>
</template>
```
## SVG Requirements
## Icon Usage Patterns
### File Naming
- Use kebab-case: `workflow-icon.svg`, `node-tree.svg`
- Avoid special characters and spaces
- The filename becomes the icon name
### In Buttons
```vue
<template>
<!-- PrimeIcon in button (simple) -->
<Button icon="pi pi-check" label="Confirm" />
<!-- Iconify/Custom in button (template) -->
<Button>
<template #icon>
<i-lucide:save />
</template>
Save File
</Button>
</template>
```
### Conditional Icons
```vue
<template>
<i-lucide:eye v-if="isVisible" />
<i-lucide:eye-off v-else />
<!-- Or with ternary -->
<component :is="isLocked ? 'i-lucide:lock' : 'i-lucide:lock-open'" />
</template>
```
### With Tooltips
```vue
<template>
<i-lucide:info
v-tooltip="'Click for more information'"
class="cursor-pointer"
/>
</template>
```
## Using Iconify Icons
### Finding Icons
1. Visit [Iconify Icon Sets](https://icon-sets.iconify.design/)
2. Search or browse collections
3. Click on any icon to get its name
4. Use with `i-[collection]:[icon-name]` format
### Popular Collections
- **Lucide** (`i-lucide:`) - Our primary icon set, clean and consistent
- **Material Design Icons** (`i-mdi:`) - Comprehensive Material Design icons
- **Heroicons** (`i-heroicons:`) - Beautiful hand-crafted SVG icons
- **Tabler** (`i-tabler:`) - 3000+ free SVG icons
- **Carbon** (`i-carbon:`) - IBM's design system icons
## Adding Custom Icons
### 1. Add Your SVG
Place your SVG file in `src/assets/icons/custom/`:
```
src/assets/icons/custom/
├── workflow-duplicate.svg
├── node-preview.svg
└── your-icon.svg
```
### 2. SVG Format Requirements
### SVG Format
```xml
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="..." />
<!-- Use currentColor for theme compatibility -->
<path fill="currentColor" d="..." />
</svg>
```
@@ -57,59 +152,98 @@ src/assets/icons/custom/
- Use `currentColor` for theme-aware icons
- Keep SVGs optimized and simple
### Color Theming
### 3. Use Immediately
For icons that adapt to the current theme, use `currentColor`:
```vue
<template>
<i-comfy:your-icon />
</template>
```
No imports needed - icons are auto-discovered!
## Icon Guidelines
### Naming Conventions
- **Files**: `kebab-case.svg` (workflow-icon.svg)
- **Usage**: `<i-comfy:workflow-icon />`
### Size & Styling
```vue
<template>
<!-- Size with Tailwind classes -->
<i-lucide:plus class="w-4 h-4" />
<!-- 16px -->
<i-lucide:plus class="w-6 h-6" />
<!-- 24px (default) -->
<i-lucide:plus class="w-8 h-8" />
<!-- 32px -->
<!-- Or text size -->
<i-lucide:plus class="text-sm" />
<i-lucide:plus class="text-2xl" />
<!-- Colors -->
<i-lucide:check class="text-green-500" />
<i-lucide:x class="text-red-500" />
</template>
```
### Theme Compatibility
Always use `currentColor` in SVGs for automatic theme adaptation:
```xml
<!-- ✅ Good: Uses currentColor -->
<!-- ✅ Good: Adapts to light/dark theme -->
<svg viewBox="0 0 24 24">
<path stroke="currentColor" fill="none" d="..." />
<path fill="currentColor" d="..." />
</svg>
<!-- ❌ Bad: Hardcoded colors -->
<!-- ❌ Bad: Fixed colors -->
<svg viewBox="0 0 24 24">
<path stroke="white" fill="black" d="..." />
<path fill="#000000" d="..." />
</svg>
```
## Usage Examples
## Migration Guide
### From PrimeIcons to Iconify/Custom
### Basic Icon
```vue
<i-comfy:workflow />
<template>
<!-- Before -->
<Button icon="pi pi-download" />
<!-- After -->
<Button>
<template #icon>
<i-lucide:download />
</template>
</Button>
</template>
```
### With Classes
```vue
<i-comfy:workflow class="text-2xl text-blue-500" />
```
### From Inline SVG to Custom Icon
### In Buttons
```vue
<Button severity="secondary" text>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
<template>
<!-- Before: Inline SVG -->
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path d="..." />
</svg>
### Conditional Icons
```vue
<template #icon>
<i-comfy:workflow v-if="isWorkflow" />
<i-comfy:node v-else />
<!-- After: Save as custom/my-icon.svg and use -->
<i-comfy:my-icon class="w-6 h-6" />
</template>
```
## Technical Details
### How It Works
### Auto-Import System
1. **unplugin-icons** automatically discovers SVG files in `custom/`
2. During build, SVGs are converted to Vue components
3. Components are tree-shaken - only used icons are bundled
4. The `i-` prefix and `comfy:` namespace identify custom icons
Icons are automatically imported using `unplugin-icons` - no manual imports needed! Just use the icon component directly.
### Configuration
@@ -119,17 +253,18 @@ The icon system is configured in `vite.config.mts`:
Icons({
compiler: 'vue3',
customCollections: {
'comfy': FileSystemIconLoader('src/assets/icons/custom'),
comfy: FileSystemIconLoader('src/assets/icons/custom')
}
})
```
### TypeScript Support
Icons are automatically typed. If TypeScript doesn't recognize a new icon:
1. Restart your dev server
2. Check that the SVG file is valid
3. Ensure the filename follows kebab-case convention
Icons are fully typed. If TypeScript doesn't recognize a new custom icon:
1. Restart the dev server
2. Ensure the SVG file is valid
3. Check filename follows kebab-case
## Troubleshooting
@@ -157,22 +292,6 @@ Icons are automatically typed. If TypeScript doesn't recognize a new icon:
4. **Theme support**: Always use `currentColor` for adaptable icons
5. **Test both themes**: Verify icons look good in light and dark modes
## Migration from PrimeIcons
When replacing a PrimeIcon with a custom icon:
```vue
<!-- Before: PrimeIcon -->
<Button icon="pi pi-box" />
<!-- After: Custom icon -->
<Button>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
## Adding Icon Collections
To add an entire icon set from npm:
@@ -181,4 +300,11 @@ To add an entire icon set from npm:
2. Configure in `vite.config.mts`
3. Use with the appropriate prefix
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
## Resources
- [PrimeIcons List](https://primevue.org/icons/#list)
- [Iconify Icon Browser](https://icon-sets.iconify.design/)
- [Lucide Icons](https://lucide.dev/icons/)
- [unplugin-icons docs](https://github.com/unplugin/unplugin-icons)

View File

@@ -1,6 +0,0 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.91396 12.7428L5.41396 10.7428C5.57175 10.1116 5.09439 9.50024 4.44382 9.50024H2.50538C2.04651 9.50024 1.64652 9.81253 1.53523 10.2577L1.03523 12.2577C0.877446 12.8888 1.3548 13.5002 2.00538 13.5002H3.94382C4.40269 13.5002 4.80267 13.1879 4.91396 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.91396 6.74277L6.41396 4.74277C6.57175 4.11163 6.09439 3.50024 5.44382 3.50024H3.50538C3.04651 3.50024 2.64652 3.81253 2.53523 4.2577L2.03523 6.2577C1.87745 6.88885 2.3548 7.50024 3.00538 7.50024H4.94382C5.40269 7.50024 5.80267 7.18794 5.91396 6.74277Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10.914 12.7428L11.414 10.7428C11.5718 10.1116 11.0944 9.50024 10.4438 9.50024H8.50538C8.04651 9.50024 7.64652 9.81253 7.53523 10.2577L7.03523 12.2577C6.87745 12.8888 7.3548 13.5002 8.00538 13.5002H9.94382C10.4027 13.5002 10.8027 13.1879 10.914 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12.2342 5.46739L11.5287 7.11354C11.4248 7.35597 11.0811 7.35597 10.9772 7.11354L10.2717 5.46739C10.2414 5.39659 10.185 5.34017 10.1141 5.30983L8.468 4.60433C8.22557 4.50044 8.22557 4.15675 8.468 4.05285L10.1141 3.34736C10.185 3.31701 10.2414 3.26059 10.2717 3.18979L10.9772 1.54364C11.0811 1.30121 11.4248 1.30121 11.5287 1.54364L12.2342 3.18979C12.2645 3.26059 12.3209 3.31701 12.3918 3.34736L14.0379 4.05285C14.2803 4.15675 14.2803 4.50044 14.0379 4.60433L12.3918 5.30983C12.3209 5.34017 12.2645 5.39659 12.2342 5.46739Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,3 +0,0 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 10L10.598 10.2577C10.4812 10.6954 10.0848 11 9.63172 11H5.30161C4.64458 11 4.16608 10.3772 4.33538 9.74234L5.40204 5.74234C5.51878 5.30458 5.91523 5 6.36828 5H10.8286C11.4199 5 11.8505 5.56051 11.6982 6.13185L11.6736 6.22389M14 8H10M4.5 8H2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

View File

@@ -1,5 +0,0 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1894 6.24254L13.6894 4.24254C13.8471 3.61139 13.3698 3 12.7192 3H3.78077C3.3219 3 2.92192 3.3123 2.81062 3.75746L2.31062 5.75746C2.15284 6.38861 2.63019 7 3.28077 7H12.2192C12.6781 7 13.0781 6.6877 13.1894 6.24254Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M13.1894 12.2425L13.6894 10.2425C13.8471 9.61139 13.3698 9 12.7192 9H8.78077C8.3219 9 7.92192 9.3123 7.81062 9.75746L7.31062 11.7575C7.15284 12.3886 7.6302 13 8.28077 13H12.2192C12.6781 13 13.0781 12.6877 13.1894 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.18936 12.2425L5.68936 10.2425C5.84714 9.61139 5.36978 9 4.71921 9H3.78077C3.3219 9 2.92192 9.3123 2.81062 9.75746L2.31062 11.7575C2.15284 12.3886 2.6302 13 3.28077 13H4.21921C4.67808 13 5.07806 12.6877 5.18936 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 970 B

View File

@@ -1,9 +1,6 @@
<template>
<div class="flex flex-col h-full">
<Tabs
:key="$i18n.locale"
v-model:value="bottomPanelStore.activeBottomPanelTabId"
>
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
<TabList pt:tab-list="border-none">
<div class="w-full flex justify-between">
<div class="tabs-container">
@@ -14,7 +11,11 @@
class="p-3 border-none"
>
<span class="font-bold">
{{ getTabDisplayTitle(tab) }}
{{
shouldCapitalizeTab(tab.id)
? tab.title.toUpperCase()
: tab.title
}}
</span>
</Tab>
</div>
@@ -59,16 +60,13 @@ import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useDialogService } from '@/services/dialogService'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const { t } = useI18n()
const isShortcutsTabActive = computed(() => {
const activeTabId = bottomPanelStore.activeBottomPanelTabId
@@ -82,11 +80,6 @@ const shouldCapitalizeTab = (tabId: string): boolean => {
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
}
const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
const title = tab.titleKey ? t(tab.titleKey) : tab.title || ''
return shouldCapitalizeTab(tab.id) ? title.toUpperCase() : title
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
}

View File

@@ -20,7 +20,7 @@
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
{{ command.getTranslatedLabel() }}
</div>
</div>
@@ -50,7 +50,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()

View File

@@ -32,6 +32,7 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -97,6 +98,18 @@ const home = computed(() => ({
}
}))
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {

View File

@@ -139,7 +139,6 @@ import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import { useKeybindingService } from '@/services/keybindingService'
@@ -149,7 +148,6 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -161,7 +159,6 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const { t } = useI18n()
interface ICommandData {
id: string
@@ -173,10 +170,7 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
label: command.getTranslatedLabel(),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
source: command.source
}))

View File

@@ -1,68 +1,39 @@
<template>
<div
v-if="visible && initialized"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
ref="containerRef"
class="litegraph-minimap absolute right-[90px] z-[1000]"
:class="{
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
}"
:style="containerStyles"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
>
<MiniMapPanel
v-if="showOptionsPanel"
:panel-styles="panelStyles"
:node-colors="nodeColors"
:show-links="showLinks"
:show-groups="showGroups"
:render-bypass="renderBypass"
:render-error="renderError"
@update-option="updateOption"
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div
ref="containerRef"
class="litegraph-minimap relative"
:style="containerStyles"
>
<Button
class="absolute z-10"
size="small"
text
severity="secondary"
@click.stop="toggleOptionsPanel"
>
<template #icon>
<i-lucide:settings-2 />
</template>
</Button>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="absolute inset-0"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
/>
</div>
<div class="minimap-viewport" :style="viewportStyles" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import MiniMapPanel from './MiniMapPanel.vue'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const bottomPanelStore = useBottomPanelStore()
const {
initialized,
@@ -73,13 +44,6 @@ const {
viewportStyles,
width,
height,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
@@ -88,12 +52,6 @@ const {
handleWheel
} = minimap
const showOptionsPanel = ref(false)
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {

View File

@@ -1,97 +0,0 @@
<template>
<div
class="minimap-panel p-3 mr-2 flex flex-col gap-3 text-sm"
:style="panelStyles"
>
<div class="flex items-center gap-2">
<Checkbox
input-id="node-colors"
name="node-colors"
:model-value="nodeColors"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
"
/>
<i-lucide:palette />
<label for="node-colors">{{ $t('minimap.nodeColors') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-links"
name="show-links"
:model-value="showLinks"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
"
/>
<i-lucide:route />
<label for="show-links">{{ $t('minimap.showLinks') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-groups"
name="show-groups"
:model-value="showGroups"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
"
/>
<i-lucide:frame />
<label for="show-groups">{{ $t('minimap.showGroups') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-bypass"
name="render-bypass"
:model-value="renderBypass"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
"
/>
<i-lucide:circle-slash-2 />
<label for="render-bypass">{{ $t('minimap.renderBypassState') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-error"
name="render-error"
:model-value="renderError"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
"
/>
<i-lucide:message-circle-warning />
<label for="render-error">{{ $t('minimap.renderErrorState') }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { MinimapOptionKey } from '@/composables/useMinimap'
defineProps<{
panelStyles: any
nodeColors: boolean
showLinks: boolean
showGroups: boolean
renderBypass: boolean
renderError: boolean
}>()
defineEmits<{
updateOption: [key: MinimapOptionKey, value: boolean]
}>()
</script>

View File

@@ -14,12 +14,13 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { provide, readonly, ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
@@ -27,6 +28,13 @@ const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
// Increment counter to notify child components of position/visibility change
// This does not include viewport changes.
const overlayUpdateCount = ref(0)
provide(SelectionOverlayInjectionKey, {
visible: readonly(visible),
updateCount: readonly(overlayUpdateCount)
})
const positionSelectionOverlay = () => {
const selectableItems = getSelectableItems()
@@ -52,6 +60,7 @@ whenever(
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
overlayUpdateCount.value++
canvasStore.getCanvas().state.selectionChanged = false
})
},
@@ -71,6 +80,7 @@ watch(
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay()
overlayUpdateCount.value++
})
} else {
// Selection change update to visible state is delayed by a frame. Here
@@ -78,6 +88,7 @@ watch(
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
overlayUpdateCount.value++
})
}
}

View File

@@ -1,6 +1,7 @@
<template>
<Panel
class="selection-toolbox absolute left-1/2 rounded-lg"
:class="{ 'animate-slide-up': shouldAnimate }"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
@@ -27,7 +28,7 @@
<script setup lang="ts">
import Panel from 'primevue/panel'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
@@ -40,16 +41,24 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
const { shouldAnimate } = useRetriggerableAnimation(
selectionOverlayState?.updateCount,
{ animateOnMount: true }
)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
@@ -71,4 +80,20 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
}
/* Slide up animation using CSS animation */
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(-120%);
opacity: 1;
}
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
</style>

View File

@@ -1,20 +1,6 @@
<template>
<Button
v-if="isUnpackVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
</template>
</Button>
<Button
v-else-if="isConvertVisible"
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
@@ -34,7 +20,6 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -42,13 +27,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
const isVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||

View File

@@ -188,16 +188,13 @@ const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
const moreItems = computed<MenuItem[]>(() => {
const allMoreItems: MenuItem[] = [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
@@ -230,6 +227,19 @@ const menuItems = computed<MenuItem[]>(() => {
}
]
// Filter for visible items only
return allMoreItems.filter((item) => item.visible !== false)
})
const hasVisibleMoreItems = computed(() => {
return !!moreItems.value.length
})
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
return [
{
key: 'docs',
@@ -276,8 +286,9 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
icon: '',
label: t('helpCenter.more'),
visible: hasVisibleMoreItems.value,
action: () => {}, // No action for more item
items: moreItems
items: moreItems.value
}
]
})

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex items-center gap-3 px-3 py-2 w-full">
<span
class="flex-shrink-0 w-5 text-center text-muted item-icon"
:class="command.icon ?? 'pi pi-chevron-right'"
/>
<span
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
>
<span
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
/>
</span>
<span
v-if="command.keybinding"
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
>
{{ command.keybinding.combo.toString() }}
</span>
</div>
</template>
<script setup lang="ts">
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { highlightQuery } from '@/utils/formatUtil'
const { command, currentQuery } = defineProps<{
command: ComfyCommandImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
.keybinding-badge {
border-color: var(--p-content-border-color);
background-color: var(--p-content-hover-background);
color: var(--p-text-muted-color);
}
</style>

View File

@@ -3,7 +3,7 @@
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
>
<div
v-if="enableNodePreview"
v-if="enableNodePreview && !isCommandMode"
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
>
<NodePreview
@@ -14,6 +14,7 @@
</div>
<Button
v-if="!isCommandMode"
icon="pi pi-filter"
severity="secondary"
class="filter-button z-10"
@@ -49,13 +50,24 @@
auto-option-focus
force-selection
multiple
:option-label="'display_name'"
:option-label="getOptionLabel"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onOptionSelect($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@input="handleInput"
>
<template #option="{ option }">
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
<!-- Command search item, Remove the '>' prefix from the query -->
<CommandSearchItem
v-if="isCommandMode"
:command="option"
:current-query="currentQuery.substring(1)"
/>
<NodeSearchItem
v-else
:node-def="option"
:current-query="currentQuery"
/>
</template>
<!-- FilterAndValue -->
<template #chip="{ value }">
@@ -80,13 +92,16 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { CommandSearchService } from '@/services/commandSearchService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const commandStore = useCommandStore()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const isCommandMode = ref(false)
// Initialize command search service
const commandSearchService = ref<CommandSearchService | null>(null)
const placeholder = computed(() => {
if (isCommandMode.value) {
return t('g.searchCommands', 'Search commands') + '...'
}
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Initialize command search service with commands
watch(
() => commandStore.commands,
(commands) => {
commandSearchService.value = new CommandSearchService(commands)
},
{ immediate: true }
)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
// Check if we're in command mode (query starts with ">")
if (query.startsWith('>')) {
isCommandMode.value = true
if (commandSearchService.value) {
suggestions.value = commandSearchService.value.searchCommands(query, {
limit: searchLimit
})
}
return
}
// Normal node search mode
isCommandMode.value = false
const queryIsEmpty = query === '' && filters.length === 0
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
@@ -132,7 +180,18 @@ const search = (query: string) => {
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const emit = defineEmits<{
(
e: 'addFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(
e: 'removeFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
(e: 'executeCommand', command: ComfyCommandImpl): void
}>()
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
await reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
if (index === -1 || isCommandMode.value) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index]
const value = suggestions.value[index] as ComfyNodeDefImpl
hoveredSuggestion.value = value
}
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
if (isCommandMode.value) {
emit('executeCommand', option as ComfyCommandImpl)
} else {
emit('addNode', option as ComfyNodeDefImpl)
}
}
const getOptionLabel = (
option: ComfyNodeDefImpl | ComfyCommandImpl
): string => {
if ('display_name' in option) {
return option.display_name
}
return option.label || option.id
}
/**
* Handles direct input changes on the AutoCompletePlus component.
* This ensures search mode switching works properly when users clear the input
* or modify it directly, as the @complete event may not always trigger.
*
* @param event - The input event from the AutoCompletePlus component
* @note Known issue on empty input complete state:
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
*/
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
// Trigger search to handle mode switching between node and command search
if (inputValue === '') {
search('')
}
}
</script>

View File

@@ -26,6 +26,7 @@
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@execute-command="executeCommand"
/>
</template>
</Dialog>
@@ -46,6 +47,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useLitegraphService } from '@/services/litegraphService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -62,6 +64,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const commandStore = useCommandStore()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
@@ -109,6 +112,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const executeCommand = async (command: ComfyCommandImpl) => {
// Close the dialog immediately
closeDialog()
// Execute the command
await commandStore.execute(command.id)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)

View File

@@ -8,13 +8,10 @@
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
@@ -46,7 +43,6 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -90,7 +86,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
box-shadow: var(--bar-shadow);
--sidebar-width: 4rem;
--sidebar-icon-size: 1rem;
--sidebar-icon-size: 1.5rem;
}
.side-tool-bar-container.small-sidebar {

View File

@@ -58,12 +58,11 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -71,9 +70,8 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const isHelpCenterVisible = ref(false)
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -82,11 +80,11 @@ const sidebarLocation = computed(() =>
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
helpCenterStore.toggle()
isHelpCenterVisible.value = !isHelpCenterVisible.value
}
const closeHelpCenter = () => {
helpCenterStore.hide()
isHelpCenterVisible.value = false
}
// Initialize release store on mount
@@ -132,7 +130,6 @@ onMounted(async () => {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);

View File

@@ -19,29 +19,12 @@
@click="emit('click', $event)"
>
<template #icon>
<div class="side-bar-button-content">
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
}}</span>
</div>
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
</template>
</Button>
</template>
@@ -50,7 +33,6 @@
import Button from 'primevue/button'
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -59,17 +41,13 @@ const {
selected = false,
tooltip = '',
tooltipSuffix = '',
iconBadge = '',
label = '',
isSmall = false
iconBadge = ''
} = defineProps<{
icon?: string | Component
icon?: string
selected?: boolean
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
label?: string
isSmall?: boolean
}>()
const emit = defineEmits<{
@@ -96,21 +74,8 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
<style scoped>
.side-bar-button {
width: var(--sidebar-width);
height: calc(var(--sidebar-width) + 0.5rem);
border-radius: 0;
}
.side-tool-bar-end .side-bar-button {
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center whitespace-nowrap;
line-height: 1;
border-radius: 0;
}
.comfyui-body-left .side-bar-button.side-bar-button-selected,

View File

@@ -1,35 +0,0 @@
<template>
<SidebarIcon
:icon="TemplateIcon"
:tooltip="$t('sideToolbar.templates')"
:label="$t('sideToolbar.labels.templates')"
:is-small="isSmall"
class="templates-tab-button"
@click="openTemplates"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import SidebarIcon from './SidebarIcon.vue'
// Import the custom template icon
const TemplateIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
)
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const openTemplates = () => {
void commandStore.execute('Comfy.BrowseTemplates')
}
</script>

View File

@@ -30,17 +30,10 @@
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-filter-slash"
text
severity="secondary"
@click="resetOrganization"
/>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
text
severity="secondary"
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
@@ -146,7 +139,6 @@ import {
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
@@ -163,7 +155,6 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
const commandStore = useCommandStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)

View File

@@ -55,30 +55,9 @@
v-bind="props.action"
:href="item.url"
target="_blank"
:class="typeof item.class === 'function' ? item.class() : item.class"
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="isZoomCommand(item) ? handleZoomClick($event) : undefined"
>
<i
v-if="hasActiveStateSiblings(item)"
class="p-menubar-item-icon pi pi-check text-sm"
:class="{ invisible: !item.comfyCommand?.active?.() }"
/>
<span
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
class="p-menubar-item-icon"
:class="item.icon"
/>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<i
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
class="ml-auto"
:class="item.icon"
/>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
@@ -115,7 +94,6 @@ import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
@@ -177,7 +155,7 @@ const showManageExtensions = () => {
}
}
const extraMenuItems = computed<MenuItem[]>(() => [
const extraMenuItems: MenuItem[] = [
{ separator: true },
{
key: 'theme',
@@ -185,32 +163,26 @@ const extraMenuItems = computed<MenuItem[]>(() => [
},
{ separator: true },
{
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'pi pi-folder-open',
command: () => commandStore.execute('Comfy.BrowseTemplates')
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
},
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
])
]
const lightLabel = computed(() => t('menu.light'))
const darkLabel = computed(() => t('menu.dark'))
const lightLabel = t('menu.light')
const darkLabel = t('menu.dark')
const activeTheme = computed(() => {
return colorPaletteStore.completedActivePalette.light_theme
? lightLabel.value
: darkLabel.value
? lightLabel
: darkLabel
})
const onThemeChange = async () => {
@@ -243,7 +215,7 @@ const translatedItems = computed(() => {
items.splice(
helpIndex,
0,
...extraMenuItems.value,
...extraMenuItems,
...(helpItem
? [
{
@@ -265,36 +237,6 @@ const onMenuShow = () => {
}
})
}
const isZoomCommand = (item: MenuItem) => {
return (
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
)
}
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
if (item.comfyCommand) {
whileMouseDown(
event,
async () => {
await commandStore.execute(item.comfyCommand!.id)
},
50
)
}
}
const handleZoomClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// Prevent the menu from closing for zoom commands
return false
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
}
</script>
<style scoped>

View File

@@ -7,19 +7,18 @@ import { BottomPanelExtension } from '@/types/extensionTypes'
export const useShortcutsTab = (): BottomPanelExtension[] => {
const { t } = useI18n()
return [
{
id: 'shortcuts-essentials',
title: t('shortcuts.essentials'), // For command labels (collected by i18n workflow)
titleKey: 'shortcuts.essentials', // For dynamic translation in UI
title: t('shortcuts.essentials'),
component: markRaw(EssentialsPanel),
type: 'vue',
targetPanel: 'shortcuts'
},
{
id: 'shortcuts-view-controls',
title: t('shortcuts.viewControls'), // For command labels (collected by i18n workflow)
titleKey: 'shortcuts.viewControls', // For dynamic translation in UI
title: t('shortcuts.viewControls'),
component: markRaw(ViewControlsPanel),
type: 'vue',
targetPanel: 'shortcuts'

View File

@@ -9,8 +9,7 @@ export const useLogsTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'logs-terminal',
title: t('g.logs'), // For command labels (collected by i18n workflow)
titleKey: 'g.logs', // For dynamic translation in UI
title: t('g.logs'),
component: markRaw(LogsTerminal),
type: 'vue'
}
@@ -20,8 +19,7 @@ export const useCommandTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'command-terminal',
title: t('g.terminal'), // For command labels (collected by i18n workflow)
titleKey: 'g.terminal', // For dynamic translation in UI
title: t('g.terminal'),
component: markRaw(CommandTerminal),
type: 'vue'
}

View File

@@ -0,0 +1,80 @@
import { onMounted, ref, watch } from 'vue'
import type { Ref, WatchSource } from 'vue'
/**
* A composable that manages retriggerable CSS animations.
* Provides a boolean ref that can be toggled to restart CSS animations.
*
* @param trigger - Optional reactive source that triggers the animation when it changes
* @param options - Configuration options
* @returns An object containing the animation state ref
*
* @example
* ```vue
* <template>
* <div :class="{ 'animate-slide-up': shouldAnimate }">
* Content
* </div>
* </template>
*
* <script setup>
* const { shouldAnimate } = useRetriggerableAnimation(someReactiveTrigger)
* </script>
* ```
*/
export function useRetriggerableAnimation<T = any>(
trigger?: WatchSource<T> | Ref<T>,
options: {
animateOnMount?: boolean
animationDelay?: number
} = {}
) {
const { animateOnMount = true, animationDelay = 0 } = options
const shouldAnimate = ref(false)
/**
* Retriggers the animation by removing and re-adding the animation class
*/
const retriggerAnimation = () => {
// Remove animation class
shouldAnimate.value = false
// Force browser reflow to ensure the class removal is processed
void document.body.offsetHeight
// Re-add animation class in the next frame
requestAnimationFrame(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Trigger animation on mount if requested
if (animateOnMount) {
onMounted(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Watch for trigger changes to retrigger animation
if (trigger) {
watch(trigger, () => {
retriggerAnimation()
})
}
return {
shouldAnimate,
retriggerAnimation
}
}

View File

@@ -261,10 +261,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.14-2.80/Run (varies with model, mode & duration)'
const modelValue = String(modelWidget.value)
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (modelValue.includes('v2-master')) {
return '$1.40/Run'
} else if (
modelValue.includes('v1-6') ||
@@ -283,19 +280,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (modelValue.includes('v2-master')) {
if (durationValue.includes('10')) {
return '$2.80/Run'
}
return '$1.40/Run' // 5s default
} else if (
modelValue.includes('v2-1') ||
modelValue.includes('v1-6') ||
modelValue.includes('v1-5')
) {
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
if (modeValue.includes('pro')) {
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
} else {
@@ -428,12 +418,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Pricing matrix from CSV data based on mode string content
if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run' // price is the same as for v2-master model
}
return '$1.40/Run' // price is the same as for v2-master model
} else if (modeValue.includes('v2-master')) {
if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -573,32 +558,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
MinimaxTextToVideoNode: {
displayPrice: '$0.43/Run'
},
MinimaxHailuoVideoNode: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!resolutionWidget || !durationWidget) {
return '$0.28-0.56/Run (varies with resolution & duration)'
}
const resolution = String(resolutionWidget.value)
const duration = String(durationWidget.value)
if (resolution.includes('768P')) {
if (duration.includes('6')) return '$0.28/Run'
if (duration.includes('10')) return '$0.56/Run'
} else if (resolution.includes('1080P')) {
if (duration.includes('6')) return '$0.49/Run'
}
return '$0.43/Run' // default median
}
},
OpenAIDalle2: {
displayPrice: (node: LGraphNode): string => {
const sizeWidget = node.widgets?.find(
@@ -1319,22 +1278,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-flash')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00125/$0.01 per 1K tokens'
} else if (model.includes('gemini-2.5-pro')) {
return '$0.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
return 'Token-based'
}
},
GeminiImageNode: {
displayPrice: '$0.03 per 1K tokens'
},
// OpenAI nodes
OpenAIChatNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1365,12 +1317,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
} else if (model.includes('gpt-5-nano')) {
return '$0.00005/$0.0004 per 1K tokens'
} else if (model.includes('gpt-5-mini')) {
return '$0.00025/$0.002 per 1K tokens'
} else if (model.includes('gpt-5')) {
return '$0.00125/$0.01 per 1K tokens'
}
return 'Token-based'
}
@@ -1412,7 +1358,6 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],

View File

@@ -1,21 +1,16 @@
import { defineAsyncComponent, markRaw } from 'vue'
import { markRaw } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
const AiModelIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/ai-model'))
)
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
icon: AiModelIcon,
icon: 'pi pi-box',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,19 +1,14 @@
import { defineAsyncComponent, markRaw } from 'vue'
import { markRaw } from 'vue'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const NodeIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/node'))
)
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'node-library',
icon: NodeIcon,
icon: 'pi pi-book',
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
label: 'sideToolbar.labels.nodes',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

View File

@@ -15,7 +15,6 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
},
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
label: 'sideToolbar.labels.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}

View File

@@ -1,20 +1,16 @@
import { defineAsyncComponent, markRaw } from 'vue'
import { markRaw } from 'vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const WorkflowIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/workflow'))
)
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
return {
id: 'workflows',
icon: WorkflowIcon,
icon: 'pi pi-folder-open',
iconBadge: () => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
@@ -26,7 +22,6 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
},
title: 'sideToolbar.workflows',
tooltip: 'sideToolbar.workflows',
label: 'sideToolbar.labels.workflows',
component: markRaw(WorkflowsSidebarTab),
type: 'vue'
}

View File

@@ -21,11 +21,8 @@ import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useToastStore } from '@/stores/toastStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -279,7 +276,6 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',
label: 'Fit view to selected nodes',
menubarLabel: 'Zoom to fit',
category: 'view-controls' as const,
function: () => {
if (app.canvas.empty) {
@@ -305,7 +301,6 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ToggleLinkVisibility',
icon: 'pi pi-eye',
label: 'Canvas Toggle Link Visibility',
menubarLabel: 'Node Links',
versionAdded: '1.3.6',
function: (() => {
@@ -327,15 +322,12 @@ export function useCoreCommands(): ComfyCommand[] {
)
}
}
})(),
active: () =>
useSettingStore().get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK
})()
},
{
id: 'Comfy.Canvas.ToggleMinimap',
icon: 'pi pi-map',
label: 'Canvas Toggle Minimap',
menubarLabel: 'Minimap',
versionAdded: '1.24.1',
function: async () => {
const settingStore = useSettingStore()
@@ -343,8 +335,7 @@ export function useCoreCommands(): ComfyCommand[] {
'Comfy.Minimap.Visible',
!settingStore.get('Comfy.Minimap.Visible')
)
},
active: () => useSettingStore().get('Comfy.Minimap.Visible')
}
},
{
id: 'Comfy.QueuePrompt',
@@ -548,25 +539,21 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Workspace.ToggleBottomPanel',
icon: 'pi pi-list',
label: 'Toggle Bottom Panel',
menubarLabel: 'Bottom Panel',
versionAdded: '1.3.22',
category: 'view-controls' as const,
function: () => {
bottomPanelStore.toggleBottomPanel()
},
active: () => bottomPanelStore.bottomPanelVisible
}
},
{
id: 'Workspace.ToggleFocusMode',
icon: 'pi pi-eye',
label: 'Toggle Focus Mode',
menubarLabel: 'Focus Mode',
versionAdded: '1.3.27',
category: 'view-controls' as const,
function: () => {
useWorkspaceStore().toggleFocusMode()
},
active: () => useWorkspaceStore().focusMode
}
},
{
id: 'Comfy.Graph.FitGroupToContents',
@@ -807,53 +794,8 @@ export function useCoreCommands(): ComfyCommand[] {
}
const { node } = res
canvas.select(node)
canvasStore.updateSelectedItems()
}
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const subgraphNode = app.canvas.selectedItems.values().next().value
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline',
label: 'Manager',
function: () => {
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.ToggleHelpCenter',
icon: 'pi pi-question-circle',
label: 'Help Center',
function: () => {
useHelpCenterStore().toggle()
},
active: () => useHelpCenterStore().isVisible
},
{
id: 'Comfy.ToggleCanvasInfo',
icon: 'pi pi-info-circle',
label: 'Canvas Performance',
function: async () => {
const settingStore = useSettingStore()
const currentValue = settingStore.get('Comfy.Graph.CanvasInfo')
await settingStore.set('Comfy.Graph.CanvasInfo', !currentValue)
},
active: () => useSettingStore().get('Comfy.Graph.CanvasInfo')
},
{
id: 'Workspace.ToggleBottomPanel.Shortcuts',
icon: 'pi pi-key',
@@ -863,21 +805,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
bottomPanelStore.togglePanel('shortcuts')
}
},
{
id: 'Comfy.Graph.ExitSubgraph',
icon: 'pi pi-arrow-up',
label: 'Exit Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = useCanvasStore().getCanvas()
const navigationStore = useSubgraphNavigationStore()
if (!canvas.graph) return
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
}
]

View File

@@ -19,7 +19,6 @@ export const useLitegraphSettings = () => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
canvasStore.canvas.show_info = canvasInfoEnabled
canvasStore.canvas.draw(false, true)
}
})

View File

@@ -2,14 +2,13 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -17,17 +16,9 @@ interface GraphCallbacks {
onConnectionChange?: (node: LGraphNode) => void
}
export type MinimapOptionKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
@@ -36,27 +27,6 @@ export function useMinimap() {
const visible = ref(true)
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
await settingStore.set(key, value)
needsFullRedraw.value = true
updateMinimap()
}
const initialized = ref(false)
const bounds = ref({
minX: 0,
@@ -93,22 +63,10 @@ export function useMinimap() {
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const nodeColorDefault = computed(
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
)
const linkColor = computed(
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const groupColor = computed(() =>
isLightTheme.value ? '#A2D3EC' : '#1F547A'
)
const groupColorDefault = computed(
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
)
const bypassColor = computed(() =>
isLightTheme.value ? '#DBDBDB' : '#4B184B'
)
const containerRect = ref({
left: 0,
@@ -148,11 +106,7 @@ export function useMinimap() {
}
const canvas = computed(() => canvasStore.canvas)
const graph = computed(() => {
// If we're in a subgraph, use that; otherwise use the canvas graph
const activeSubgraph = workflowStore.activeSubgraph
return activeSubgraph || canvas.value?.graph
})
const graph = ref(app.canvas?.graph)
const containerStyles = computed(() => ({
width: `${width}px`,
@@ -162,14 +116,6 @@ export function useMinimap() {
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
@@ -243,35 +189,6 @@ export function useMinimap() {
return Math.min(scaleX, scaleY) * 0.9
}
const renderGroups = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._groups || g._groups.length === 0) return
for (const group of g._groups) {
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = group.size[0] * scale.value
const h = group.size[1] * scale.value
let color = groupColor.value
if (nodeColors.value) {
color = group.color ?? groupColorDefault.value
if (isLightTheme.value) {
color = adjustColor(color, { opacity: 0.5 })
}
}
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
@@ -286,29 +203,9 @@ export function useMinimap() {
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
let color = nodeColor.value
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
color = bypassColor.value
} else if (nodeColors.value) {
color = nodeColorDefault.value
if (node.bgcolor) {
color = isLightTheme.value
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
// Render solid node blocks
ctx.fillStyle = color
ctx.fillStyle = nodeColor.value
ctx.fillRect(x, y, w, h)
if (renderError.value && node.has_errors) {
ctx.strokeStyle = '#FF0000'
ctx.lineWidth = 0.3
ctx.strokeRect(x, y, w, h)
}
}
}
@@ -321,9 +218,9 @@ export function useMinimap() {
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 0.3
ctx.lineWidth = 1.4
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
@@ -407,15 +304,8 @@ export function useMinimap() {
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
if (showGroups.value) {
renderGroups(ctx, offsetX, offsetY)
}
if (showLinks.value) {
renderConnections(ctx, offsetX, offsetY)
}
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
@@ -632,8 +522,7 @@ export function useMinimap() {
c.setDirty(true, true)
}
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
let originalCallbacks: GraphCallbacks = {}
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
@@ -647,18 +536,11 @@ export function useMinimap() {
const g = graph.value
if (!g) return
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
originalCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
@@ -683,19 +565,15 @@ export function useMinimap() {
const g = graph.value
if (!g) return
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
'Attempted to cleanup event listeners for graph that was never set up'
)
return
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
}
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
originalCallbacksMap.delete(g.id)
}
const init = async () => {
@@ -768,19 +646,6 @@ export function useMinimap() {
{ immediate: true, flush: 'post' }
)
// Watch for graph changes (e.g., when navigating to/from subgraphs)
watch(graph, (newGraph, oldGraph) => {
if (newGraph && newGraph !== oldGraph) {
cleanupEventListeners()
setupEventListeners()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}
})
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
@@ -825,16 +690,9 @@ export function useMinimap() {
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
@@ -843,7 +701,6 @@ export function useMinimap() {
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef,
updateOption
setMinimapRef
}
}

View File

@@ -190,11 +190,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'k'
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
}
]

View File

@@ -1,9 +1,8 @@
export const CORE_MENU_COMMANDS = [
[[], ['Comfy.NewBlankWorkflow']],
[[], []], // Separator after New
[['File'], ['Comfy.OpenWorkflow']],
[['Workflow'], ['Comfy.NewBlankWorkflow']],
[['Workflow'], ['Comfy.OpenWorkflow', 'Comfy.BrowseTemplates']],
[
['File'],
['Workflow'],
[
'Comfy.SaveWorkflow',
'Comfy.SaveWorkflowAs',
@@ -12,6 +11,8 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[
['Help'],

View File

@@ -300,8 +300,7 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' },
{ value: 'ar', text: 'عربي' }
{ value: 'es', text: 'Español' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
@@ -790,11 +789,11 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'combo',
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Drag Navigation' }
{ value: 'legacy', text: 'Left-Click Pan (Legacy)' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'legacy'
'1.25.0': 'standard'
}
},
{
@@ -831,41 +830,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Minimap.NodeColors',
name: 'Display node with its original color on minimap',
type: 'hidden',
defaultValue: false,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowLinks',
name: 'Display links on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowGroups',
name: 'Display node groups on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderBypassState',
name: 'Render bypass state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderErrorState',
name: 'Render error state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',

View File

@@ -3,7 +3,6 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode
@@ -1173,7 +1172,8 @@ export class GroupNodeHandler {
// @ts-expect-error fixme ts strict error
getExtraMenuOptions?.apply(this, arguments)
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
// @ts-expect-error fixme ts strict error
let optionIndex = options.findIndex((o) => o.content === 'Outputs')
if (optionIndex === -1) optionIndex = options.length
else optionIndex++
options.splice(
@@ -1634,57 +1634,6 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node (Deprecated)`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1780,9 +1729,6 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -3976,19 +3976,13 @@ class UIManager {
const mainImageFilename =
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
let combinedImageFilename: string | null | undefined
if (
const combinedImageFilename =
ComfyApp.clipspace?.combinedIndex !== undefined &&
ComfyApp.clipspace?.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length &&
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src
) {
combinedImageFilename = new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
} else {
combinedImageFilename = undefined
}
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src
? new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
: undefined
const imageLayerFilenames =
mainImageFilename !== undefined

View File

@@ -1,9 +1,5 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json'
import ar from './locales/ar/main.json'
import arNodes from './locales/ar/nodeDefs.json'
import arSettings from './locales/ar/settings.json'
import enCommands from './locales/en/commands.json'
import en from './locales/en/main.json'
import enNodes from './locales/en/nodeDefs.json'
@@ -54,8 +50,7 @@ const messages = {
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings)
es: buildLocale(es, esNodes, esCommands, esSettings)
}
export const i18n = createI18n({

View File

@@ -495,16 +495,6 @@
padding-left: 12px;
}
.graphmenu-entry.danger,
.litemenu-entry.danger {
color: var(--error-text) !important;
}
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
color: var(--error-text) !important;
opacity: 0.8;
}
.graphmenu-entry.disabled {
opacity: 0.3;
}

View File

@@ -43,19 +43,6 @@ export class CanvasPointer {
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
static #maxClickDrift2 = this.#maxClickDrift ** 2
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
static trackpadThreshold = 60
/**
* The minimum time between "wheel" events to allow switching between trackpad
* and mouse modes.
*
* This prevents trackpad "flick" panning from registering as regular mouse wheel.
* After a flick gesture is complete, the automatic wheel events are sent with
* reduced frequency, but much higher deltaX and deltaY values.
*/
static trackpadMaxGap = 200
/** The element this PointerState should capture input against when dragging. */
element: Element
/** Pointer ID used by drag capture. */
@@ -90,9 +77,6 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
@@ -273,35 +257,6 @@ export class CanvasPointer {
delete this.onDragStart
}
/**
* Checks if the given wheel event is part of a continued trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
*/
#isContinuationOfGesture(e: WheelEvent): boolean {
const { lastTrackpadEvent } = this
if (!lastTrackpadEvent) return false
return (
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
)
}
/**
* Checks if the given wheel event is part of a trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
*/
isTrackpadGesture(e: WheelEvent): boolean {
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
return true
}
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
}
/**
* Resets the state of this {@link CanvasPointer} instance.
*

View File

@@ -1,5 +1,7 @@
import { clamp } from 'lodash'
import type { Point, Rect } from './interfaces'
import { LGraphCanvas, clamp } from './litegraph'
import { LGraphCanvas } from './litegraph'
import { distance } from './measure'
// used by some widgets to render a curve editor

View File

@@ -18,7 +18,6 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
import type {
DefaultConnectionColors,
Dictionary,
HasBoundingRect,
IContextMenuValue,
INodeInputSlot,
INodeOutputSlot,
@@ -27,8 +26,7 @@ import type {
MethodNames,
OptionalProps,
Point,
Positionable,
Size
Positionable
} from './interfaces'
import { LiteGraph, SubgraphNode } from './litegraph'
import {
@@ -1570,9 +1568,6 @@ export class LGraph
boundingRect
)
//Correct for title height. It's included in bounding box, but not _posSize
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
// Add the subgraph node to the graph
this.add(subgraphNode)
@@ -1668,271 +1663,6 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
unpackSubgraph(subgraphNode: SubgraphNode) {
if (!(subgraphNode instanceof SubgraphNode))
throw new Error('Can only unpack Subgraph Nodes')
this.beforeChange()
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
const positionables = [
...subgraphNode.subgraph.nodes,
...subgraphNode.subgraph.reroutes.values(),
...subgraphNode.subgraph.groups
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
return {
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
}
})
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
const toSelect: Positionable[] = []
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
const nodeIdMap = new Map<NodeId, NodeId>()
for (const n_info of movedNodes) {
const node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
throw new Error('Node not found')
}
nodeIdMap.set(n_info.id, ++this.last_node_id)
node.id = this.last_node_id
n_info.id = this.last_node_id
this.add(node, true)
node.configure(n_info)
node.pos[0] += offsetX
node.pos[1] += offsetY
for (const input of node.inputs) {
input.link = null
}
for (const output of node.outputs) {
output.links = []
}
toSelect.push(node)
}
const groups = structuredClone(
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
)
for (const g_info of groups) {
const group = new LGraphGroup(g_info.title, g_info.id)
this.add(group, true)
group.configure(g_info)
group.pos[0] += offsetX
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
tid: NodeId
tslot: number
id: LinkId
iparent?: RerouteId
eparent?: RerouteId
externalFirst: boolean
}[] = []
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
if (!outerLinkId) {
console.error('Missing Link ID when unpacking')
continue
}
const outerLink = this.links[outerLinkId]
link.origin_id = outerLink.origin_id
link.origin_slot = outerLink.origin_slot
externalParentId = outerLink.parentId
} else {
const origin_id = nodeIdMap.get(link.origin_id)
if (!origin_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: sublink.target_id,
tslot: sublink.target_slot,
id: link.id,
iparent: link.parentId,
eparent: sublink.parentId,
externalFirst: true
})
sublink.parentId = undefined
}
continue
} else {
const target_id = nodeIdMap.get(link.target_id)
if (!target_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.target_id = target_id
}
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: link.target_id,
tslot: link.target_slot,
id: link.id,
iparent: link.parentId,
eparent: externalParentId,
externalFirst: false
})
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const newLink of newLinks) {
let created: LLink | null | undefined
if (newLink.oid == SUBGRAPH_INPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.tid]
created = this.inputNode.slots[newLink.oslot].connect(
tnode.inputs[newLink.tslot],
tnode
)
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.oid]
created = this.outputNode.slots[newLink.tslot].connect(
tnode.outputs[newLink.oslot],
tnode
)
} else {
created = this._nodes_by_id[newLink.oid].connect(
newLink.oslot,
this._nodes_by_id[newLink.tid],
newLink.tslot
)
}
if (!created) {
console.error('Failed to create link')
continue
}
//This is a little unwieldy since Map.has isn't a type guard
const linkIds = linkIdMap.get(newLink.id) ?? []
linkIds.push(created.id)
if (!linkIdMap.has(newLink.id)) {
linkIdMap.set(newLink.id, linkIds)
}
newLink.id = created.id
}
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
for (const newLink of newLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined = undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) throw new Error('Broken Id link when unpacking')
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) throw new Error('Broken Id link when unpacking')
parentId = oldReroute.parentId
}
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
}
for (const nodeId of nodeIdMap.values()) {
const node = this._nodes_by_id[nodeId]
node._setConcreteSlots()
node.arrange()
}
this.canvasAction((c) => c.selectItems(toSelect))
this.afterChange()
}
/**
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
* Not intended to be run from subgraphs.
@@ -2601,9 +2331,6 @@ export class Subgraph
nodes: this.nodes.map((node) => node.serialize()),
groups: this.groups.map((group) => group.serialize()),
links: [...this.links.values()].map((x) => x.asSerialisable()),
reroutes: this.reroutes.size
? [...this.reroutes.values()].map((x) => x.asSerialisable())
: undefined,
extra: this.extra
}
}

View File

@@ -3455,6 +3455,10 @@ export class LGraphCanvas
processMouseWheel(e: WheelEvent): void {
if (!this.graph || !this.allow_dragcanvas) return
// TODO: Mouse wheel zoom rewrite
// @ts-expect-error wheelDeltaY is non-standard property on WheelEvent
const delta = e.wheelDeltaY ?? e.detail * -60
this.adjustMouseEvent(e)
const pos: Point = [e.clientX, e.clientY]
@@ -3462,34 +3466,35 @@ export class LGraphCanvas
let { scale } = this.ds
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
} else {
// Mouse wheel - use stepped scaling
if (e.deltaY < 0) {
scale *= this.zoom_speed
} else if (e.deltaY > 0) {
if (
LiteGraph.canvasNavigationMode === 'legacy' ||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
) {
if (delta > 0) {
scale *= this.zoom_speed
} else if (delta < 0) {
scale *= 1 / this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
} else if (
LiteGraph.macTrackpadGestures &&
(!LiteGraph.macGesturesRequireMac || navigator.userAgent.includes('Mac'))
) {
if (e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.deltaY > 0) {
scale *= 1 / this.zoom_speed
} else if (e.deltaY < 0) {
scale *= this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
}
} else {
// Standard mode without ctrl - use wheel / gestures to pan
// Trackpads and mice work on significantly different scales
const factor = isTrackpad ? 0.18 : 0.008_333
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
} else if (e.ctrlKey) {
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
} else if (e.shiftKey) {
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
}
}
@@ -3607,7 +3612,6 @@ export class LGraphCanvas
subgraphs: []
}
// NOTE: logic for traversing nested subgraphs depends on this being a set.
const subgraphs = new Set<Subgraph>()
// Create serialisable objects
@@ -3646,13 +3650,8 @@ export class LGraphCanvas
}
// Add unique subgraph entries
// NOTE: subgraphs is appended to mid iteration.
// TODO: Must find all nested subgraphs
for (const subgraph of subgraphs) {
for (const node of subgraph.nodes) {
if (node instanceof SubgraphNode) {
subgraphs.add(node.subgraph)
}
}
const cloned = subgraph.clone(true).asSerialisable()
serialisable.subgraphs.push(cloned)
}
@@ -3769,19 +3768,12 @@ export class LGraphCanvas
created.push(group)
}
// Update subgraph ids with nesting
function updateSubgraphIds(nodes: { type: string }[]) {
for (const info of nodes) {
const subgraph = results.subgraphs.get(info.type)
if (!subgraph) continue
info.type = subgraph.id
updateSubgraphIds(subgraph.nodes)
}
}
updateSubgraphIds(parsed.nodes)
// Nodes
for (const info of parsed.nodes) {
// If the subgraph was cloned, update references to use the new subgraph ID.
const subgraph = results.subgraphs.get(info.type)
if (subgraph) info.type = subgraph.id
const node = info.type == null ? null : LiteGraph.createNode(info.type)
if (!node) {
// failedNodes.push(info)
@@ -4140,6 +4132,7 @@ export class LGraphCanvas
const selected = this.selectedItems
if (!selected.size) return
const initialSelectionSize = selected.size
let wasSelected: Positionable | undefined
for (const sel of selected) {
if (sel === keepSelected) {
@@ -4180,8 +4173,12 @@ export class LGraphCanvas
}
}
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
// Only set selectionChanged if selection actually changed
const finalSelectionSize = selected.size
if (initialSelectionSize !== finalSelectionSize) {
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
}
}
/** @deprecated See {@link LGraphCanvas.deselectAll} */
@@ -8247,9 +8244,7 @@ export class LGraphCanvas
if (_slot.removable) {
menu_info.push(null)
menu_info.push(
_slot.locked
? 'Cannot remove'
: { content: 'Remove Slot', slot, className: 'danger' }
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
)
}

View File

@@ -414,18 +414,6 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
*/
disconnect(network: LinkNetwork, keepReroutes?: 'input' | 'output'): void {
// Clean up the target node's input slot
if (this.target_id !== -1) {
const targetNode = network.getNodeById(this.target_id)
if (targetNode) {
const targetInput = targetNode.inputs?.[this.target_slot]
if (targetInput && targetInput.link === this.id) {
targetInput.link = null
targetNode.setDirtyCanvas?.(true, false)
}
}
}
const reroutes = LLink.getReroutes(network, this)
const lastReroute = reroutes.at(-1)

View File

@@ -284,7 +284,6 @@ export class LiteGraphGlobal {
]
/**
* @deprecated Removed; has no effect.
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
* Tested on MacBook M4 Pro.
* @default false
@@ -293,7 +292,6 @@ export class LiteGraphGlobal {
macTrackpadGestures: boolean = false
/**
* @deprecated Removed; has no effect.
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
* only be enabled when the browser user agent includes "Mac".
* @default true

View File

@@ -135,10 +135,6 @@ export class FloatingRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
}
connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -651,20 +651,6 @@ export class LinkConnector {
if (!input) throw new Error('No input slot found for link.')
for (const link of renderLinks) {
// Validate the connection type before proceeding
if (
'canConnectToSubgraphInput' in link &&
!link.canConnectToSubgraphInput(input)
) {
console.warn(
'Invalid connection type',
link.fromSlot.type,
'->',
input.type
)
continue
}
link.connectToSubgraphInput(input, this.events)
}
} else {
@@ -909,14 +895,6 @@ export class LinkConnector {
)
}
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
return this.renderLinks.some(
(link) =>
'canConnectToSubgraphInput' in link &&
link.canConnectToSubgraphInput(input)
)
}
/**
* Checks if a reroute is a valid drop target for any of the links being connected.
* @param reroute The reroute that would be dropped on.

View File

@@ -55,10 +55,6 @@ export class MovingOutputLink extends MovingLinkBase {
return reroute.origin_id !== this.outputNode.id
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToInput(): never {
throw new Error('MovingOutputLink cannot connect to an input.')
}

View File

@@ -58,10 +58,6 @@ export class ToOutputRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToOutput(
node: LGraphNode,
output: INodeOutputSlot,

View File

@@ -1,9 +1,10 @@
import { clamp } from 'lodash'
import type {
ReadOnlyRect,
ReadOnlySize,
Size
} from '@/lib/litegraph/src/interfaces'
import { clamp } from '@/lib/litegraph/src/litegraph'
/**
* Basic width and height, with min/max constraints.

View File

@@ -134,7 +134,7 @@ export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { type LinkId, LLink } from './LLink'
export { clamp, createBounds } from './measure'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'
export {
type ExecutableLGraphNode,

View File

@@ -450,7 +450,3 @@ export function alignOutsideContainer(
}
return rect
}
export function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}

View File

@@ -170,13 +170,28 @@ export abstract class SubgraphIONodeBase<
}
}
/**
* Handles double-click on an IO slot to rename it.
* @param slot The slot that was double-clicked.
* @param event The event that triggered the double-click.
*/
protected handleSlotDoubleClick(
slot: TSlot,
event: CanvasPointerEvent
): void {
// Only allow renaming non-empty slots
if (slot !== this.emptySlot) {
this.#promptForSlotRename(slot, event)
}
}
/**
* Shows the context menu for an IO slot.
* @param slot The slot to show the context menu for.
* @param event The event that triggered the context menu.
*/
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
const options: (IContextMenuValue | null)[] = this.#getSlotMenuOptions(slot)
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
if (!(options.length > 0)) return
new LiteGraph.ContextMenu(options, {
@@ -193,26 +208,20 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot to get the context menu options for.
* @returns The context menu options.
*/
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
const options: (IContextMenuValue | null)[] = []
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
const options: IContextMenuValue[] = []
// Disconnect option if slot has connections
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
options.push({ content: 'Disconnect Links', value: 'disconnect' })
}
// Rename slot option (except for the empty slot)
// Remove / rename slot option (except for the empty slot)
if (slot !== this.emptySlot) {
options.push({ content: 'Rename Slot', value: 'rename' })
}
if (slot !== this.emptySlot) {
options.push(null) // separator
options.push({
content: 'Remove Slot',
value: 'remove',
className: 'danger'
})
options.push(
{ content: 'Remove Slot', value: 'remove' },
{ content: 'Rename Slot', value: 'rename' }
)
}
return options
@@ -245,16 +254,7 @@ export abstract class SubgraphIONodeBase<
// Rename the slot
case 'rename':
if (slot !== this.emptySlot) {
this.subgraph.canvasAction((c) =>
c.prompt(
'Slot name',
slot.name,
(newName: string) => {
if (newName) this.renameSlot(slot, newName)
},
event
)
)
this.#promptForSlotRename(slot, event)
}
break
}
@@ -262,6 +262,24 @@ export abstract class SubgraphIONodeBase<
this.subgraph.setDirtyCanvas(true)
}
/**
* Prompts the user to rename a slot.
* @param slot The slot to rename.
* @param event The event that triggered the rename.
*/
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
this.subgraph.canvasAction((c) =>
c.prompt(
'Slot name',
slot.displayName,
(newName: string) => {
if (newName) this.renameSlot(slot, newName)
},
event
)
)
}
/** Arrange the slots in this node. */
arrange(): void {
const { minWidth, roundedRadius } = SubgraphIONodeBase

View File

@@ -4,7 +4,6 @@ import { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type {
DefaultConnectionColors,
INodeInputSlot,
@@ -51,18 +50,17 @@ export class SubgraphInputNode
// Left-click handling for dragging connections
if (e.button === 0) {
for (const slot of this.allSlots) {
const slotBounds = Rectangle.fromCentre(
slot.pos,
slot.boundingRect.height
)
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
// Check if click is within the full slot area (including label)
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
pointer.onDragStart = () => {
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
}
pointer.onDragEnd = (eUp) => {
linkConnector.dropLinks(this.subgraph, eUp)
}
pointer.onDoubleClick = () => {
this.handleSlotDoubleClick(slot, e)
}
pointer.finally = () => {
linkConnector.reset(true)
}

View File

@@ -16,10 +16,7 @@ import type {
GraphOrSubgraph,
Subgraph
} from '@/lib/litegraph/src/subgraph/Subgraph'
import type {
ExportedSubgraphInstance,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
@@ -174,12 +171,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
) {
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
input._listenerController?.abort()
input._listenerController = new AbortController()
const { signal } = input._listenerController
@@ -215,12 +207,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override configure(info: ExportedSubgraphInstance): void {
for (const input of this.inputs) {
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
input._listenerController?.abort()
}
this.inputs.length = 0
@@ -269,14 +256,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (!subgraphInput) {
// Skip inputs that don't exist in the subgraph definition
// This can happen when loading workflows with dynamically added inputs
console.warn(
`[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping`
if (!subgraphInput)
throw new Error(
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
)
continue
}
this.#addSubgraphInputListeners(subgraphInput, input)
@@ -535,44 +518,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
for (const input of this.inputs) {
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
input._listenerController?.abort()
}
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization
for (let i = 0; i < this.widgets.length; i++) {
const widget = this.widgets[i]
const input = this.inputs.find((inp) => inp.name === widget.name)
if (input) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (subgraphInput) {
// Find all widgets connected to this subgraph input
const connectedWidgets = subgraphInput.getConnectedWidgets()
// Update the value of all connected widgets
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = widget.value
}
}
}
}
// Call parent serialize method
return super.serialize()
}
}

View File

@@ -4,7 +4,6 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type {
DefaultConnectionColors,
INodeInputSlot,
@@ -51,18 +50,17 @@ export class SubgraphOutputNode
// Left-click handling for dragging connections
if (e.button === 0) {
for (const slot of this.allSlots) {
const slotBounds = Rectangle.fromCentre(
slot.pos,
slot.boundingRect.height
)
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
// Check if click is within the full slot area (including label)
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
pointer.onDragStart = () => {
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
}
pointer.onDragEnd = (eUp) => {
linkConnector.dropLinks(this.subgraph, eUp)
}
pointer.onDoubleClick = () => {
this.handleSlotDoubleClick(slot, e)
}
pointer.finally = () => {
linkConnector.reset(true)
}

View File

@@ -1,8 +0,0 @@
export function omitBy<T extends object>(
obj: T,
predicate: (value: any) => boolean
): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_key, value]) => !predicate(value))
) as Partial<T>
}

View File

@@ -1,5 +1,7 @@
import { clamp } from 'lodash'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IComboWidget,
IStringComboWidget

View File

@@ -1,4 +1,5 @@
import { clamp } from '@/lib/litegraph/src/litegraph'
import { clamp } from 'lodash'
import type { IKnobWidget } from '@/lib/litegraph/src/types/widgets'
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'

View File

@@ -1,4 +1,5 @@
import { clamp } from '@/lib/litegraph/src/litegraph'
import { clamp } from 'lodash'
import type { ISliderWidget } from '@/lib/litegraph/src/types/widgets'
import {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect } from 'vitest'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
@@ -14,84 +14,4 @@ describe('LLink', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('disconnect', () => {
it('should clear the target input link reference when disconnecting', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode)
graph.add(targetNode)
// Add slots
sourceNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Connect the nodes
const link = sourceNode.connect(0, targetNode, 0)
expect(link).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link?.id)
// Mock setDirtyCanvas
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
// Disconnect the link
link?.disconnect(graph)
// Verify the target input's link reference is cleared
expect(targetNode.inputs[0].link).toBeNull()
// Verify setDirtyCanvas was called
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
})
it('should handle disconnecting when target node is not found', () => {
// Create a link with invalid target
const graph = new LGraph()
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
// Should not throw when disconnecting
expect(() => link.disconnect(graph)).not.toThrow()
})
it('should only clear link reference if it matches the current link id', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode1 = new LGraphNode('Source1')
const sourceNode2 = new LGraphNode('Source2')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode1)
graph.add(sourceNode2)
graph.add(targetNode)
// Add slots
sourceNode1.addOutput('out', 'number')
sourceNode2.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Create first connection
const link1 = sourceNode1.connect(0, targetNode, 0)
expect(link1).toBeDefined()
// Disconnect first connection
targetNode.disconnectInput(0)
// Create second connection
const link2 = sourceNode2.connect(0, targetNode, 0)
expect(link2).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link2?.id)
// Try to disconnect the first link (which is already disconnected)
// It should not affect the current connection
link1?.disconnect(graph)
// The input should still have the second link
expect(targetNode.inputs[0].link).toBe(link2?.id)
})
})
})

View File

@@ -1,310 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { MovingOutputLink } from '@/lib/litegraph/src/canvas/MovingOutputLink'
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
describe('LinkConnector SubgraphInput connection validation', () => {
let connector: LinkConnector
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})
describe('MovingOutputLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Verify the method exists
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
})
it('should validate type compatibility correctly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid link (number -> number)
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
subgraph._links.set(validLink.id, validLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
// Create invalid link (string -> number)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(invalidLink.id, invalidLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const numberInput = subgraph.inputs[0]
// Test validation
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
false
)
})
it('should handle wildcard types', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'wildcard_input', type: '*' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
const wildcardInput = subgraph.inputs[0]
// Wildcard should accept any type
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
})
})
describe('ToOutputRenderLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
// Create a minimal valid setup
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
node.id = 1
node.addInput('test_in', 'number')
subgraph.add(node)
const slot = node.inputs[0] as NodeInputSlot
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
// Verify the method exists
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
})
})
describe('dropOnIoNode validation', () => {
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create an invalid link (string output -> string input, but subgraph expects number)
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Mock console.warn to verify it's called
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it's NOT called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the invalid connection was skipped
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Invalid connection type',
'string',
'->',
'number'
)
expect(connectSpy).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('should allow valid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
// Create a valid link (number -> number)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it IS called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the valid connection was made
expect(connectSpy).toHaveBeenCalledWith(
subgraph.inputs[0],
connector.events
)
})
})
describe('isSubgraphInputValidDrop', () => {
it('should check if render links can connect to SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid and invalid links
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(validLink.id, validLink)
subgraph._links.set(invalidLink.id, invalidLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const subgraphInput = subgraph.inputs[0]
// Test with only invalid link
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
// Test with valid link
connector.renderLinks.length = 0
connector.renderLinks.push(validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
// Test with mixed links
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink, validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
})
it('should handle render links without canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
// Create a mock render link without the method
const mockLink = {
fromSlot: { type: 'number' }
// No canConnectToSubgraphInput method
} as any
connector.renderLinks.push(mockLink)
const subgraphInput = subgraph.inputs[0]
// Should return false as the link doesn't have the method
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
})
})
})

View File

@@ -1,7 +1,8 @@
import { clamp } from 'lodash'
import { beforeEach, describe, expect, vi } from 'vitest'
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
import { LGraphCanvas, LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'

View File

@@ -1,200 +0,0 @@
import { assert, describe, expect, it } from 'vitest'
import {
ISlotType,
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from './fixtures/subgraphHelpers'
function createNode(
graph: LGraph,
inputs: ISlotType[] = [],
outputs: ISlotType[] = [],
title?: string
) {
const type = JSON.stringify({ inputs, outputs })
if (!LiteGraph.registered_node_types[type]) {
class testnode extends LGraphNode {
constructor(title: string) {
super(title)
let i_count = 0
for (const input of inputs) this.addInput('input_' + i_count++, input)
let o_count = 0
for (const output of outputs)
this.addOutput('output_' + o_count++, output)
}
}
LiteGraph.registered_node_types[type] = testnode
}
const node = LiteGraph.createNode(type, title)
if (!node) {
throw new Error('Failed to create node')
}
graph.add(node)
return node
}
describe('SubgraphConversion', () => {
describe('Subgraph Unpacking Functionality', () => {
it('Should keep interior nodes and links', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const node1 = createNode(subgraph, [], ['number'])
const node2 = createNode(subgraph, ['number'])
node1.connect(0, node2, 0)
graph.unpackSubgraph(subgraphNode)
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
})
it('Should merge boundry links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }],
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const innerNode1 = createNode(subgraph, [], ['number'])
const innerNode2 = createNode(subgraph, ['number'], [])
subgraph.inputNode.slots[0].connect(innerNode2.inputs[0], innerNode2)
subgraph.outputNode.slots[0].connect(innerNode1.outputs[0], innerNode1)
const outerNode1 = createNode(graph, [], ['number'])
const outerNode2 = createNode(graph, ['number'])
outerNode1.connect(0, subgraphNode, 0)
subgraphNode.connect(0, outerNode2, 0)
graph.unpackSubgraph(subgraphNode)
expect(graph.nodes.length).toBe(4)
expect(graph.links.size).toBe(2)
})
it('Should keep reroutes and groups', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
subgraph.add(new LGraphGroup())
subgraph.createReroute([10, 10], innerLink)
graph.createReroute([10, 10], outerLink)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(2)
expect(graph.groups.length).toBe(1)
})
it('Should map reroutes onto split outputs', () => {
const subgraph = createTestSubgraph({
outputs: [
{ name: 'value1', type: 'number' },
{ name: 'value2', type: 'number' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number', 'number'])
const innerLink1 = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
const innerLink2 = subgraph.outputNode.slots[1].connect(
inner.outputs[1],
inner
)
const outer1 = createNode(graph, ['number'])
const outer2 = createNode(graph, ['number'])
const outer3 = createNode(graph, ['number'])
const outerLink1 = subgraphNode.connect(0, outer1, 0)
assert(innerLink1 && innerLink2 && outerLink1)
subgraphNode.connect(0, outer2, 0)
subgraphNode.connect(1, outer3, 0)
subgraph.createReroute([10, 10], innerLink1)
subgraph.createReroute([10, 20], innerLink2)
graph.createReroute([10, 10], outerLink1)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(3)
expect(graph.links.size).toBe(3)
let linkRefCount = 0
for (const reroute of graph.reroutes.values()) {
linkRefCount += reroute.linkIds.size
}
expect(linkRefCount).toBe(4)
})
it('Should map reroutes onto split inputs', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'value1', type: 'number' },
{ name: 'value2', type: 'number' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner1 = createNode(subgraph, ['number', 'number'])
const inner2 = createNode(subgraph, ['number'])
const innerLink1 = subgraph.inputNode.slots[0].connect(
inner1.inputs[0],
inner1
)
const innerLink2 = subgraph.inputNode.slots[1].connect(
inner1.inputs[1],
inner1
)
const innerLink3 = subgraph.inputNode.slots[1].connect(
inner2.inputs[0],
inner2
)
assert(innerLink1 && innerLink2 && innerLink3)
const outer = createNode(graph, [], ['number'])
const outerLink1 = outer.connect(0, subgraphNode, 0)
const outerLink2 = outer.connect(0, subgraphNode, 1)
assert(outerLink1 && outerLink2)
graph.createReroute([10, 10], outerLink1)
graph.createReroute([10, 20], outerLink2)
subgraph.createReroute([10, 10], innerLink1)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(3)
expect(graph.links.size).toBe(3)
let linkRefCount = 0
for (const reroute of graph.reroutes.values()) {
linkRefCount += reroute.linkIds.size
}
expect(linkRefCount).toBe(4)
})
})
})

View File

@@ -1,279 +0,0 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "التحقق من التحديثات"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "فتح مجلد العقد المخصصة"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "فتح مجلد المدخلات"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "فتح مجلد السجلات"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "فتح extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "فتح مجلد النماذج"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "فتح مجلد المخرجات"
},
"Comfy-Desktop_OpenDevTools": {
"label": "فتح أدوات المطور"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "دليل المستخدم لسطح المكتب"
},
"Comfy-Desktop_Quit": {
"label": "خروج"
},
"Comfy-Desktop_Reinstall": {
"label": "إعادة التثبيت"
},
"Comfy-Desktop_Restart": {
"label": "إعادة التشغيل"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
},
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "إضافة خطوة تحرير النموذج"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "حذف العناصر المحددة"
},
"Comfy_Canvas_FitView": {
"label": "تعديل العرض ليناسب العقد المحددة"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "تحريك العقد المحددة للأسفل"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "تحريك العقد المحددة لليسار"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "تحريك العقد المحددة لليمين"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "تحريك العقد المحددة للأعلى"
},
"Comfy_Canvas_ResetView": {
"label": "إعادة تعيين العرض"
},
"Comfy_Canvas_Resize": {
"label": "تغيير حجم العقد المحددة"
},
"Comfy_Canvas_ToggleLinkVisibility": {
"label": "تبديل رؤية الروابط في اللوحة"
},
"Comfy_Canvas_ToggleLock": {
"label": "تبديل القفل في اللوحة"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "تبديل الخريطة المصغرة في اللوحة"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "تثبيت/إلغاء تثبيت العناصر المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "تجاوز/إلغاء تجاوز العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Collapse": {
"label": "طي/توسيع العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Mute": {
"label": "كتم/إلغاء كتم العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "تثبيت/إلغاء تثبيت العقد المحددة"
},
"Comfy_Canvas_ZoomIn": {
"label": "تكبير"
},
"Comfy_Canvas_ZoomOut": {
"label": "تصغير"
},
"Comfy_ClearPendingTasks": {
"label": "مسح المهام المعلقة"
},
"Comfy_ClearWorkflow": {
"label": "مسح سير العمل"
},
"Comfy_ContactSupport": {
"label": "الاتصال بالدعم"
},
"Comfy_DuplicateWorkflow": {
"label": "تكرار سير العمل الحالي"
},
"Comfy_ExportWorkflow": {
"label": "تصدير سير العمل"
},
"Comfy_ExportWorkflowAPI": {
"label": "تصدير سير العمل (تنسيق API)"
},
"Comfy_Feedback": {
"label": "إرسال ملاحظات"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "تحويل التحديد إلى رسم فرعي"
},
"Comfy_Graph_FitGroupToContents": {
"label": "ضبط المجموعة على المحتويات"
},
"Comfy_Graph_GroupSelectedNodes": {
"label": "تجميع العقد المحددة"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "تحويل العقد المحددة إلى عقدة مجموعة"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "إدارة عقد المجموعات"
},
"Comfy_GroupNode_UngroupSelectedGroupNodes": {
"label": "إلغاء تجميع عقد المجموعات المحددة"
},
"Comfy_Help_AboutComfyUI": {
"label": "حول ComfyUI"
},
"Comfy_Help_OpenComfyOrgDiscord": {
"label": "فتح خادم Comfy-Org على Discord"
},
"Comfy_Help_OpenComfyUIDocs": {
"label": "فتح مستندات ComfyUI"
},
"Comfy_Help_OpenComfyUIForum": {
"label": "فتح منتدى ComfyUI"
},
"Comfy_Help_OpenComfyUIIssues": {
"label": "فتح مشكلات ComfyUI"
},
"Comfy_Interrupt": {
"label": "إيقاف مؤقت"
},
"Comfy_LoadDefaultWorkflow": {
"label": "تحميل سير العمل الافتراضي"
},
"Comfy_Manager_CustomNodesManager": {
"label": "تبديل مدير العقد المخصصة"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "تبديل شريط تقدم مدير العقد المخصصة"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "تقليل حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "زيادة حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "فتح محرر القناع للعقدة المحددة"
},
"Comfy_NewBlankWorkflow": {
"label": "سير عمل جديد فارغ"
},
"Comfy_OpenClipspace": {
"label": "Clipspace"
},
"Comfy_OpenManagerDialog": {
"label": "مدير"
},
"Comfy_OpenWorkflow": {
"label": "فتح سير عمل"
},
"Comfy_QueuePrompt": {
"label": "إضافة الأمر إلى قائمة الانتظار"
},
"Comfy_QueuePromptFront": {
"label": "إضافة الأمر إلى مقدمة قائمة الانتظار"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "إدراج عقد الإخراج المحددة في قائمة الانتظار"
},
"Comfy_Redo": {
"label": "إعادة"
},
"Comfy_RefreshNodeDefinitions": {
"label": "تحديث تعريفات العقد"
},
"Comfy_SaveWorkflow": {
"label": "حفظ سير العمل"
},
"Comfy_SaveWorkflowAs": {
"label": "حفظ سير العمل باسم"
},
"Comfy_ShowSettingsDialog": {
"label": "عرض نافذة الإعدادات"
},
"Comfy_ToggleCanvasInfo": {
"label": "أداء اللوحة"
},
"Comfy_ToggleHelpCenter": {
"label": "مركز المساعدة"
},
"Comfy_ToggleTheme": {
"label": "تبديل النمط (فاتح/داكن)"
},
"Comfy_Undo": {
"label": "تراجع"
},
"Comfy_User_OpenSignInDialog": {
"label": "فتح نافذة تسجيل الدخول"
},
"Comfy_User_SignOut": {
"label": "تسجيل الخروج"
},
"Workspace_CloseWorkflow": {
"label": "إغلاق سير العمل الحالي"
},
"Workspace_NextOpenedWorkflow": {
"label": "سير العمل التالي المفتوح"
},
"Workspace_PreviousOpenedWorkflow": {
"label": "سير العمل السابق المفتوح"
},
"Workspace_SearchBox_Toggle": {
"label": "تبديل مربع البحث"
},
"Workspace_ToggleBottomPanel": {
"label": "تبديل اللوحة السفلية"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "عرض مربع حوار اختصارات لوحة المفاتيح"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "تبديل لوحة الطرفية السفلية"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "تبديل لوحة السجلات السفلية"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "تبديل اللوحة السفلية الأساسية"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "تبديل لوحة تحكم العرض السفلية"
},
"Workspace_ToggleFocusMode": {
"label": "تبديل وضع التركيز"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "تبديل الشريط الجانبي لمكتبة النماذج",
"tooltip": "مكتبة النماذج"
},
"Workspace_ToggleSidebarTab_node-library": {
"label": "تبديل الشريط الجانبي لمكتبة العقد",
"tooltip": "مكتبة العقد"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
"tooltip": "قائمة الانتظار"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "تبديل الشريط الجانبي لسير العمل",
"tooltip": "سير العمل"
}
}

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