Compare commits

..

18 Commits

Author SHA1 Message Date
bymyself
a2c8f4b3cc fix(litegraph): Resolve workflow corruption from subgraph serialization
This commit fixes a critical issue where saving a workflow with complex subgraphs could lead to a corrupted file, causing errors upon loading.

The root cause was twofold:

1.  **Unsafe Object Cloning:** The `LiteGraph.cloneObject` function used `JSON.stringify`, which cannot handle circular references that can exist in a nodes properties, especially in complex graphs with promoted widgets. This has been replaced with `_.cloneDeep` from lodash for robust and safe deep cloning.

2.  **Unsafe Node Configuration:** The `LGraphNode.configure` method used a generic loop that would overwrite the `inputs` and `outputs` arrays with plain objects from the serialized data. This created a temporary but dangerous state where class instances were replaced by plain objects, leading to `TypeError` exceptions when methods or private fields were accessed on them. The `configure` method has been refactored to handle `inputs`, `outputs`, and `properties` explicitly and safely, ensuring they are always proper class instances.

This fix makes the node loading process more robust and predictable, preventing data corruption and ensuring the stability of workflows with subgraphs.
2025-08-12 11:55:00 -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
105 changed files with 1507 additions and 14649 deletions

View File

@@ -22,7 +22,7 @@ jobs:
uses: lewagon/wait-on-check-action@v1.3.1
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:

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

@@ -767,8 +767,8 @@ export class ComfyPage {
await this.nextFrame()
}
async rightClickCanvas(x: number = 10, y: number = 10) {
await this.page.mouse.click(x, y, { button: 'right' })
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}
@@ -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

@@ -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) => {

View File

@@ -1,4 +1,4 @@
import { Locator, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
@@ -767,17 +767,6 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(comfyPage.emptySpace)
// If tooltip is visible, wait for it to hide
await expect(
comfyPage.page.locator('.workflow-popover-fade')
).toHaveCount(0)
}
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
@@ -805,13 +794,15 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await changeTab(tabA)
await tabA.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await changeTab(tabB)
await tabB.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)

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

@@ -48,9 +48,7 @@ test.describe('LiteGraph Native Reroute Node', () => {
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
})
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
comfyPage
}) => {
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

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', () => {
@@ -196,68 +372,6 @@ test.describe('Subgraph Operations', () => {
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)
})
})
})
test.describe('Operations Inside Subgraphs', () => {
@@ -528,103 +642,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

@@ -1,155 +0,0 @@
import { expect } from '@playwright/test'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Workflow Tab Thumbnails', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.setup()
})
async function getTab(comfyPage: ComfyPage, index: number) {
const tab = comfyPage.page
.locator(`.workflow-tabs .p-togglebutton`)
.nth(index)
return tab
}
async function getTabPopover(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const tab = await getTab(comfyPage, index)
await tab.hover()
const popover = comfyPage.page.locator('.workflow-popover-fade')
await expect(popover).toHaveCount(1)
await expect(popover).toBeVisible({ timeout: 500 })
if (name) {
await expect(popover).toContainText(name)
}
return popover
}
async function getTabThumbnailImage(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const popover = await getTabPopover(comfyPage, index, name)
const thumbnailImg = popover.locator('.workflow-preview-thumbnail img')
return thumbnailImg
}
async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) {
const thumbnailImg = await getTabThumbnailImage(comfyPage, index)
const src = (await thumbnailImg.getAttribute('src'))!
// Convert blob to base64, need to execute a script to get the base64
const base64 = await comfyPage.page.evaluate(async (src: string) => {
const blob = await fetch(src).then((res) => res.blob())
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}, src)
return base64
}
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
'Unsaved Workflow'
)
await expect(thumbnailImg).toBeVisible()
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
'Unsaved Workflow (2)'
)
await expect(thumbnailImg).not.toBeVisible()
})
async function addNode(comfyPage: ComfyPage, category: string, node: string) {
const canvasArea = await comfyPage.canvas.boundingBox()
await comfyPage.page.mouse.move(
canvasArea!.x + canvasArea!.width - 100,
100
)
await comfyPage.delay(300) // Wait for the popover to hide
await comfyPage.rightClickCanvas(200, 200)
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(category).click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(node).click()
await comfyPage.nextFrame()
}
test('Thumbnail should update when switching tabs', async ({ comfyPage }) => {
// Wait for initial workflow to load
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
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)
// Tab 1 is currently active, so we can only get thumbnail for tab 0
// Step 1: Different tabs should show different previews
const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0)
// Add a node to tab 1 (current active tab)
await addNode(comfyPage, 'loaders', 'Load Checkpoint')
await comfyPage.nextFrame()
// Switch to tab 0 so we can get tab 1's thumbnail
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1)
// The thumbnails should be different
expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode)
// Step 2: Switching without changes shouldn't update thumbnail
const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1)
// Switch to tab 1 and back to tab 0 without making changes
await (await getTab(comfyPage, 1)).click()
await comfyPage.nextFrame()
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1)
expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter)
// Step 3: Adding another node should cause thumbnail to change
// We're on tab 0, add a node
await addNode(comfyPage, 'loaders', 'Load VAE')
await comfyPage.nextFrame()
// Switch to tab 1 and back to update tab 0's thumbnail
await (await getTab(comfyPage, 1)).click()
const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0)
// The thumbnail should have changed after adding a node
expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode)
})
})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.5",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.5",
"version": "1.26.0",
"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.6",
"version": "1.26.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -49,13 +49,6 @@ export default defineConfig({
grep: /@2x/ // Run all tests tagged with @2x
},
{
name: 'chromium-0.5x',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
timeout: 15000,
grep: /@0.5x/ // Run all tests tagged with @0.5x
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },

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

@@ -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.label || command.id }}
</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

@@ -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

@@ -1,13 +1,13 @@
<template>
<div
ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip',
showDelay: 512
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
>
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
@@ -22,33 +22,23 @@
/>
</div>
</div>
<WorkflowTabPopover
ref="popoverRef"
:workflow-filename="workflowOption.workflow.filename"
:thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { useWorkflowService } from '@/services/workflowService'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
interface WorkflowOption {
value: string
workflow: ComfyWorkflow
@@ -65,8 +55,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
@@ -102,27 +90,6 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
// Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event)
}
const handleMouseLeave = () => {
popoverRef.value?.hidePopover()
}
const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (
@@ -168,10 +135,6 @@ usePragmaticDroppable(tabGetter, {
}
}
})
onUnmounted(() => {
popoverRef.value?.hidePopover()
})
</script>
<style scoped>

View File

