Compare commits
42 Commits
core/1.25
...
command-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da195c925 | ||
|
|
d3398944d5 | ||
|
|
b70b2c89b2 | ||
|
|
c0303a6553 | ||
|
|
1e6803fd65 | ||
|
|
38a77abecb | ||
|
|
fcffa51a24 | ||
|
|
d0ef1bb81a | ||
|
|
34b7fe14c4 | ||
|
|
a4d7b4dd55 | ||
|
|
109542dca3 | ||
|
|
ffc812a8f5 | ||
|
|
b745f533ba | ||
|
|
8f289c8e67 | ||
|
|
79b4c78116 | ||
|
|
48aea928e0 | ||
|
|
03ad06ea14 | ||
|
|
ff5943f770 | ||
|
|
b1117b9838 | ||
|
|
2d11fb1f90 | ||
|
|
e70b127f2a | ||
|
|
0d8e4fe719 | ||
|
|
5f5f44b310 | ||
|
|
b42878a9da | ||
|
|
5cc269eff1 | ||
|
|
16d7436883 | ||
|
|
db452c1e63 | ||
|
|
10d80165c4 | ||
|
|
c3997dfdb0 | ||
|
|
7bbbf59722 | ||
|
|
8bf60777e7 | ||
|
|
ba28fa4621 | ||
|
|
95ab88693c | ||
|
|
5d71d6f9cf | ||
|
|
8899b425a8 | ||
|
|
1fc4fd2ca8 | ||
|
|
1b9bacaeef | ||
|
|
65cc06771c | ||
|
|
3c154d8487 | ||
|
|
c6c20e53fb | ||
|
|
70c06d10bb | ||
|
|
f4482eb35a |
6
.github/workflows/claude-pr-review.yml
vendored
@@ -19,10 +19,10 @@ jobs:
|
||||
should-proceed: ${{ steps.check-status.outputs.proceed }}
|
||||
steps:
|
||||
- name: Wait for other CI checks
|
||||
uses: lewagon/wait-on-check-action@v1.3.1
|
||||
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
check-regexp: '^(eslint|prettier|test|playwright-tests)'
|
||||
check-regexp: '^(lint-and-format|test|playwright-tests)'
|
||||
wait-interval: 30
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
id: check-status
|
||||
run: |
|
||||
# Get all check runs for this commit
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format|test|playwright-tests")) | {name, conclusion}')
|
||||
|
||||
# Check if any required checks failed
|
||||
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
|
||||
|
||||
@@ -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": {
|
||||
|
||||
17
.github/workflows/eslint.yaml
vendored
@@ -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
|
||||
23
.github/workflows/format.yaml
vendored
@@ -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
@@ -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.'
|
||||
})
|
||||
4
.github/workflows/test-ui.yaml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration]
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
2
.github/workflows/update-manager-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
|
||||
1
.gitignore
vendored
@@ -41,6 +41,7 @@ tests-ui/workflows/examples
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser_tests/**/*-darwin.png
|
||||
|
||||
.env
|
||||
|
||||
|
||||
40
AGENTS.md
Normal 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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -767,8 +767,8 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async rightClickCanvas() {
|
||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
||||
async rightClickCanvas(x: number = 10, y: number = 10) {
|
||||
await this.page.mouse.click(x, y, { 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
280
browser_tests/tests/bottomPanelShortcuts.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Click shortcuts toggle button in sidebar
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click toggle button again to hide
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden again
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials tab should be visible and active by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display shortcut categories
|
||||
await expect(
|
||||
comfyPage.page.locator('.subcategory-title').first()
|
||||
).toBeVisible()
|
||||
|
||||
// Should display some keyboard shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have workflow, node, and queue sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Workflow' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Node' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Queue' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls tab should be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display view controls shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have view and panel controls sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'View' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Panel Controls' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials should be active initially
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls should now be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Switch back to essentials
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
|
||||
|
||||
// Essentials should be active again
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Wait for shortcuts to load
|
||||
await comfyPage.page.waitForSelector('.key-badge')
|
||||
|
||||
// Check for common formatted keys
|
||||
const keyBadges = comfyPage.page.locator('.key-badge')
|
||||
const count = await keyBadges.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Should show formatted modifier keys
|
||||
const badgeText = await keyBadges.allTextContents()
|
||||
const hasModifiers = badgeText.some((text) =>
|
||||
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
|
||||
)
|
||||
expect(hasModifiers).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should maintain panel state when switching to terminal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel first
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Open terminal panel (should switch panels)
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Toggle Bottom Panel"]')
|
||||
.click()
|
||||
|
||||
// Panel should still be visible but showing terminal content
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Switch back to shortcuts
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should show shortcuts content again
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should handle keyboard navigation', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Focus the first tab
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
|
||||
|
||||
// Use arrow keys to navigate between tabs
|
||||
await comfyPage.page.keyboard.press('ArrowRight')
|
||||
|
||||
// View controls tab should now have focus
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toBeFocused()
|
||||
|
||||
// Press Enter to activate the tab
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Tab should be selected
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should close panel by clicking shortcuts button again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click shortcuts button again to close
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display shortcuts in organized columns', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should have 3-column grid layout
|
||||
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
|
||||
|
||||
// Should have multiple subcategory sections
|
||||
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
|
||||
const titleCount = await subcategoryTitles.count()
|
||||
expect(titleCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Press Ctrl+Shift+K to open shortcuts panel
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Should show essentials tab by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should open settings dialog when clicking manage shortcuts button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Manage shortcuts button should be visible
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
).toBeVisible()
|
||||
|
||||
// Click manage shortcuts button
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
.click()
|
||||
|
||||
// Settings dialog should open with keybinding tab
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
|
||||
// Should show keybinding settings (check for keybinding-related content)
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
161
browser_tests/tests/commandSearchBox.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Command search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
})
|
||||
|
||||
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
|
||||
// Type ">" to enter command mode
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
|
||||
// Verify filter button is hidden in command mode
|
||||
const filterButton = comfyPage.page.locator('.filter-button')
|
||||
await expect(filterButton).not.toBeVisible()
|
||||
|
||||
// Verify placeholder text changes
|
||||
await expect(comfyPage.searchBox.input).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search Commands')
|
||||
)
|
||||
})
|
||||
|
||||
test('Shows command list when entering command mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
// Check that commands are shown
|
||||
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
|
||||
await expect(firstItem).toBeVisible()
|
||||
|
||||
// Verify it shows a command item with icon
|
||||
const commandIcon = firstItem.locator('.item-icon')
|
||||
await expect(commandIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can search and filter commands', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>save')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
|
||||
|
||||
// Get all visible command items
|
||||
const items = comfyPage.searchBox.dropdown.locator('li')
|
||||
const count = await items.count()
|
||||
|
||||
// Should have filtered results
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(count).toBeLessThan(10) // Should be filtered, not showing all
|
||||
|
||||
// Verify first result contains "save"
|
||||
const firstLabel = await items.first().locator('.item-label').textContent()
|
||||
expect(firstLabel?.toLowerCase()).toContain('save')
|
||||
})
|
||||
|
||||
test('Shows keybindings for commands that have them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>undo')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Find the undo command
|
||||
const undoItem = comfyPage.searchBox.dropdown
|
||||
.locator('li')
|
||||
.filter({ hasText: 'Undo' })
|
||||
.first()
|
||||
|
||||
// Check if keybinding is shown (if configured)
|
||||
const keybinding = undoItem.locator('.item-keybinding')
|
||||
const keybindingCount = await keybinding.count()
|
||||
|
||||
// Keybinding might or might not be present depending on configuration
|
||||
if (keybindingCount > 0) {
|
||||
await expect(keybinding).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Executes command on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>new blank')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Count nodes before
|
||||
const nodesBefore = await comfyPage.page
|
||||
.locator('.litegraph.litenode')
|
||||
.count()
|
||||
|
||||
// Select the new blank workflow command
|
||||
const newBlankItem = comfyPage.searchBox.dropdown
|
||||
.locator('li')
|
||||
.filter({ hasText: 'New Blank Workflow' })
|
||||
.first()
|
||||
await newBlankItem.click()
|
||||
|
||||
// Search box should close
|
||||
await expect(comfyPage.searchBox.input).not.toBeVisible()
|
||||
|
||||
// Verify workflow was cleared (no nodes)
|
||||
const nodesAfter = await comfyPage.page
|
||||
.locator('.litegraph.litenode')
|
||||
.count()
|
||||
expect(nodesAfter).toBe(0)
|
||||
})
|
||||
|
||||
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
|
||||
// Enter command mode
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
|
||||
|
||||
// Return to node search by filling with empty string to trigger search
|
||||
await comfyPage.searchBox.input.fill('')
|
||||
|
||||
// Small wait for UI update
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
// Filter button should be visible again
|
||||
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
|
||||
|
||||
// Placeholder should change back
|
||||
await expect(comfyPage.searchBox.input).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search Nodes')
|
||||
)
|
||||
})
|
||||
|
||||
test('Command search is case insensitive', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
|
||||
// Search with lowercase
|
||||
await comfyPage.searchBox.input.fill('>SAVE')
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Should find save commands
|
||||
const items = comfyPage.searchBox.dropdown.locator('li')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
// Verify it found save-related commands
|
||||
const firstLabel = await items.first().locator('.item-label').textContent()
|
||||
expect(firstLabel?.toLowerCase()).toContain('save')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Locator, expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
@@ -767,6 +767,17 @@ 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')
|
||||
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabA)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotA
|
||||
)
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabB)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotB
|
||||
)
|
||||
|
||||
@@ -48,7 +48,9 @@ test.describe('LiteGraph Native Reroute Node', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
})
|
||||
|
||||
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
|
||||
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
157
browser_tests/tests/subgraph-rename-dialog.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,6 +372,68 @@ 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', () => {
|
||||
|
||||
155
browser_tests/tests/workflowTabThumbnail.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -49,6 +49,13 @@ 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'] },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,18 +11,33 @@
|
||||
class="p-3 border-none"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ tab.title.toUpperCase() }}
|
||||
{{
|
||||
shouldCapitalizeTab(tab.id)
|
||||
? tab.title.toUpperCase()
|
||||
: tab.title
|
||||
}}
|
||||
</span>
|
||||
</Tab>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
:label="$t('shortcuts.manageShortcuts')"
|
||||
icon="pi pi-cog"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="openKeybindingSettings"
|
||||
/>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="closeBottomPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
const activeTabId = bottomPanelStore.activeBottomPanelTabId
|
||||
return (
|
||||
activeTabId === 'shortcuts-essentials' ||
|
||||
activeTabId === 'shortcuts-view-controls'
|
||||
)
|
||||
})
|
||||
|
||||
const shouldCapitalizeTab = (tabId: string): boolean => {
|
||||
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
bottomPanelStore.activePanel = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="essentialsCommands"
|
||||
:subcategories="essentialsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
ESSENTIALS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const essentialsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'essentials')
|
||||
)
|
||||
|
||||
const { subcategories: essentialsSubcategories } = useCommandSubcategories(
|
||||
essentialsCommands,
|
||||
ESSENTIALS_CONFIG
|
||||
)
|
||||
</script>
|
||||
119
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="shortcuts-list flex justify-center">
|
||||
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
|
||||
<div
|
||||
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
|
||||
:key="subcategory"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h3
|
||||
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
|
||||
>
|
||||
{{ getSubcategoryTitle(subcategory) }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="command in subcategoryCommands"
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ command.getTranslatedLabel() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keybinding-display flex-shrink-0">
|
||||
<div
|
||||
class="keybinding-combo flex gap-1"
|
||||
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
|
||||
>
|
||||
<span
|
||||
v-for="key in command.keybinding!.combo.getKeySequences()"
|
||||
:key="key"
|
||||
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
|
||||
>
|
||||
{{ formatKey(key) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { subcategories } = defineProps<{
|
||||
commands: ComfyCommandImpl[]
|
||||
subcategories: Record<string, ComfyCommandImpl[]>
|
||||
}>()
|
||||
|
||||
const filteredSubcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const [subcategory, commands] of Object.entries(subcategories)) {
|
||||
result[subcategory] = commands.filter((cmd) => !!cmd.keybinding)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const getSubcategoryTitle = (subcategory: string): string => {
|
||||
const titleMap: Record<string, string> = {
|
||||
workflow: t('shortcuts.subcategories.workflow'),
|
||||
node: t('shortcuts.subcategories.node'),
|
||||
queue: t('shortcuts.subcategories.queue'),
|
||||
view: t('shortcuts.subcategories.view'),
|
||||
'panel-controls': t('shortcuts.subcategories.panelControls')
|
||||
}
|
||||
|
||||
return titleMap[subcategory] || subcategory
|
||||
}
|
||||
|
||||
const formatKey = (key: string): string => {
|
||||
const keyMap: Record<string, string> = {
|
||||
Control: 'Ctrl',
|
||||
Meta: 'Cmd',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Backspace: '⌫',
|
||||
Delete: '⌦',
|
||||
Enter: '↵',
|
||||
Escape: 'Esc',
|
||||
Tab: '⇥',
|
||||
' ': 'Space'
|
||||
}
|
||||
|
||||
return keyMap[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subcategory-title {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.key-badge {
|
||||
background-color: var(--p-surface-200);
|
||||
border: 1px solid var(--p-surface-300);
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark-theme .key-badge {
|
||||
background-color: var(--p-surface-600);
|
||||
border-color: var(--p-surface-500);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="viewControlsCommands"
|
||||
:subcategories="viewControlsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
VIEW_CONTROLS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const viewControlsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'view-controls')
|
||||
)
|
||||
|
||||
const { subcategories: viewControlsSubcategories } = useCommandSubcategories(
|
||||
viewControlsCommands,
|
||||
VIEW_CONTROLS_CONFIG
|
||||
)
|
||||
</script>
|
||||
@@ -139,7 +139,6 @@ import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
@@ -149,7 +148,6 @@ import {
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
@@ -161,7 +159,6 @@ const filters = ref({
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const commandStore = useCommandStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ICommandData {
|
||||
id: string
|
||||
@@ -173,10 +170,7 @@ interface ICommandData {
|
||||
const commandsData = computed<ICommandData[]>(() => {
|
||||
return Object.values(commandStore.commands).map((command) => ({
|
||||
id: command.id,
|
||||
label: t(
|
||||
`commands.${normalizeI18nKey(command.id)}.label`,
|
||||
command.label ?? ''
|
||||
),
|
||||
label: command.getTranslatedLabel(),
|
||||
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
|
||||
source: command.source
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
class="litegraph-minimap absolute right-[90px] z-[1000]"
|
||||
:class="{
|
||||
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
|
||||
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
|
||||
}"
|
||||
:style="containerStyles"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -25,9 +29,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const minimap = useMinimap()
|
||||
const canvasStore = useCanvasStore()
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const {
|
||||
initialized,
|
||||
|
||||
@@ -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++
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -188,16 +188,13 @@ const showVersionUpdates = computed(() =>
|
||||
settingStore.get('Comfy.Notification.ShowVersionUpdates')
|
||||
)
|
||||
|
||||
const moreMenuItem = computed(() =>
|
||||
menuItems.value.find((item) => item.key === 'more')
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const moreItems: MenuItem[] = [
|
||||
const moreItems = computed<MenuItem[]>(() => {
|
||||
const allMoreItems: MenuItem[] = [
|
||||
{
|
||||
key: 'desktop-guide',
|
||||
type: 'item',
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
|
||||
emit('close')
|
||||
@@ -230,6 +227,19 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
}
|
||||
]
|
||||
|
||||
// Filter for visible items only
|
||||
return allMoreItems.filter((item) => item.visible !== false)
|
||||
})
|
||||
|
||||
const hasVisibleMoreItems = computed(() => {
|
||||
return !!moreItems.value.length
|
||||
})
|
||||
|
||||
const moreMenuItem = computed(() =>
|
||||
menuItems.value.find((item) => item.key === 'more')
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'docs',
|
||||
@@ -276,8 +286,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems
|
||||
items: moreItems.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
50
src/components/searchbox/CommandSearchItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-3 py-2 w-full">
|
||||
<span
|
||||
class="flex-shrink-0 w-5 text-center text-muted item-icon"
|
||||
:class="command.icon ?? 'pi pi-chevron-right'"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
|
||||
>
|
||||
<span
|
||||
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="command.keybinding"
|
||||
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
|
||||
>
|
||||
{{ command.keybinding.combo.toString() }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
import { highlightQuery } from '@/utils/formatUtil'
|
||||
|
||||
const { command, currentQuery } = defineProps<{
|
||||
command: ComfyCommandImpl
|
||||
currentQuery: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.highlight) {
|
||||
background-color: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color);
|
||||
font-weight: bold;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
.keybinding-badge {
|
||||
border-color: var(--p-content-border-color);
|
||||
background-color: var(--p-content-hover-background);
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
|
||||
>
|
||||
<div
|
||||
v-if="enableNodePreview"
|
||||
v-if="enableNodePreview && !isCommandMode"
|
||||
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
|
||||
>
|
||||
<NodePreview
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!isCommandMode"
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
class="filter-button z-10"
|
||||
@@ -49,13 +50,24 @@
|
||||
auto-option-focus
|
||||
force-selection
|
||||
multiple
|
||||
:option-label="'display_name'"
|
||||
:option-label="getOptionLabel"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@option-select="onOptionSelect($event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
|
||||
<!-- Command search item, Remove the '>' prefix from the query -->
|
||||
<CommandSearchItem
|
||||
v-if="isCommandMode"
|
||||
:command="option"
|
||||
:current-query="currentQuery.substring(1)"
|
||||
/>
|
||||
<NodeSearchItem
|
||||
v-else
|
||||
:node-def="option"
|
||||
:current-query="currentQuery"
|
||||
/>
|
||||
</template>
|
||||
<!-- FilterAndValue -->
|
||||
<template #chip="{ value }">
|
||||
@@ -80,13 +92,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
|
||||
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import { CommandSearchService } from '@/services/commandSearchService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ComfyNodeDefImpl,
|
||||
useNodeDefStore,
|
||||
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const enableNodePreview = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
||||
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
|
||||
|
||||
const nodeSearchFilterVisible = ref(false)
|
||||
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
|
||||
const suggestions = ref<ComfyNodeDefImpl[]>([])
|
||||
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
|
||||
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
|
||||
const currentQuery = ref('')
|
||||
const isCommandMode = ref(false)
|
||||
|
||||
// Initialize command search service
|
||||
const commandSearchService = ref<CommandSearchService | null>(null)
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (isCommandMode.value) {
|
||||
return t('g.searchCommands', 'Search commands') + '...'
|
||||
}
|
||||
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
// Initialize command search service with commands
|
||||
watch(
|
||||
() => commandStore.commands,
|
||||
(commands) => {
|
||||
commandSearchService.value = new CommandSearchService(commands)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const search = (query: string) => {
|
||||
const queryIsEmpty = query === '' && filters.length === 0
|
||||
currentQuery.value = query
|
||||
|
||||
// Check if we're in command mode (query starts with ">")
|
||||
if (query.startsWith('>')) {
|
||||
isCommandMode.value = true
|
||||
if (commandSearchService.value) {
|
||||
suggestions.value = commandSearchService.value.searchCommands(query, {
|
||||
limit: searchLimit
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Normal node search mode
|
||||
isCommandMode.value = false
|
||||
const queryIsEmpty = query === '' && filters.length === 0
|
||||
suggestions.value = queryIsEmpty
|
||||
? nodeFrequencyStore.topNodeDefs
|
||||
: [
|
||||
@@ -132,7 +180,18 @@ const search = (query: string) => {
|
||||
]
|
||||
}
|
||||
|
||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'addFilter',
|
||||
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
): void
|
||||
(
|
||||
e: 'removeFilter',
|
||||
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
): void
|
||||
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
|
||||
(e: 'executeCommand', command: ComfyCommandImpl): void
|
||||
}>()
|
||||
|
||||
let inputElement: HTMLInputElement | null = null
|
||||
const reFocusInput = async () => {
|
||||
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
|
||||
await reFocusInput()
|
||||
}
|
||||
const setHoverSuggestion = (index: number) => {
|
||||
if (index === -1) {
|
||||
if (index === -1 || isCommandMode.value) {
|
||||
hoveredSuggestion.value = null
|
||||
return
|
||||
}
|
||||
const value = suggestions.value[index]
|
||||
const value = suggestions.value[index] as ComfyNodeDefImpl
|
||||
hoveredSuggestion.value = value
|
||||
}
|
||||
|
||||
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
|
||||
if (isCommandMode.value) {
|
||||
emit('executeCommand', option as ComfyCommandImpl)
|
||||
} else {
|
||||
emit('addNode', option as ComfyNodeDefImpl)
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionLabel = (
|
||||
option: ComfyNodeDefImpl | ComfyCommandImpl
|
||||
): string => {
|
||||
if ('display_name' in option) {
|
||||
return option.display_name
|
||||
}
|
||||
return option.label || option.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles direct input changes on the AutoCompletePlus component.
|
||||
* This ensures search mode switching works properly when users clear the input
|
||||
* or modify it directly, as the @complete event may not always trigger.
|
||||
*
|
||||
* @param event - The input event from the AutoCompletePlus component
|
||||
* @note Known issue on empty input complete state:
|
||||
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
|
||||
*/
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const inputValue = target.value
|
||||
|
||||
// Trigger search to handle mode switching between node and command search
|
||||
if (inputValue === '') {
|
||||
search('')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@execute-command="executeCommand"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -62,6 +64,7 @@ let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const { visible } = storeToRefs(useSearchBoxStore())
|
||||
const dismissable = ref(true)
|
||||
@@ -109,6 +112,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
const executeCommand = async (command: ComfyCommandImpl) => {
|
||||
// Close the dialog immediately
|
||||
closeDialog()
|
||||
|
||||
// Execute the command
|
||||
await commandStore.execute(command.id)
|
||||
}
|
||||
|
||||
const newSearchBoxEnabled = computed(
|
||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarShortcutsToggleButton />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -32,6 +33,7 @@ import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
44
src/components/sidebar/SidebarShortcutsToggleButton.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="
|
||||
$t('shortcuts.keyboardShortcuts') +
|
||||
' (' +
|
||||
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
|
||||
')'
|
||||
"
|
||||
:selected="isShortcutsPanelVisible"
|
||||
@click="toggleShortcutsPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:keyboard />
|
||||
</template>
|
||||
</SidebarIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const command = useCommandStore().getCommand(
|
||||
'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
)
|
||||
|
||||
const isShortcutsPanelVisible = computed(
|
||||
() => bottomPanelStore.activePanel === 'shortcuts'
|
||||
)
|
||||
|
||||
const toggleShortcutsPanel = () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
|
||||
const formatKeySequence = (sequences: string[]): string => {
|
||||
return sequences
|
||||
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
|
||||
.join(' + ')
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
@@ -22,23 +22,33 @@
|
||||
/>
|
||||
</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, ref } from 'vue'
|
||||
import { computed, onUnmounted, 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
|
||||
@@ -55,6 +65,8 @@ 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(() =>
|
||||
@@ -90,6 +102,27 @@ 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 (
|
||||
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
popoverRef.value?.hidePopover()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
229
src/components/topbar/WorkflowTabPopover.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<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>
|
||||
78
src/composables/bottomPanelTabs/useCommandSubcategories.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
import { type ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
export type SubcategoryRule = {
|
||||
pattern: string | RegExp
|
||||
subcategory: string
|
||||
}
|
||||
|
||||
export type SubcategoryConfig = {
|
||||
defaultSubcategory: string
|
||||
rules: SubcategoryRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for grouping commands by subcategory based on configurable rules
|
||||
*/
|
||||
export function useCommandSubcategories(
|
||||
commands: ComputedRef<ComfyCommandImpl[]>,
|
||||
config: SubcategoryConfig
|
||||
) {
|
||||
const subcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const command of commands.value) {
|
||||
let subcategory = config.defaultSubcategory
|
||||
|
||||
// Find the first matching rule
|
||||
for (const rule of config.rules) {
|
||||
const matches =
|
||||
typeof rule.pattern === 'string'
|
||||
? command.id.includes(rule.pattern)
|
||||
: rule.pattern.test(command.id)
|
||||
|
||||
if (matches) {
|
||||
subcategory = rule.subcategory
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!result[subcategory]) {
|
||||
result[subcategory] = []
|
||||
}
|
||||
result[subcategory].push(command)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
subcategories
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for view controls subcategories
|
||||
*/
|
||||
export const VIEW_CONTROLS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'view',
|
||||
rules: [
|
||||
{ pattern: 'Zoom', subcategory: 'view' },
|
||||
{ pattern: 'Fit', subcategory: 'view' },
|
||||
{ pattern: 'Panel', subcategory: 'panel-controls' },
|
||||
{ pattern: 'Sidebar', subcategory: 'panel-controls' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for essentials subcategories
|
||||
*/
|
||||
export const ESSENTIALS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'workflow',
|
||||
rules: [
|
||||
{ pattern: 'Workflow', subcategory: 'workflow' },
|
||||
{ pattern: 'Node', subcategory: 'node' },
|
||||
{ pattern: 'Queue', subcategory: 'queue' }
|
||||
]
|
||||
}
|
||||
27
src/composables/bottomPanelTabs/useShortcutsTab.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useShortcutsTab = (): BottomPanelExtension[] => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'shortcuts-essentials',
|
||||
title: t('shortcuts.essentials'),
|
||||
component: markRaw(EssentialsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts-view-controls',
|
||||
title: t('shortcuts.viewControls'),
|
||||
component: markRaw(ViewControlsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
]
|
||||
}
|
||||
80
src/composables/element/useRetriggerableAnimation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -70,6 +73,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
category: 'essentials' as const,
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
@@ -77,6 +81,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Open Workflow',
|
||||
menubarLabel: 'Open',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
app.ui.loadFile()
|
||||
}
|
||||
@@ -92,6 +97,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -104,6 +110,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -116,6 +123,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-download',
|
||||
label: 'Export Workflow',
|
||||
menubarLabel: 'Export',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await workflowService.exportWorkflow('workflow', 'workflow')
|
||||
}
|
||||
@@ -133,6 +141,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Undo',
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
@@ -141,6 +150,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Redo',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
@@ -149,6 +159,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearWorkflow',
|
||||
icon: 'pi pi-trash',
|
||||
label: 'Clear Workflow',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const settingStore = useSettingStore()
|
||||
if (
|
||||
@@ -190,6 +201,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.RefreshNodeDefinitions',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Refresh Node Definitions',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await app.refreshComboInNodes()
|
||||
}
|
||||
@@ -198,6 +210,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Interrupt',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
@@ -212,6 +225,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearPendingTasks',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Clear Pending Tasks',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await useQueueStore().clear(['queue'])
|
||||
toastStore.add({
|
||||
@@ -234,6 +248,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomIn',
|
||||
icon: 'pi pi-plus',
|
||||
label: 'Zoom In',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -247,6 +262,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomOut',
|
||||
icon: 'pi pi-minus',
|
||||
label: 'Zoom Out',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -260,6 +276,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
@@ -325,6 +342,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(0, batchCount)
|
||||
@@ -335,6 +353,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt (Front)',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
@@ -371,6 +390,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-cog',
|
||||
label: 'Show Settings Dialog',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
@@ -380,6 +400,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Group Selected Nodes',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) {
|
||||
@@ -423,6 +444,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-volume-off',
|
||||
label: 'Mute/Unmute Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -433,6 +455,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-shield',
|
||||
label: 'Bypass/Unbypass Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -443,6 +466,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-pin',
|
||||
label: 'Pin/Unpin Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
getSelectedNodes().forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
@@ -516,8 +540,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useBottomPanelStore().toggleBottomPanel()
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -525,6 +550,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
@@ -750,6 +776,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
@@ -768,6 +795,16 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
label: 'Show Keybindings Dialog',
|
||||
versionAdded: '1.24.1',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -696,6 +696,7 @@ export function useMinimap() {
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
renderMinimap,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
|
||||
108
src/composables/useWorkflowThumbnail.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -182,5 +182,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'k'
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { Point, Rect } from './interfaces'
|
||||
import { LGraphCanvas, clamp } from './litegraph'
|
||||
import { LGraphCanvas } from './litegraph'
|
||||
import { distance } from './measure'
|
||||
|
||||
// used by some widgets to render a curve editor
|
||||
|
||||
@@ -2299,6 +2299,8 @@ 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)
|
||||
@@ -2384,7 +2386,7 @@ 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 = window?.devicePixelRatio || 1
|
||||
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
@@ -4130,6 +4132,7 @@ export class LGraphCanvas
|
||||
const selected = this.selectedItems
|
||||
if (!selected.size) return
|
||||
|
||||
const initialSelectionSize = selected.size
|
||||
let wasSelected: Positionable | undefined
|
||||
for (const sel of selected) {
|
||||
if (sel === keepSelected) {
|
||||
@@ -4170,8 +4173,12 @@ export class LGraphCanvas
|
||||
}
|
||||
}
|
||||
|
||||
this.state.selectionChanged = true
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
// Only set selectionChanged if selection actually changed
|
||||
const finalSelectionSize = selected.size
|
||||
if (initialSelectionSize !== finalSelectionSize) {
|
||||
this.state.selectionChanged = true
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated See {@link LGraphCanvas.deselectAll} */
|
||||
@@ -8063,18 +8070,6 @@ 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: () => {
|
||||
@@ -8242,14 +8237,16 @@ export class LGraphCanvas
|
||||
'Both in put and output slots were null when processing context menu.'
|
||||
)
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||
menu_info.push({ content: 'Rename Slot', slot })
|
||||
}
|
||||
|
||||
if (node.getExtraSlotMenuOptions) {
|
||||
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
||||
|
||||
@@ -795,7 +795,10 @@ export class LinkConnector {
|
||||
*/
|
||||
disconnectLinks(): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (link instanceof MovingLinkBase) {
|
||||
if (
|
||||
link instanceof MovingLinkBase ||
|
||||
link instanceof ToInputFromIoNodeLink
|
||||
) {
|
||||
link.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,4 +135,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
connectToRerouteOutput() {
|
||||
throw new Error('ToInputRenderLink cannot connect to an output.')
|
||||
}
|
||||
disconnect(): boolean {
|
||||
if (!this.existingLink) return false
|
||||
this.existingLink.disconnect(this.network, 'input')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type {
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Basic width and height, with min/max constraints.
|
||||
|
||||
@@ -134,7 +134,7 @@ export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
|
||||
export { LGraphGroup } from './LGraphGroup'
|
||||
export { LGraphNode, type NodeId } from './LGraphNode'
|
||||
export { type LinkId, LLink } from './LLink'
|
||||
export { clamp, createBounds } from './measure'
|
||||
export { createBounds } from './measure'
|
||||
export { Reroute, type RerouteId } from './Reroute'
|
||||
export {
|
||||
type ExecutableLGraphNode,
|
||||
|
||||
@@ -450,7 +450,3 @@ export function alignOutsideContainer(
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return value < min ? min : value > max ? max : value
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
CallbackReturn,
|
||||
ISlotType
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { Subgraph } from './Subgraph'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
@@ -263,13 +263,8 @@ 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 parentInputIndexes = Object.keys(inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [slot, ...parentInputIndexes]
|
||||
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) {
|
||||
@@ -326,6 +321,44 @@ 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.
|
||||
|
||||
@@ -170,6 +170,21 @@ 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.
|
||||
@@ -239,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
|
||||
}
|
||||
@@ -256,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!input) throw new Error('Subgraph input not found')
|
||||
|
||||
input.label = newName
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export function omitBy<T extends object>(
|
||||
obj: T,
|
||||
predicate: (value: any) => boolean
|
||||
): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_key, value]) => !predicate(value))
|
||||
) as Partial<T>
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IComboWidget,
|
||||
IStringComboWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { IKnobWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { ISliderWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { clamp } from 'lodash'
|
||||
import { beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
|
||||
import { LGraphCanvas, LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
|
||||
@@ -230,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"
|
||||
},
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"searchWorkflows": "Search Workflows",
|
||||
"searchSettings": "Search Settings",
|
||||
"searchNodes": "Search Nodes",
|
||||
"searchCommands": "Search Commands",
|
||||
"searchModels": "Search Models",
|
||||
"searchKeybindings": "Search Keybindings",
|
||||
"searchExtensions": "Search Extensions",
|
||||
@@ -1011,8 +1012,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",
|
||||
@@ -1630,5 +1634,19 @@
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"enterNewName": "Enter new name"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
"noKeybinding": "No keybinding",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"subcategories": {
|
||||
"workflow": "Workflow",
|
||||
"node": "Node",
|
||||
"queue": "Queue",
|
||||
"view": "View",
|
||||
"panelControls": "Panel Controls"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,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"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "Encontrados {count} resultados",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"searchCommands": "Buscar comandos",
|
||||
"searchExtensions": "Buscar extensiones",
|
||||
"searchFailedMessage": "No pudimos encontrar ninguna configuración que coincida con tu búsqueda. Intenta ajustar tus términos de búsqueda.",
|
||||
"searchKeybindings": "Buscar combinaciones de teclas",
|
||||
@@ -808,9 +809,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",
|
||||
@@ -819,6 +822,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",
|
||||
@@ -1145,6 +1149,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",
|
||||
|
||||
@@ -236,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"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "{count} Résultats Trouvés",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement",
|
||||
"searchCommands": "Rechercher des commandes",
|
||||
"searchExtensions": "Rechercher des extensions",
|
||||
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
|
||||
"searchKeybindings": "Rechercher des raccourcis clavier",
|
||||
@@ -808,9 +809,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",
|
||||
@@ -819,6 +822,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",
|
||||
@@ -1145,6 +1149,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 d’attente",
|
||||
"view": "Affichage",
|
||||
"workflow": "Flux de travail"
|
||||
},
|
||||
"viewControls": "Contrôles d’affichage"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Parcourir les modèles d'exemple",
|
||||
"downloads": "Téléchargements",
|
||||
|
||||
@@ -236,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": "フォーカスモードの切り替え"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "{count}件の結果が見つかりました",
|
||||
"save": "保存",
|
||||
"saving": "保存中",
|
||||
"searchCommands": "コマンドを検索",
|
||||
"searchExtensions": "拡張機能を検索",
|
||||
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
|
||||
"searchKeybindings": "キーバインディングを検索",
|
||||
@@ -808,9 +809,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": "モデルライブラリサイドバーを切り替え",
|
||||
@@ -819,6 +822,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": "カスタムノードマネージャーの進行状況バーを切り替え",
|
||||
@@ -1145,6 +1149,20 @@
|
||||
"Window": "ウィンドウ",
|
||||
"Workflow": "ワークフロー"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "基本",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"manageShortcuts": "ショートカット管理",
|
||||
"noKeybinding": "キー割り当てなし",
|
||||
"subcategories": {
|
||||
"node": "ノード",
|
||||
"panelControls": "パネルコントロール",
|
||||
"queue": "キュー",
|
||||
"view": "ビュー",
|
||||
"workflow": "ワークフロー"
|
||||
},
|
||||
"viewControls": "表示コントロール"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "サンプルテンプレートを表示",
|
||||
"downloads": "ダウンロード",
|
||||
|
||||
@@ -236,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": "포커스 모드 토글"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "{count} 개의 결과를 찾았습니다",
|
||||
"save": "저장",
|
||||
"saving": "저장 중",
|
||||
"searchCommands": "명령어 검색",
|
||||
"searchExtensions": "확장 검색",
|
||||
"searchFailedMessage": "검색어와 일치하는 설정을 찾을 수 없습니다. 검색어를 조정해 보세요.",
|
||||
"searchKeybindings": "키 바인딩 검색",
|
||||
@@ -808,9 +809,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": "모델 라이브러리 사이드바 전환",
|
||||
@@ -819,6 +822,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": "커스텀 노드 매니저 진행률 표시줄 전환",
|
||||
@@ -1145,6 +1149,20 @@
|
||||
"Window": "창",
|
||||
"Workflow": "워크플로"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "기본",
|
||||
"keyboardShortcuts": "키보드 단축키",
|
||||
"manageShortcuts": "단축키 관리",
|
||||
"noKeybinding": "단축키 없음",
|
||||
"subcategories": {
|
||||
"node": "노드",
|
||||
"panelControls": "패널 컨트롤",
|
||||
"queue": "대기열",
|
||||
"view": "보기",
|
||||
"workflow": "워크플로우"
|
||||
},
|
||||
"viewControls": "보기 컨트롤"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "예제 템플릿 탐색",
|
||||
"downloads": "다운로드",
|
||||
|
||||
@@ -236,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": "Переключить режим фокуса"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "Найдено {count} результатов",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение",
|
||||
"searchCommands": "Поиск команд",
|
||||
"searchExtensions": "Поиск расширений",
|
||||
"searchFailedMessage": "Мы не смогли найти настройки, соответствующие вашему запросу. Попробуйте изменить поисковые термины.",
|
||||
"searchKeybindings": "Поиск сочетаний клавиш",
|
||||
@@ -808,9 +809,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": "Показать/скрыть боковую панель библиотеки моделей",
|
||||
@@ -819,6 +822,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": "Переключить индикатор выполнения менеджера пользовательских узлов",
|
||||
@@ -1145,6 +1149,20 @@
|
||||
"Window": "Окно",
|
||||
"Workflow": "Рабочий процесс"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Основные",
|
||||
"keyboardShortcuts": "Горячие клавиши",
|
||||
"manageShortcuts": "Управление горячими клавишами",
|
||||
"noKeybinding": "Нет привязки клавиши",
|
||||
"subcategories": {
|
||||
"node": "Узел",
|
||||
"panelControls": "Управление панелью",
|
||||
"queue": "Очередь",
|
||||
"view": "Просмотр",
|
||||
"workflow": "Рабочий процесс"
|
||||
},
|
||||
"viewControls": "Управление просмотром"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Просмотреть примеры шаблонов",
|
||||
"downloads": "Загрузки",
|
||||
|
||||
@@ -236,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": "切換專注模式"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "找到 {count} 筆結果",
|
||||
"save": "儲存",
|
||||
"saving": "儲存中",
|
||||
"searchCommands": "搜尋指令",
|
||||
"searchExtensions": "搜尋擴充套件",
|
||||
"searchFailedMessage": "找不到符合您搜尋的設定。請嘗試調整搜尋條件。",
|
||||
"searchKeybindings": "搜尋快捷鍵",
|
||||
@@ -808,9 +809,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": "切換模型庫側邊欄",
|
||||
@@ -819,6 +822,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": "切換自訂節點管理器進度條",
|
||||
@@ -1145,6 +1149,20 @@
|
||||
"Window": "視窗",
|
||||
"Workflow": "工作流程"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "基本",
|
||||
"keyboardShortcuts": "鍵盤快捷鍵",
|
||||
"manageShortcuts": "管理快捷鍵",
|
||||
"noKeybinding": "無快捷鍵",
|
||||
"subcategories": {
|
||||
"node": "節點",
|
||||
"panelControls": "面板控制",
|
||||
"queue": "佇列",
|
||||
"view": "檢視",
|
||||
"workflow": "工作流程"
|
||||
},
|
||||
"viewControls": "檢視控制"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "瀏覽範例模板",
|
||||
"downloads": "下載",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"label": "锁定视图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleMinimap": {
|
||||
"label": "畫布切換小地圖"
|
||||
"label": "画布切换小地图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
||||
"label": "忽略/取消忽略选中节点"
|
||||
@@ -236,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": "切换焦点模式"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"resultsCount": "找到 {count} 个结果",
|
||||
"save": "保存",
|
||||
"saving": "正在保存",
|
||||
"searchCommands": "搜尋指令",
|
||||
"searchExtensions": "搜索扩展",
|
||||
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
|
||||
"searchKeybindings": "搜索快捷键",
|
||||
@@ -410,7 +411,7 @@
|
||||
"resetView": "重置视图",
|
||||
"selectMode": "选择模式",
|
||||
"toggleLinkVisibility": "切换连线可见性",
|
||||
"toggleMinimap": "切換小地圖",
|
||||
"toggleMinimap": "切换小地图",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
},
|
||||
@@ -746,7 +747,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": "清除工作流",
|
||||
@@ -808,18 +809,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": "撤销",
|
||||
@@ -1145,6 +1149,20 @@
|
||||
"Window": "窗口",
|
||||
"Workflow": "工作流"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "基本",
|
||||
"keyboardShortcuts": "鍵盤快捷鍵",
|
||||
"manageShortcuts": "管理快捷鍵",
|
||||
"noKeybinding": "無快捷鍵",
|
||||
"subcategories": {
|
||||
"node": "節點",
|
||||
"panelControls": "面板控制",
|
||||
"queue": "佇列",
|
||||
"view": "檢視",
|
||||
"workflow": "工作流程"
|
||||
},
|
||||
"viewControls": "檢視控制"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "浏览示例模板",
|
||||
"downloads": "下载",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -727,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()
|
||||
|
||||
74
src/services/commandSearchService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
export interface CommandSearchOptions {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export class CommandSearchService {
|
||||
private fuse: Fuse<ComfyCommandImpl>
|
||||
private commands: ComfyCommandImpl[]
|
||||
|
||||
constructor(commands: ComfyCommandImpl[]) {
|
||||
this.commands = commands
|
||||
this.fuse = new Fuse(commands, {
|
||||
keys: [
|
||||
{
|
||||
name: 'translatedLabel',
|
||||
weight: 2,
|
||||
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
|
||||
},
|
||||
{ name: 'id', weight: 1 }
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
shouldSort: true,
|
||||
minMatchCharLength: 1
|
||||
})
|
||||
}
|
||||
|
||||
public updateCommands(commands: ComfyCommandImpl[]) {
|
||||
this.commands = commands
|
||||
const options = {
|
||||
keys: [
|
||||
{
|
||||
name: 'translatedLabel',
|
||||
weight: 2,
|
||||
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
|
||||
},
|
||||
{ name: 'id', weight: 1 }
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
shouldSort: true,
|
||||
minMatchCharLength: 1
|
||||
}
|
||||
this.fuse = new Fuse(commands, options)
|
||||
}
|
||||
|
||||
public searchCommands(
|
||||
query: string,
|
||||
options?: CommandSearchOptions
|
||||
): ComfyCommandImpl[] {
|
||||
// Remove the leading ">" if present
|
||||
const searchQuery = query.startsWith('>') ? query.slice(1).trim() : query
|
||||
|
||||
// If empty query, return all commands sorted alphabetically by translated label
|
||||
if (!searchQuery) {
|
||||
const sortedCommands = [...this.commands].sort((a, b) => {
|
||||
const labelA = a.getTranslatedLabel()
|
||||
const labelB = b.getTranslatedLabel()
|
||||
return labelA.localeCompare(labelB)
|
||||
})
|
||||
return options?.limit
|
||||
? sortedCommands.slice(0, options.limit)
|
||||
: sortedCommands
|
||||
}
|
||||
|
||||
const results = this.fuse.search(searchQuery)
|
||||
const commands = results.map((result) => result.item)
|
||||
|
||||
return options?.limit ? commands.slice(0, options.limit) : commands
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,7 @@ export const useLitegraphService = () => {
|
||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||
// because `registerNodeType` will overwrite the assignments.
|
||||
node.category = nodeDef.category
|
||||
node.skip_list = true
|
||||
node.title = nodeDef.display_name || nodeDef.name
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -21,6 +22,7 @@ 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> {
|
||||
@@ -287,8 +289,14 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
||||
domWidgetStore.clear()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,9 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
|
||||
|
||||
@@ -17,6 +19,7 @@ export interface ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
|
||||
}
|
||||
|
||||
export class ComfyCommandImpl implements ComfyCommand {
|
||||
@@ -29,6 +32,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls'
|
||||
|
||||
constructor(command: ComfyCommand) {
|
||||
this.id = command.id
|
||||
@@ -40,6 +44,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
this.versionAdded = command.versionAdded
|
||||
this.confirmation = command.confirmation
|
||||
this.source = command.source
|
||||
this.category = command.category
|
||||
}
|
||||
|
||||
get label() {
|
||||
@@ -63,6 +68,19 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
get keybinding(): KeybindingImpl | null {
|
||||
return useKeybindingStore().getKeybindingByCommandId(this.id)
|
||||
}
|
||||
|
||||
getTranslatedLabel(): string {
|
||||
// Use the same pattern as KeybindingPanel to get translated labels
|
||||
return t(`commands.${normalizeI18nKey(this.id)}.label`, this.label ?? '')
|
||||
}
|
||||
|
||||
getTranslatedMenubarLabel(): string {
|
||||
// Use the same pattern but for menubar labels
|
||||
return t(
|
||||
`commands.${normalizeI18nKey(this.id)}.menubarLabel`,
|
||||
this.menubarLabel ?? this.getTranslatedLabel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const useCommandStore = defineStore('command', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
(command) =>
|
||||
({
|
||||
command: () => commandStore.execute(command.id),
|
||||
label: command.menubarLabel,
|
||||
label: command.getTranslatedMenubarLabel(),
|
||||
icon: command.icon,
|
||||
tooltip: command.tooltip,
|
||||
comfyCommand: command
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -327,6 +328,8 @@ 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()
|
||||
@@ -387,12 +390,14 @@ 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)
|
||||
@@ -403,6 +408,9 @@ 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)
|
||||
@@ -420,6 +428,8 @@ 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
|
||||
import {
|
||||
useCommandTerminalTab,
|
||||
useLogsTerminalTab
|
||||
@@ -10,45 +11,110 @@ import { ComfyExtension } from '@/types/comfy'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
type PanelType = 'terminal' | 'shortcuts'
|
||||
|
||||
interface PanelState {
|
||||
tabs: BottomPanelExtension[]
|
||||
activeTabId: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
const bottomPanelVisible = ref(false)
|
||||
const toggleBottomPanel = () => {
|
||||
// If there are no tabs, don't show the bottom panel
|
||||
if (bottomPanelTabs.value.length === 0) {
|
||||
return
|
||||
// Multi-panel state
|
||||
const panels = ref<Record<PanelType, PanelState>>({
|
||||
terminal: { tabs: [], activeTabId: '', visible: false },
|
||||
shortcuts: { tabs: [], activeTabId: '', visible: false }
|
||||
})
|
||||
|
||||
const activePanel = ref<PanelType | null>(null)
|
||||
|
||||
// Computed properties for active panel
|
||||
const activePanelState = computed(() =>
|
||||
activePanel.value ? panels.value[activePanel.value] : null
|
||||
)
|
||||
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
const state = activePanelState.value
|
||||
if (!state) return null
|
||||
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
||||
})
|
||||
|
||||
const bottomPanelVisible = computed({
|
||||
get: () => !!activePanel.value,
|
||||
set: (visible: boolean) => {
|
||||
if (!visible) {
|
||||
activePanel.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const bottomPanelTabs = computed(() => activePanelState.value?.tabs ?? [])
|
||||
const activeBottomPanelTabId = computed({
|
||||
get: () => activePanelState.value?.activeTabId ?? '',
|
||||
set: (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const togglePanel = (panelType: PanelType) => {
|
||||
const panel = panels.value[panelType]
|
||||
if (panel.tabs.length === 0) return
|
||||
|
||||
if (activePanel.value === panelType) {
|
||||
// Hide current panel
|
||||
activePanel.value = null
|
||||
} else {
|
||||
// Show target panel
|
||||
activePanel.value = panelType
|
||||
if (!panel.activeTabId && panel.tabs.length > 0) {
|
||||
panel.activeTabId = panel.tabs[0].id
|
||||
}
|
||||
}
|
||||
bottomPanelVisible.value = !bottomPanelVisible.value
|
||||
}
|
||||
|
||||
const bottomPanelTabs = ref<BottomPanelExtension[]>([])
|
||||
const activeBottomPanelTabId = ref<string>('')
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
return (
|
||||
bottomPanelTabs.value.find(
|
||||
(tab) => tab.id === activeBottomPanelTabId.value
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
const setActiveTab = (tabId: string) => {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
const toggleBottomPanel = () => {
|
||||
// Legacy method - toggles terminal panel
|
||||
togglePanel('terminal')
|
||||
}
|
||||
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBottomPanelTab = (tabId: string) => {
|
||||
if (activeBottomPanelTabId.value === tabId && bottomPanelVisible.value) {
|
||||
bottomPanelVisible.value = false
|
||||
} else {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
bottomPanelVisible.value = true
|
||||
// Find which panel contains this tab
|
||||
for (const [panelType, panel] of Object.entries(panels.value)) {
|
||||
const tab = panel.tabs.find((t) => t.id === tabId)
|
||||
if (tab) {
|
||||
if (activePanel.value === panelType && panel.activeTabId === tabId) {
|
||||
activePanel.value = null
|
||||
} else {
|
||||
activePanel.value = panelType as PanelType
|
||||
panel.activeTabId = tabId
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
|
||||
bottomPanelTabs.value = [...bottomPanelTabs.value, tab]
|
||||
if (bottomPanelTabs.value.length === 1) {
|
||||
activeBottomPanelTabId.value = tab.id
|
||||
const targetPanel = tab.targetPanel ?? 'terminal'
|
||||
const panel = panels.value[targetPanel]
|
||||
|
||||
panel.tabs = [...panel.tabs, tab]
|
||||
if (panel.tabs.length === 1) {
|
||||
panel.activeTabId = tab.id
|
||||
}
|
||||
|
||||
useCommandStore().registerCommand({
|
||||
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
|
||||
icon: 'pi pi-list',
|
||||
label: `Toggle ${tab.title} Bottom Panel`,
|
||||
category: 'view-controls' as const,
|
||||
function: () => toggleBottomPanelTab(tab.id),
|
||||
source: 'System'
|
||||
})
|
||||
@@ -59,6 +125,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
if (isElectron()) {
|
||||
registerBottomPanelTab(useCommandTerminalTab())
|
||||
}
|
||||
useShortcutsTab().forEach(registerBottomPanelTab)
|
||||
}
|
||||
|
||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||
@@ -68,6 +135,11 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Multi-panel API
|
||||
panels,
|
||||
activePanel,
|
||||
togglePanel,
|
||||
|
||||
bottomPanelVisible,
|
||||
toggleBottomPanel,
|
||||
bottomPanelTabs,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
label: labelFunction,
|
||||
tooltip: tooltipFunction,
|
||||
versionAdded: '1.3.9',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
toggleSidebarTab(tab.id)
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface BaseSidebarTabExtension {
|
||||
export interface BaseBottomPanelExtension {
|
||||
id: string
|
||||
title: string
|
||||
targetPanel?: 'terminal' | 'shortcuts'
|
||||
}
|
||||
|
||||
export interface VueExtension {
|
||||
|
||||
9
src/types/selectionOverlayTypes.ts
Normal 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')
|
||||
131
tests-ui/tests/commandSearchService.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CommandSearchService } from '@/services/commandSearchService'
|
||||
import { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
describe('CommandSearchService', () => {
|
||||
// Mock commands
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
new ComfyCommandImpl({
|
||||
id: 'Comfy.NewBlankWorkflow',
|
||||
label: 'New Blank Workflow',
|
||||
icon: 'pi pi-plus',
|
||||
function: () => {}
|
||||
}),
|
||||
new ComfyCommandImpl({
|
||||
id: 'Comfy.SaveWorkflow',
|
||||
label: 'Save Workflow',
|
||||
icon: 'pi pi-save',
|
||||
function: () => {}
|
||||
}),
|
||||
new ComfyCommandImpl({
|
||||
id: 'Comfy.OpenWorkflow',
|
||||
label: 'Open Workflow',
|
||||
icon: 'pi pi-folder-open',
|
||||
function: () => {}
|
||||
}),
|
||||
new ComfyCommandImpl({
|
||||
id: 'Comfy.ClearWorkflow',
|
||||
label: 'Clear Workflow',
|
||||
icon: 'pi pi-trash',
|
||||
function: () => {}
|
||||
}),
|
||||
new ComfyCommandImpl({
|
||||
id: 'Comfy.Undo',
|
||||
label: 'Undo',
|
||||
icon: 'pi pi-undo',
|
||||
function: () => {}
|
||||
})
|
||||
]
|
||||
|
||||
describe('searchCommands', () => {
|
||||
it('should return all commands sorted alphabetically when query is empty', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('')
|
||||
|
||||
expect(results).toHaveLength(mockCommands.length)
|
||||
expect(results[0].label).toBe('Clear Workflow')
|
||||
expect(results[1].label).toBe('New Blank Workflow')
|
||||
expect(results[2].label).toBe('Open Workflow')
|
||||
expect(results[3].label).toBe('Save Workflow')
|
||||
expect(results[4].label).toBe('Undo')
|
||||
})
|
||||
|
||||
it('should handle query with leading ">"', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('>workflow')
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
results.every(
|
||||
(cmd) =>
|
||||
cmd.label?.toLowerCase().includes('workflow') ||
|
||||
cmd.id.toLowerCase().includes('workflow')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should search by label', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('save')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].label).toBe('Save Workflow')
|
||||
})
|
||||
|
||||
it('should search by id', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('ClearWorkflow')
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0].id).toBe('Comfy.ClearWorkflow')
|
||||
})
|
||||
|
||||
it('should respect search limit', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('', { limit: 2 })
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('work')
|
||||
|
||||
expect(results.length).toBeGreaterThan(1)
|
||||
expect(
|
||||
results.every(
|
||||
(cmd) =>
|
||||
cmd.label?.toLowerCase().includes('work') ||
|
||||
cmd.id.toLowerCase().includes('work')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty array for no matches', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const results = service.searchCommands('xyz123')
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCommands', () => {
|
||||
it('should update the commands list', () => {
|
||||
const service = new CommandSearchService(mockCommands)
|
||||
const newCommands = [
|
||||
new ComfyCommandImpl({
|
||||
id: 'Test.Command',
|
||||
label: 'Test Command',
|
||||
function: () => {}
|
||||
})
|
||||
]
|
||||
|
||||
service.updateCommands(newCommands)
|
||||
const results = service.searchCommands('')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('Test.Command')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock ShortcutsList component
|
||||
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
|
||||
default: {
|
||||
name: 'ShortcutsList',
|
||||
props: ['commands', 'subcategories', 'columns'],
|
||||
template:
|
||||
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock command store
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Other.Command',
|
||||
label: 'Other Command',
|
||||
category: 'view-controls',
|
||||
function: vi.fn(),
|
||||
icon: 'pi pi-test',
|
||||
tooltip: 'Test tooltip',
|
||||
menubarLabel: 'Other Command',
|
||||
keybinding: null,
|
||||
getTranslatedLabel: () => 'Other Command',
|
||||
getTranslatedMenubarLabel: () => 'Other Command'
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: mockCommands
|
||||
})
|
||||
}))
|
||||
|
||||
describe('EssentialsPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should render ShortcutsList with essentials commands', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
expect(shortcutsList.exists()).toBe(true)
|
||||
|
||||
// Should pass only essentials commands
|
||||
const commands = shortcutsList.props('commands')
|
||||
expect(commands).toHaveLength(3)
|
||||
commands.forEach((cmd: ComfyCommandImpl) => {
|
||||
expect(cmd.category).toBe('essentials')
|
||||
})
|
||||
})
|
||||
|
||||
it('should categorize commands into subcategories', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
const subcategories = shortcutsList.props('subcategories')
|
||||
|
||||
expect(subcategories).toHaveProperty('workflow')
|
||||
expect(subcategories).toHaveProperty('node')
|
||||
expect(subcategories).toHaveProperty('queue')
|
||||
|
||||
expect(subcategories.workflow).toContain(mockCommands[0])
|
||||
expect(subcategories.node).toContain(mockCommands[1])
|
||||
expect(subcategories.queue).toContain(mockCommands[2])
|
||||
})
|
||||
})
|
||||
170
tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock vue-i18n
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'shortcuts.subcategories.workflow': 'Workflow',
|
||||
'shortcuts.subcategories.node': 'Node',
|
||||
'shortcuts.subcategories.queue': 'Queue',
|
||||
'shortcuts.subcategories.view': 'View',
|
||||
'shortcuts.subcategories.panelControls': 'Panel Controls'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: mockT
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ShortcutsList', () => {
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'n']
|
||||
}
|
||||
},
|
||||
getTranslatedLabel: () => 'New Workflow'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Shift', 'a']
|
||||
}
|
||||
},
|
||||
getTranslatedLabel: () => 'Add Node'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'Shift', 'c']
|
||||
}
|
||||
},
|
||||
getTranslatedLabel: () => 'Clear Queue'
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const mockSubcategories = {
|
||||
workflow: [mockCommands[0]],
|
||||
node: [mockCommands[1]],
|
||||
queue: [mockCommands[2]]
|
||||
}
|
||||
|
||||
it('should render shortcuts organized by subcategories', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check that subcategories are rendered
|
||||
expect(wrapper.text()).toContain('Workflow')
|
||||
expect(wrapper.text()).toContain('Node')
|
||||
expect(wrapper.text()).toContain('Queue')
|
||||
|
||||
// Check that commands are rendered
|
||||
expect(wrapper.text()).toContain('New Workflow')
|
||||
expect(wrapper.text()).toContain('Add Node')
|
||||
expect(wrapper.text()).toContain('Clear Queue')
|
||||
})
|
||||
|
||||
it('should format keyboard shortcuts correctly', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check for formatted keys
|
||||
expect(wrapper.text()).toContain('Ctrl')
|
||||
expect(wrapper.text()).toContain('n')
|
||||
expect(wrapper.text()).toContain('Shift')
|
||||
expect(wrapper.text()).toContain('a')
|
||||
expect(wrapper.text()).toContain('c')
|
||||
})
|
||||
|
||||
it('should filter out commands without keybindings', () => {
|
||||
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
|
||||
...mockCommands,
|
||||
{
|
||||
id: 'No.Keybinding',
|
||||
label: 'No Keybinding',
|
||||
category: 'essentials',
|
||||
keybinding: null,
|
||||
getTranslatedLabel: () => 'No Keybinding'
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: commandsWithoutKeybinding,
|
||||
subcategories: {
|
||||
...mockSubcategories,
|
||||
other: [commandsWithoutKeybinding[3]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('No Keybinding')
|
||||
})
|
||||
|
||||
it('should handle special key formatting', () => {
|
||||
const specialKeyCommand: ComfyCommandImpl = {
|
||||
id: 'Special.Keys',
|
||||
label: 'Special Keys',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
|
||||
}
|
||||
},
|
||||
getTranslatedLabel: () => 'Special Keys'
|
||||
} as ComfyCommandImpl
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: [specialKeyCommand],
|
||||
subcategories: {
|
||||
special: [specialKeyCommand]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Cmd') // Meta -> Cmd
|
||||
expect(text).toContain('↑') // ArrowUp -> ↑
|
||||
expect(text).toContain('↵') // Enter -> ↵
|
||||
expect(text).toContain('Esc') // Escape -> Esc
|
||||
expect(text).toContain('Space') // ' ' -> Space
|
||||
})
|
||||
|
||||
it('should use fallback subcategory titles', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: {
|
||||
unknown: [mockCommands[0]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
})
|
||||
})
|
||||
282
tests-ui/tests/composables/useWorkflowThumbnail.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
vi.mock('@/composables/useMinimap', () => ({
|
||||
useMinimap: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
moveUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
const { useWorkflowThumbnail } = await import(
|
||||
'@/composables/useWorkflowThumbnail'
|
||||
)
|
||||
const { useMinimap } = await import('@/composables/useMinimap')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useWorkflowThumbnail', () => {
|
||||
let mockMinimapInstance: any
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
workflowStore = useWorkflowStore()
|
||||
|
||||
// Clear any existing thumbnails from previous tests BEFORE mocking
|
||||
const { clearAllThumbnails } = useWorkflowThumbnail()
|
||||
clearAllThumbnails()
|
||||
|
||||
// Now set up mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
const blob = new Blob()
|
||||
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Mock API responses
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
|
||||
|
||||
mockMinimapInstance = {
|
||||
renderMinimap: vi.fn(),
|
||||
canvasRef: {
|
||||
value: {
|
||||
toBlob: vi.fn((cb) => cb(blob))
|
||||
}
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
|
||||
})
|
||||
|
||||
it('should capture minimap thumbnail', async () => {
|
||||
const { createMinimapPreview } = useWorkflowThumbnail()
|
||||
const thumbnail = await createMinimapPreview()
|
||||
|
||||
expect(useMinimap).toHaveBeenCalledOnce()
|
||||
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
|
||||
|
||||
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||
})
|
||||
|
||||
it('should store and retrieve thumbnails', async () => {
|
||||
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
const thumbnail = getThumbnail('test-workflow-key')
|
||||
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||
})
|
||||
|
||||
it('should clear thumbnail', async () => {
|
||||
const { storeThumbnail, getThumbnail, clearThumbnail } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
expect(getThumbnail('test-workflow-key')).toBeDefined()
|
||||
|
||||
clearThumbnail('test-workflow-key')
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
expect(getThumbnail('test-workflow-key')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear all thumbnails', async () => {
|
||||
const { storeThumbnail, getThumbnail, clearAllThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow1 = { key: 'workflow-1' } as ComfyWorkflow
|
||||
const mockWorkflow2 = { key: 'workflow-2' } as ComfyWorkflow
|
||||
|
||||
await storeThumbnail(mockWorkflow1)
|
||||
await storeThumbnail(mockWorkflow2)
|
||||
|
||||
expect(getThumbnail('workflow-1')).toBeDefined()
|
||||
expect(getThumbnail('workflow-2')).toBeDefined()
|
||||
|
||||
clearAllThumbnails()
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
|
||||
expect(getThumbnail('workflow-1')).toBeUndefined()
|
||||
expect(getThumbnail('workflow-2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should automatically handle thumbnail cleanup when workflow is renamed', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a temporary workflow
|
||||
const workflow = workflowStore.createTemporary('test-workflow.json')
|
||||
const originalKey = workflow.key
|
||||
|
||||
// Store thumbnail for the workflow
|
||||
await storeThumbnail(workflow)
|
||||
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Rename the workflow - this should automatically handle thumbnail cleanup
|
||||
const newPath = 'workflows/renamed-workflow.json'
|
||||
await workflowStore.renameWorkflow(workflow, newPath)
|
||||
|
||||
const newKey = workflow.key // The workflow's key should now be the new path
|
||||
|
||||
// The thumbnail should be moved from old key to new key
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(newKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// No URL should be revoked since we're moving the thumbnail, not deleting it
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should properly revoke old URL when storing thumbnail over existing one', async () => {
|
||||
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const mockWorkflow = { key: 'test-workflow' } as ComfyWorkflow
|
||||
|
||||
// Store first thumbnail
|
||||
await storeThumbnail(mockWorkflow)
|
||||
const firstThumbnail = getThumbnail('test-workflow')
|
||||
expect(firstThumbnail).toBe('data:image/png;base64,test')
|
||||
|
||||
// Reset the mock to track new calls and create different URL
|
||||
vi.clearAllMocks()
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
|
||||
|
||||
// Store second thumbnail for same workflow - should revoke the first URL
|
||||
await storeThumbnail(mockWorkflow)
|
||||
const secondThumbnail = getThumbnail('test-workflow')
|
||||
expect(secondThumbnail).toBe('data:image/png;base64,test2')
|
||||
|
||||
// URL.revokeObjectURL should have been called for the first thumbnail
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear thumbnail when workflow is deleted', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('test-delete.json')
|
||||
await storeThumbnail(workflow)
|
||||
|
||||
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Delete the workflow - this should clear the thumbnail
|
||||
await workflowStore.deleteWorkflow(workflow)
|
||||
|
||||
// Thumbnail should be cleared and URL revoked
|
||||
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear thumbnail when temporary workflow is closed', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create a temporary workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('temp-workflow.json')
|
||||
await storeThumbnail(workflow)
|
||||
|
||||
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Close the workflow - this should clear the thumbnail for temporary workflows
|
||||
await workflowStore.closeWorkflow(workflow)
|
||||
|
||||
// Thumbnail should be cleared and URL revoked
|
||||
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle multiple renames without leaking', async () => {
|
||||
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||
useWorkflowThumbnail()
|
||||
|
||||
// Create workflow and store thumbnail
|
||||
const workflow = workflowStore.createTemporary('original.json')
|
||||
await storeThumbnail(workflow)
|
||||
const originalKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// Rename multiple times
|
||||
await workflowStore.renameWorkflow(workflow, 'workflows/renamed1.json')
|
||||
const firstRenameKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(firstRenameKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
await workflowStore.renameWorkflow(workflow, 'workflows/renamed2.json')
|
||||
const secondRenameKey = workflow.key
|
||||
|
||||
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||
expect(getThumbnail(firstRenameKey)).toBeUndefined()
|
||||
expect(getThumbnail(secondRenameKey)).toBe('data:image/png;base64,test')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
|
||||
// No URLs should be revoked since we're just moving thumbnails
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle edge cases like empty keys or invalid operations', async () => {
|
||||
const {
|
||||
getThumbnail,
|
||||
clearThumbnail,
|
||||
moveWorkflowThumbnail,
|
||||
workflowThumbnails
|
||||
} = useWorkflowThumbnail()
|
||||
|
||||
// Test getting non-existent thumbnail
|
||||
expect(getThumbnail('non-existent')).toBeUndefined()
|
||||
|
||||
// Test clearing non-existent thumbnail (should not throw)
|
||||
expect(() => clearThumbnail('non-existent')).not.toThrow()
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
|
||||
// Test moving non-existent thumbnail (should not throw)
|
||||
expect(() => moveWorkflowThumbnail('non-existent', 'target')).not.toThrow()
|
||||
expect(workflowThumbnails.value.size).toBe(0)
|
||||
|
||||
// Test moving to same key (should not cause issues)
|
||||
const { storeThumbnail } = useWorkflowThumbnail()
|
||||
const mockWorkflow = { key: 'test-key' } as ComfyWorkflow
|
||||
await storeThumbnail(mockWorkflow)
|
||||
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
moveWorkflowThumbnail('test-key', 'test-key')
|
||||
expect(workflowThumbnails.value.size).toBe(1)
|
||||
expect(getThumbnail('test-key')).toBe('data:image/png;base64,test')
|
||||
})
|
||||
})
|
||||