Compare commits
53 Commits
v1.35.3
...
remove-que
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e88605e6c | ||
|
|
3ee6d53423 | ||
|
|
4e257bedca | ||
|
|
108cfaaa4b | ||
|
|
5d745c952a | ||
|
|
fddd703c4e | ||
|
|
dec929909b | ||
|
|
18ce8e940a | ||
|
|
38d01548d3 | ||
|
|
79ddff692e | ||
|
|
fb944fef56 | ||
|
|
38eaf4b30e | ||
|
|
f065654a1a | ||
|
|
42a292932e | ||
|
|
4ec4da785b | ||
|
|
abf966ab83 | ||
|
|
a89fa5a784 | ||
|
|
c414635ead | ||
|
|
e96593fe4c | ||
|
|
93178c80ba | ||
|
|
585d46d4fb | ||
|
|
d70039103c | ||
|
|
3a091277d0 | ||
|
|
209903e1f1 | ||
|
|
9ca58ce525 | ||
|
|
c0d3fb312f | ||
|
|
eb04dc20f3 | ||
|
|
194fbdf520 | ||
|
|
5f20d554f3 | ||
|
|
fd9747375d | ||
|
|
5187a77234 | ||
|
|
b22ba97a13 | ||
|
|
aab9a30511 | ||
|
|
b0f5a9ffe2 | ||
|
|
3f777fb54f | ||
|
|
6a73288ce3 | ||
|
|
d21ea0f65b | ||
|
|
7613e70f63 | ||
|
|
56b67085d0 | ||
|
|
4a91330e30 | ||
|
|
a1a507ed09 | ||
|
|
a7c694f248 | ||
|
|
0646bb368a | ||
|
|
a8ef7a602f | ||
|
|
9c157296be | ||
|
|
3e97225ff6 | ||
|
|
1cdea3063d | ||
|
|
b5ab45673a | ||
|
|
7d326cbc14 | ||
|
|
6b1bd4be16 | ||
|
|
57eee5c218 | ||
|
|
caca6c4163 | ||
|
|
0385a7de9b |
59
.github/workflows/pr-backport.yaml
vendored
@@ -361,6 +361,42 @@ jobs:
|
||||
if: steps.filter-targets.outputs.skip != 'true' && failure() && steps.backport.outputs.failed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BACKPORT_AGENT_PROMPT_TEMPLATE: |
|
||||
Backport PR #${PR_NUMBER} (${PR_URL}) to ${target}.
|
||||
Cherry-pick merge commit ${MERGE_COMMIT} onto new branch
|
||||
${BACKPORT_BRANCH} from origin/${target}.
|
||||
Resolve conflicts in: ${CONFLICTS_INLINE}.
|
||||
For test snapshots (browser_tests/**/*-snapshots/), accept PR version if
|
||||
changed in original PR, else keep target. For package.json versions, keep
|
||||
target branch. For pnpm-lock.yaml, regenerate with pnpm install.
|
||||
Ask user for non-obvious conflicts.
|
||||
Create PR titled "[backport ${target}] <original title>" with label "backport".
|
||||
See .github/workflows/pr-backport.yaml for workflow details.
|
||||
COMMENT_BODY_TEMPLATE: |
|
||||
### ⚠️ Backport to `${target}` failed
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
```
|
||||
${CONFLICTS_BLOCK}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🤖 Prompt for AI Agents</summary>
|
||||
|
||||
```
|
||||
${AGENT_PROMPT}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
cc @${PR_AUTHOR}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
|
||||
@@ -383,10 +419,27 @@ jobs:
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
# Convert comma-separated conflicts back to newlines for display
|
||||
CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
|
||||
# envsubst is provided by gettext-base
|
||||
if ! command -v envsubst >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y gettext-base
|
||||
fi
|
||||
|
||||
AGENT_PROMPT=$(envsubst '${PR_NUMBER} ${PR_URL} ${target} ${MERGE_COMMIT} ${BACKPORT_BRANCH} ${CONFLICTS_INLINE}' <<<"$BACKPORT_AGENT_PROMPT_TEMPLATE")
|
||||
|
||||
# Use fenced code block for conflicts to handle special chars in filenames
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -124,12 +124,16 @@ jobs:
|
||||
- name: Stage changed snapshot files
|
||||
id: changed-snapshots
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=========================================="
|
||||
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
|
||||
echo "=========================================="
|
||||
|
||||
# Get list of changed snapshot files
|
||||
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
|
||||
# Get list of changed snapshot files (including untracked/new files)
|
||||
changed_files=$( (
|
||||
git diff --name-only browser_tests/ 2>/dev/null || true
|
||||
git ls-files --others --exclude-standard browser_tests/ 2>/dev/null || true
|
||||
) | sort -u | grep -E '\-snapshots/' || true )
|
||||
|
||||
if [ -z "$changed_files" ]; then
|
||||
echo "No snapshot changes in this shard"
|
||||
@@ -151,6 +155,11 @@ jobs:
|
||||
# Strip 'browser_tests/' prefix to avoid double nesting
|
||||
echo "Copying changed files to staging directory..."
|
||||
while IFS= read -r file; do
|
||||
# Skip paths that no longer exist (e.g. deletions)
|
||||
if [ ! -f "$file" ]; then
|
||||
echo " → (skipped; not a file) $file"
|
||||
continue
|
||||
fi
|
||||
# Remove 'browser_tests/' prefix
|
||||
file_without_prefix="${file#browser_tests/}"
|
||||
# Create parent directories
|
||||
@@ -261,11 +270,19 @@ jobs:
|
||||
echo "CHANGES SUMMARY"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Changed files in browser_tests:"
|
||||
git diff --name-only browser_tests/ | head -20 || echo "No changes"
|
||||
echo ""
|
||||
echo "Total changes:"
|
||||
git diff --name-only browser_tests/ | wc -l || echo "0"
|
||||
echo "Changed files in browser_tests (including untracked):"
|
||||
CHANGES=$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)
|
||||
if [ -z "$CHANGES" ]; then
|
||||
echo "No changes"
|
||||
echo ""
|
||||
echo "Total changes:"
|
||||
echo "0"
|
||||
else
|
||||
echo "$CHANGES" | head -50
|
||||
echo ""
|
||||
echo "Total changes:"
|
||||
echo "$CHANGES" | wc -l
|
||||
fi
|
||||
|
||||
- name: Commit updated expectations
|
||||
id: commit
|
||||
@@ -273,7 +290,7 @@ jobs:
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
|
||||
if git diff --quiet browser_tests/; then
|
||||
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
|
||||
124
.github/workflows/release-version-bump.yaml
vendored
@@ -20,6 +20,13 @@ on:
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
schedule:
|
||||
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: release-version-bump
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
@@ -29,15 +36,99 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Prepare inputs
|
||||
id: prepared-inputs
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
|
||||
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
|
||||
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION_TYPE="$RAW_VERSION_TYPE"
|
||||
PRE_RELEASE="$RAW_PRE_RELEASE"
|
||||
TARGET_BRANCH="$RAW_BRANCH"
|
||||
|
||||
if [[ -z "$VERSION_TYPE" ]]; then
|
||||
VERSION_TYPE='patch'
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_BRANCH" ]]; then
|
||||
TARGET_BRANCH='main'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version_type=$VERSION_TYPE"
|
||||
echo "pre_release=$PRE_RELEASE"
|
||||
echo "branch=$TARGET_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Close stale nightly version bump PRs
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prefix = 'version-bump-'
|
||||
const closed = []
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
})
|
||||
|
||||
for (const pr of prs) {
|
||||
if (!pr.head?.ref?.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (pr.user?.login !== 'github-actions[bot]') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only clean up stale nightly PRs targeting main.
|
||||
// Adjust here if other target branches should be cleaned.
|
||||
if (pr.base?.ref !== 'main') {
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${pr.head.ref}`
|
||||
})
|
||||
} catch (error) {
|
||||
if (![404, 422].includes(error.status)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
closed.push(pr.number)
|
||||
}
|
||||
|
||||
core.info(`Closed ${closed.length} stale PR(s).`)
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
ref: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate branch exists
|
||||
env:
|
||||
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.inputs.branch }}"
|
||||
BRANCH="$TARGET_BRANCH"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
|
||||
echo "❌ Branch '$BRANCH' does not exist"
|
||||
echo ""
|
||||
@@ -51,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -62,16 +153,31 @@ jobs:
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
|
||||
run: |
|
||||
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
|
||||
set -euo pipefail
|
||||
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
|
||||
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$PRE_RELEASE" ]]; then
|
||||
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
|
||||
else
|
||||
pnpm version "$VERSION_TYPE" --no-git-tag-version
|
||||
fi
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Format PR string
|
||||
id: capitalised
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
run: |
|
||||
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
|
||||
CAPITALISED_TYPE="$VERSION_TYPE"
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
@@ -82,8 +188,8 @@ jobs:
|
||||
body: |
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ github.event.inputs.branch }}`
|
||||
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
base: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
labels: |
|
||||
Release
|
||||
|
||||
@@ -51,8 +51,6 @@ defineProps<{
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
92
browser_tests/assets/groups/nested-groups-1-inner-node.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
|
||||
"revision": 0,
|
||||
"last_node_id": 17,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
318.8446183157076,
|
||||
355.3961392345528
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Outer Group",
|
||||
"bounding": [
|
||||
-46.25245366331014,
|
||||
-150.82497138023245,
|
||||
1034.4034361963616,
|
||||
1007.338460439933
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Inner Group",
|
||||
"bounding": [
|
||||
80.96059074101554,
|
||||
28.123757436778178,
|
||||
718.286373661183,
|
||||
691.2397164539732
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7121393732101533,
|
||||
"offset": [
|
||||
289.18242848011835,
|
||||
367.0747755524199
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.35.5",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true,
|
||||
"workflowRendererVersion": "Vue"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 422 B |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 82 KiB |
@@ -1653,6 +1653,55 @@ export class ComfyPage {
|
||||
}, focusMode)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of a group by title.
|
||||
* @param title The title of the group to find
|
||||
* @returns The group's canvas position
|
||||
* @throws Error if group not found
|
||||
*/
|
||||
async getGroupPosition(title: string): Promise<Position> {
|
||||
const pos = await this.page.evaluate((title) => {
|
||||
const groups = window['app'].graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
return { x: group.pos[0], y: group.pos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a group by its title.
|
||||
* @param options.name The title of the group to drag
|
||||
* @param options.deltaX Horizontal drag distance in screen pixels
|
||||
* @param options.deltaY Vertical drag distance in screen pixels
|
||||
*/
|
||||
async dragGroup(options: {
|
||||
name: string
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
}): Promise<void> {
|
||||
const { name, deltaX, deltaY } = options
|
||||
const screenPos = await this.page.evaluate((title) => {
|
||||
const app = window['app']
|
||||
const groups = app.graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
// Position in the title area of the group
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, name)
|
||||
if (!screenPos) throw new Error(`Group "${name}" not found`)
|
||||
|
||||
await this.dragAndDrop(screenPos, {
|
||||
x: screenPos.x + deltaX,
|
||||
y: screenPos.y + deltaY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
@@ -160,7 +160,7 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').last()
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
29
browser_tests/tests/mobileBaseline.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -260,6 +260,12 @@ test.describe('Release context menu', () => {
|
||||
|
||||
test('Can trigger on link release', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
// Wait for context menu with correct title (slot name | slot type)
|
||||
// The title shows the output slot name and type from the disconnected link
|
||||
await expect(contextMenu.locator('.litemenu-title')).toContainText(
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should move nested groups together when dragging outer group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
|
||||
// Get initial positions with null guards
|
||||
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const initialOffsetX = innerInitial.x - outerInitial.x
|
||||
const initialOffsetY = innerInitial.y - outerInitial.y
|
||||
|
||||
// Drag the outer group
|
||||
const dragDelta = { x: 100, y: 80 }
|
||||
await comfyPage.dragGroup({
|
||||
name: 'Outer Group',
|
||||
deltaX: dragDelta.x,
|
||||
deltaY: dragDelta.y
|
||||
})
|
||||
|
||||
// Use retrying assertion to wait for positions to update
|
||||
await expect(async () => {
|
||||
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const finalOffsetX = innerFinal.x - outerFinal.x
|
||||
const finalOffsetY = innerFinal.y - outerFinal.y
|
||||
|
||||
// Both groups should have moved
|
||||
expect(outerFinal.x).not.toBe(outerInitial.x)
|
||||
expect(innerFinal.x).not.toBe(innerInitial.x)
|
||||
|
||||
// The relative offset should be maintained (inner group moved with outer)
|
||||
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const seedWidget = comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'seed')
|
||||
.first()
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
|
||||
97
docs/adr/0005-remove-importmap-for-vue-extensions.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 5. Remove Import Map for Vue Extensions
|
||||
|
||||
Date: 2025-12-13
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ComfyUI frontend previously used a Vite plugin (`generateImportMapPlugin`) to inject an HTML import map exposing shared modules to extensions. This allowed Vue-based extensions to mark dependencies as external in their Vite configs:
|
||||
|
||||
```typescript
|
||||
// Extension vite.config.ts (old pattern)
|
||||
rollupOptions: {
|
||||
external: ['vue', 'vue-i18n', 'pinia', /^primevue\/?.*/, ...]
|
||||
}
|
||||
```
|
||||
|
||||
The import map resolved bare specifiers like `import { ref } from 'vue'` at runtime by mapping them to pre-built ESM files served from `/assets/lib/`.
|
||||
|
||||
**Modules exposed via import map:**
|
||||
|
||||
- `vue` (vue.esm-browser.prod.js)
|
||||
- `vue-i18n` (vue-i18n.esm-browser.prod.js)
|
||||
- `primevue/*` (all PrimeVue components)
|
||||
- `@primevue/themes/*`
|
||||
- `@primevue/forms/*`
|
||||
|
||||
**Problems with import map approach:**
|
||||
|
||||
1. **Blocked tree shaking**: Vue and PrimeVue loaded as remote modules at runtime, preventing bundler optimizations. The entire Vue runtime was loaded even if only a few APIs were used.
|
||||
|
||||
2. **Poor code splitting**: PrimeVue's component library split into hundreds of small chunks, each requiring a separate network request on mount. This significantly impacted initial page load.
|
||||
|
||||
3. **Cold start performance**: Each externalized module required a separate HTTP request and browser module resolution step. This compounded on lower-end systems and slower networks.
|
||||
|
||||
4. **Version alignment complexity**: Extensions relied on the frontend's Vue version at runtime. Subtle version mismatches between build-time types and runtime code caused debugging difficulties.
|
||||
|
||||
5. **Incompatible with Cloud distribution**: The Cloud deployment model requires fully bundled, optimized assets. Import maps added a layer of indirection incompatible with our CDN and caching strategy.
|
||||
|
||||
## Decision
|
||||
|
||||
Remove the `generateImportMapPlugin` and require Vue-based extensions to bundle their own Vue instance.
|
||||
|
||||
**Implementation (PR #6899):**
|
||||
|
||||
- Deleted `build/plugins/generateImportMapPlugin.ts`
|
||||
- Removed plugin configuration from `vite.config.mts`
|
||||
- Removed `fast-glob` dependency used by the plugin
|
||||
|
||||
**Extension migration path:**
|
||||
|
||||
1. Remove `external: ['vue', ...]` from Vite rollup options
|
||||
2. Vue and related dependencies will be bundled into the extension output
|
||||
3. No code changes required in extension source files
|
||||
|
||||
The import map was already disabled for Cloud builds (PR #6559) before complete removal. Removal aligns all distribution channels on the same bundling strategy.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Improved page load**: Full tree shaking and optimal code splitting now apply to Vue and PrimeVue
|
||||
- **Faster development**: No import map generation step; simplified build pipeline
|
||||
- **Better debugging**: Extension's bundled Vue matches build-time expectations exactly
|
||||
- **Cloud compatibility**: All assets fully bundled and CDN-optimizable
|
||||
- **Consistent behavior**: Same bundling strategy across desktop, localhost, and cloud distributions
|
||||
- **Reduced network requests**: Fewer module fetches on initial page load
|
||||
|
||||
### Negative
|
||||
|
||||
- **Breaking change for existing extensions**: Extensions using `external: ['vue']` pattern fail with "Failed to resolve module specifier 'vue'" error
|
||||
- **Larger extension bundles**: Each extension now includes its own Vue instance (~30KB gzipped)
|
||||
- **Potential version fragmentation**: Different extensions may bundle different Vue versions (mitigated by Vue's stable API)
|
||||
|
||||
### Migration Impact
|
||||
|
||||
Extensions affected must update their build configuration. The migration is straightforward:
|
||||
|
||||
```diff
|
||||
// vite.config.ts
|
||||
rollupOptions: {
|
||||
- external: ['vue', 'vue-i18n', 'primevue', ...]
|
||||
}
|
||||
```
|
||||
|
||||
Affected versions:
|
||||
|
||||
- **v1.32.x - v1.33.8**: Import map present, external pattern works
|
||||
- **v1.33.9+**: Import map removed, bundling required
|
||||
|
||||
## Notes
|
||||
|
||||
- [ComfyUI_frontend_vue_basic](https://github.com/jtydhr88/ComfyUI_frontend_vue_basic) has been updated to demonstrate the new bundled pattern
|
||||
- Issue #7267 documents the user-facing impact and migration discussion
|
||||
- Future Extension API v2 (Issue #4668) may provide alternative mechanisms for shared dependencies
|
||||
@@ -14,6 +14,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<title>ComfyUI</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
|
||||
|
||||
<!-- Fullscreen mode on mobile browsers -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.35.3",
|
||||
"version": "1.36.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1328,6 +1328,15 @@ audio.comfy-audio.empty-audio-widget {
|
||||
font-size 0.1s ease;
|
||||
}
|
||||
|
||||
/* Performance optimization during canvas interaction */
|
||||
.transform-pane--interacting .lg-node * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transform-pane--interacting .lg-node {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
19
packages/design-system/src/icons/nodeSlot2.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="hollow">
|
||||
<path
|
||||
d="M -50 50
|
||||
A 100 100, 0, 0, 1, 150 50
|
||||
A 100 100, 0, 0, 1, -50 50
|
||||
M 30 50
|
||||
A 20 20, 0, 0, 0, 70 50
|
||||
A 20 20, 0, 0, 0, 30 50"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="var(--shape)" stroke-width="4">
|
||||
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
|
||||
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
|
||||
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
|
||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 693 B |
20
packages/design-system/src/icons/nodeSlot3.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="hollow">
|
||||
<path
|
||||
d="M-50 50
|
||||
A100 100 0 0 1 150 50
|
||||
A100 100 0 0 1 -50 50
|
||||
M30 50
|
||||
A20 20 0 0 0 70 50
|
||||
A20 20 0 0 0 30 50"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="var(--shape)" stroke-width="4">
|
||||
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
|
||||
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
|
||||
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
|
||||
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
|
||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@@ -264,7 +264,7 @@ if (!releaseInfo) {
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
|
||||
@@ -10,48 +10,66 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
<IconButton
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -74,18 +92,23 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -93,6 +116,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
@@ -112,10 +138,20 @@ onMounted(() => {
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actionbar-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
toastErrorHandler(error)
|
||||
} catch (toastError) {
|
||||
console.error(error)
|
||||
console.error(toastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
// Global variable from vite build defined in global.d.ts
|
||||
// eslint-disable-next-line no-undef
|
||||
const isStaging = !__USE_PROD_CONFIG__
|
||||
import { isStaging } from '@/config/staging'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<!-- Selection rectangle overlay for Vue nodes mode -->
|
||||
<SelectionRectangle v-if="shouldRenderVueNodes && comfyAppReady" />
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
:style="{
|
||||
...stringifiedMinimapStyles.buttonGroupStyles
|
||||
}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="pointer-events-none absolute border border-blue-400 bg-blue-500/20"
|
||||
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-[60]'
|
||||
class: 'absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-full z-[8888] flex flex-col justify-between bg-comfy-menu-bg">
|
||||
<div class="h-full z-8888 flex flex-col justify-between bg-comfy-menu-bg">
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
@@ -110,7 +109,6 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const popoverToggleSpy = vi.fn()
|
||||
const popoverHideSpy = vi.fn()
|
||||
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots, expose }) {
|
||||
const toggle = (event: Event) => {
|
||||
popoverToggleSpy(event)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverHideSpy()
|
||||
}
|
||||
expose({ toggle, hide })
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
const tooltipDirectiveStub = {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history'
|
||||
running: 'running'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +27,7 @@ const mountHeader = (props = {}) =>
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: tooltipDirectiveStub }
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,20 +48,4 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const moreButton = wrapper.get('button[aria-label="More options"]')
|
||||
await moreButton.trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
|
||||
expect(spy).toHaveBeenCalledWith('More')
|
||||
|
||||
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
|
||||
await clearHistoryButton.trigger('click')
|
||||
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,85 +17,17 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="morePopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<IconTextButton
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
@click="onClearHistoryFromMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { PopoverMethods } from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const morePopoverRef = ref<PopoverMethods | null>(null)
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const onMoreClick = (event: MouseEvent) => {
|
||||
morePopoverRef.value?.toggle(event)
|
||||
}
|
||||
const onClearHistoryFromMenu = () => {
|
||||
morePopoverRef.value?.hide()
|
||||
emit('clearHistory')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="openAssetsSidebar"
|
||||
@clear-history="onClearHistoryFromMenu"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@@ -66,7 +65,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
@@ -79,7 +77,6 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -105,7 +102,6 @@ const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
@@ -280,29 +276,4 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onClearHistoryFromMenu = () => {
|
||||
showClearHistoryDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<section
|
||||
class="w-[360px] rounded-2xl border border-interface-stroke bg-interface-panel-surface text-text-primary shadow-interface font-inter"
|
||||
>
|
||||
<header
|
||||
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
|
||||
>
|
||||
<p class="m-0 text-[14px] font-normal leading-none">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
|
||||
</p>
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
|
||||
<p class="m-0">
|
||||
{{
|
||||
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer class="flex items-center justify-end px-4 py-4">
|
||||
<div class="flex items-center gap-4 text-[14px] leading-none">
|
||||
<TextButton
|
||||
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
|
||||
type="transparent"
|
||||
:label="t('g.cancel')"
|
||||
@click="onCancel"
|
||||
/>
|
||||
<TextButton
|
||||
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
|
||||
type="secondary"
|
||||
:label="t('g.clear')"
|
||||
:disabled="isClearing"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const isClearing = ref(false)
|
||||
|
||||
const clearHistory = wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
await queueStore.clear(['history'])
|
||||
dialogStore.closeDialog()
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
isClearing.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (isClearing.value) return
|
||||
isClearing.value = true
|
||||
await clearHistory()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -67,7 +67,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-1">
|
||||
<div class="relative z-1 flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] min-w-0 flex-1">
|
||||
<div class="relative z-1 min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
|
||||
|
||||
@@ -56,15 +56,13 @@
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<Divider type="dashed" class="m-2" />
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading">
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!displayAssets.length">
|
||||
<div v-else-if="!loading && !displayAssets.length">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
@@ -77,7 +75,6 @@
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<VirtualGrid
|
||||
:items="mediaAssetsWithKey"
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ScrollPanel class="comfy-vue-side-bar-body h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('CurrentUserPopover', () => {
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/pricing',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ const handleTopUp = () => {
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
|
||||
buildDocsUrl('/tutorials/partner-nodes/pricing', {
|
||||
includeLocale: true
|
||||
}),
|
||||
'_blank'
|
||||
|
||||
190
src/composables/graph/contextMenuConverter.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import {
|
||||
buildStructuredMenu,
|
||||
convertContextMenuToOptions
|
||||
} from './contextMenuConverter'
|
||||
|
||||
describe('contextMenuConverter', () => {
|
||||
describe('buildStructuredMenu', () => {
|
||||
it('should order core items before extension items', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Custom Extension Item', source: 'litegraph' },
|
||||
{ label: 'Copy', source: 'vue' },
|
||||
{ label: 'Rename', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Core items (Rename, Copy) should come before extension items
|
||||
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
|
||||
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
|
||||
const extensionIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Custom Extension Item'
|
||||
)
|
||||
|
||||
expect(renameIndex).toBeLessThan(extensionIndex)
|
||||
expect(copyIndex).toBeLessThan(extensionIndex)
|
||||
})
|
||||
|
||||
it('should add Extensions category label before extension items', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Copy', source: 'vue' },
|
||||
{ label: 'My Custom Extension', source: 'litegraph' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const extensionsLabel = result.find(
|
||||
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
||||
)
|
||||
expect(extensionsLabel).toBeDefined()
|
||||
expect(extensionsLabel?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should place Delete at the very end', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Delete', action: () => {}, source: 'vue' },
|
||||
{ label: 'Copy', source: 'vue' },
|
||||
{ label: 'Rename', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const lastNonDivider = [...result]
|
||||
.reverse()
|
||||
.find((opt) => opt.type !== 'divider')
|
||||
expect(lastNonDivider?.label).toBe('Delete')
|
||||
})
|
||||
|
||||
it('should deduplicate items with same label, preferring vue source', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Copy', action: () => {}, source: 'litegraph' },
|
||||
{ label: 'Copy', action: () => {}, source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const copyItems = result.filter((opt) => opt.label === 'Copy')
|
||||
expect(copyItems).toHaveLength(1)
|
||||
expect(copyItems[0].source).toBe('vue')
|
||||
})
|
||||
|
||||
it('should preserve dividers between sections', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Copy', source: 'vue' },
|
||||
{ label: 'Pin', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const dividers = result.filter((opt) => opt.type === 'divider')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = buildStructuredMenu([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle only dividers', () => {
|
||||
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Should be empty since dividers are filtered initially
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should recognize Remove as equivalent to Delete', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Remove', action: () => {}, source: 'vue' },
|
||||
{ label: 'Copy', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Remove should be placed at the end like Delete
|
||||
const lastNonDivider = [...result]
|
||||
.reverse()
|
||||
.find((opt) => opt.type !== 'divider')
|
||||
expect(lastNonDivider?.label).toBe('Remove')
|
||||
})
|
||||
|
||||
it('should group core items in correct section order', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Color', source: 'vue' },
|
||||
{ label: 'Node Info', source: 'vue' },
|
||||
{ label: 'Pin', source: 'vue' },
|
||||
{ label: 'Rename', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Get indices of items (excluding dividers and categories)
|
||||
const getIndex = (label: string) =>
|
||||
result.findIndex((opt) => opt.label === label)
|
||||
|
||||
// Rename (section 1) should come before Pin (section 2)
|
||||
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
|
||||
// Pin (section 2) should come before Node Info (section 4)
|
||||
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
|
||||
// Node Info (section 4) should come before or with Color (section 4)
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertContextMenuToOptions', () => {
|
||||
it('should convert empty array to empty result', () => {
|
||||
const result = convertContextMenuToOptions([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should convert null items to dividers', () => {
|
||||
const result = convertContextMenuToOptions([null], undefined, false)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].type).toBe('divider')
|
||||
})
|
||||
|
||||
it('should skip blacklisted items like Properties', () => {
|
||||
const items = [{ content: 'Properties', callback: () => {} }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should convert basic menu items with content', () => {
|
||||
const items = [{ content: 'Test Item', callback: () => {} }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].label).toBe('Test Item')
|
||||
})
|
||||
|
||||
it('should mark items as litegraph source', () => {
|
||||
const items = [{ content: 'Test Item', callback: () => {} }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result[0].source).toBe('litegraph')
|
||||
})
|
||||
|
||||
it('should pass through disabled state', () => {
|
||||
const items = [{ content: 'Disabled Item', disabled: true }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result[0].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply structuring by default', () => {
|
||||
const items = [
|
||||
{ content: 'Copy', callback: () => {} },
|
||||
{ content: 'Custom Extension', callback: () => {} }
|
||||
]
|
||||
const result = convertContextMenuToOptions(items)
|
||||
|
||||
// With structuring, there should be Extensions category
|
||||
const hasExtensionsCategory = result.some(
|
||||
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
||||
)
|
||||
expect(hasExtensionsCategory).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
620
src/composables/graph/contextMenuConverter.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode,
|
||||
IContextMenuOptions,
|
||||
ContextMenu
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu'
|
||||
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
/**
|
||||
* Hard blacklist - items that should NEVER be included
|
||||
*/
|
||||
const HARD_BLACKLIST = new Set([
|
||||
'Properties', // Never include Properties submenu
|
||||
'Colors', // Use singular "Color" instead
|
||||
'Shapes', // Use singular "Shape" instead
|
||||
'Title',
|
||||
'Mode',
|
||||
'Properties Panel',
|
||||
'Copy (Clipspace)'
|
||||
])
|
||||
|
||||
/**
|
||||
* Core menu items - items that should appear in the main menu, not under Extensions
|
||||
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
|
||||
*/
|
||||
const CORE_MENU_ITEMS = new Set([
|
||||
// Basic operations
|
||||
'Rename',
|
||||
'Copy',
|
||||
'Duplicate',
|
||||
'Clone',
|
||||
// Node state operations
|
||||
'Run Branch',
|
||||
'Pin',
|
||||
'Unpin',
|
||||
'Bypass',
|
||||
'Remove Bypass',
|
||||
'Mute',
|
||||
// Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
// Info and adjustments
|
||||
'Node Info',
|
||||
'Resize',
|
||||
'Title',
|
||||
'Properties Panel',
|
||||
'Adjust Size',
|
||||
// Visual
|
||||
'Color',
|
||||
'Colors',
|
||||
'Shape',
|
||||
'Shapes',
|
||||
'Mode',
|
||||
// Built-in node operations (node-specific)
|
||||
'Open Image',
|
||||
'Copy Image',
|
||||
'Save Image',
|
||||
'Open in Mask Editor',
|
||||
'Edit Subgraph Widgets',
|
||||
'Unpack Subgraph',
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)',
|
||||
// Selection and alignment
|
||||
'Align Selected To',
|
||||
'Distribute Nodes',
|
||||
// Deletion
|
||||
'Delete',
|
||||
'Remove',
|
||||
// LiteGraph base items
|
||||
'Show Advanced',
|
||||
'Hide Advanced'
|
||||
])
|
||||
|
||||
/**
|
||||
* Normalize menu item label for duplicate detection
|
||||
* Handles variations like Colors/Color, Shapes/Shape, Pin/Unpin, Remove/Delete
|
||||
*/
|
||||
function normalizeLabel(label: string): string {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/^un/, '') // Remove 'un' prefix (Unpin -> Pin)
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a similar menu item already exists in the results
|
||||
* Returns true if an item with the same normalized label exists
|
||||
*/
|
||||
function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
|
||||
const normalizedLabel = normalizeLabel(label)
|
||||
|
||||
// Map of equivalent items
|
||||
const equivalents: Record<string, string[]> = {
|
||||
color: ['color', 'colors'],
|
||||
shape: ['shape', 'shapes'],
|
||||
pin: ['pin', 'unpin'],
|
||||
delete: ['remove', 'delete'],
|
||||
duplicate: ['clone', 'duplicate']
|
||||
}
|
||||
|
||||
return existingItems.some((item) => {
|
||||
if (!item.label) return false
|
||||
|
||||
const existingNormalized = normalizeLabel(item.label)
|
||||
|
||||
// Check direct match
|
||||
if (existingNormalized === normalizedLabel) return true
|
||||
|
||||
// Check if they're in the same equivalence group
|
||||
for (const values of Object.values(equivalents)) {
|
||||
if (
|
||||
values.includes(normalizedLabel) &&
|
||||
values.includes(existingNormalized)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a menu item is a core menu item (not an extension)
|
||||
* Core items include LiteGraph base items and ComfyUI built-in functionality
|
||||
*/
|
||||
function isCoreMenuItem(label: string): boolean {
|
||||
return CORE_MENU_ITEMS.has(label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out duplicate menu items based on label
|
||||
* Gives precedence to Vue hardcoded options over LiteGraph options
|
||||
*/
|
||||
function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
|
||||
// Group items by label
|
||||
const itemsByLabel = new Map<string, MenuOption[]>()
|
||||
const itemsWithoutLabel: MenuOption[] = []
|
||||
|
||||
for (const opt of options) {
|
||||
// Always keep dividers and category items
|
||||
if (opt.type === 'divider' || opt.type === 'category') {
|
||||
itemsWithoutLabel.push(opt)
|
||||
continue
|
||||
}
|
||||
|
||||
// Items without labels are kept as-is
|
||||
if (!opt.label) {
|
||||
itemsWithoutLabel.push(opt)
|
||||
continue
|
||||
}
|
||||
|
||||
// Group by label
|
||||
if (!itemsByLabel.has(opt.label)) {
|
||||
itemsByLabel.set(opt.label, [])
|
||||
}
|
||||
itemsByLabel.get(opt.label)!.push(opt)
|
||||
}
|
||||
|
||||
// Select best item for each label (prefer vue over litegraph)
|
||||
const result: MenuOption[] = []
|
||||
const seenLabels = new Set<string>()
|
||||
|
||||
for (const opt of options) {
|
||||
// Add non-labeled items in original order
|
||||
if (opt.type === 'divider' || opt.type === 'category' || !opt.label) {
|
||||
if (itemsWithoutLabel.includes(opt)) {
|
||||
result.push(opt)
|
||||
const idx = itemsWithoutLabel.indexOf(opt)
|
||||
itemsWithoutLabel.splice(idx, 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already processed this label
|
||||
if (seenLabels.has(opt.label)) {
|
||||
continue
|
||||
}
|
||||
seenLabels.add(opt.label)
|
||||
|
||||
// Get all items with this label
|
||||
const duplicates = itemsByLabel.get(opt.label)!
|
||||
|
||||
// If only one item, add it
|
||||
if (duplicates.length === 1) {
|
||||
result.push(duplicates[0])
|
||||
continue
|
||||
}
|
||||
|
||||
// Multiple items: prefer vue source over litegraph
|
||||
const vueItem = duplicates.find((item) => item.source === 'vue')
|
||||
if (vueItem) {
|
||||
result.push(vueItem)
|
||||
} else {
|
||||
// No vue item, just take the first one
|
||||
result.push(duplicates[0])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Order groups for menu items - defines the display order of sections
|
||||
*/
|
||||
const MENU_ORDER: string[] = [
|
||||
// Section 1: Basic operations
|
||||
'Rename',
|
||||
'Copy',
|
||||
'Duplicate',
|
||||
// Section 2: Node actions
|
||||
'Run Branch',
|
||||
'Pin',
|
||||
'Unpin',
|
||||
'Bypass',
|
||||
'Remove Bypass',
|
||||
'Mute',
|
||||
// Section 3: Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
'Resize',
|
||||
'Clone',
|
||||
// Section 4: Node properties
|
||||
'Node Info',
|
||||
'Color',
|
||||
// Section 5: Node-specific operations
|
||||
'Open in Mask Editor',
|
||||
'Open Image',
|
||||
'Copy Image',
|
||||
'Save Image',
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)',
|
||||
// Fallback for other core items
|
||||
'Convert to Group Node (Deprecated)'
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the order index for a menu item (lower = earlier in menu)
|
||||
*/
|
||||
function getMenuItemOrder(label: string): number {
|
||||
const index = MENU_ORDER.indexOf(label)
|
||||
return index === -1 ? 999 : index
|
||||
}
|
||||
|
||||
/**
|
||||
* Build structured menu with core items first, then extensions under a labeled section
|
||||
* Ensures Delete always appears at the bottom
|
||||
*/
|
||||
export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
|
||||
// First, remove duplicates (giving precedence to Vue hardcoded options)
|
||||
const deduplicated = removeDuplicateMenuOptions(options)
|
||||
const coreItemsMap = new Map<string, MenuOption>()
|
||||
const extensionItems: MenuOption[] = []
|
||||
let deleteItem: MenuOption | undefined
|
||||
|
||||
// Separate items into core and extension categories
|
||||
for (const option of deduplicated) {
|
||||
// Skip dividers for now - we'll add them between sections later
|
||||
if (option.type === 'divider') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip category labels (they'll be added separately)
|
||||
if (option.type === 'category') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the Delete/Remove item - save it for the end
|
||||
const isDeleteItem = option.label === 'Delete' || option.label === 'Remove'
|
||||
if (isDeleteItem && !option.hasSubmenu) {
|
||||
deleteItem = option
|
||||
continue
|
||||
}
|
||||
|
||||
// Categorize based on label
|
||||
if (option.label && isCoreMenuItem(option.label)) {
|
||||
coreItemsMap.set(option.label, option)
|
||||
} else {
|
||||
extensionItems.push(option)
|
||||
}
|
||||
}
|
||||
// Build ordered core items based on MENU_ORDER
|
||||
const orderedCoreItems: MenuOption[] = []
|
||||
const coreLabels = Array.from(coreItemsMap.keys())
|
||||
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
|
||||
|
||||
// Section boundaries based on MENU_ORDER indices
|
||||
// Section 1: 0-2 (Rename, Copy, Duplicate)
|
||||
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
|
||||
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
|
||||
// Section 4: 16-17 (Node Info, Color)
|
||||
// Section 5: 18+ (Image operations and fallback items)
|
||||
const getSectionNumber = (index: number): number => {
|
||||
if (index <= 2) return 1
|
||||
if (index <= 8) return 2
|
||||
if (index <= 15) return 3
|
||||
if (index <= 17) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
let lastSection = 0
|
||||
for (const label of coreLabels) {
|
||||
const item = coreItemsMap.get(label)!
|
||||
const itemIndex = getMenuItemOrder(label)
|
||||
const currentSection = getSectionNumber(itemIndex)
|
||||
|
||||
// Add divider when moving to a new section
|
||||
if (lastSection > 0 && currentSection !== lastSection) {
|
||||
orderedCoreItems.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
orderedCoreItems.push(item)
|
||||
lastSection = currentSection
|
||||
}
|
||||
|
||||
// Build the final menu structure
|
||||
const result: MenuOption[] = []
|
||||
|
||||
// Add ordered core items with their dividers
|
||||
result.push(...orderedCoreItems)
|
||||
|
||||
// Add extensions section if there are extension items
|
||||
if (extensionItems.length > 0) {
|
||||
// Add divider before Extensions section
|
||||
result.push({ type: 'divider' })
|
||||
|
||||
// Add non-clickable Extensions label
|
||||
result.push({
|
||||
label: 'Extensions',
|
||||
type: 'category',
|
||||
disabled: true
|
||||
})
|
||||
|
||||
// Add extension items
|
||||
result.push(...extensionItems)
|
||||
}
|
||||
|
||||
// Add Delete at the bottom if it exists
|
||||
if (deleteItem) {
|
||||
result.push({ type: 'divider' })
|
||||
result.push(deleteItem)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LiteGraph IContextMenuValue items to Vue MenuOption format
|
||||
* Used to bridge LiteGraph context menus into Vue node menus
|
||||
* @param items - The LiteGraph menu items to convert
|
||||
* @param node - The node context (optional)
|
||||
* @param applyStructuring - Whether to apply menu structuring (core/extensions separation). Defaults to true.
|
||||
*/
|
||||
export function convertContextMenuToOptions(
|
||||
items: (IContextMenuValue | null)[],
|
||||
node?: LGraphNode,
|
||||
applyStructuring: boolean = true
|
||||
): MenuOption[] {
|
||||
const result: MenuOption[] = []
|
||||
|
||||
for (const item of items) {
|
||||
// Null items are separators in LiteGraph
|
||||
if (item === null) {
|
||||
result.push({ type: 'divider' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip items without content (shouldn't happen, but be safe)
|
||||
if (!item.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip hard blacklisted items
|
||||
if (HARD_BLACKLIST.has(item.content)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if a similar item already exists in results
|
||||
if (isDuplicateItem(item.content, result)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const option: MenuOption = {
|
||||
label: item.content,
|
||||
source: 'litegraph'
|
||||
}
|
||||
|
||||
// Pass through disabled state
|
||||
if (item.disabled) {
|
||||
option.disabled = true
|
||||
}
|
||||
|
||||
// Handle submenus
|
||||
if (item.has_submenu) {
|
||||
// Static submenu with pre-defined options
|
||||
if (item.submenu?.options) {
|
||||
option.hasSubmenu = true
|
||||
option.submenu = convertSubmenuToOptions(item.submenu.options)
|
||||
}
|
||||
// Dynamic submenu - callback creates it on-demand
|
||||
else if (item.callback && !item.disabled) {
|
||||
option.hasSubmenu = true
|
||||
// Intercept the callback to capture dynamic submenu items
|
||||
const capturedSubmenu = captureDynamicSubmenu(item, node)
|
||||
if (capturedSubmenu) {
|
||||
option.submenu = capturedSubmenu
|
||||
} else {
|
||||
console.warn(
|
||||
'[ContextMenuConverter] Failed to capture submenu for:',
|
||||
item.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle callback (only if not disabled and not a submenu)
|
||||
else if (item.callback && !item.disabled) {
|
||||
// Wrap the callback to match the () => void signature
|
||||
option.action = () => {
|
||||
try {
|
||||
void item.callback?.call(
|
||||
item as unknown as ContextMenuDivElement,
|
||||
item.value,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
item
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error executing context menu callback:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(option)
|
||||
}
|
||||
|
||||
// Apply structured menu with core items and extensions section (if requested)
|
||||
if (applyStructuring) {
|
||||
return buildStructuredMenu(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture submenu items from a dynamic submenu callback
|
||||
* Intercepts ContextMenu constructor to extract items without creating HTML menu
|
||||
*/
|
||||
function captureDynamicSubmenu(
|
||||
item: IContextMenuValue,
|
||||
node?: LGraphNode
|
||||
): SubMenuOption[] | undefined {
|
||||
let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined
|
||||
let capturedOptions: IContextMenuOptions | undefined
|
||||
|
||||
// Store original ContextMenu constructor
|
||||
const OriginalContextMenu = LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
// Mock ContextMenu constructor to capture submenu items and options
|
||||
LiteGraph.ContextMenu = function (
|
||||
items: readonly (IContextMenuValue | string | null)[],
|
||||
options?: IContextMenuOptions
|
||||
) {
|
||||
// Capture both items and options
|
||||
capturedItems = items
|
||||
capturedOptions = options
|
||||
// Return a minimal mock object to prevent errors
|
||||
return {
|
||||
close: () => {},
|
||||
root: document.createElement('div')
|
||||
} as unknown as ContextMenu
|
||||
} as unknown as typeof ContextMenu
|
||||
|
||||
// Execute the callback to trigger submenu creation
|
||||
try {
|
||||
// Create a mock MouseEvent for the callback
|
||||
const mockEvent = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 0,
|
||||
clientY: 0
|
||||
})
|
||||
|
||||
// Create a mock parent menu
|
||||
const mockMenu = {
|
||||
close: () => {},
|
||||
root: document.createElement('div')
|
||||
} as unknown as ContextMenu
|
||||
|
||||
// Call the callback which should trigger ContextMenu constructor
|
||||
// Callback signature varies, but typically: (value, options, event, menu, node)
|
||||
void item.callback?.call(
|
||||
item as unknown as ContextMenuDivElement,
|
||||
item.value,
|
||||
{},
|
||||
mockEvent,
|
||||
mockMenu,
|
||||
node // Pass the node context for callbacks that need it
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[ContextMenuConverter] Error executing callback for:',
|
||||
item.content,
|
||||
error
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Always restore original constructor
|
||||
LiteGraph.ContextMenu = OriginalContextMenu
|
||||
}
|
||||
|
||||
// Convert captured items to Vue submenu format
|
||||
if (capturedItems) {
|
||||
const converted = convertSubmenuToOptions(capturedItems, capturedOptions)
|
||||
return converted
|
||||
}
|
||||
|
||||
console.warn('[ContextMenuConverter] No items captured for:', item.content)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LiteGraph submenu items to Vue SubMenuOption format
|
||||
*/
|
||||
function convertSubmenuToOptions(
|
||||
items: readonly (IContextMenuValue | string | null)[],
|
||||
options?: IContextMenuOptions
|
||||
): SubMenuOption[] {
|
||||
const result: SubMenuOption[] = []
|
||||
|
||||
for (const item of items) {
|
||||
// Skip null separators
|
||||
if (item === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle string items (simple labels like in Mode/Shapes menus)
|
||||
if (typeof item === 'string') {
|
||||
const subOption: SubMenuOption = {
|
||||
label: item,
|
||||
action: () => {
|
||||
try {
|
||||
// Call the options callback with the string value
|
||||
if (options?.callback) {
|
||||
void options.callback.call(
|
||||
null,
|
||||
item,
|
||||
options,
|
||||
undefined,
|
||||
undefined,
|
||||
options.extra
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing string item callback:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(subOption)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle object items
|
||||
if (!item.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract text content from HTML if present
|
||||
const content = stripHtmlTags(item.content)
|
||||
|
||||
const subOption: SubMenuOption = {
|
||||
label: content,
|
||||
action: () => {
|
||||
try {
|
||||
void item.callback?.call(
|
||||
item as unknown as ContextMenuDivElement,
|
||||
item.value,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
item
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error executing submenu callback:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass through disabled state
|
||||
if (item.disabled) {
|
||||
subOption.disabled = true
|
||||
}
|
||||
|
||||
result.push(subOption)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from content string safely
|
||||
* LiteGraph menu items often include HTML for styling
|
||||
*/
|
||||
function stripHtmlTags(html: string): string {
|
||||
// Use DOMPurify to sanitize and strip all HTML tags
|
||||
const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
|
||||
const result = sanitized.trim()
|
||||
return result || html.replace(/<[^>]*>/g, '').trim() || html
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
controlWidget?: SafeControlWidget
|
||||
borderStyle?: string
|
||||
}
|
||||
|
||||
@@ -84,6 +86,17 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
@@ -122,7 +135,8 @@ export function safeWidgetMapper(
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo
|
||||
slotMetadata: slotInfo,
|
||||
controlWidget: getControlWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -204,6 +218,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
@@ -238,7 +261,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
|
||||
@@ -15,10 +15,13 @@ export interface MenuOption {
|
||||
icon?: string
|
||||
shortcut?: string
|
||||
hasSubmenu?: boolean
|
||||
type?: 'divider'
|
||||
type?: 'divider' | 'category'
|
||||
action?: () => void
|
||||
submenu?: SubMenuOption[]
|
||||
badge?: BadgeVariant
|
||||
disabled?: boolean
|
||||
source?: 'litegraph' | 'vue'
|
||||
isColorPicker?: boolean
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
@@ -26,6 +29,7 @@ export interface SubMenuOption {
|
||||
icon?: string
|
||||
action: () => void
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
@@ -173,7 +177,12 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
|
||||
// Section 5: Subgraph operations
|
||||
options.push(...getSubgraphOptions(hasSubgraphsSelected))
|
||||
options.push(
|
||||
...getSubgraphOptions({
|
||||
hasSubgraphs: hasSubgraphsSelected,
|
||||
hasMultipleSelection: hasMultipleNodes.value
|
||||
})
|
||||
)
|
||||
|
||||
// Section 6: Multiple nodes operations
|
||||
if (hasMultipleNodes.value) {
|
||||
|
||||
106
src/composables/graph/useSelectionMenuOptions.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
|
||||
|
||||
const subgraphMocks = vi.hoisted(() => ({
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn(),
|
||||
addSubgraphToLibrary: vi.fn(),
|
||||
createI18nMock: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn(),
|
||||
te: vi.fn(),
|
||||
d: vi.fn()
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: subgraphMocks.createI18nMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionOperations', () => ({
|
||||
useSelectionOperations: () => ({
|
||||
copySelection: vi.fn(),
|
||||
duplicateSelection: vi.fn(),
|
||||
deleteSelection: vi.fn(),
|
||||
renameSelection: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeArrangement', () => ({
|
||||
useNodeArrangement: () => ({
|
||||
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
|
||||
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
|
||||
applyAlign: vi.fn(),
|
||||
applyDistribute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({
|
||||
convertToSubgraph: subgraphMocks.convertToSubgraph,
|
||||
unpackSubgraph: subgraphMocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useFrameNodes', () => ({
|
||||
useFrameNodes: () => ({
|
||||
frameNodes: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns only convert option when no subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: false,
|
||||
hasMultipleSelection: true
|
||||
})
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
|
||||
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
hasMultipleSelection: true
|
||||
})
|
||||
const labels = options.map((option) => option.label)
|
||||
|
||||
expect(labels).toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).toContain('contextMenu.Unpack Subgraph')
|
||||
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
hasMultipleSelection: false
|
||||
})
|
||||
|
||||
const labels = options.map((option) => option.label)
|
||||
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).toEqual([
|
||||
'contextMenu.Add Subgraph to Library',
|
||||
'contextMenu.Unpack Subgraph'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -63,9 +63,29 @@ export function useSelectionMenuOptions() {
|
||||
}
|
||||
]
|
||||
|
||||
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
|
||||
const getSubgraphOptions = ({
|
||||
hasSubgraphs,
|
||||
hasMultipleSelection
|
||||
}: {
|
||||
hasSubgraphs: boolean
|
||||
hasMultipleSelection: boolean
|
||||
}): MenuOption[] => {
|
||||
const convertOption: MenuOption = {
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
|
||||
const options: MenuOption[] = []
|
||||
const showConvertOption = !hasSubgraphs || hasMultipleSelection
|
||||
|
||||
if (showConvertOption) {
|
||||
options.push(convertOption)
|
||||
}
|
||||
|
||||
if (hasSubgraphs) {
|
||||
return [
|
||||
options.push(
|
||||
{
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
@@ -76,17 +96,10 @@ export function useSelectionMenuOptions() {
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
|
||||
@@ -231,6 +231,36 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const klingVideoWithAudioPricingCalculator: PricingFunction = (
|
||||
node: LGraphNode
|
||||
): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !generateAudioWidget) {
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
const duration = String(durationWidget.value)
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (duration === '5') {
|
||||
return generateAudio ? '$0.70/Run' : '$0.35/Run'
|
||||
}
|
||||
|
||||
if (duration === '10') {
|
||||
return generateAudio ? '$1.40/Run' : '$0.70/Run'
|
||||
}
|
||||
|
||||
// Fallback for unexpected duration values
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
// ---- constants ----
|
||||
const SORA_SIZES = {
|
||||
BASIC: new Set(['720x1280', '1280x720']),
|
||||
@@ -744,6 +774,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingOmniProImageNode: {
|
||||
displayPrice: '$0.028/Run'
|
||||
},
|
||||
KlingTextToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
KlingImageToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1931,6 +1967,8 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
|
||||
@@ -22,7 +22,10 @@ export const useContextMenuTranslation = () => {
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof getCanvasMenuOptions>
|
||||
) {
|
||||
const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args)
|
||||
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
|
||||
this,
|
||||
args
|
||||
)
|
||||
|
||||
// Add items from new extension API
|
||||
const newApiItems = app.collectCanvasMenuItems(this)
|
||||
@@ -58,13 +61,16 @@ export const useContextMenuTranslation = () => {
|
||||
LGraphCanvas.prototype
|
||||
)
|
||||
|
||||
// Install compatibility layer for getNodeMenuOptions
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
|
||||
|
||||
// Wrap getNodeMenuOptions to add new API items
|
||||
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
const getNodeMenuOptionsWithExtensions = function (
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof nodeMenuFn>
|
||||
) {
|
||||
const res = nodeMenuFn.apply(this, args)
|
||||
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
|
||||
|
||||
// Add items from new extension API
|
||||
const node = args[0]
|
||||
@@ -73,11 +79,28 @@ export const useContextMenuTranslation = () => {
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
// Add legacy monkey-patched items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getNodeMenuOptions',
|
||||
this,
|
||||
...args
|
||||
)
|
||||
for (const item of legacyItems) {
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
|
||||
|
||||
legacyMenuCompat.registerWrapper(
|
||||
'getNodeMenuOptions',
|
||||
getNodeMenuOptionsWithExtensions,
|
||||
nodeMenuFn,
|
||||
LGraphCanvas.prototype
|
||||
)
|
||||
|
||||
function translateMenus(
|
||||
values: readonly (IContextMenuValue | string | null)[] | undefined,
|
||||
options: IContextMenuOptions
|
||||
|
||||
@@ -13,7 +13,8 @@ export enum ServerFeatureFlag {
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
|
||||
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,12 @@ export function useFeatureFlags() {
|
||||
true // Default to true (new design)
|
||||
)
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
20
src/config/staging.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
|
||||
|
||||
/**
|
||||
* Returns whether the current environment is staging.
|
||||
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
*/
|
||||
export const isStaging = computed(() => {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_IS_STAGING
|
||||
}
|
||||
|
||||
const projectId = remoteConfig.value.firebase_config?.projectId
|
||||
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
|
||||
})
|
||||
@@ -389,6 +389,13 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'enable-manager-legacy-ui',
|
||||
name: 'Use legacy Manager UI',
|
||||
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'disable-all-custom-nodes',
|
||||
name: 'Disable loading all custom nodes.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
@@ -23,22 +24,41 @@ import type { ComfyApp } from '@/scripts/app'
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
}
|
||||
type AutogrowNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
|
||||
comfyDynamic: {
|
||||
autogrow: Record<
|
||||
string,
|
||||
{
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
prefix?: string
|
||||
names?: string[]
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
|
||||
if (input.widget?.name) return
|
||||
node.widgets ??= []
|
||||
const { widget } = input
|
||||
if (widget && node.widgets.some((w) => w.name === widget.name)) return
|
||||
node.widgets.push({
|
||||
name: input.name,
|
||||
y: 0,
|
||||
type: 'shim',
|
||||
options: {},
|
||||
draw(ctx, _n, _w, y) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.fillText(input.label ?? input.name, 20, y + 15)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
name: input.name,
|
||||
options: {},
|
||||
serialize: false,
|
||||
type: 'shim',
|
||||
y: 0
|
||||
})
|
||||
input.alwaysVisible = true
|
||||
input.widget = { name: input.name }
|
||||
@@ -66,72 +86,47 @@ function dynamicComboWidget(
|
||||
appArg,
|
||||
widgetName
|
||||
)
|
||||
let currentDynamicNames: string[] = []
|
||||
function isInGroup(e: { name: string }): boolean {
|
||||
return e.name.startsWith(inputName + '.')
|
||||
}
|
||||
const updateWidgets = (value?: string) => {
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
const inputsToRemove: Record<string, INodeInputSlot> = {}
|
||||
for (const name of currentDynamicNames) {
|
||||
const input = node.inputs.find((input) => input.name === name)
|
||||
if (input) inputsToRemove[input.name] = input
|
||||
const widgetIndex = node.widgets.findIndex(
|
||||
(widget) => widget.name === name
|
||||
)
|
||||
if (widgetIndex === -1) continue
|
||||
node.widgets[widgetIndex].value = undefined
|
||||
node.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
currentDynamicNames = []
|
||||
if (!newSpec) {
|
||||
for (const input of Object.values(inputsToRemove)) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (inputIndex === -1) continue
|
||||
node.removeInput(inputIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
remove(node.widgets, isInGroup)
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
|
||||
const startingLength = node.widgets.length
|
||||
const initialInputIndex =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
let startingInputLength = node.inputs.length
|
||||
const startingInputLength = node.inputs.length
|
||||
|
||||
if (insertionPoint === 0)
|
||||
throw new Error("Dynamic widget doesn't exist on node")
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[newSpec.required, false],
|
||||
[newSpec.optional, true]
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
newSpec.required,
|
||||
newSpec.optional
|
||||
]
|
||||
for (const [inputType, isOptional] of inputTypes)
|
||||
inputTypes.forEach((inputType, idx) => {
|
||||
for (const key in inputType ?? {}) {
|
||||
const name = `${widget.name}.${key}`
|
||||
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
|
||||
name,
|
||||
isOptional
|
||||
isOptional: idx !== 0
|
||||
})
|
||||
specToAdd.display_name = key
|
||||
addNodeInput(node, specToAdd)
|
||||
currentDynamicNames.push(name)
|
||||
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
|
||||
if (
|
||||
!inputsToRemove[name] ||
|
||||
Array.isArray(inputType![key][0]) ||
|
||||
!LiteGraph.isValidConnection(
|
||||
inputsToRemove[name].type,
|
||||
inputType![key][0]
|
||||
)
|
||||
)
|
||||
continue
|
||||
node.inputs.at(-1)!.link = inputsToRemove[name].link
|
||||
inputsToRemove[name].link = null
|
||||
const newInputs = node.inputs
|
||||
.slice(startingInputLength)
|
||||
.filter((inp) => inp.name.startsWith(name))
|
||||
for (const newInput of newInputs) {
|
||||
if (INLINE_INPUTS && !newInput.widget)
|
||||
ensureWidgetForInput(node, newInput)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const input of Object.values(inputsToRemove)) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (inputIndex === -1) continue
|
||||
if (inputIndex < initialInputIndex) startingInputLength--
|
||||
node.removeInput(inputIndex)
|
||||
}
|
||||
const inputInsertionPoint =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
const addedWidgets = node.widgets.splice(startingLength)
|
||||
@@ -157,6 +152,28 @@ function dynamicComboWidget(
|
||||
)
|
||||
//assume existing inputs are in correct order
|
||||
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
|
||||
|
||||
for (const input of removedInputs) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
|
||||
if (inputIndex === -1) {
|
||||
node.inputs.push(input)
|
||||
node.removeInput(node.inputs.length - 1)
|
||||
} else {
|
||||
node.inputs[inputIndex].link = input.link
|
||||
if (!input.link) continue
|
||||
const link = node.graph?.links?.[input.link]
|
||||
if (!link) continue
|
||||
link.target_slot = inputIndex
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
node.inputs[inputIndex]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
if (!node.graph) return
|
||||
node._setConcreteSlots()
|
||||
@@ -243,8 +260,9 @@ function changeOutputType(
|
||||
}
|
||||
|
||||
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (node.comfyMatchType) return
|
||||
node.comfyMatchType = {}
|
||||
if (node.comfyDynamic?.matchType) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.matchType = {}
|
||||
|
||||
const outputGroups = node.constructor.nodeData?.output_matchtypes
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
@@ -258,9 +276,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
) {
|
||||
const input = this.inputs[slot]
|
||||
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
|
||||
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
|
||||
([, group]) => input.name in group
|
||||
) ?? ['', undefined]
|
||||
const [matchKey, matchGroup] = Object.entries(
|
||||
this.comfyDynamic.matchType
|
||||
).find(([, group]) => input.name in group) ?? ['', undefined]
|
||||
if (!matchGroup) return
|
||||
if (iscon && linf) {
|
||||
const { output, subgraphInput } = linf.resolve(this.graph)
|
||||
@@ -317,8 +335,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
const typedSpec = { ...inputSpec, type: allowed_types }
|
||||
addNodeInput(node, typedSpec)
|
||||
withComfyMatchType(node)
|
||||
node.comfyMatchType[template_id] ??= {}
|
||||
node.comfyMatchType[template_id][name] = allowed_types
|
||||
node.comfyDynamic.matchType[template_id] ??= {}
|
||||
node.comfyDynamic.matchType[template_id][name] = allowed_types
|
||||
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
@@ -329,160 +347,215 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
)
|
||||
}
|
||||
|
||||
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
|
||||
function autogrowOrdinalToName(
|
||||
ordinal: number,
|
||||
key: string,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
) {
|
||||
const {
|
||||
names,
|
||||
prefix = '',
|
||||
inputSpecs
|
||||
} = node.comfyDynamic.autogrow[groupName]
|
||||
const baseName = names
|
||||
? names[ordinal]
|
||||
: (inputSpecs.length == 1 ? prefix : key) + ordinal
|
||||
return { name: `${groupName}.${baseName}`, display_name: baseName }
|
||||
}
|
||||
|
||||
function addAutogrowGroup(
|
||||
ordinal: number,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
if (ordinal >= max) return
|
||||
|
||||
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
|
||||
if (!parseResult.success) throw new Error('invalid Autogrow spec')
|
||||
const inputSpec = parseResult.data
|
||||
const namedSpecs = inputSpecs.map((input) => ({
|
||||
...input,
|
||||
isOptional: ordinal >= (min ?? 0) || input.isOptional,
|
||||
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
|
||||
}))
|
||||
|
||||
const { input, min, names, prefix, max } = inputSpec.template
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[input.required, false],
|
||||
[input.optional, true]
|
||||
]
|
||||
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
|
||||
Object.entries(inputType ?? {}).map(([name, v]) =>
|
||||
transformInputSpecV1ToV2(v, { name, isOptional })
|
||||
const newInputs = namedSpecs
|
||||
.filter(
|
||||
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
|
||||
)
|
||||
.map((namedSpec) => {
|
||||
addNodeInput(node, namedSpec)
|
||||
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
|
||||
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
|
||||
ensureWidgetForInput(node, input)
|
||||
return input
|
||||
})
|
||||
|
||||
const lastIndex = node.inputs.findLastIndex((inp) =>
|
||||
inp.name.startsWith(groupName)
|
||||
)
|
||||
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
|
||||
spliceInputs(node, insertionIndex, 0, ...newInputs)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function nameToInputIndex(name: string) {
|
||||
const index = node.inputs.findIndex((input) => input.name === name)
|
||||
if (index === -1) throw new Error('Failed to find input')
|
||||
return index
|
||||
}
|
||||
function nameToInput(name: string) {
|
||||
return node.inputs[nameToInputIndex(name)]
|
||||
const ORDINAL_REGEX = /\d+$/
|
||||
function resolveAutogrowOrdinal(
|
||||
inputName: string,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
): number | undefined {
|
||||
//TODO preslice groupname?
|
||||
const name = inputName.slice(groupName.length + 1)
|
||||
const { names } = node.comfyDynamic.autogrow[groupName]
|
||||
if (names) {
|
||||
const ordinal = names.findIndex((s) => s === name)
|
||||
return ordinal === -1 ? undefined : ordinal
|
||||
}
|
||||
const match = name.match(ORDINAL_REGEX)
|
||||
if (!match) return undefined
|
||||
const ordinal = parseInt(match[0])
|
||||
return ordinal !== ordinal ? undefined : ordinal
|
||||
}
|
||||
function autogrowInputConnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const lastInput = node.inputs.findLast((inp) =>
|
||||
inp.name.startsWith(groupName)
|
||||
)
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (
|
||||
!lastInput ||
|
||||
ordinal == undefined ||
|
||||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
|
||||
)
|
||||
return
|
||||
addAutogrowGroup(ordinal + 1, groupName, node)
|
||||
}
|
||||
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (ordinal == undefined || ordinal + 1 < min) return
|
||||
|
||||
//In the distance, someone shouting YAGNI
|
||||
const trackedInputs: string[][] = []
|
||||
function addInputGroup(insertionIndex: number) {
|
||||
const ordinal = trackedInputs.length
|
||||
const inputGroup = inputsV2.map((input) => ({
|
||||
...input,
|
||||
name: names
|
||||
? names[ordinal]
|
||||
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
|
||||
isOptional: ordinal >= (min ?? 0) || input.isOptional
|
||||
}))
|
||||
const newInputs = inputGroup
|
||||
.filter(
|
||||
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
|
||||
)
|
||||
.map((namedSpec) => {
|
||||
addNodeInput(node, namedSpec)
|
||||
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
|
||||
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
|
||||
return input
|
||||
})
|
||||
spliceInputs(node, insertionIndex, 0, ...newInputs)
|
||||
trackedInputs.push(inputGroup.map((inp) => inp.name))
|
||||
app.canvas?.setDirty(true, true)
|
||||
//resolve all inputs in group
|
||||
const groupInputs = node.inputs.filter(
|
||||
(inp) =>
|
||||
inp.name.startsWith(groupName + '.') &&
|
||||
inp.name.lastIndexOf('.') === groupName.length
|
||||
)
|
||||
const stride = inputSpecs.length
|
||||
if (groupInputs.length % stride !== 0) {
|
||||
console.error('Failed to group multi-input autogrow inputs')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
|
||||
function removeInputGroup(inputName: string) {
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inpName) => inpName === inputName)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
const group = trackedInputs[groupIndex]
|
||||
for (const nameToRemove of group) {
|
||||
const inputIndex = nameToInputIndex(nameToRemove)
|
||||
const input = spliceInputs(node, inputIndex, 1)[0]
|
||||
if (!input.widget?.name) continue
|
||||
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
|
||||
if (!widget) return
|
||||
widget.value = undefined
|
||||
node.removeWidget(widget)
|
||||
}
|
||||
trackedInputs.splice(groupIndex, 1)
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function inputConnected(index: number) {
|
||||
const input = node.inputs[index]
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inputName) => inputName === input.name)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
if (
|
||||
groupIndex + 1 === trackedInputs.length &&
|
||||
trackedInputs.length < (max ?? names?.length ?? 100)
|
||||
app.canvas?.setDirty(true, true)
|
||||
//groupBy would be nice here, but may not be supported
|
||||
for (let column = 0; column < stride; column++) {
|
||||
for (
|
||||
let bubbleOrdinal = ordinal * stride + column;
|
||||
bubbleOrdinal + stride < groupInputs.length;
|
||||
bubbleOrdinal += stride
|
||||
) {
|
||||
const lastInput = trackedInputs[groupIndex].at(-1)
|
||||
if (!lastInput) return
|
||||
const insertionIndex = nameToInputIndex(lastInput) + 1
|
||||
if (insertionIndex === 0) throw new Error('Failed to find Input')
|
||||
addInputGroup(insertionIndex)
|
||||
const curInput = groupInputs[bubbleOrdinal]
|
||||
curInput.link = groupInputs[bubbleOrdinal + stride].link
|
||||
if (!curInput.link) continue
|
||||
const link = node.graph?.links[curInput.link]
|
||||
if (!link) continue
|
||||
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
|
||||
if (curIndex === -1) throw new Error('missing input')
|
||||
link.target_slot = curIndex
|
||||
}
|
||||
const lastInput = groupInputs.at(column - stride)
|
||||
if (!lastInput) continue
|
||||
lastInput.link = null
|
||||
}
|
||||
function inputDisconnected(index: number) {
|
||||
const input = node.inputs[index]
|
||||
if (trackedInputs.length === 1) return
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inputName) => inputName === input.name)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
if (
|
||||
trackedInputs[groupIndex].some(
|
||||
(inputName) => nameToInput(inputName).link != null
|
||||
)
|
||||
)
|
||||
return
|
||||
if (groupIndex + 1 < (min ?? 0)) return
|
||||
//For each group from here to last group, bubble swap links
|
||||
for (let column = 0; column < trackedInputs[0].length; column++) {
|
||||
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
|
||||
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
|
||||
const curInput = nameToInputIndex(trackedInputs[i][column])
|
||||
const linkId = node.inputs[curInput].link
|
||||
node.inputs[prevInput].link = linkId
|
||||
const link = linkId && node.graph?.links?.[linkId]
|
||||
if (link) link.target_slot = prevInput
|
||||
prevInput = curInput
|
||||
}
|
||||
node.inputs[prevInput].link = null
|
||||
}
|
||||
if (
|
||||
trackedInputs.at(-2) &&
|
||||
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
|
||||
)
|
||||
removeInputGroup(trackedInputs.at(-1)![0])
|
||||
const removalChecks = groupInputs.slice((min - 1) * stride)
|
||||
let i
|
||||
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
|
||||
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
|
||||
}
|
||||
const toRemove = removalChecks.slice(i + stride * 2)
|
||||
remove(node.inputs, (inp) => toRemove.includes(inp))
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
|
||||
if (node.comfyDynamic?.autogrow) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.autogrow = {}
|
||||
|
||||
let pendingConnection: number | undefined
|
||||
let swappingConnection = false
|
||||
|
||||
const originalOnConnectInput = node.onConnectInput
|
||||
node.onConnectInput = function (slot: number, ...args) {
|
||||
pendingConnection = slot
|
||||
requestAnimationFrame(() => (pendingConnection = undefined))
|
||||
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
|
||||
}
|
||||
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
(
|
||||
type: ISlotType,
|
||||
index: number,
|
||||
function (
|
||||
this: AutogrowNode,
|
||||
contype: ISlotType,
|
||||
slot: number,
|
||||
iscon: boolean,
|
||||
linf: LLink | null | undefined
|
||||
) => {
|
||||
if (type !== NodeSlotType.INPUT) return
|
||||
const inputName = node.inputs[index].name
|
||||
if (!trackedInputs.flat().some((name) => name === inputName)) return
|
||||
if (iscon) {
|
||||
) {
|
||||
const input = this.inputs[slot]
|
||||
if (contype !== LiteGraph.INPUT || !input) return
|
||||
//Return if input isn't known autogrow
|
||||
const key = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const autogrowGroup = this.comfyDynamic.autogrow[key]
|
||||
if (!autogrowGroup) return
|
||||
if (app.configuringGraph && input.widget)
|
||||
ensureWidgetForInput(node, input)
|
||||
if (iscon && linf) {
|
||||
if (swappingConnection || !linf) return
|
||||
inputConnected(index)
|
||||
autogrowInputConnected(slot, this)
|
||||
} else {
|
||||
if (pendingConnection === index) {
|
||||
if (pendingConnection === slot) {
|
||||
swappingConnection = true
|
||||
requestAnimationFrame(() => (swappingConnection = false))
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => inputDisconnected(index))
|
||||
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
|
||||
withComfyAutogrow(node)
|
||||
|
||||
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
|
||||
if (!parseResult.success) throw new Error('invalid Autogrow spec')
|
||||
const inputSpec = parseResult.data
|
||||
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
|
||||
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
input.required,
|
||||
input.optional
|
||||
]
|
||||
const inputsV2 = inputTypes.flatMap((inputType, index) =>
|
||||
Object.entries(inputType ?? {}).map(([name, v]) =>
|
||||
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
|
||||
)
|
||||
)
|
||||
node.comfyDynamic.autogrow[inputSpecV2.name] = {
|
||||
names,
|
||||
min,
|
||||
max: names?.length ?? max,
|
||||
prefix,
|
||||
inputSpecs: inputsV2
|
||||
}
|
||||
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
|
||||
}
|
||||
|
||||
113
src/extensions/core/customCombo.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
// For each output link copy our value over the original widget value
|
||||
for (const linkInfo of links) {
|
||||
const node = this.graph?.getNodeById(linkInfo.target_id)
|
||||
const input = node?.inputs[linkInfo.target_slot]
|
||||
if (!input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) {
|
||||
console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`)
|
||||
continue
|
||||
}
|
||||
|
||||
widget.value = v
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
node,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onNodeCreated(this: LGraphNode) {
|
||||
this.applyToGraph = useChainCallback(this.applyToGraph, applyToGraph)
|
||||
|
||||
const comboWidget = this.widgets![0]
|
||||
const values = shallowReactive<string[]>([])
|
||||
comboWidget.options.values = values
|
||||
|
||||
const updateCombo = () => {
|
||||
values.splice(
|
||||
0,
|
||||
values.length,
|
||||
...this.widgets!.filter(
|
||||
(w) => w.name.startsWith('option') && w.value
|
||||
).map((w) => `${w.value}`)
|
||||
)
|
||||
if (app.configuringGraph) return
|
||||
if (values.includes(`${comboWidget.value}`)) return
|
||||
comboWidget.value = values[0] ?? ''
|
||||
comboWidget.callback?.(comboWidget.value)
|
||||
}
|
||||
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
|
||||
this.applyToGraph!()
|
||||
)
|
||||
|
||||
function addOption(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
const newCount = node.widgets.length - 1
|
||||
node.addWidget('string', `option${newCount}`, '', () => {})
|
||||
const widget = node.widgets.at(-1)
|
||||
if (!widget) return
|
||||
|
||||
let value = ''
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return value
|
||||
},
|
||||
set(v) {
|
||||
value = v
|
||||
updateCombo()
|
||||
if (!node.widgets) return
|
||||
const lastWidget = node.widgets.at(-1)
|
||||
if (lastWidget === this) {
|
||||
if (v) addOption(node)
|
||||
return
|
||||
}
|
||||
if (v || node.widgets.at(-2) !== this || lastWidget?.value) return
|
||||
node.widgets.pop()
|
||||
node.computeSize(node.size)
|
||||
this.callback(v)
|
||||
}
|
||||
})
|
||||
}
|
||||
addOption(this)
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.CustomCombo',
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== 'CustomCombo') return
|
||||
nodeType.prototype.onNodeCreated = useChainCallback(
|
||||
nodeType.prototype.onNodeCreated,
|
||||
onNodeCreated
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './customCombo'
|
||||
import './dynamicPrompts'
|
||||
import './editAttention'
|
||||
import './electronAdapter'
|
||||
|
||||
@@ -707,11 +707,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** The start position of the drag zoom and original read-only state. */
|
||||
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
|
||||
|
||||
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
|
||||
liveSelection: boolean = false
|
||||
|
||||
getMenuOptions?(): IContextMenuValue<string>[]
|
||||
getExtraMenuOptions?(
|
||||
canvas: LGraphCanvas,
|
||||
options: IContextMenuValue<string>[]
|
||||
): IContextMenuValue<string>[]
|
||||
options: (IContextMenuValue<string> | null)[]
|
||||
): (IContextMenuValue<string> | null)[]
|
||||
static active_node: LGraphNode
|
||||
/** called before modifying the graph */
|
||||
onBeforeChange?(graph: LGraph): void
|
||||
@@ -2627,7 +2630,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.processSelect(clickedItem, eUp)
|
||||
}
|
||||
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
|
||||
pointer.onDragEnd = (upEvent) => this.#handleMultiSelect(upEvent, dragRect)
|
||||
|
||||
if (this.liveSelection) {
|
||||
const initialSelection = new Set(this.selectedItems)
|
||||
|
||||
pointer.onDrag = (eMove) =>
|
||||
this.handleLiveSelect(eMove, dragRect, initialSelection)
|
||||
|
||||
pointer.onDragEnd = () => this.finalizeLiveSelect()
|
||||
} else {
|
||||
// Classic mode: select only when drag ends
|
||||
pointer.onDragEnd = (upEvent) =>
|
||||
this.#handleMultiSelect(upEvent, dragRect)
|
||||
}
|
||||
|
||||
pointer.finally = () => (this.dragging_rectangle = null)
|
||||
}
|
||||
|
||||
@@ -4087,76 +4103,156 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.setDirty(true)
|
||||
}
|
||||
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
|
||||
// Process drag
|
||||
// Convert Point pair (pos, offset) to Rect
|
||||
const { graph, selectedItems, subgraph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
/**
|
||||
* Normalizes a drag rectangle to have positive width and height.
|
||||
* @param dragRect The drag rectangle to normalize (modified in place)
|
||||
* @returns The normalized rectangle
|
||||
*/
|
||||
#normalizeDragRect(dragRect: Rect): Rect {
|
||||
const w = Math.abs(dragRect[2])
|
||||
const h = Math.abs(dragRect[3])
|
||||
if (dragRect[2] < 0) dragRect[0] -= w
|
||||
if (dragRect[3] < 0) dragRect[1] -= h
|
||||
dragRect[2] = w
|
||||
dragRect[3] = h
|
||||
return dragRect
|
||||
}
|
||||
|
||||
// Select nodes - any part of the node is in the select area
|
||||
const isSelected = new Set<Positionable>()
|
||||
const notSelected: Positionable[] = []
|
||||
/**
|
||||
* Gets all positionable items that overlap with the given rectangle.
|
||||
* @param rect The rectangle to check against
|
||||
* @returns Set of positionable items that overlap with the rectangle
|
||||
*/
|
||||
#getItemsInRect(rect: Rect): Set<Positionable> {
|
||||
const { graph, subgraph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
const items = new Set<Positionable>()
|
||||
|
||||
if (subgraph) {
|
||||
const { inputNode, outputNode } = subgraph
|
||||
|
||||
if (overlapBounding(dragRect, inputNode.boundingRect)) {
|
||||
addPositionable(inputNode)
|
||||
}
|
||||
if (overlapBounding(dragRect, outputNode.boundingRect)) {
|
||||
addPositionable(outputNode)
|
||||
}
|
||||
if (overlapBounding(rect, inputNode.boundingRect)) items.add(inputNode)
|
||||
if (overlapBounding(rect, outputNode.boundingRect)) items.add(outputNode)
|
||||
}
|
||||
|
||||
for (const nodeX of graph._nodes) {
|
||||
if (overlapBounding(dragRect, nodeX.boundingRect)) {
|
||||
addPositionable(nodeX)
|
||||
}
|
||||
for (const node of graph._nodes) {
|
||||
if (overlapBounding(rect, node.boundingRect)) items.add(node)
|
||||
}
|
||||
|
||||
// Select groups - the group is wholly inside the select area
|
||||
// Check groups (must be wholly inside)
|
||||
for (const group of graph.groups) {
|
||||
if (!containsRect(dragRect, group._bounding)) continue
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
addPositionable(group)
|
||||
if (containsRect(rect, group._bounding)) {
|
||||
group.recomputeInsideNodes()
|
||||
items.add(group)
|
||||
}
|
||||
}
|
||||
|
||||
// Select reroutes - the centre point is inside the select area
|
||||
// Check reroutes (center point must be inside)
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
if (!isPointInRect(reroute.pos, dragRect)) continue
|
||||
|
||||
selectedItems.add(reroute)
|
||||
reroute.selected = true
|
||||
addPositionable(reroute)
|
||||
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles live selection updates during drag. Called on each pointer move.
|
||||
* @param e The pointer move event
|
||||
* @param dragRect The current drag rectangle
|
||||
* @param initialSelection The selection state before the drag started
|
||||
*/
|
||||
private handleLiveSelect(
|
||||
e: CanvasPointerEvent,
|
||||
dragRect: Rect,
|
||||
initialSelection: Set<Positionable>
|
||||
): void {
|
||||
// Ensure rect is current even if pointer.onDrag fires before processMouseMove updates it
|
||||
dragRect[2] = e.canvasX - dragRect[0]
|
||||
dragRect[3] = e.canvasY - dragRect[1]
|
||||
|
||||
// Create a normalized copy for overlap checking
|
||||
const normalizedRect: Rect = [
|
||||
dragRect[0],
|
||||
dragRect[1],
|
||||
dragRect[2],
|
||||
dragRect[3]
|
||||
]
|
||||
this.#normalizeDragRect(normalizedRect)
|
||||
|
||||
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
||||
|
||||
const desired = new Set<Positionable>()
|
||||
if (e.shiftKey && !e.altKey) {
|
||||
for (const item of initialSelection) desired.add(item)
|
||||
for (const item of itemsInRect) desired.add(item)
|
||||
} else if (e.altKey && !e.shiftKey) {
|
||||
for (const item of initialSelection)
|
||||
if (!itemsInRect.has(item)) desired.add(item)
|
||||
} else {
|
||||
for (const item of itemsInRect) desired.add(item)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const item of [...this.selectedItems]) {
|
||||
if (!desired.has(item)) {
|
||||
this.deselect(item)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const item of desired) {
|
||||
if (!this.selectedItems.has(item)) {
|
||||
this.select(item)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
this.setDirty(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the live selection when drag ends.
|
||||
*/
|
||||
private finalizeLiveSelect(): void {
|
||||
// Selection is already updated by handleLiveSelect
|
||||
// Just trigger the final selection change callback
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles multi-select when drag ends (classic mode).
|
||||
* @param e The pointer up event
|
||||
* @param dragRect The drag rectangle
|
||||
*/
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
|
||||
const normalizedRect: Rect = [
|
||||
dragRect[0],
|
||||
dragRect[1],
|
||||
dragRect[2],
|
||||
dragRect[3]
|
||||
]
|
||||
this.#normalizeDragRect(normalizedRect)
|
||||
|
||||
const itemsInRect = this.#getItemsInRect(normalizedRect)
|
||||
const { selectedItems } = this
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Add to selection
|
||||
for (const item of notSelected) this.select(item)
|
||||
for (const item of itemsInRect) this.select(item)
|
||||
} else if (e.altKey) {
|
||||
// Remove from selection
|
||||
for (const item of isSelected) this.deselect(item)
|
||||
for (const item of itemsInRect) this.deselect(item)
|
||||
} else {
|
||||
// Replace selection
|
||||
for (const item of selectedItems.values()) {
|
||||
if (!isSelected.has(item)) this.deselect(item)
|
||||
if (!itemsInRect.has(item)) this.deselect(item)
|
||||
}
|
||||
for (const item of notSelected) this.select(item)
|
||||
for (const item of itemsInRect) this.select(item)
|
||||
}
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
|
||||
function addPositionable(item: Positionable): void {
|
||||
if (!item.selected || !selectedItems.has(item)) notSelected.push(item)
|
||||
else isSelected.add(item)
|
||||
}
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4796,30 +4892,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.#renderSnapHighlight(ctx, highlightPos)
|
||||
}
|
||||
|
||||
// Area-selection rectangle
|
||||
// In Vue nodes mode, selection rectangle is rendered in DOM layer
|
||||
if (this.dragging_rectangle && !LiteGraph.vueNodesMode) {
|
||||
const { eDown, eMove } = this.pointer
|
||||
ctx.strokeStyle = '#FFF'
|
||||
|
||||
if (eDown && eMove) {
|
||||
// Do not scale the selection box
|
||||
const transform = ctx.getTransform()
|
||||
const ratio = Math.max(1, window.devicePixelRatio)
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
|
||||
const x = eDown.safeOffsetX
|
||||
const y = eDown.safeOffsetY
|
||||
ctx.strokeRect(x, y, eMove.safeOffsetX - x, eMove.safeOffsetY - y)
|
||||
|
||||
ctx.setTransform(transform)
|
||||
} else {
|
||||
// Fallback to legacy behaviour
|
||||
const [x, y, w, h] = this.dragging_rectangle
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// on top of link center
|
||||
if (
|
||||
!this.isDragging &&
|
||||
@@ -8019,8 +8091,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
getCanvasMenuOptions(): IContextMenuValue[] {
|
||||
let options: IContextMenuValue<string>[]
|
||||
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
|
||||
let options: (IContextMenuValue<string> | null)[]
|
||||
if (this.getMenuOptions) {
|
||||
options = this.getMenuOptions()
|
||||
} else {
|
||||
@@ -8564,9 +8636,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else {
|
||||
// Non-node children (nested groups, reroutes)
|
||||
child.move(deltaX, deltaY)
|
||||
} else if (!(child instanceof LGraphGroup)) {
|
||||
// Non-node, non-group children (reroutes, etc.)
|
||||
// Skip groups here - they're already in allItems and will be
|
||||
// processed in the main loop of moveChildNodesInGroupVueMode
|
||||
child.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ export class LGraphNode
|
||||
selected?: boolean
|
||||
showAdvanced?: boolean
|
||||
|
||||
declare comfyMatchType?: Record<string, Record<string, string>>
|
||||
declare comfyDynamic?: Record<string, object>
|
||||
declare comfyClass?: string
|
||||
declare isVirtualNode?: boolean
|
||||
applyToGraph?(extraLinks?: LLink[]): void
|
||||
|
||||