@@ -1,229 +0,0 @@
<template>
<div
ref="positionRef"
class="absolute left-1/2 -translate-x-1/2"
:class="positions.positioner"
></div>
<Popover
ref="popoverRef"
append-to="body"
:pt="{
root: {
class: 'workflow-popover-fade fit-content ' + positions.root,
'data-popover-id': id,
style: {
transform: positions.active
}
}
}"
@mouseenter="cancelHidePopover"
@mouseleave="hidePopover"
>
<div class="workflow-preview-content">
<div
v-if="thumbnailUrl && !isActiveTab"
class="workflow-preview-thumbnail relative"
>
<img
:src="thumbnailUrl"
class="block h-[200px] object-cover rounded-lg p-2"
:style="{ width: `${POPOVER_WIDTH}px` }"
/>
</div>
<div class="workflow-preview-footer">
<span class="workflow-preview-name">{{ workflowFilename }}</span>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, nextTick, ref, toRefs, useId } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
const POPOVER_WIDTH = 250
interface Props {
workflowFilename: string
thumbnailUrl?: string
isActiveTab: boolean
}
const props = defineProps<Props>()
const { thumbnailUrl, isActiveTab } = toRefs(props)
const settingStore = useSettingStore()
const positions = computed<{
positioner: string
root?: string
active?: string
}>(() => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar' &&
settingStore.get('Comfy.UseNewMenu') === 'Bottom'
) {
return {
positioner: 'top-0',
root: 'p-popover-flipped',
active: isActiveTab.value ? 'translateY(-100%)' : undefined
}
}
return {
positioner: 'bottom-0'
}
})
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
const positionRef = ref<HTMLElement | null>(null)
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
const id = useId()
const showPopover = (event: Event) => {
// Clear any existing timeouts
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
// Show popover after a short delay
showTimeout = setTimeout(async () => {
if (popoverRef.value && positionRef.value) {
popoverRef.value.show(event, positionRef.value)
await nextTick()
// PrimeVue has a bug where when the tabs are scrolled, it positions the element incorrectly
// Manually set the position to the middle of the tab and prevent it from going off the left/right edge
const el = document.querySelector(
`.workflow-popover-fade[data-popover-id="${id}"]`
) as HTMLElement
if (el) {
const middle = positionRef.value!.getBoundingClientRect().left
const popoverWidth = el.getBoundingClientRect().width
const halfWidth = popoverWidth / 2
let pos = middle - halfWidth
let shift = 0
// Calculate shift when clamping is needed
if (pos < 0) {
shift = pos - 8 // Negative shift to move arrow left
pos = 8
} else if (pos + popoverWidth > window.innerWidth) {
const newPos = window.innerWidth - popoverWidth - 16
shift = pos - newPos // Positive shift to move arrow right
pos = newPos
}
if (shift + halfWidth < 0) {
shift = -halfWidth + 24
}
el.style.left = `${pos}px`
el.style.setProperty('--shift', `${shift}px`)
}
}
}, 200) // 200ms delay before showing
}
const cancelHidePopover = () => {
// Temporarily disable this functionality until we need the popover to be interactive:
/*
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
*/
}
const hidePopover = () => {
// Clear show timeout if mouse leaves before popover appears
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
hideTimeout = setTimeout(() => {
if (popoverRef.value) {
popoverRef.value.hide()
}
}, 100) // Minimal delay to allow moving to popover
}
const togglePopover = (event: Event) => {
if (popoverRef.value) {
popoverRef.value.toggle(event)
}
}
defineExpose({
showPopover,
hidePopover,
togglePopover
})
</script>
<style scoped>
.workflow-preview-content {
@apply flex flex-col rounded-xl overflow-hidden;
max-width: var(--popover-width);
background-color: var(--comfy-menu-secondary-bg);
color: var(--fg-color);
}
.workflow-preview-thumbnail {
@apply relative p-2;
}
.workflow-preview-thumbnail img {
@apply shadow-md;
background-color: color-mix(
in srgb,
var(--comfy-menu-secondary-bg) 70%,
black
);
}
.dark-theme .workflow-preview-thumbnail img {
@apply shadow-lg;
}
.workflow-preview-footer {
@apply pt-1 pb-2 px-3;
}
.workflow-preview-name {
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
color: var(--fg-color);
}
</style>
<style>
.workflow-popover-fade {
--p-popover-background: transparent;
--p-popover-content-padding: 0;
@apply bg-transparent rounded-xl shadow-lg;
transition: opacity 0.15s ease-out !important;
}
.workflow-popover-fade.p-popover-flipped {
@apply -translate-y-full;
}
.dark-theme .workflow-popover-fade {
@apply shadow-2xl;
}
.workflow-popover-fade.p-popover:after,
.workflow-popover-fade.p-popover:before {
--p-popover-border-color: var(--comfy-menu-secondary-bg);
left: 50%;
transform: translateX(calc(-50% + var(--shift)));
margin-left: 0;
}
</style>

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,13 +1278,9 @@ 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
@@ -1362,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'
}
@@ -1409,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

@@ -21,10 +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 { 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'
@@ -796,23 +794,6 @@ 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)
}
},
{
@@ -824,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

@@ -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,18 +565,15 @@ export function useMinimap() {
const g = graph.value
if (!g) return
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
throw new Error(
'Attempted to cleanup event listeners for graph that was never set up'
)
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 () => {
@@ -767,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) {
@@ -824,25 +690,16 @@ export function useMinimap() {
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef,
updateOption
setMinimapRef
}
}

View File

@@ -1,108 +0,0 @@
import { ref } from 'vue'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useMinimap } from './useMinimap'
// Store thumbnails for each workflow
const workflowThumbnails = ref<Map<string, string>>(new Map())
// Shared minimap instance
let minimap: ReturnType<typeof useMinimap> | null = null
export const useWorkflowThumbnail = () => {
/**
* Capture a thumbnail of the canvas
*/
const createMinimapPreview = (): Promise<string | null> => {
try {
if (!minimap) {
minimap = useMinimap()
minimap.canvasRef.value = document.createElement('canvas')
minimap.canvasRef.value.width = minimap.width
minimap.canvasRef.value.height = minimap.height
}
minimap.renderMinimap()
return new Promise((resolve) => {
minimap!.canvasRef.value!.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob))
} else {
resolve(null)
}
})
})
} catch (error) {
console.error('Failed to capture canvas thumbnail:', error)
return Promise.resolve(null)
}
}
/**
* Store a thumbnail for a workflow
*/
const storeThumbnail = async (workflow: ComfyWorkflow) => {
const thumbnail = await createMinimapPreview()
if (thumbnail) {
// Clean up existing thumbnail if it exists
const existingThumbnail = workflowThumbnails.value.get(workflow.key)
if (existingThumbnail) {
URL.revokeObjectURL(existingThumbnail)
}
workflowThumbnails.value.set(workflow.key, thumbnail)
}
}
/**
* Get a thumbnail for a workflow
*/
const getThumbnail = (workflowKey: string): string | undefined => {
return workflowThumbnails.value.get(workflowKey)
}
/**
* Clear a thumbnail for a workflow
*/
const clearThumbnail = (workflowKey: string) => {
const thumbnail = workflowThumbnails.value.get(workflowKey)
if (thumbnail) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.delete(workflowKey)
}
/**
* Clear all thumbnails
*/
const clearAllThumbnails = () => {
for (const thumbnail of workflowThumbnails.value.values()) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.clear()
}
/**
* Move a thumbnail from one workflow key to another (useful for workflow renaming)
*/
const moveWorkflowThumbnail = (oldKey: string, newKey: string) => {
// Don't do anything if moving to the same key
if (oldKey === newKey) return
const thumbnail = workflowThumbnails.value.get(oldKey)
if (thumbnail) {
workflowThumbnails.value.set(newKey, thumbnail)
workflowThumbnails.value.delete(oldKey)
}
}
return {
createMinimapPreview,
storeThumbnail,
getThumbnail,
clearThumbnail,
clearAllThumbnails,
moveWorkflowThumbnail,
workflowThumbnails
}
}

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

@@ -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'
},
@@ -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

@@ -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

@@ -2299,8 +2299,6 @@ export class LGraphCanvas
const node_data = node.clone()?.serialize()
if (node_data?.type != null) {
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
node_data.type = newType
const cloned = LiteGraph.createNode(newType)
if (cloned) {
cloned.configure(node_data)
@@ -2386,7 +2384,6 @@ export class LGraphCanvas
// Set the width of the line for isPointInStroke checks
const { lineWidth } = this.ctx
this.ctx.lineWidth = this.connections_width + 7
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
@@ -2396,7 +2393,7 @@ export class LGraphCanvas
if (
(e.shiftKey || e.altKey) &&
linkSegment.path &&
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
this.ctx.isPointInStroke(linkSegment.path, x, y)
) {
this.ctx.lineWidth = lineWidth
@@ -3455,6 +3452,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 +3463,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 +3609,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 +3647,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 +3765,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 +4129,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 +4170,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} */
@@ -8073,6 +8067,18 @@ export class LGraphCanvas
options = node.getMenuOptions(this)
} else {
options = [
{
content: 'Inputs',
has_submenu: true,
disabled: true
},
{
content: 'Outputs',
has_submenu: true,
disabled: true,
callback: LGraphCanvas.showMenuNodeOptionalOutputs
},
null,
{
content: 'Convert to Subgraph 🆕',
callback: () => {
@@ -8240,19 +8246,15 @@ export class LGraphCanvas
'Both in put and output slots were null when processing context menu.'
)
if (_slot.removable) {
menu_info.push(
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
)
}
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
menu_info.push({ content: 'Rename Slot', slot })
}
if (_slot.removable) {
menu_info.push(null)
menu_info.push(
_slot.locked
? 'Cannot remove'
: { content: 'Remove Slot', slot, className: 'danger' }
)
}
if (node.getExtraSlotMenuOptions) {
menu_info.push(...node.getExtraSlotMenuOptions(slot))
}

View File

@@ -769,12 +769,7 @@ export class LGraphNode
this.graph._version++
}
for (const j in info) {
if (j == 'properties') {
// i don't want to clone properties, I want to reuse the old container
for (const k in info.properties) {
this.properties[k] = info.properties[k]
this.onPropertyChanged?.(k, info.properties[k])
}
if (j === 'properties' || j === 'inputs' || j === 'outputs') {
continue
}
@@ -798,14 +793,28 @@ export class LGraphNode
}
}
if (info.properties) {
for (const k in info.properties) {
this.properties[k] = info.properties[k]
this.onPropertyChanged?.(k, info.properties[k])
}
}
if (!info.title) {
this.title = this.constructor.title
}
this.inputs ??= []
this.inputs = this.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
if (info.inputs) {
this.inputs = info.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
} else {
this.inputs ??= []
this.inputs = this.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
}
for (const [i, input] of this.inputs.entries()) {
const link =
this.graph && input.link != null
@@ -815,10 +824,17 @@ export class LGraphNode
this.onInputAdded?.(input)
}
this.outputs ??= []
this.outputs = this.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
if (info.outputs) {
this.outputs = info.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
} else {
this.outputs ??= []
this.outputs = this.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
}
for (const [i, output] of this.outputs.entries()) {
if (!output.links) continue

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

@@ -1,3 +1,5 @@
import _ from 'lodash'
import { ContextMenu } from './ContextMenu'
import { CurveEditor } from './CurveEditor'
import { DragAndScale } from './DragAndScale'
@@ -284,7 +286,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 +294,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
@@ -643,7 +643,7 @@ export class LiteGraphGlobal {
): WhenNullish<T, null> {
if (obj == null) return null as WhenNullish<T, null>
const r = JSON.parse(JSON.stringify(obj))
const r = _.cloneDeep(obj)
if (!target) return r
for (const i in r) {

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

@@ -9,7 +9,7 @@ import type {
CallbackReturn,
ISlotType
} from '@/lib/litegraph/src/interfaces'
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { Subgraph } from './Subgraph'
import type { SubgraphNode } from './SubgraphNode'
@@ -263,8 +263,13 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
const { inputs } = this
// Bypass nodes by finding first input with matching type
const matchingIndex = this.#getBypassSlotIndex(slot, type)
const parentInputIndexes = Object.keys(inputs).map(Number)
// Prioritise exact slot index
const indexes = [slot, ...parentInputIndexes]
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
// No input types match
if (matchingIndex === undefined) {
@@ -321,44 +326,6 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
}
}
/**
* Finds the index of the input slot on this node that matches the given output {@link slot} index.
* Used when bypassing nodes.
* @param slot The output slot index on this node
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `undefined`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
const oppositeInput = inputs[slot]
const outputType = this.node.outputs[slot].type
// Any type short circuit - match slot ID, fallback to first slot
if (type === '*' || type === '') {
return inputs.length > slot ? slot : 0
}
// Prefer input with the same slot ID
if (
oppositeInput &&
LiteGraph.isValidConnection(oppositeInput.type, outputType) &&
LiteGraph.isValidConnection(oppositeInput.type, type)
) {
return slot
}
// Find first matching slot - prefer exact type
return (
// Preserve legacy behaviour; use exact match first.
inputs.findIndex((input) => input.type === type) ??
inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
)
}
/**
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
* @param slot The slot index of the output on the subgraph node.

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

@@ -171,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
@@ -212,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
@@ -266,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)
@@ -532,12 +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()
}
}
}

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,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,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,270 +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_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_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": "سير العمل"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,416 +0,0 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "التحقق تلقائيًا من التحديثات"
},
"Comfy-Desktop_SendStatistics": {
"name": "إرسال إحصائيات الاستخدام المجهولة"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "مرآة تثبيت Pypi",
"tooltip": "مرآة التثبيت الافتراضية لـ pip"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "مرآة تثبيت بايثون",
"tooltip": "يتم تحميل تثبيتات بايثون المدارة من مشروع Astral python-build-standalone. يمكن تعيين هذا المتغير إلى عنوان مرآة لاستخدام مصدر مختلف لتثبيتات بايثون. سيحل العنوان المقدم محل https://github.com/astral-sh/python-build-standalone/releases/download في، مثلاً، https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. يمكن قراءة التوزيعات من دليل محلي باستخدام نظام ملفات file://."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "مرآة تثبيت Torch",
"tooltip": "مرآة تثبيت pip لـ pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "نمط النافذة",
"tooltip": "مخصص: استبدال شريط عنوان النظام بالقائمة العلوية لـ ComfyUI",
"options": {
"default": "افتراضي",
"custom": "مخصص"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "صورة خلفية اللوحة",
"tooltip": "رابط صورة لخلفية اللوحة. يمكنك النقر بزر الفأرة الأيمن على صورة في لوحة النتائج واختيار \"تعيين كخلفية\" لاستخدامها، أو رفع صورتك الخاصة باستخدام زر الرفع."
},
"Comfy_Canvas_NavigationMode": {
"name": "وضع تنقل اللوحة",
"options": {
"Standard (New)": "قياسي (جديد)",
"Left-Click Pan (Legacy)": "سحب بالنقر الأيسر (قديم)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "عرض صندوق أدوات التحديد"
},
"Comfy_ConfirmClear": {
"name": "طلب التأكيد عند مسح سير العمل"
},
"Comfy_DevMode": {
"name": "تمكين خيارات وضع المطور (حفظ API، إلخ)"
},
"Comfy_DisableFloatRounding": {
"name": "تعطيل تقريب عناصر التحكم العائمة الافتراضية",
"tooltip": "(يتطلب إعادة تحميل الصفحة) لا يمكن تعطيل التقريب عندما يتم تعيينه من العقدة في الخلفية."
},
"Comfy_DisableSliders": {
"name": "تعطيل منزلقات أدوات العقد"
},
"Comfy_DOMClippingEnabled": {
"name": "تمكين قص عناصر DOM (قد يقلل التمكين من الأداء)"
},
"Comfy_EditAttention_Delta": {
"name": "دقة تحكم +Ctrl فوق/تحت"
},
"Comfy_EnableTooltips": {
"name": "تمكين التلميحات"
},
"Comfy_EnableWorkflowViewRestore": {
"name": "حفظ واستعادة موقع اللوحة ومستوى التكبير في سير العمل"
},
"Comfy_FloatRoundingPrecision": {
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
},
"Comfy_Graph_CanvasInfo": {
"name": "عرض معلومات اللوحة في الزاوية السفلى اليسرى (الإطارات في الثانية، إلخ)"
},
"Comfy_Graph_CanvasMenu": {
"name": "عرض قائمة لوحة الرسم البياني"
},
"Comfy_Graph_CtrlShiftZoom": {
"name": "تمكين اختصار التكبير السريع (Ctrl + Shift + سحب)"
},
"Comfy_Graph_LinkMarkers": {
"name": "علامات منتصف الروابط",
"options": {
"None": "لا شيء",
"Circle": "دائرة",
"Arrow": "سهم"
}
},
"Comfy_Graph_ZoomSpeed": {
"name": "سرعة تكبير اللوحة"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "انقر مزدوج على عنوان المجموعة للتحرير"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "تباعد حول العقد المحددة في المجموعة"
},
"Comfy_LinkRelease_Action": {
"name": "الإجراء عند تحرير الرابط (بدون مفتاح تعديل)",
"options": {
"context menu": "قائمة السياق",
"search box": "صندوق البحث",
"no action": "لا إجراء"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "الإجراء عند تحرير الرابط (Shift)",
"options": {
"context menu": "قائمة السياق",
"search box": "صندوق البحث",
"no action": "لا إجراء"
}
},
"Comfy_LinkRenderMode": {
"name": "وضع عرض الروابط",
"options": {
"Straight": "مستقيم",
"Linear": "خطي",
"Spline": "منحنى",
"Hidden": "مخفي"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "تمكين عارض ثلاثي الأبعاد (تجريبي)",
"tooltip": "تمكين عارض ثلاثي الأبعاد (تجريبي) للعقد المحددة. تتيح هذه الميزة عرض النماذج ثلاثية الأبعاد والتفاعل معها مباشرة داخل العارض ثلاثي الأبعاد بحجمه الكامل."
},
"Comfy_Load3D_BackgroundColor": {
"name": "لون الخلفية الابتدائي",
"tooltip": "يحدد لون الخلفية الافتراضي للمشهد ثلاثي الأبعاد. يمكن تعديل هذا اللون لكل عنصر ثلاثي الأبعاد بعد الإنشاء."
},
"Comfy_Load3D_CameraType": {
"name": "نوع الكاميرا الابتدائي",
"tooltip": "يحدد ما إذا كانت الكاميرا منظور أو متعامدة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد. يمكن تعديل هذا الإعداد لكل عنصر بعد الإنشاء.",
"options": {
"perspective": "منظور",
"orthographic": "متعامد"
}
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "زيادة تعديل الضوء",
"tooltip": "يتحكم في حجم الخطوة عند تعديل شدة الإضاءة في المشاهد ثلاثية الأبعاد. قيمة أصغر تسمح بتحكم أدق، وأكبر قيمة تعطي تغييرات أكثر وضوحًا."
},
"Comfy_Load3D_LightIntensity": {
"name": "شدة الإضاءة الابتدائية",
"tooltip": "يحدد مستوى سطوع الإضاءة الافتراضي في المشهد ثلاثي الأبعاد. يمكن تعديله لكل عنصر بعد الإنشاء."
},
"Comfy_Load3D_LightIntensityMaximum": {
"name": "أقصى شدة إضاءة",
"tooltip": "يحدد الحد الأقصى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
},
"Comfy_Load3D_LightIntensityMinimum": {
"name": "أدنى شدة إضاءة",
"tooltip": "يحدد الحد الأدنى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
},
"Comfy_Load3D_ShowGrid": {
"name": "رؤية الشبكة الابتدائية",
"tooltip": "يتحكم في ظهور الشبكة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."
},
"Comfy_Load3D_ShowPreview": {
"name": "رؤية المعاينة الابتدائية",
"tooltip": "يتحكم في ظهور شاشة المعاينة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."
},
"Comfy_Locale": {
"name": "اللغة"
},
"Comfy_MaskEditor_BrushAdjustmentSpeed": {
"name": "مضاعف سرعة تعديل الفرشاة",
"tooltip": "يتحكم في سرعة تغير حجم الفرشاة وصلابتها أثناء التعديل. القيم الأعلى تعني تغييرات أسرع."
},
"Comfy_MaskEditor_UseDominantAxis": {
"name": "تقييد تعديل الفرشاة إلى المحور السائد",
"tooltip": "عند التمكين، تؤثر التعديلات على الحجم أو الصلابة فقط بناءً على الاتجاه الذي تتحرك فيه أكثر."
},
"Comfy_MaskEditor_UseNewEditor": {
"name": "استخدام محرر القناع الجديد",
"tooltip": "التحويل إلى واجهة محرر القناع الجديدة"
},
"Comfy_ModelLibrary_AutoLoadAll": {
"name": "تحميل جميع مجلدات النماذج تلقائيًا",
"tooltip": "إذا كانت صحيحة، سيتم تحميل جميع المجلدات عند فتح مكتبة النماذج (قد يسبب تأخيرًا أثناء التحميل). إذا كانت خاطئة، يتم تحميل مجلدات النماذج على مستوى الجذر فقط عند النقر عليها."
},
"Comfy_ModelLibrary_NameFormat": {
"name": "اسم العرض في شجرة مكتبة النماذج",
"tooltip": "اختر \"اسم الملف\" لعرض اسم الملف المبسط بدون المجلد أو الامتداد \".safetensors\" في قائمة النماذج. اختر \"العنوان\" لعرض عنوان بيانات النموذج القابل للتكوين.",
"options": {
"filename": "اسم الملف",
"title": "العنوان"
}
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "عرض العرض × الارتفاع تحت معاينة الصورة"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "التثبيت التلقائي للرابط إلى فتحة العقدة",
"tooltip": "عند سحب رابط فوق عقدة، يتم تثبيت الرابط تلقائيًا على فتحة إدخال صالحة في العقدة"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "الحفاظ على جميع الروابط عند حذف العقد",
"tooltip": "عند حذف عقدة، حاول إعادة توصيل جميع روابط الإدخال والإخراج (تجاوز العقدة المحذوفة)"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "النقر المزدوج على عنوان العقدة للتحرير"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "النقر الأوسط ينشئ عقدة إعادة توجيه جديدة"
},
"Comfy_Node_Opacity": {
"name": "شفافية العقدة"
},
"Comfy_Node_ShowDeprecated": {
"name": "عرض العقدة المهجورة في البحث",
"tooltip": "العقد المهجورة مخفية افتراضيًا في واجهة المستخدم، لكنها تظل فعالة في سير العمل الحالي الذي يستخدمها."
},
"Comfy_Node_ShowExperimental": {
"name": "عرض العقدة التجريبية في البحث",
"tooltip": "يتم تمييز العقد التجريبية في واجهة المستخدم وقد تخضع لتغييرات كبيرة أو إزالتها في الإصدارات المستقبلية. استخدمها بحذر في سير العمل الإنتاجي."
},
"Comfy_Node_SnapHighlightsNode": {
"name": "تثبيت يبرز العقدة",
"tooltip": "عند سحب رابط فوق عقدة تحتوي على فتحة إدخال صالحة، يتم تمييز العقدة"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "وضع شارة معرف العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل"
}
},
"Comfy_NodeBadge_NodeLifeCycleBadgeMode": {
"name": "وضع شارة دورة حياة العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل"
}
},
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "وضع شارة مصدر العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل",
"Hide built-in": "إخفاء المدمج"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "عرض شارة تسعير عقدة API"
},
"Comfy_NodeSearchBoxImpl": {
"name": "تنفيذ مربع بحث العقدة",
"options": {
"default": "افتراضي",
"litegraph (legacy)": "لايت جراف (قديم)"
}
},
"Comfy_NodeSearchBoxImpl_NodePreview": {
"name": "معاينة العقدة",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "عرض فئة العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "عرض اسم معرف العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowNodeFrequency": {
"name": "عرض تكرار العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSuggestions_number": {
"name": "عدد اقتراحات العقد",
"tooltip": "خاص بمربع بحث / قائمة السياق في لايت جراف فقط"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "عرض تحديثات الإصدار",
"tooltip": "عرض التحديثات للنماذج الجديدة والميزات الرئيسية."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "تأخير انحراف نقرة المؤشر",
"tooltip": "بعد الضغط على زر المؤشر، هذا هو الوقت الأقصى (بالملي ثانية) الذي يمكن تجاهل حركة المؤشر خلاله.\n\nيساعد على منع دفع الكائنات عن طريق الخطأ إذا تم تحريك المؤشر أثناء النقر."
},
"Comfy_Pointer_ClickDrift": {
"name": "انحراف نقرة المؤشر (أقصى مسافة)",
"tooltip": "إذا تحرك المؤشر أكثر من هذه المسافة أثناء الضغط على زر، يعتبر سحبًا بدلاً من نقرة.\n\nيساعد على منع دفع الكائنات عن طريق الخطأ إذا تم تحريك المؤشر أثناء النقر."
},
"Comfy_Pointer_DoubleClickTime": {
"name": "فترة النقر المزدوج (قصوى)",
"tooltip": "الوقت الأقصى بالملي ثانية بين النقرتين في النقر المزدوج. زيادة هذه القيمة قد تساعد إذا لم يتم تسجيل النقرات المزدوجة أحيانًا."
},
"Comfy_PreviewFormat": {
"name": "تنسيق صورة المعاينة",
"tooltip": "عند عرض معاينة في ويدجت الصورة، يتم تحويلها إلى صورة خفيفة الوزن، مثل webp، jpeg، webp;50، إلخ."
},
"Comfy_PromptFilename": {
"name": "طلب اسم الملف عند حفظ سير العمل"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "حجم تاريخ قائمة الانتظار",
"tooltip": "العدد الأقصى للمهام المعروضة في تاريخ قائمة الانتظار."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "حد عدد الدُفعات",
"tooltip": "العدد الأقصى للمهام التي تضاف إلى القائمة بنقرة زر واحدة"
},
"Comfy_Sidebar_Location": {
"name": "موقع الشريط الجانبي",
"options": {
"left": "يسار",
"right": "يمين"
}
},
"Comfy_Sidebar_Size": {
"name": "حجم الشريط الجانبي",
"options": {
"normal": "عادي",
"small": "صغير"
}
},
"Comfy_Sidebar_UnifiedWidth": {
"name": "عرض موحد للشريط الجانبي"
},
"Comfy_SnapToGrid_GridSize": {
"name": "حجم الالتصاق بالشبكة",
"tooltip": "عند سحب وتغيير حجم العقد مع الضغط على shift، يتم محاذاتها إلى الشبكة، هذا يتحكم في حجم تلك الشبكة."
},
"Comfy_TextareaWidget_FontSize": {
"name": "حجم خط ويدجت منطقة النص"
},
"Comfy_TextareaWidget_Spellcheck": {
"name": "التحقق من الإملاء في ويدجت منطقة النص"
},
"Comfy_TreeExplorer_ItemPadding": {
"name": "حشو عناصر مستعرض الشجرة"
},
"Comfy_UseNewMenu": {
"name": "استخدام القائمة الجديدة",
"tooltip": "موقع شريط القائمة. على الأجهزة المحمولة، تُعرض القائمة دائمًا في الأعلى.",
"options": {
"Disabled": "معطل",
"Top": "أعلى",
"Bottom": "أسفل"
}
},
"Comfy_Validation_Workflows": {
"name": "التحقق من صحة سير العمل"
},
"Comfy_WidgetControlMode": {
"name": "وضع التحكم في الودجت",
"tooltip": "يتحكم في متى يتم تحديث قيم الودجت (توليد عشوائي/زيادة/نقصان)، إما قبل إدراج الطلب في الطابور أو بعده.",
"options": {
"before": "قبل",
"after": "بعد"
}
},
"Comfy_Window_UnloadConfirmation": {
"name": "عرض تأكيد عند إغلاق النافذة"
},
"Comfy_Workflow_AutoSave": {
"name": "الحفظ التلقائي",
"options": {
"off": "إيقاف",
"after delay": "بعد تأخير"
}
},
"Comfy_Workflow_AutoSaveDelay": {
"name": "تأخير الحفظ التلقائي (بالملي ثانية)",
"tooltip": "ينطبق فقط إذا تم تعيين الحفظ التلقائي إلى \"بعد تأخير\"."
},
"Comfy_Workflow_ConfirmDelete": {
"name": "عرض تأكيد عند حذف سير العمل"
},
"Comfy_Workflow_Persist": {
"name": "الاحتفاظ بحالة سير العمل واستعادتها عند (إعادة) تحميل الصفحة"
},
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "عرض تحذير النماذج المفقودة"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "عرض تحذير العقد المفقودة"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "ترتيب معرفات العقد عند حفظ سير العمل"
},
"Comfy_Workflow_WorkflowTabsPosition": {
"name": "موضع تبويبات سير العمل المفتوحة",
"options": {
"Sidebar": "الشريط الجانبي",
"Topbar": "شريط الأعلى",
"Topbar (2nd-row)": "شريط الأعلى (الصف الثاني)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "عتبة التكبير للرسم بجودة منخفضة",
"tooltip": "عرض أشكال بجودة منخفضة عند التكبير للخارج"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "الحد الأقصى للإطارات في الثانية",
"tooltip": "الحد الأقصى لعدد الإطارات في الثانية التي يسمح للرسم أن يعرضها. يحد من استخدام GPU على حساب السلاسة. إذا كانت 0، يتم استخدام معدل تحديث الشاشة. الافتراضي: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "تغيير مقياس قوائم ودجت كومبو العقدة عند التكبير"
},
"LiteGraph_Node_DefaultPadding": {
"name": "تصغير العقد الجديدة دائمًا",
"tooltip": "تغيير حجم العقد إلى أصغر حجم ممكن عند الإنشاء. عند التعطيل، يتم توسيع العقدة المضافة حديثًا قليلاً لإظهار قيم الودجت."
},
"LiteGraph_Node_TooltipDelay": {
"name": "تأخير التلميح"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "إزاحة منحنى إعادة التوجيه",
"tooltip": "إزاحة نقطة تحكم بيزير من نقطة مركز إعادة التوجيه"
},
"pysssss_SnapToGrid": {
"name": "الالتصاق بالشبكة دائمًا"
}
}

View File

@@ -119,12 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},
@@ -236,12 +230,21 @@
"Workspace_ToggleBottomPanel": {
"label": "Toggle Bottom Panel"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Show Keybindings Dialog"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "Toggle Terminal Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Toggle Logs Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Toggle Essential Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Toggle View Controls Bottom Panel"
},
"Workspace_ToggleFocusMode": {
"label": "Toggle Focus Mode"
},

View File

@@ -974,7 +974,6 @@
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1012,8 +1011,11 @@
"Previous Opened Workflow": "Previous Opened Workflow",
"Toggle Search Box": "Toggle Search Box",
"Toggle Bottom Panel": "Toggle Bottom Panel",
"Show Keybindings Dialog": "Show Keybindings Dialog",
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Toggle Focus Mode": "Toggle Focus Mode",
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
@@ -1645,12 +1647,5 @@
"view": "View",
"panelControls": "Panel Controls"
}
},
"minimap": {
"nodeColors": "Node Colors",
"showLinks": "Show Links",
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
}
}

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Salir de subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Alternar Panel Inferior de Registros"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Alternar panel inferior esencial"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Alternar panel inferior de controles de vista"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Mostrar diálogo de combinaciones de teclas"
},
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
"Edit": "Editar",
"Exit Subgraph": "Salir de subgrafo",
"Export": "Exportar",
"Export (API)": "Exportar (API)",
"Fit Group To Contents": "Ajustar grupo a contenidos",
@@ -809,9 +808,11 @@
"Restart": "Reiniciar",
"Save": "Guardar",
"Save As": "Guardar como",
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
@@ -820,6 +821,7 @@
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
@@ -829,13 +831,6 @@
"Zoom In": "Acercar",
"Zoom Out": "Alejar"
},
"minimap": {
"nodeColors": "Colores de nodos",
"renderBypassState": "Mostrar estado de omisión",
"renderErrorState": "Mostrar estado de error",
"showGroups": "Mostrar marcos/grupos",
"showLinks": "Mostrar enlaces"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
"missingModels": "Modelos faltantes",
@@ -1153,6 +1148,20 @@
"Window": "Ventana",
"Workflow": "Flujo de Trabajo"
},
"shortcuts": {
"essentials": "Esenciales",
"keyboardShortcuts": "Atajos de teclado",
"manageShortcuts": "Gestionar atajos",
"noKeybinding": "Sin asignación de tecla",
"subcategories": {
"node": "Nodo",
"panelControls": "Controles del panel",
"queue": "Cola",
"view": "Vista",
"workflow": "Flujo de trabajo"
},
"viewControls": "Controles de vista"
},
"sideToolbar": {
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Quitter le sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Basculer le panneau inférieur des journaux"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Afficher/Masquer le panneau inférieur essentiel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Afficher/Masquer le panneau inférieur des contrôles de vue"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Afficher la boîte de dialogue des raccourcis clavier"
},
"Workspace_ToggleFocusMode": {
"label": "Basculer le mode focus"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
"Edit": "Éditer",
"Exit Subgraph": "Quitter le sous-graphe",
"Export": "Exporter",
"Export (API)": "Exporter (API)",
"Fit Group To Contents": "Ajuster le groupe au contenu",
@@ -809,9 +808,11 @@
"Restart": "Redémarrer",
"Save": "Enregistrer",
"Save As": "Enregistrer sous",
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Essential Bottom Panel": "Afficher/Masquer le panneau inférieur essentiel",
"Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
@@ -820,6 +821,7 @@
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle View Controls Bottom Panel": "Afficher/Masquer le panneau inférieur des contrôles de vue",
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
@@ -829,13 +831,6 @@
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"
},
"minimap": {
"nodeColors": "Couleurs des nœuds",
"renderBypassState": "Afficher létat de contournement",
"renderErrorState": "Afficher létat derreur",
"showGroups": "Afficher les cadres/groupes",
"showLinks": "Afficher les liens"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
"missingModels": "Modèles manquants",
@@ -1153,6 +1148,20 @@
"Window": "Fenêtre",
"Workflow": "Flux de Travail"
},
"shortcuts": {
"essentials": "Essentiel",
"keyboardShortcuts": "Raccourcis clavier",
"manageShortcuts": "Gérer les raccourcis",
"noKeybinding": "Aucun raccourci",
"subcategories": {
"node": "Nœud",
"panelControls": "Contrôles du panneau",
"queue": "File dattente",
"view": "Affichage",
"workflow": "Flux de travail"
},
"viewControls": "Contrôles daffichage"
},
"sideToolbar": {
"browseTemplates": "Parcourir les modèles d'exemple",
"downloads": "Téléchargements",

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_ExitSubgraph": {
"label": "サブグラフを終了"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "ログパネル下部の切り替え"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "必須な下部パネルを切り替え"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "表示コントロール下部パネルの切り替え"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "キーバインドダイアログを表示"
},
"Workspace_ToggleFocusMode": {
"label": "フォーカスモードの切り替え"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
"Edit": "編集",
"Exit Subgraph": "サブグラフを終了",
"Export": "エクスポート",
"Export (API)": "エクスポート (API)",
"Fit Group To Contents": "グループを内容に合わせる",
@@ -809,9 +808,11 @@
"Restart": "再起動",
"Save": "保存",
"Save As": "名前を付けて保存",
"Show Keybindings Dialog": "キーバインドダイアログを表示",
"Show Settings Dialog": "設定ダイアログを表示",
"Sign Out": "サインアウト",
"Toggle Bottom Panel": "下部パネルの切り替え",
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",
"Toggle Focus Mode": "フォーカスモードの切り替え",
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
@@ -820,6 +821,7 @@
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle View Controls Bottom Panel": "ビューコントロール下部パネルの切り替え",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
@@ -829,13 +831,6 @@
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
},
"minimap": {
"nodeColors": "ノードの色",
"renderBypassState": "バイパス状態を表示",
"renderErrorState": "エラー状態を表示",
"showGroups": "フレーム/グループを表示",
"showLinks": "リンクを表示"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
"missingModels": "モデルが見つかりません",
@@ -1153,6 +1148,20 @@
"Window": "ウィンドウ",
"Workflow": "ワークフロー"
},
"shortcuts": {
"essentials": "基本",
"keyboardShortcuts": "キーボードショートカット",
"manageShortcuts": "ショートカット管理",
"noKeybinding": "キー割り当てなし",
"subcategories": {
"node": "ノード",
"panelControls": "パネルコントロール",
"queue": "キュー",
"view": "ビュー",
"workflow": "ワークフロー"
},
"viewControls": "表示コントロール"
},
"sideToolbar": {
"browseTemplates": "サンプルテンプレートを表示",
"downloads": "ダウンロード",

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 종료"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "로그 하단 패널 토글"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "필수 하단 패널 전환"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "보기 컨트롤 하단 패널 전환"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "키 바인딩 대화상자 표시"
},
"Workspace_ToggleFocusMode": {
"label": "포커스 모드 토글"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
"Edit": "편집",
"Exit Subgraph": "서브그래프 종료",
"Export": "내보내기",
"Export (API)": "내보내기 (API)",
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
@@ -809,9 +808,11 @@
"Restart": "재시작",
"Save": "저장",
"Save As": "다른 이름으로 저장",
"Show Keybindings Dialog": "단축키 대화상자 표시",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
"Toggle Bottom Panel": "하단 패널 전환",
"Toggle Essential Bottom Panel": "필수 하단 패널 전환",
"Toggle Focus Mode": "포커스 모드 전환",
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
@@ -820,6 +821,7 @@
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
@@ -829,13 +831,6 @@
"Zoom In": "확대",
"Zoom Out": "축소"
},
"minimap": {
"nodeColors": "노드 색상",
"renderBypassState": "바이패스 상태 렌더링",
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
"missingModels": "모델이 없습니다",
@@ -1153,6 +1148,20 @@
"Window": "창",
"Workflow": "워크플로"
},
"shortcuts": {
"essentials": "기본",
"keyboardShortcuts": "키보드 단축키",
"manageShortcuts": "단축키 관리",
"noKeybinding": "단축키 없음",
"subcategories": {
"node": "노드",
"panelControls": "패널 컨트롤",
"queue": "대기열",
"view": "보기",
"workflow": "워크플로우"
},
"viewControls": "보기 컨트롤"
},
"sideToolbar": {
"browseTemplates": "예제 템플릿 탐색",
"downloads": "다운로드",

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Выйти из подграфа"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Переключить нижнюю панель логов"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Показать/скрыть основную нижнюю панель"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Показать/скрыть нижнюю панель управления просмотром"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Показать диалог клавиш"
},
"Workspace_ToggleFocusMode": {
"label": "Переключить режим фокуса"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
"Edit": "Редактировать",
"Exit Subgraph": "Выйти из подграфа",
"Export": "Экспортировать",
"Export (API)": "Экспорт (API)",
"Fit Group To Contents": "Подогнать группу под содержимое",
@@ -809,9 +808,11 @@
"Restart": "Перезапустить",
"Save": "Сохранить",
"Save As": "Сохранить как",
"Show Keybindings Dialog": "Показать диалог клавиш быстрого доступа",
"Show Settings Dialog": "Показать диалог настроек",
"Sign Out": "Выйти",
"Toggle Bottom Panel": "Переключить нижнюю панель",
"Toggle Essential Bottom Panel": "Показать/скрыть основную нижнюю панель",
"Toggle Focus Mode": "Переключить режим фокуса",
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Показать/скрыть боковую панель библиотеки моделей",
@@ -820,6 +821,7 @@
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle View Controls Bottom Panel": "Показать/скрыть панель управления просмотром",
"Toggle Workflows Sidebar": "Показать/скрыть боковую панель рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
@@ -829,13 +831,6 @@
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
},
"minimap": {
"nodeColors": "Цвета узлов",
"renderBypassState": "Отображать состояние обхода",
"renderErrorState": "Отображать состояние ошибки",
"showGroups": "Показать фреймы/группы",
"showLinks": "Показать связи"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
"missingModels": "Отсутствующие модели",
@@ -1153,6 +1148,20 @@
"Window": "Окно",
"Workflow": "Рабочий процесс"
},
"shortcuts": {
"essentials": "Основные",
"keyboardShortcuts": "Горячие клавиши",
"manageShortcuts": "Управление горячими клавишами",
"noKeybinding": "Нет привязки клавиши",
"subcategories": {
"node": "Узел",
"panelControls": "Управление панелью",
"queue": "Очередь",
"view": "Просмотр",
"workflow": "Рабочий процесс"
},
"viewControls": "Управление просмотром"
},
"sideToolbar": {
"browseTemplates": "Просмотреть примеры шаблонов",
"downloads": "Загрузки",

View File

@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_ExitSubgraph": {
"label": "離開子圖"
},
"Comfy_Graph_FitGroupToContents": {
"label": "調整群組以符合內容"
},
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "切換日誌底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切換基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "顯示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切換專注模式"
},

View File

@@ -765,7 +765,6 @@
"Desktop User Guide": "桌面應用程式使用指南",
"Duplicate Current Workflow": "複製目前工作流程",
"Edit": "編輯",
"Exit Subgraph": "離開子圖",
"Export": "匯出",
"Export (API)": "匯出API",
"Fit Group To Contents": "群組貼合內容",
@@ -809,9 +808,11 @@
"Restart": "重新啟動",
"Save": "儲存",
"Save As": "另存新檔",
"Show Keybindings Dialog": "顯示快捷鍵對話框",
"Show Settings Dialog": "顯示設定對話框",
"Sign Out": "登出",
"Toggle Bottom Panel": "切換下方面板",
"Toggle Essential Bottom Panel": "切換基本下方面板",
"Toggle Focus Mode": "切換專注模式",
"Toggle Logs Bottom Panel": "切換日誌下方面板",
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
@@ -820,6 +821,7 @@
"Toggle Search Box": "切換搜尋框",
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle View Controls Bottom Panel": "切換檢視控制下方面板",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
@@ -829,13 +831,6 @@
"Zoom In": "放大",
"Zoom Out": "縮小"
},
"minimap": {
"nodeColors": "節點顏色",
"renderBypassState": "顯示繞過狀態",
"renderErrorState": "顯示錯誤狀態",
"showGroups": "顯示框架/群組",
"showLinks": "顯示連結"
},
"missingModelsDialog": {
"doNotAskAgain": "不要再顯示此訊息",
"missingModels": "缺少模型",
@@ -1153,6 +1148,20 @@
"Window": "視窗",
"Workflow": "工作流程"
},
"shortcuts": {
"essentials": "基本",
"keyboardShortcuts": "鍵盤快捷鍵",
"manageShortcuts": "管理快捷鍵",
"noKeybinding": "無快捷鍵",
"subcategories": {
"node": "節點",
"panelControls": "面板控制",
"queue": "佇列",
"view": "檢視",
"workflow": "工作流程"
},
"viewControls": "檢視控制"
},
"sideToolbar": {
"browseTemplates": "瀏覽範例模板",
"downloads": "下載",

View File

@@ -72,7 +72,7 @@
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "布切小地"
"label": "布切小地"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
@@ -119,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_ExitSubgraph": {
"label": "退出子图"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},
@@ -165,10 +162,10 @@
"label": "切换进度对话框"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "小 MaskEditor 中的刷大小"
"label": "小 MaskEditor 中的刷大小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "增加 MaskEditor 画笔大小"
"label": "增加 MaskEditor 畫筆大小"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"
@@ -239,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切換基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "顯示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
},

View File

@@ -83,10 +83,10 @@
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清工作流",
"deleteWorkflow": "除工作流",
"duplicate": "复制",
"enterNewName": "入新名"
"clearWorkflow": "清工作流",
"deleteWorkflow": "除工作流",
"duplicate": "複製",
"enterNewName": "入新名"
},
"chatHistory": {
"cancelEdit": "取消",
@@ -218,7 +218,7 @@
"WEBCAM": "摄像头"
},
"desktopMenu": {
"confirmQuit": "存在未保存的工作流;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmQuit": "未保存的工作流程开启;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
"quit": "退出",
"reinstall": "重新安装"
@@ -272,7 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearFilters": "清除筛选",
"clearFilters": "清除篩選",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -297,7 +297,7 @@
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"dismiss": "关闭",
"dismiss": "關閉",
"download": "下载",
"edit": "编辑",
"empty": "空",
@@ -312,8 +312,8 @@
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
@@ -399,8 +399,8 @@
"upload": "上传",
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 请参考 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
@@ -410,7 +410,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -720,7 +720,7 @@
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "明",
"help": "明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
@@ -734,9 +734,9 @@
"run": "运行",
"runWorkflow": "运行工作流程Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面)",
"settings": "定",
"settings": "定",
"showMenu": "显示菜单",
"theme": "主",
"theme": "主",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -746,7 +746,7 @@
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Toggle Lock": "切换视图锁定",
"Canvas Toggle Minimap": "布切小地",
"Canvas Toggle Minimap": "布切小地",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",
@@ -765,7 +765,6 @@
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
"Exit Subgraph": "退出子圖",
"Export": "导出",
"Export (API)": "导出 (API)",
"Fit Group To Contents": "适应组内容",
@@ -809,18 +808,21 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "顯示快捷鍵對話框",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Bottom Panel": "切换底部面板",
"Toggle Essential Bottom Panel": "切換基本下方面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle View Controls Bottom Panel": "切換檢視控制下方面板",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
@@ -829,13 +831,6 @@
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"
},
"minimap": {
"nodeColors": "節點顏色",
"renderBypassState": "顯示繞過狀態",
"renderErrorState": "顯示錯誤狀態",
"showGroups": "顯示框架/群組",
"showLinks": "顯示連結"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
"missingModels": "缺少模型",
@@ -1153,6 +1148,20 @@
"Window": "窗口",
"Workflow": "工作流"
},
"shortcuts": {
"essentials": "基本",
"keyboardShortcuts": "鍵盤快捷鍵",
"manageShortcuts": "管理快捷鍵",
"noKeybinding": "無快捷鍵",
"subcategories": {
"node": "節點",
"panelControls": "面板控制",
"queue": "佇列",
"view": "檢視",
"workflow": "工作流程"
},
"viewControls": "檢視控制"
},
"sideToolbar": {
"browseTemplates": "浏览示例模板",
"downloads": "下载",
@@ -1620,10 +1629,10 @@
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "关闭",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
"title": "版本容性警告",
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"title": "版本容性警告",
"updateFrontend": "更新前端"
},
"welcome": {

View File

@@ -30,10 +30,10 @@
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片并选择“设为背景”来使用它。"
},
"Comfy_Canvas_NavigationMode": {
"name": "画布导航模式",
"name": "畫布導航模式",
"options": {
"Left-Click Pan (Legacy)": "左拖曳(版)",
"Standard (New)": "标准(新)"
"Left-Click Pan (Legacy)": "左拖曳(版)",
"Standard (New)": "標準(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
@@ -334,7 +334,7 @@
"Disabled": "禁用",
"Top": "顶部"
},
"tooltip": "菜单栏位置。在移动设备上,菜单始终显示于顶端。"
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"

View File

@@ -394,9 +394,7 @@ const zSettings = z.object({
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
'Comfy.GroupSelectedNodes.Padding': z.number(),
'Comfy.InvertMenuScrolling': z.boolean(),
'Comfy.Locale': z.string(),
'Comfy.Logging.Enabled': z.boolean(),
'Comfy.NodeLibrary.Bookmarks': z.array(z.string()),
'Comfy.NodeLibrary.Bookmarks.V2': z.array(z.string()),
'Comfy.NodeLibrary.BookmarksCustomization': z.record(
@@ -426,14 +424,12 @@ const zSettings = z.object({
'Comfy.Sidebar.Location': z.enum(['left', 'right']),
'Comfy.Sidebar.Size': z.enum(['small', 'normal']),
'Comfy.Sidebar.UnifiedWidth': z.boolean(),
'Comfy.SwitchUser': z.any(),
'Comfy.SnapToGrid.GridSize': z.number(),
'Comfy.TextareaWidget.FontSize': z.number(),
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
'Comfy.UseNewMenu': z.enum(['Disabled', 'Top', 'Bottom']),
'Comfy.TreeExplorer.ItemPadding': z.number(),
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Validation.NodeDefs': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Workflow.WorkflowTabsPosition': z.enum([
@@ -454,7 +450,6 @@ const zSettings = z.object({
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
'Comfy.Extension.Disabled': z.array(z.string()),
'Comfy.Settings.ExtensionPanel': z.boolean(),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
'Comfy.Node.SnapHighlightsNode': z.boolean(),
@@ -476,11 +471,6 @@ const zSettings = z.object({
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),
'Comfy.Minimap.NodeColors': z.boolean(),
'Comfy.Minimap.ShowLinks': z.boolean(),
'Comfy.Minimap.ShowGroups': z.boolean(),
'Comfy.Minimap.RenderBypassState': z.boolean(),
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),

View File

@@ -385,15 +385,8 @@ export class ComfyApp {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
let combinedImgSrc: string | undefined
if (
ComfyApp.clipspace.combinedIndex !== undefined &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length
) {
combinedImgSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
}
const combinedImgSrc =
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
@@ -734,7 +727,12 @@ export class ComfyApp {
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
setNodePreviewsByExecutionId(displayNodeId, [blobUrl])
const nodeParents = displayNodeId.split(':')
for (let i = 1; i <= nodeParents.length; i++) {
setNodePreviewsByExecutionId(nodeParents.slice(0, i).join(':'), [
blobUrl
])
}
})
api.init()

View File

@@ -1,6 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
@@ -12,7 +11,6 @@ export const useKeybindingService = () => {
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
@@ -34,19 +32,6 @@ export const useKeybindingService = () => {
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
// Special handling for Escape key - let dialogs handle it first
if (
event.key === 'Escape' &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
) {
// If dialogs are open, don't execute the keybinding - let the dialog handle it
if (dialogStore.dialogStack.length > 0) {
return
}
}
// Prevent default browser behavior first, then execute the command
event.preventDefault()
await commandStore.execute(keybinding.commandId)

View File

@@ -1,6 +1,5 @@
import _ from 'lodash'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
@@ -64,7 +63,6 @@ export const useLitegraphService = () => {
const toastStore = useToastStore()
const widgetStore = useWidgetStore()
const canvasStore = useCanvasStore()
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
function registerSubgraphNodeDef(
@@ -764,8 +762,15 @@ export const useLitegraphService = () => {
options.push({
content: 'Bypass',
callback: () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
const mode =
this.mode === LGraphEventMode.BYPASS
? LGraphEventMode.ALWAYS
: LGraphEventMode.BYPASS
for (const item of app.canvas.selectedItems) {
if (item instanceof LGraphNode) item.mode = mode
}
// @ts-expect-error fixme ts strict error
this.graph.change()
}
})
@@ -800,15 +805,6 @@ export const useLitegraphService = () => {
})
}
}
if (this instanceof SubgraphNode) {
options.unshift({
content: 'Unpack Subgraph',
callback: () => {
useNodeOutputStore().revokeSubgraphPreviews(this)
this.graph.unpackSubgraph(this)
}
})
}
return []
}

View File

@@ -1,6 +1,5 @@
import { toRaw } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
@@ -22,7 +21,6 @@ export const useWorkflowService = () => {
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
@@ -289,14 +287,8 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
}
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
}
/**

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
ExecutedWsMessage,
ResultItem,
@@ -268,20 +268,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
app.nodePreviewImages = {}
}
/**
* Revoke all preview of a subgraph node and the graph it contains.
* Does not recurse to contents of nested subgraphs.
*/
function revokeSubgraphPreviews(subgraphNode: SubgraphNode) {
const graphId = subgraphNode.graph.isRootGraph
? ''
: subgraphNode.graph.id + ':'
revokePreviewsByLocatorId(graphId + subgraphNode.id)
for (const node of subgraphNode.subgraph.nodes) {
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
}
}
return {
getNodeOutputs,
getNodeImageUrls,
@@ -293,7 +279,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodePreviewsByNodeId,
revokePreviewsByExecutionId,
revokeAllPreviews,
revokeSubgraphPreviews,
getPreviewParam
}
})

View File

@@ -226,14 +226,6 @@ export const useReleaseStore = defineStore('release', () => {
return
}
// Skip fetching if API nodes are disabled via argv
if (
systemStatsStore.systemStats?.system?.argv?.includes(
'--disable-api-nodes'
)
) {
return
}
isLoading.value = true
error.value = null

View File

@@ -2,7 +2,6 @@ import _ from 'lodash'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
@@ -328,8 +327,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
(path) => path !== workflow.path
)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()
@@ -390,14 +387,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
const isBusy = ref<boolean>(false)
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
isBusy.value = true
try {
// Capture all needed values upfront
const oldPath = workflow.path
const oldKey = workflow.key
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const openIndex = detachWorkflow(workflow)
@@ -408,9 +403,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
attachWorkflow(workflow, openIndex)
}
// Move thumbnail from old key to new key (using workflow keys, not full paths)
const newKey = workflow.key
moveWorkflowThumbnail(oldKey, newKey)
// Update bookmarks
if (wasBookmarked) {
await bookmarkStore.setBookmarked(oldPath, false)
@@ -428,8 +420,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}
// Clear thumbnail when workflow is deleted
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} finally {
isBusy.value = false

View File

@@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
export interface SelectionOverlayState {
visible: Readonly<Ref<boolean>>
updateCount: Readonly<Ref<number>>
}
export const SelectionOverlayInjectionKey: InjectionKey<SelectionOverlayState> =
Symbol('selectionOverlayState')

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